W3docs

Введение в многопоточность Java

Что такое потоки, зачем их использовать в Java и компромиссы параллельного программирования.

Введение в многопоточность Java

Каждая программа Java, которую вы писали до сих пор, имела один поток выполнения — один курсор, шагающий по байт-коду, одну переменную на стеке, по одному вызову метода за раз. Это поток «main», который JVM запускает для вас. Многопоточность — это когда JVM запускает несколько таких курсоров одновременно, разделяющих одну и ту же кучу. Два потока могут в один и тот же момент находиться в двух разных методах на двух разных объектах — и в этом одновременно и сила, и опасность.

У современных процессоров много ядер. Однопоточная программа оставляет все, кроме одного, простаивать. Веб-сервер, обрабатывающий по одному запросу за раз, не может использовать 16-ядерную машину лучше, чем 1-ядерную. Вся причина, по которой существует многопоточность, — заставить эти ядра работать и сохранять отзывчивость программы, когда одна её часть чего-то ждёт (диск, сеть, пользователя).

Что такое поток на самом деле

Поток 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 выполняется параллельно с 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 МБ нативного стека, и ОС ограничивает, сколько их может быть у процесса, так что вы создаёте их с осторожностью. Виртуальные потоки дёшевы — миллионы это нормально — и они снова делают блокирующий ввод-вывод бесплатным. Мы рассмотрим их в конце этой части книги; до тех пор «поток» означает «платформенный поток».

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

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

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, крупным планом рассматривает сам объект Thread — его конструкторы, разницу между расширением Thread и передачей ему Runnable, а также API для именования, приоритета, статуса демона и прерывания.

Практика

Практика

Два потока каждый выполняют `counter++` 100 000 раз на разделяемом `int counter`, который начинается с 0. После `join()` обоих какое значение `counter` наиболее вероятно?