Введение в многопоточность Java
Что такое потоки, зачем они нужны в Java и какие компромиссы несёт конкурентное программирование.
Каждая написанная вами программа на Java имела один поток выполнения — один курсор, шагающий по байт-коду, одну переменную в стеке, один вызов метода за раз. Это «главный» поток, который JVM запускает для вас. Многопоточность — это когда JVM одновременно ведёт несколько таких курсоров, разделяющих одну кучу (heap). Два потока могут в один и тот же момент находиться внутри двух разных методов на двух разных объектах — и в этом одновременно сила и опасность многопоточности.
Современные процессоры имеют множество ядер. Однопоточная программа оставляет все ядра, кроме одного, простаивать. Веб-сервер, обрабатывающий один запрос за раз, не способен задействовать 16-ядерную машину лучше, чем одноядерную. Вся суть многопоточности — заставить эти ядра работать и поддерживать отзывчивость программы, когда одна её часть ждёт (диска, сети, пользователя).
Что такое поток на самом деле
Поток Java — это два компонента, склеенных вместе:
- Поток уровня ОС, который операционная система планирует на ядро процессора. У него есть счётчик команд, набор регистров и нативный стек. ОС делит процессорное время между ним и всеми остальными исполняемыми потоками на машине.
- Java-объект типа
java.lang.Thread. Он несёт имя, приоритет, флаг демона и — самое главное — ссылку наRunnable, чей методrun()он будет выполнять.
Когда вы вызываете thread.start(), JVM просит ОС создать новый нативный поток, который при первом планировании вызовет ваш метод run(). Исходный поток немедленно продолжает работу; теперь оба выполняются параллельно.
public static void main(String[] args) {
System.out.println("main: hello from " + Thread.currentThread().getName());
Thread t = new Thread(() -> {
System.out.println("worker: hello from " + Thread.currentThread().getName());
}, "worker-1");
t.start(); // worker runs concurrently with main
System.out.println("main: continuing");
}Порядок вывода недетерминирован — ОС решает, какой поток запустится первым, и это решение меняется от запуска к запуску. Эта недетерминированность — центральный факт конкурентного программирования.
Зачем использовать потоки
Два разных мотива, которые часто путают:
- Пропускная способность. У вас есть задача, ограниченная процессором — изменение размера изображений, разбор данных, сжатие. Один поток использует одно ядро; восемь потоков используют восемь ядер и завершают работу примерно в восемь раз быстрее. Это параллелизм.
- Отзывчивость. У вас есть поток, который иначе заблокируется — в ожидании ответа от сети, базы данных, клика пользователя. Вынос этой работы в отдельный поток позволяет остальной программе продолжать делать полезные вещи во время ожидания. Это конкурентность.
В большинстве реальных программ нужно и то и другое. Веб-сервер использует множество потоков, чтобы один медленный запрос не блокировал остальные (конкурентность) и чтобы много быстрых запросов обрабатывалось параллельно на разных ядрах (параллелизм).
Почему потоки сложны
Потоки разделяют память. Одни и те же HashMap, ArrayList или int counter++ могут быть затронуты двумя потоками в один момент — а JVM, кэши процессора и компилятор вправе переупорядочивать операции так, что это вас удивит. Три проблемы, с которыми многопоточный код сталкивается постоянно:
- Состояния гонки. Два потока выполняют чтение-изменение-запись одной переменной; одно из обновлений теряется.
counter++не атомарна — это прочитать counter, прибавить один, записать counter. Два потока могут прочитать одинаковое значение и оба записатьvalue + 1, в результате один тик теряется. Исправление — синхронизация, заставляющая чтение-изменение-запись происходить как один неделимый шаг. - Видимость. Поток записывает поле; другой поток читает его и видит старое значение, потому что у каждого потока свой кэш процессора и нет правила, принуждающего запись распространиться без барьера памяти. Именно поэтому существуют
volatile,synchronizedиjava.util.concurrent. - Взаимная блокировка (deadlock). Поток A держит блокировку X и ждёт блокировку Y; поток B держит блокировку Y и ждёт блокировку X. Ни один не продвигается. Программа зависает без исключений и без строчки в логе. В главе о взаимных блокировках показано, как их обнаружить и избежать.
Вся оставшаяся часть этого раздела книги в основном посвящена предотвращению этих трёх видов ошибок при сохранении прироста пропускной способности.
Термины, которые вы встретите
Несколько понятий, которые встречаются повсюду и на которые опираются все последующие главы:
| Термин | Значение |
|---|---|
| Конкурентность | Несколько задач прогрессируют в течение одного периода времени. Они могут и не выполняться буквально в один и тот же момент. |
| Параллелизм | Несколько задач буквально выполняются в один и тот же момент на разных ядрах. Подмножество конкурентности. |
| Взаимное исключение | В критической секции одновременно разрешён только один поток. Обеспечивается блокировками и synchronized. |
| Модель памяти | Правила, определяющие, когда один поток гарантированно видит запись другого потока. Определяется JLS, уточняется JSR-133. |
| Атомарность | Операция, которую нельзя наблюдать выполненной наполовину. Либо она произошла, либо нет — никакого промежуточного состояния, видимого другим потокам. |
| Потокобезопасность | Класс, чей публичный API можно вызывать из нескольких потоков без внешней синхронизации, и при этом он ведёт себя корректно. |
Потоки-демоны и правило завершения JVM
Одна вещь, которая удивляет новичков: JVM завершается, когда заканчивает последний не-демонский поток. Поток main — не демон. Потоки, созданные через new Thread(...), по умолчанию тоже не являются демонами — поэтому запуск рабочего потока удерживает JVM живой до тех пор, пока этот поток не вернётся.
Поток можно пометить как демон с помощью t.setDaemon(true) до вызова start(). Потоки-демоны не удерживают JVM живой; когда все не-демонские потоки завершаются, JVM выдёргивает их из-под ног. Используйте демоны для фоновой работы, которая должна умирать вместе с программой (таймер опроса, сборщик метрик) — но никогда для работы, завершение которой вам действительно нужно (запись файлов, коммиты транзакций).
Потоки и виртуальные потоки
В Java 21 были введены виртуальные потоки, которые выглядят идентично на уровне API, но планируются JVM поверх небольшого пула потоков ОС. Ментальная модель из этой главы — один Java-Thread равен одному потоку ОС — описывает «платформенные потоки», которые вы получаете при использовании обычного конструктора new Thread(...). Платформенные потоки дороги: каждый занимает около 1 МБ нативного стека, а ОС ограничивает их количество в процессе, поэтому их создают осторожно. Виртуальные потоки дёшевы — миллионы допустимы — и они снова делают блокирующий I/O бесплатным. Мы рассмотрим их в разделе Java Virtual Threads; до тех пор «поток» означает «платформенный поток».
Рабочий пример: последовательно против параллельно
Программа ниже суммирует некую нагрузку на процессор двумя способами — однократно последовательно в главном потоке, и однократно разделив работу между четырьмя потоками — и печатает настенное время для каждого. Цифры варьируются в зависимости от машины, но форма везде одинакова: больше потоков — меньше времени, пока не кончатся ядра.
Что следует вынести из запуска:
- Последовательный запуск использовал одно ядро; параллельный — четыре. Ускорение сублинейное (ближе к 3x, чем к 4x), потому что ОС, GC и другие потоки JVM тоже хотят процессорного времени. Закон Амдала в действии — небольшая последовательная часть (финальный цикл суммирования частичных результатов, запуск цикла) ограничивает ускорение.
- Каждый рабочий поток записывал в свой слот в
partials[]. Никакие два потока никогда не трогали одинаковый индекс, поэтому синхронизация не потребовалась. Это простейшая форма параллелизма — разбить данные на части и дать каждому потоку владеть своей частью. t.join()— это то, какmainждёт завершенияworker-3. Без join'ов цикл читал быpartialsдо того, как рабочие потоки завершат запись, иparallelSumбыл бы неверным.join— единственная координация потоков в этой программе; следующие главы познакомят вас со многими другими.- Поток-демон внизу не удержал JVM живой. Он собирался спать 60 секунд, но
mainзавершился и JVM вышла, прервав демона посреди сна без выполнения его оператора печати. Таков контракт демона. Thread.currentThread().getName()и явное имя, переданное конструкторуThread, — вот как вы различаете потоки в логах, профилировщиках и дампах потоков. Всегда именуйте свои потоки —Thread-3бесполезен, когда вы пытаетесь выяснить, какой из них завис.
Что дальше
Следующая глава, Java Thread Class, подробнее рассматривает сам объект Thread — его конструкторы, разницу между расширением Thread и передачей ему Runnable, а также API для именования, приоритета, статуса демона и прерывания.