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()на одном и том же объекте — ошибка времени выполнения.