Thread и Runnable: как запустить поток

Вопрос-крючок: «Какой из двух способов запуска потока вы используете и почему?»

Поток (Thread) — независимая последовательность выполнения кода внутри программы. Несколько потоков могут работать «одновременно», разделяя память процесса.

Это один из первых вопросов, который задают на собеседовании по Java, если в резюме упомянута многопоточность. Формулировка обычно звучит невинно: «Как создать поток?» Но интервьюера интересует не сам факт, что вы знаете два способа, а понимаете ли вы, почему один из них на практике почти не используют.

Способ первый: наследование от Thread

Самый очевидный вариант — унаследоваться от класса Thread и переопределить его метод run().

class PrintTask extends Thread {
    @Override
    public void run() {
        System.out.println("Работает: " + Thread.currentThread().getName());
    }
}

public class Main {
    public static void main(String[] args) {
        PrintTask task = new PrintTask();
        task.start();
    }
}

Вывод:

Работает: Thread-0

Выглядит просто, но здесь уже спрятана первая ловушка: чтобы получить свой поток, класс PrintTask потратил своё единственное наследование именно на Thread. А в Java класс не может наследоваться от двух родителей одновременно.

Способ второй: реализация Runnable

Runnable — это не класс, а интерфейс с одним методом run(). Вместо того чтобы «становиться» потоком, ваш класс просто описывает задачу, а поток создаётся отдельно и получает эту задачу на исполнение.

class PrintTask implements Runnable {
    @Override
    public void run() {
        System.out.println("Работает: " + Thread.currentThread().getName());
    }
}

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(new PrintTask());
        thread.start();
    }
}

Вывод:

Работает: Thread-0

Результат тот же самый, но класс PrintTask теперь свободен: он может дополнительно наследоваться от любого другого класса (кроме Thread, естественно), реализовывать другие интерфейсы, и вообще не обязан «быть» потоком — он просто описывает, что нужно сделать.

Почему это важно на практике

Дело не только в наследовании. Runnable отделяет задачу от механизма её выполнения. Это разделение — прямая дорога к пулам потоков (ExecutorService), куда вы отдаёте объекты Runnable, а сам executor решает, каким именно потоком и когда их выполнить.

ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(new PrintTask());
executor.submit(() -> System.out.println("Лямбда тоже Runnable"));
executor.shutdown();

Если бы вы всюду наследовались от Thread, объект задачи и объект потока были бы жёстко склеены — а значит, пул потоков в таком виде просто не сработал бы: у вас всегда создавался бы новый поток под каждую задачу, что дорого по ресурсам.

Как это работает под капотом

Когда вы вызываете thread.start(), JVM обращается к операционной системе и просит создать настоящий системный поток (это не бесплатно — счёт идёт на сотни килобайт памяти под каждый стек). Внутри этого нового потока JVM вызывает ваш метод run(). Важная деталь: если вызвать run() напрямую, без start(), никакого нового потока не будет — код просто выполнится в том же самом потоке, где вы его вызвали, как обычный метод.

PrintTask task = new PrintTask();
task.run();   // выполнится в main, НЕ создаст новый поток
task.start(); // а вот это создаст

Частые ошибки на собеседовании

  • Путают run() и start() — говорят, что оба «запускают поток». На деле только start() создаёт новый поток; run() — это просто метод, который можно вызвать как любой другой.
  • Не могут объяснить, зачем вообще нужен Runnable, если можно унаследоваться от Thread — не упоминают проблему единственного наследования и связь с пулами потоков.
  • Забывают, что Thread тоже реализует Runnable — это исторический факт об архитектуре класса, который иногда спрашивают отдельно.
  • Пытаются повторно вызвать start() на том же объекте потока — JVM бросит IllegalThreadStateException, поток можно запустить только один раз.

Шпаргалка

  • Runnable — это описание задачи (интерфейс с методом run()), Thread — исполнитель этой задачи.
  • Runnable предпочтительнее, потому что не тратит единственное наследование класса и хорошо ложится на пулы потоков.
  • start() создаёт новый поток операционной системы и вызывает в нём run(); прямой вызов run() — это просто обычный вызов метода, без нового потока.
  • Повторный start() на одном и том же объекте — ошибка времени выполнения.
Проверьте себя
1. Чем отличается вызов thread.start() от прямого вызова thread.run()?
AНичем, оба варианта создают новый поток
Bstart() создаёт новый поток и в нём вызывает run(); run() напрямую выполняется в текущем потоке как обычный метод
Crun() создаёт новый поток, а start() просто помечает объект как готовый к запуску
Dstart() работает только с Runnable, а run() — только с наследниками Thread
2. Почему реализация Runnable обычно предпочтительнее наследования от Thread?
ARunnable работает быстрее благодаря особой оптимизации JVM
BНаследование от Thread вообще запрещено спецификацией Java
CКласс с Runnable не тратит единственное наследование на Thread и легко переиспользуется в пулах потоков
DУ Runnable больше методов, чем у Thread