Перейти к содержимому
Теория · Потоки: первое знакомство · урок 3 из 6

Как запустить поток

Пока на нашей кухне работает один повар — главный поток main. Чтобы нанять второго, нужны две вещи: задача, которую он будет делать, и сам повар, который за неё возьмётся.

Задача и поток

Задачу в Java описывает интерфейс Runnable из стандартной библиотеки. В нём один метод, void run(), и в его теле записано, что нужно сделать. Метод один, поэтому Runnable удобно записать лямбдой: () -> System.out.println("cook").

Повара описывает класс Thread. Его конструктор принимает задачу: new Thread(task). Сам объект Thread ещё ничего не делает — это повар, которого наняли, но пока не поставили к плите.

Вот программа, в которой работают два потока:

JAVA
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> System.out.println("cook");
        Thread t = new Thread(task);
        t.start();
        System.out.println("main");
        t.join();
        System.out.println("done");
    }
}

Давайте разберём её по шагам:

  1. Runnable task = … — задача. Она только записана, и ничего пока не выполняется.
  2. new Thread(task) создаёт объект потока, но поток ещё не работает.
  3. t.start() запускает новый поток, и тот выполняет run() нашей задачи. Сам start() сразу возвращает управление и ничего не ждёт.
  4. main идёт дальше, не дожидаясь t, и печатает main.
  5. t.join() ждёт, пока поток t закончит.
  6. Программа печатает done.

Строки cook и main могут прийти в любом порядке. Обычно первым успевает main: новому потоку нужно время, чтобы запуститься. Но решает операционная система, а не порядок строк в коде, и иногда первой оказывается строка cook. Строка done всегда последняя: перед ней main дождался t.

join и InterruptedException

Вызов join() значит «подожди, пока этот повар закончит». Без него done могла бы напечататься раньше cook. Строка cook при этом всё равно бы появилась: JVM не завершает программу, пока работает хоть один обычный поток.

Почему у main появилось throws InterruptedException? Пока поток ждёт в join(), его могут попросить прерваться, и тогда join() бросает проверяемое исключение InterruptedException. Проверяемое исключение нужно поймать или объявить, а нашу программу прерывать некому, поэтому достаточно объявить его в заголовке main. Без throws компилятор ответит unreported exception InterruptedException; must be caught or declared to be thrown.

start и run

Этот вопрос часто задают на собеседовании: чем start() отличается от run()? У объекта Thread есть оба метода, но делают они разное:

  • start() создаёт новый поток, и уже этот поток вызывает run();
  • run() — обычный вызов метода: задача выполняется в том потоке, который его вызвал, и нового потока не появляется.
JAVA
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println(Thread.currentThread().getName());
        });
        t.run();   // main
        t.start(); // Thread-0
        t.join();
    }
}

Разберём, что происходит:

  1. Задача печатает имя того потока, который её выполняет.
  2. t.run() выполняет задачу прямо в main, как вызов любого другого метода, и печатается main.
  3. t.start() запускает новый поток, и задача печатает его имя — Thread-0. Потоки без имени JVM нумерует сама: Thread-0, Thread-1 и так далее.

Имя можно дать и самим, вторым аргументом конструктора: поток new Thread(task, "cook") называется cook.

Вызвать run() вместо start() — всё равно что главный повар сам приготовил блюдо по рецепту помощника. Блюдо готово, но помощник так и не встал к плите.

Виртуальные потоки

Обычный поток Java — это поток операционной системы, и он недешёв: под него сразу резервируют память, обычно около мегабайта. Тысячи таких потоков программа выдержит, миллион — уже нет.

В Java 21 появились виртуальные потоки — лёгкие потоки, которыми управляет сама JVM. Она раскладывает много виртуальных потоков на немного обычных. Виртуальных потоков можно запустить миллион, и они особенно полезны там, где задача в основном ждёт: ответа по сети или от базы данных. Здесь картинка с кухней перестаёт работать: миллион поваров на одну кухню не нанять, а миллион виртуальных потоков в одной программе запустить можно.

JAVA
Thread v = Thread.startVirtualThread(() -> {
    System.out.println("virtual cook"); // virtual cook
});
v.join();
System.out.println(v.isVirtual()); // true

Разберём по строкам:

  1. Thread.startVirtualThread(…) создаёт виртуальный поток и сразу его запускает: вызывать start() не нужно.
  2. v.join() ждёт его, как и обычный поток.
  3. v.isVirtual() подтверждает, что поток виртуальный, и программа печатает true.

Здесь join() обязателен. JVM не ждёт виртуальные потоки, и без join() программа может закончиться раньше, чем задача успеет что-то напечатать.

Частая ошибка

Один и тот же поток запустили второй раз:

JAVA
Thread t = new Thread(() -> System.out.println("cook"));
t.start();
t.join();
t.start(); // ✗
TEXT
Exception in thread "main" java.lang.IllegalThreadStateException

Программа компилируется, печатает cook и останавливается на втором start(). Поток запускается только один раз, и закончившийся поток снова не запустить. Чтобы выполнить задачу ещё раз, создайте новый поток: new Thread(task).start().

Главное

  • Задачу описывает Runnable, поток — объект Thread, а запускает новый поток start().
  • run() — обычный вызов метода: задача выполняется в текущем потоке, и нового потока нет.
  • join() ждёт, пока поток закончит; без него порядок вывода разных потоков не задан.
  • Виртуальный поток (Java 21) запускает Thread.startVirtualThread(…); JVM его не ждёт, поэтому нужен join().