🧵 Розділ 9 · Питання #26

В чому різниця між 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. У сучасному комерційному коді це визнано антипатерном із трьох причин:

  1. Порушення Single Responsibility Principle (SRP): клас одночасно відповідає і за бізнес-логіку обчислень, і за низькорівневе керування системним потоком.
  2. Втрата множинного успадкування: в Java заборонено множинне успадкування класів. Успадкувавши Thread, клас більше не може успадкувати доменний базовий клас (наприклад, BaseEntityService).
  3. Неможливість роботи з пулами потоків: методи 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 ]
  1. NEW: об’єкт Thread створено в купі, проте системний потік ядра ще не виділено (метод start() не викликався).
  2. RUNNABLE: потік виконується на процесорі або очікує черги планувальника ОС (у Java стани Ready та Running об’єднано в один).
  3. BLOCKED: потік призупинено в очікуванні захоплення монітора synchronized (не плутати з очікуванням блокувань ReentrantLock).
  4. WAITING: потік очікує сигналу без таймауту (Object.wait(), Thread.join(), LockSupport.park()).
  5. TIMED_WAITING: очікування з обмеженням за часом (Thread.sleep(millis), Object.wait(timeout)).
  6. 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()
  1. Перевірка стану: насамперед метод start() перевіряє внутрішнє поле threadStatus. Якщо воно не дорівнює нулю (потік уже запускався), негайно викидається IllegalThreadStateException. Потік у Java є одноразовим скінченним автоматом.
  2. Зміна стану: після системного виклику 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 опитує обробники за суворим каскадом:

  1. Індивідуальний обробник потоку: thread.getUncaughtExceptionHandler();
  2. Якщо він не встановлений, опитується обробник групи потоків (ThreadGroup), до якої належить потік (за замовчуванням група делегує пошук батьку);
  3. Якщо і він відсутній, опитується глобальний обробник рівня всієї JVM: Thread.getDefaultUncaughtExceptionHandler();
  4. Якщо жоден не заданий, 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.


4. Пов’язані питання