Вопрос 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]]