W3docs

Введение в многопоточность 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; до тех пор «поток» означает «платформенный поток».

Рабочий пример: последовательно против параллельно

Программа ниже суммирует некую нагрузку на процессор двумя способами — однократно последовательно в главном потоке, и однократно разделив работу между четырьмя потоками — и печатает настенное время для каждого. Цифры варьируются в зависимости от машины, но форма везде одинакова: больше потоков — меньше времени, пока не кончатся ядра.

java— editable, runs on the server

Что следует вынести из запуска:

  • Последовательный запуск использовал одно ядро; параллельный — четыре. Ускорение сублинейное (ближе к 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 для именования, приоритета, статуса демона и прерывания.

Практика

Практика
Два потока выполняют `counter++` по 100 000 раз над общим `int counter`, начинающимся с 0. После того как оба завершатся через `join()`, каково наиболее вероятное значение `counter`?
Два потока выполняют `counter++` по 100 000 раз над общим `int counter`, начинающимся с 0. После того как оба завершатся через `join()`, каково наиболее вероятное значение `counter`?
Was this page helpful?