В чому різниця між Thread та Runnable
Різниця між Thread та Runnable полягає у фундаментальному розділенні обов'язків за патерном «Команда» (Command Pattern): Runnable — це завдання («що робити»), а Thread — це вико...
🟢 Junior Level
Короткий опис для співбесіди (30 секунд)
Різниця між Thread та Runnable полягає у фундаментальному розділенні обов’язків за патерном «Команда» (Command Pattern): Runnable — це завдання («що робити»), а Thread — це виконавець і контекст виконання («хто і в якому середовищі виконує»).
Runnable — це функціональний інтерфейс з єдиним методом void run(), що представляє собою абстрактний блок виконуваного коду без прив’язки до конкретного потоку.
Thread — це клас середовища виконання, який керує потоком операційної системи (або віртуальним потоком у Java 21), його станом, пріоритетом, стеком викликів та життєвим циклом. Успадкування від Thread намертво зв’язує завдання з виконавцем, тоді як реалізація Runnable дозволяє виконувати одне й те саме завдання в пулах потоків (ExecutorService), віртуальних потоках (Virtual Threads) або навіть синхронно в поточному потоці.
Порівняльна таблиця: Thread vs Runnable
| Характеристика | Runnable |
Thread |
|---|---|---|
| Тип сутності | Функціональний інтерфейс (@FunctionalInterface) |
Конкретний клас (java.lang.Thread) |
| Роль в архітектурі | Одиниця роботи / Завдання (Task) | Носій виконання / Потік (Worker/Carrier) |
| Виділення ресурсів | Звичайний об’єкт у купі (~24–32 байти) | Нативний стек ядра ОС (~1 МБ) + дескриптор OSThread |
| Спосіб запуску | Метод run() (виконується в поточному потоці) |
Метод start() (породжує новий потік ОС через JNI) |
| Перевикористання | Багаторазове (у циклі, в пулі, на льоту) | Одноразовий (повторний start() заборонено) |
| Успадкування | Дозволяє класу успадковувати будь-який інший клас | Займає єдиний слот успадкування Java (extends) |
| Інтеграція | Стандарт для ExecutorService, Loom, Streams |
Низькорівневий примітив, рідко створюється напряму |
Приклади в Java 21: Правильний запуск і критична пастка
import java.util.concurrent.Executors;
public class ThreadVsRunnableDemo {
public static void main(String[] args) throws Exception {
// 1. Визначення завдання через лямбду Runnable
Runnable printTask = () -> {
System.out.println("Виконання завдання в потоці: " + Thread.currentThread().getName());
};
// ❌ ГРУБА ПОМИЛКА: Прямий виклик run() не створює нового потоку!
printTask.run(); // Виведе: "Виконання завдання в потоці: main"
// ✅ Запуск через класичний Platform Thread
Thread platformThread = new Thread(printTask, "platform-worker-1");
platformThread.start(); // Створює системний потік ОС
// ✅ Сучасний запуск через Virtual Thread (Java 21)
Thread virtualThread = Thread.ofVirtual()
.name("virtual-worker-1")
.start(printTask);
// ✅ Продакшен-стандарт: передача Runnable до ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(printTask);
}
platformThread.join();
virtualThread.join();
}
}
🟡 Middle Level
Принцип «Композиція замість успадкування» (Composition over Inheritance)
У застарілих підручниках часто пропонується створювати потоки через успадкування: class MyTask extends Thread. У сучасному комерційному коді це визнано антипатерном із трьох причин:
- Порушення Single Responsibility Principle (SRP): клас одночасно відповідає і за бізнес-логіку обчислень, і за низькорівневе керування системним потоком.
- Втрата множинного успадкування: в Java заборонено множинне успадкування класів. Успадкувавши
Thread, клас більше не може успадкувати доменний базовий клас (наприклад,BaseEntityService). - Неможливість роботи з пулами потоків: методи
ExecutorService.submit()приймаютьRunnableабоCallable. Якщо завдання «зашите» вThread, пул не зможе перевикористати свої робочі потоки для його виконання.
Життєвий цикл потоку (Thread.State)
Об’єкт Thread у процесі життя проходить через строго визначені стани, зафіксовані в java.lang.Thread.State:
[ NEW ]
│ thread.start()
▼
[ RUNNABLE ] ◄────────────────────────┐
│ │ │
Чекає замок │ │ wait() / join() / park() │ Оповіщення / замок вільний
synchronized │ ▼ │
│ [ WAITING / TIMED_WAITING ] ─────┘
▼
[ BLOCKED ]
│
│ Завершення методу run()
▼
[ TERMINATED ]
NEW: об’єктThreadстворено в купі, проте системний потік ядра ще не виділено (методstart()не викликався).RUNNABLE: потік виконується на процесорі або очікує черги планувальника ОС (у Java стани Ready та Running об’єднано в один).BLOCKED: потік призупинено в очікуванні захоплення монітораsynchronized(не плутати з очікуванням блокуваньReentrantLock).WAITING: потік очікує сигналу без таймауту (Object.wait(),Thread.join(),LockSupport.park()).TIMED_WAITING: очікування з обмеженням за часом (Thread.sleep(millis),Object.wait(timeout)).TERMINATED: виконання методуrun()завершено (успішно або за винятком).
Обробка неперехоплених винятків: UncaughtExceptionHandler
Метод Runnable.run() не декларує перевірюваних винятків (throws Exception). Якщо всередині run() викидається неперевірюваний виняток (RuntimeException), він перериває виконання потоку:
public class ThreadExceptionHandlerDemo {
public static void main(String[] args) {
Thread worker = new Thread(() -> {
throw new IllegalStateException("Критичний збій бізнес-логіки!");
});
// Реєстрація обробника збоїв для конкретного потоку:
worker.setUncaughtExceptionHandler((thread, throwable) -> {
System.err.println("Потік " + thread.getName() + " аварійно завершився: " + throwable.getMessage());
// Тут відправляється алерт у моніторинг (Sentry, Prometheus)
});
// Глобальний обробник за замовчуванням для всієї JVM:
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
System.err.println("Глобальне перехоплення збою потоку: " + t.getName());
});
worker.start();
}
}
🔴 Senior Level
Under the Hood JVM HotSpot: Анатомія thread.start()
Що в точності відбувається під час виклику методу thread.start():
Java Code: thread.start()
│
▼ (Java Native Interface - JNI)
HotSpot C++: JVM_StartThread (jvm.cpp)
│
├─► Виділення C++ структури JavaThread
├─► Виклик os::create_thread (os_linux.cpp)
│ │
│ ▼ System Call (Linux Kernel)
│ pthread_create(..., &thread_native_entry, ...)
│ │
│ ├─► Резервування стека (-Xss: 1 MB)
│ ├─► Алокація ядра OSThread
│ └─► Додавання до черги планувальника Linux CFS
│
▼ HotSpot C++
Виклик thread_native_entry()
│
▼ Зворотний виклик (Java Callback)
Виклик цільового методу runnable.run()
- Перевірка стану: насамперед метод
start()перевіряє внутрішнє полеthreadStatus. Якщо воно не дорівнює нулю (потік уже запускався), негайно викидаєтьсяIllegalThreadStateException. Потік у Java є одноразовим скінченним автоматом. - Зміна стану: після системного виклику
pthread_createопераційна система повертає дескрипторpthread_t. Потік переходить у статусRUNNABLE, і керування передається планувальнику ядра.
Пріоритети потоків (Thread.setPriority) та планувальник Linux CFS
У класі Thread є методи setPriority(int priority) із діапазоном від 1 (MIN_PRIORITY) до 10 (MAX_PRIORITY).
[!WARNING] У сучасних серверних середовищах на Linux виклик
setPriority()практично марний:
Стандартний планувальник Linux — CFS (Completely Fair Scheduler) або EEVDF (у ядрах Linux 6.6+) — оперує концепцією «віртуального часу виконання» (vruntime) та значеннямиnice(від -20 до +19). Для того щоб звичайний непривілейований Java-процес міг підвищити пріоритет потоку через системний викликsetpriority(), потрібні права суперкористувача або Linux-капабілітіCAP_SYS_NICE. У Docker/Kubernetes контейнерах ці права за замовчуванням вимкнені, тому JVM мовчки ігнорує пріоритети, виділяючи всім потокам рівні кванти процесорного часу.
Thread Context ClassLoader (TCCL)
Кожен об’єкт Thread зберігає посилання на ClassLoader, званий Context ClassLoader:
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(customClassLoader);
Навіщо він потрібен Senior-розробнику:
У Java існує сувора ієрархія завантажувачів класів (Bootstrap $\to$ Platform $\to$ Application). Базові класи JDK (наприклад, java.sql.DriverManager), розташовані в Bootstrap ClassLoader, не можуть бачити класи драйверів (наприклад, org.postgresql.Driver), що знаходяться нижче за ієрархією в Application ClassLoader.
Щоб обійти це обмеження архітектури (Service Provider Interface, SPI), DriverManager бере завантажувач із контексту поточного потоку (Thread.currentThread().getContextClassLoader()), динамічно знаходячи та завантажуючи сторонні драйвери й плагіни в середовищі виконання.
🎯 Шпаргалка для інтерв’ю
1. 30-секундний есенс (Elevator Pitch)
Runnable — це функціональний інтерфейс абстрактного завдання («що робити»), а Thread — конкретний клас системного виконавця («хто і на яких ресурсах виконує»). У професійному коді завжди використовується Runnable (або Callable), а пряме створення Thread або успадкування від нього (extends Thread) вважається антипатерном. Запуск потоку здійснюється виключно через start() (який через JNI викликає системний pthread_create), тоді як прямий виклик run() просто виконає код синхронно в поточному потоці.
2. 4 каверзних питання з глибокими відповідями
Питання 1: Що станеться на рівні JVM та операційної системи, якщо викликати thread.run() замість thread.start()?
Відповідь:
Жодного нового потоку операційної системи створено не буде. Метод run() виконається як звичайний синхронний виклик методу в стеку поточного потоку (наприклад, main). Системні виклики ядра (pthread_create / CreateThread) не ініціюються, нативний стек пам’яті не виділяється, а стан об’єкта Thread залишиться NEW. Це поширена помилка новачків, що призводить до блокування викликаючого потоку.
Питання 2: Чому повторний виклик start() на об’єкті Thread викидає IllegalThreadStateException, навіть якщо потік уже завершився (TERMINATED)?
Відповідь:
Життєвий цикл Thread спроєктований як необоротний скінченний автомат (State Machine). На початку методу start() перевіряється приватне поле threadStatus:
if (threadStatus != 0) throw new IllegalThreadStateException();
Коли потік стартує, threadStatus змінюється, а при виході з run() переходить у статус TERMINATED. Нативний потік ядра ОС після завершення знищується та звільняє свій системний стек. Перезапуск того самого екземпляра Java Thread порушив би цілісність структур JNI та HotSpot.
Питання 3: У якому порядку JVM шукає обробник неперехопленого винятку (UncaughtExceptionHandler) у разі збою потоку?
Відповідь: JVM опитує обробники за суворим каскадом:
- Індивідуальний обробник потоку:
thread.getUncaughtExceptionHandler(); - Якщо він не встановлений, опитується обробник групи потоків (
ThreadGroup), до якої належить потік (за замовчуванням група делегує пошук батьку); - Якщо і він відсутній, опитується глобальний обробник рівня всієї JVM:
Thread.getDefaultUncaughtExceptionHandler(); - Якщо жоден не заданий, JVM виводить стек помилки в системний потік
System.err.
Питання 4: Чим відрізняється стан BLOCKED від стану WAITING у дампах потоків?
Відповідь:
- У стан
BLOCKEDпотік переходить виключно при спробі входу в синхронізований блок або метод (synchronized), коли асоційований моніторObjectMonitorуже захоплений іншим потоком. - У стан
WAITINGпотік переходить добровільно в результаті викликів методів очікування без таймауту:Object.wait(),Thread.join()абоLockSupport.park()(на якому побудовані всі примітивиjava.util.concurrent, включно зReentrantLockта віртуальними потоками).
3. Типові помилки та Red Flags
❌ «Успадкування класу від Thread для реалізації фонового завдання.»
Правильно: Це зв’язує логіку завдання з життєвим циклом потоку. Слід реалізовувати Runnable і передавати його в ExecutorService або Thread.startVirtualThread().
❌ «Використання thread.stop() або thread.suspend() для зупинки потоку.»
Правильно: Ці методи застаріли (deprecated for removal) і є небезпечними: вони миттєво розблоковують усі монітори, залишаючи спільні структури даних у пошкодженому неузгодженому стані. Зупинка реалізується кооперативно через thread.interrupt().
❌ «Спроба керувати порядком виконання критичних завдань через thread.setPriority().»
Правильно: Пріоритети Java ігноруються більшістю серверних ядер Linux. Для пріоритизації завдань слід використовувати спеціалізовані черги, наприклад, PriorityBlockingQueue.