Создание потоков через Thread и Runnable: где кроются ошибки
Создание потоков через Thread и Runnable: где кроются ошибки
Представьте, что вы написали веб-сервер для генерации сложных финансовых отчетов. Пользователь нажимает кнопку, и сервер начинает считать данные. Это занимает 5 секунд. Если в этот момент придет второй пользователь, ему придется ждать, пока сервер не закончит с первым. Ваш мощный многоядерный процессор простаивает, пока очередь клиентов растет.
Чтобы решать задачи параллельно, нам нужны потоки. В этой статье мы разберем базовые механизмы создания потоков в Java, поймем, почему один подход безнадежно устарел, и препарируем самую частую ошибку новичков, которая превращает многопоточный код в однопоточный.
Что такое поток в Java?
Когда вы запускаете Java-программу, виртуальная машина (JVM) создает один главный поток — он так и называется main. Именно в нем последовательно выполняются инструкции вашего метода public static void main.
Но сам по себе класс java.lang.Thread в Java — это лишь объект-обертка. Это пульт управления. Когда вы создаете объект Thread через new, никакого реального потока в операционной системе еще не существует. Это просто кусок памяти в куче (Heap), хранящий имя потока, его приоритет и состояние.
Настоящая магия происходит только в момент запуска. JVM обращается к операционной системе и просит выделить ресурсы: системный поток, отдельный стек вызовов и регистры процессора.
Два пути создания потоков
Исторически в Java есть два способа описать код, который должен выполниться параллельно.
Путь 1: Наследование от класса Thread (Плохой подход)
Первый инстинкт — создать свой класс, унаследовать его от Thread и переопределить метод run().
public class ReportGenerator extends Thread {
@Override
public void run() {
System.out.println("Генерация отчета началась в потоке: " + Thread.currentThread().getName());
// Долгая логика генерации...
}
}
// Использование:
ReportGenerator generator = new ReportGenerator();
generator.start();
Почему так делать не стоит?
- Отсутствие множественного наследования. В Java класс может наследоваться только от одного предка. Если ваш
ReportGeneratorуже наследуетThread, он не сможет унаследовать базовый классBaseServiceиз вашей бизнес-логики. - Нарушение архитектуры. Класс
Threadотвечает за механику управления системным потоком (приоритеты, прерывания, состояния). Ваша логика отчета — это бизнес-задача. Смешивать их в одном классе — значит нарушать принцип единственной ответственности (Single Responsibility Principle).
Путь 2: Реализация интерфейса Runnable (Правильный подход)
Чтобы отделить задачу от механизма ее выполнения, в Java существует интерфейс Runnable. В нем есть всего один метод — run().
public class ReportTask implements Runnable {
@Override
public void run() {
System.out.println("Генерация отчета началась в потоке: " + Thread.currentThread().getName());
}
}
// Использование:
ReportTask task = new ReportTask(); // Создали задачу
Thread worker = new Thread(task); // Наняли рабочего и дали ему задачу
worker.start(); // Сказали рабочему начать
Здесь ReportTask ничего не знает о потоках, приоритетах и операционной системе. Это просто инструкция. А класс Thread выступает в роли рабочего, которому передали эту инструкцию.
Поскольку Runnable является функциональным интерфейсом (содержит ровно один абстрактный метод), современный Java-код позволяет использовать лямбда-выражения, избавляя нас от создания отдельных классов:
Thread worker = new Thread(() -> {
System.out.println("Анонимная задача в потоке: " + Thread.currentThread().getName());
});
worker.start();
Главная ловушка: start() против run()
Самая частая ошибка при работе с потоками выглядит так: разработчик создает объект Thread (или Runnable), описывает в нем тяжелую задачу, а затем вызывает метод run() вместо start().
Код скомпилируется. Никаких исключений не упадет. Программа даже отработает и выдаст правильный результат. Но она сделает это медленно и в одном потоке.
Почему это происходит?
Метод run() — это самый обычный метод Java. В нем нет никакой магии. Если вы вызываете worker.run(), вы просто говорите текущему потоку (например, main): «Выполни-ка инструкции из этого метода прямо сейчас». Текущий поток послушно идет в метод run(), выполняет его от начала до конца, и только потом переходит к следующей строчке кода.
Метод start() — это нативный вызов (Native Method). Он делает следующее:
- Обращается к JVM и ОС для создания нового системного потока.
- Подготавливает для него отдельный стек.
- Возвращает управление вызывающему потоку (вызывающий поток мгновенно идет дальше).
- А внутри нового потока JVM сама вызывает ваш метод
run().
Цена создания потоков
Теперь вы умеете правильно создавать потоки через Runnable и запускать их через start(). Возникает соблазн: «Отлично, теперь на каждый запрос пользователя я буду делать new Thread(task).start()!».
Это вторая фундаментальная ошибка, которая приведет к падению вашего приложения (Out Of Memory) при росте нагрузки.
Поток в Java — это тяжеловесный объект. Создание каждого нового потока требует времени на согласование с ОС. Кроме того, каждому потоку по умолчанию выделяется около 1 Мегабайта памяти под стек (Call Stack). Если к вам придет 5000 пользователей одновременно, создание 5000 потоков мгновенно съест 5 Гигабайт оперативной памяти только на пустые стеки, даже если задачи тривиальны.
Ручное создание потоков через new Thread() оправдано только для долгоживущих фоновых процессов. Для обработки множества коротких задач потоки нужно переиспользовать. Как именно это делается и почему пулы потоков спасают серверы от падения, мы разберем в следующих главах.
