В чому різниця між Thread та Runnable?
Thread та Runnable — це два фундаментально різних поняття в Java, які часто плутають. Runnable описує задачу ("що робити"), а Thread — виконавець ("хто виконує"). Це розділення...
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
- Реалізуйте Runnable — не успадковуйте Thread
- Використовуйте ExecutorService — не створюйте Thread напряму
- Викликайте start() — не run() для запуску потоку
- Встановлюйте UncaughtExceptionHandler — для обробки помилок
- Іменуйте потоки — кастомний ThreadFactory для відлагодження
- Thread Context ClassLoader — для динамічного завантаження класів
- Не використовуйте ThreadGroup — ExecutorService або StructuredTaskScope
- Не покладайтесь на пріоритети — ОС їх ігнорує
Коли НЕ потрібно використовувати 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]]