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

В чому різниця між Thread та Runnable?

Thread та Runnable — це два фундаментально різних поняття в Java, які часто плутають. Runnable описує задачу ("що робити"), а Thread — виконавець ("хто виконує"). Це розділення...

Мовні версії: English Russian Ukrainian

Thread та Runnable — це два фундаментально різних поняття в Java, які часто плутають. Runnable описує задачу (“що робити”), а Thread — виконавець (“хто виконує”). Це розділення — приклад патерну Command: об’єкт-команда (Runnable) окремо від об’єкта-виконавця (Thread).

Чому це розділення важливо: якби задача була прив’язана до виконавця (як при успадкуванні від Thread), ви не могли б перевикористати одну задачу в різних контекстах (пул потоків, Virtual Thread, поточний потік). Розділення дає гнучкість.


Junior рівень

Базове розуміння

Thread та Runnable — це два різних поняття в Java:

Поняття Що це Роль
Runnable Інтерфейс (interface) Задача — “що робити”
Thread Клас (class) Носій — “хто виконує”

Runnable — це просто блок коду, який можна виконати. Сам по собі він не створює потік. Thread — це обгортка над потоком операційної системи, яка бере Runnable і запускає його в окремому потоці виконання.

Проста аналогія

  • Runnable = рецепт (опис що приготувати)
  • Thread = кухар (хто готує за рецептом)

Приклад Runnable

// Runnable — це просто задача
Runnable task = () -> {
    System.out.println("Виконую задачу!");
};

Приклад Thread

// Thread — це виконавець
Thread thread = new Thread(task); // Передаємо задачу
thread.start(); // Запускаємо новий потік

Повне порівняння

Характеристика Runnable Thread
Тип Інтерфейс Клас
Метод run() start(), join(), interrupt(), …
Створює потік? Ні Так (при виклику start())
Пам’ять Об’єкт у купі (~24 байти) Об’єкт + нативний стек (~1MB)
Можна перевикористати? Так Ні (один Thread = одне виконання)

Часта помилка: run() vs start()

Runnable task = () -> System.out.println("Потік: " + Thread.currentThread().getName());

// ПОГАНО: run() — виконується в ПОТОЧНОМУ потоці
task.run(); // "Потік: main" — НЕ новий потік!

// ГАРНО: start() — створює НОВИЙ потік
Thread thread = new Thread(task);
thread.start(); // "Потік: Thread-0" — новий потік!

Middle рівень

Composition over Inheritance

// ПОГАНО: успадкування від Thread
class MyTask extends Thread {
    @Override
    public void run() {
        // Тепер MyTask НЕ може успадкувати нічого іншого
    }
}

// ГАРНО: реалізація Runnable
class MyTask implements Runnable {
    @Override
    public void run() {
        // MyTask може ще й успадкувати інший клас
    }
}

Чому Runnable краще?

Критерій extends Thread implements Runnable
Множинне успадкування Ні (зайнято Thread) Так
Перевикористання Ні (один Thread) Так (в будь-який ExecutorService)
Розділення відповідальності Ні (задача + носій) Так (задача окремо)

Передача Runnable у різні виконавці

Runnable task = () -> System.out.println("Working...");

// 1. В Thread
new Thread(task).start();

// 2. В ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(task);

// 3. В ForkJoinPool
ForkJoinPool.commonPool().execute(task);

// 4. Просто в поточному потоці
task.run();

Склад Thread (що всередині)

Thread thread = new Thread(task);

// Thread містить:
thread.getId();                    // Унікальний ID
thread.getName();                  // Ім'я ("Thread-0")
thread.getPriority();              // Пріоритет (1-10)
thread.isDaemon();                 // Демон чи ні
thread.getContextClassLoader();    // ClassLoader для фреймворків
thread.getState();                 // NEW, RUNNABLE, BLOCKED, WAITING, ...

Thread States

Стан Опис
NEW Створено, але start() не викликано
RUNNABLE Виконується або готовий до виконання
BLOCKED Чекає звільнення монітора (synchronized)
WAITING Чекає notify() або park()
TIMED_WAITING Чекає з таймаутом (sleep, wait(timeout))
TERMINATED Завершено
Thread t = new Thread(() -> {});
System.out.println(t.getState()); // NEW

t.start();
System.out.println(t.getState()); // RUNNABLE (або TERMINATED якщо швидкий)

t.join();
System.out.println(t.getState()); // TERMINATED

Senior рівень

Under the Hood: Створення потоку

// При виклику start():
thread.start();

// JVM робить:
// 1. Викликає нативний метод:
//    - Linux: pthread_create()
//    - Windows: CreateThread()
// 2. ОС виділяє:
//    - Stack (за замовчуванням 1MB)
//    - Thread-local storage
//    - Записи у планувальнику (Scheduler)
// 3. Додає в чергу планувальника ОС
// 4. І тільки потім викликає Runnable.run()

Stack Memory

1000 об'єктів Runnable:  ~24 KB  (просто об'єкти в Young Gen)
1000 об'єктів Thread:    ~1 GB   (1000 × 1MB нативних стеків)

Thread Context ClassLoader

// Фреймворки використовують для завантаження класів
Thread.currentThread().setContextClassLoader(myClassLoader);

// Hibernate, Spring та інші використовують:
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Class<?> clazz = cl.loadClass("com.example.MyEntity");

Priority — міф і реальність

Thread t = new Thread(task);
t.setPriority(Thread.MAX_PRIORITY); // 10

// На сучасних ОС (Linux CFS) пріоритети Java
// найчастіше ІГНОРУЮТЬСЯ планувальником!
// ОС використовує свої складні алгоритми чесності

UncaughtExceptionHandler

Thread t = new Thread(() -> {
    throw new RuntimeException("Непоймана помилка!");
});

// Без обробника — помилка просто в System.err
t.setUncaughtExceptionHandler((thread, ex) -> {
    System.err.println("Потік " + thread.getName() + " впав: " + ex);
    // Логування, алерт, restart
});

t.start();

Thread Groups — застаріла концепція

// ThreadGroup — НЕ рекомендується використовувати
ThreadGroup group = new ThreadGroup("my-group");
new Thread(group, task).start();

// Зараз для групування використовують:
// 1. ExecutorService
// 2. StructuredTaskScope (Java 21+)
// 3. Кастомний ThreadFactory з іменами

Діагностика

jstack

jstack <pid>
"main" #1 prio=5 os_prio=0 tid=0x00007f... nid=0x1234 runnable
  at com.example.MyClass.myMethod(MyClass.java:42)

"worker-1" #10 prio=5 os_prio=0 tid=0x00007f... nid=0x1235 waiting on condition
  - parking to wait for <0x000000076af0c8d0>
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)

Thread Dumps — що видно

  • Thread — видно стан, ім’я, стек-трейс
  • Runnable — НЕ видно окремо, він “розчиняється” всередині кадру стеку методу run()

Best Practices

  1. Реалізуйте Runnable — не успадковуйте Thread
  2. Використовуйте ExecutorService — не створюйте Thread напряму
  3. Викликайте start() — не run() для запуску потоку
  4. Встановлюйте UncaughtExceptionHandler — для обробки помилок
  5. Іменуйте потоки — кастомний ThreadFactory для відлагодження
  6. Thread Context ClassLoader — для динамічного завантаження класів
  7. Не використовуйте ThreadGroup — ExecutorService або StructuredTaskScope
  8. Не покладайтесь на пріоритети — ОС їх ігнорує

Коли НЕ потрібно використовувати Runnable

  • Однопотокова задача — якщо код виконується лише в одному потоці, Runnable додає зайву абстракцію без користі
  • Потрібен результат — використовуйте Callable<V> замість Runnable (Runnable.run() повертає void)
  • Потрібно кинути checked exception — Runnable.run() не declares throws, використовуйте Callable

Коли НЕ потрібно створювати Thread напряму

  • Є ExecutorService — пул потоків ефективніше (перевикористовує потоки, керує чергою)
  • Virtual Threads доступні (Java 21+) — для I/O-bound задач Thread.ofVirtual() простіший і легший
  • StructuredTaskScope (Java 21 Preview) — для групи пов’язаних задач із спільним життєвим циклом

Runnable vs Callable vs Thread: що обрати?

Ситуація Вибір Чому
Задача без результату (лог, відправка) Runnable Простий функціональний інтерфейс
Задача з результатом Callable Повертає V, може кинути checked exception
Швидкий прототип (1-2 потоки) new Thread(runnable) Мінімум коду
Production (пул, управління) ExecutorService.submit(runnable) Перевикористання потоків, черга
I/O-bound, багато задач (Java 21+) Thread.ofVirtual().start(runnable) Мільйони легких потоків

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • Runnable — інтерфейс (задача, “що робити”), Thread — клас (виконавець, “хто виконує”)
  • Runnable.run() виконується в ПОТОЧНОМУ потоці, Thread.start() створює НОВИЙ потік
  • Composition over Inheritance: implements Runnable краще extends Thread (гнучкість, пул потоків, Virtual Threads)
  • 1000 Runnable об’єктів = ~24 KB, 1000 Thread = ~1 GB (нативні стеки)
  • Thread States: NEW → RUNNABLE → BLOCKED/WAITING/TIMED_WAITING → TERMINATED
  • Thread priority на сучасних ОС (Linux CFS) найчастіше ігнорується планувальником

Часті уточнюючі запитання:

  • Чому не можна успадковувати Thread? — Можна, але це антипатерн: втрачаєте множинне успадкування, неможливо перевикористати у пулі потоків
  • Навіщо потрібен Context ClassLoader у Thread? — Фреймворки (Hibernate, Spring) використовують для динамічного завантаження класів з правильного classpath
  • Чому ThreadGroup застарів? — Замість нього використовують ExecutorService, StructuredTaskScope (Java 21+), або кастомний ThreadFactory
  • Що робити з непойманими винятками у Thread?setUncaughtExceptionHandler() — інакше помилка лише в System.err

Червоні прапорці (НЕ говорити):

  • ❌ “Thread та Runnable — це одне й те саме” — Runnable це задача, Thread це виконавець
  • ❌ “Виклик run() запускає новий потік” — run() виконується в поточному потоці, потрібен start()
  • ❌ “Thread priority гарантує порядок виконання” — сучасні ОС ігнорують Java-пріоритети
  • ❌ “ThreadGroup — це гарний спосіб групування” — застарів, використовуйте ExecutorService або StructuredTaskScope

Пов’язані теми:

  • [[19. Які умови необхідні для виникнення deadlock]]
  • [[21. Що таке race condition]]
  • [[23. Що таке Virtual Threads в Java 21]]
  • [[28. Що таке Callable та Future]]