Практические лучшие практики многопоточности: издание Java — соглашения эпохи виртуальных потоков
· Го Комура · Многопоточность, Java, Бизнес-приложения, Расследование ошибок, Проектирование
«Хотим распараллелить бизнес-пакетную задачу на Java.» «Общий кэш в нашем веб-приложении на Spring иногда портится.» «Мы унаследовали старое Swing-приложение, нашпигованное new Thread.» — Java встроила многопоточность в язык с JDK 1.0, выросла в набор инструментов java.util.concurrent и с виртуальными потоками в JDK 21 ещё раз переписала привычные представления о конкурентном программировании. Именно потому, что набор инструментов так богат, выбор инструмента становится самим качеством проектирования.
Эта статья — издание Java практической серии о многопоточности. Она адресована разработчикам, которые пишут на Java бизнес-системы, пакетные задачи и серверные приложения, переносит принципы проектирования многопоточности — никогда не создавать потоки напрямую, сокращать общее изменяемое состояние, дисциплинировать блокировки, проектировать остановку раньше всего остального — на инструменты Java (в основном LTS-выпуск, JDK 21 и новее) и собирает выборы эпохи виртуальных потоков и специфические для Java ловушки, опираясь на первоисточники по состоянию на август 2026. Написана так, чтобы читаться самостоятельно. Те же принципы, применённые к другим языкам, разобраны в сопутствующих статьях «издание .NET», «издание C++» и «издание C».
1. Сначала вывод
- Не пишите
new Threadв бизнес-коде — то же правило действует и в Java. Передавайте задачи вExecutorServiceи оставляйте управление жизненным циклом потоков библиотеке.1 - Направляйте задачи, которые в основном ждут ввода-вывода, в виртуальные потоки. Виртуальные потоки, ставшие официальными в JDK 21, используют по одному на задачу и никогда не должны попадать в пул. Ограничивайте параллелизм через
Semaphore, а не размером пула.23 - Виртуальные потоки — инструмент пропускной способности, а не ускорения вычислений. Распараллеливание, ограниченное процессором, по-прежнему дело платформенных потоков примерно по числу ядер — фиксированный пул или parallel stream, как раньше.3
- Блокируйтесь на объекте блокировки
private finalили на выделенномReentrantLock.synchronized(this)и блокировка на публично открытом объекте могут столкнуться с внешним кодом. Если нужен захват с таймаутом (tryLock), используйтеReentrantLock. - В JDK 21-23 блокировка внутри блока
synchronizedприкрепляла виртуальные потоки; JDK 24 (JEP 491) это исправил. Отличайте старые предупреждения от нынешней реальности.34 volatileгарантирует видимость и упорядочение, не атомарность. Для счётчиков используйтеAtomicInteger/LongAdder, для составного состояния — блокировки.5- Кооперативная остановка через прерывание — единственный правильный способ остановить поток.
Thread.stop/suspend/resumeтеперь бросаютUnsupportedOperationException. Не проглатывайтеInterruptedException— восстановите статус или пробросьте.6 - Останавливайте
ExecutorServiceдвухэтапным шаблоном: shutdown → awaitTermination → shutdownNow.shutdownNow— best-effort (стандартная реализация работает через прерывание), поэтому предполагает, что задачи отвечают на прерывание.1 - Интерфейс Swing принадлежит исключительно EDT (Event Dispatch Thread). Запрашивайте обновления из других потоков через
SwingUtilities.invokeLater.7
2. Почему многопоточность трудна — гонки, взаимные блокировки и модель памяти
Сведите проблемы, которые вносит многопоточность, независимо от языка, и останутся два вида.
Гонка — ошибка, при которой результат зависит от порядка, в котором несколько потоков доходят до конкретного участка кода. Классический пример — общий счётчик: одно выражение count++ на деле распадается на три шага — прочитать, прибавить, записать обратно. Если два потока входят в эти три шага одновременно, инкремент одного потока перезаписывается и теряется при записи другого. Результат меняется на каждом запуске, и предсказать, какой получится, нельзя.
sequenceDiagram
participant A as Поток A
participant M as Общая переменная count
participant B as Поток B
Note over M: count = 10
A->>M: Чтение (10)
B->>M: Чтение (10)
A->>A: Локальное сложение (11)
B->>B: Локальное сложение (11)
A->>M: Запись (11)
B->>M: Запись (11)
Note over M: Было два инкремента,<br/>но count = 11 — инкремент потока A потерян
Рис. 1: Классическая гонка, в которой теряется инкремент общего счётчика. Если другой поток вклинивается в три шага count++, последняя запись затирает другую
Взаимная блокировка — состояние, в котором два потока ждут блокировку, которую держит другой, и никто не может продвинуться. Поток A держит блокировку 1 и ждёт блокировку 2; поток B держит блокировку 2 и ждёт блокировку 1 — этого достаточно, чтобы оба остановились навсегда.
flowchart LR
A["Поток A<br/>держит блокировку 1"] -->|"ждёт освобождения блокировки 2"| B["Поток B<br/>держит блокировку 2"]
B -->|"ждёт освобождения блокировки 1"| A
Рис. 2: Циклическое ожидание взаимной блокировки. Как только стрелки ожидания образуют петлю, каждый поток внутри этой петли останавливается навсегда
И то и другое зависит от тайминга. Переплетение, которое на машине разработки проявляется раз в десятки тысяч запусков, на продакшен-сервере с другим числом ядер и другой нагрузкой может случаться каждый день. «Перестаёт воспроизводиться, как только подключаю отладчик» и «исчезло, когда добавил логирование» — оба потому, что наблюдение меняет тайминг: классическое поведение гоночной ошибки. Именно поэтому все принципы этой статьи смотрят в сторону сокращения мест, которые требуют синхронизации, прежде чем беспокоиться о правильной синхронизации.
2.1. Специфичная для Java предпосылка — модель памяти и happens-before
Поверх этого специфика Java в том, что то, как видны общие данные, определяется отношениями happens-before в Java Memory Model (JMM).
Чтение и запись общей переменной без синхронизации не становятся «неопределённым поведением» в стиле C++, но законно могут привести к тому, что продолжают быть видны устаревшие значения или записи появляются в другом порядке. Ошибка согласованности памяти, при которой «цикл смотрит на флаг boolean, но значение, изменённое другим потоком, так и не становится видимым», — поведение, которое JMM разрешает, а не ошибка JVM.5 Инструменты, которые от этого защищают, — механизмы, создающие отношения happens-before: synchronized, volatile и классы из java.util.concurrent. Используйте конкурентные коллекции правильно — и библиотека гарантирует вам «отношение happens-before между операцией обновления и последующим извлечением».8
Иначе говоря, практическое руководство Java можно свести к такому: не мудрите с сырой общей переменной. Для совместного доступа используйте инструменты java.util.concurrent и пусть библиотека создаёт отношения happens-before.
3. Как создавать потоки — ExecutorService и виртуальные потоки
3.1. Отделение задачи от того, как она выполняется
ExecutorService — то, что несёт принцип «не создавайте потоки сами» в Java. Он отделяет работу (Runnable / Callable) от того, как она выполняется — сколько потоков, какая очередь — и оставляет создание, повторное использование и уничтожение потоков библиотеке.1
Начиная с JDK 21 выбор способа выполнения стал простой развилкой из двух вариантов.23
flowchart TB
S["Задача, которую хотите выполнить конкурентно"] --> Q1{"Что движет задачей?"}
Q1 -->|"В основном ожидание ввода-вывода<br/>HTTP, БД, файлы"| VT["Виртуальные потоки<br/>Executors.newVirtualThreadPerTaskExecutor<br/>один на задачу, никогда не в пуле"]
Q1 -->|"Вычисление, ограниченное процессором"| PT["Фиксированный пул платформенных потоков<br/>Executors.newFixedThreadPool - примерно по числу ядер<br/>или parallel stream"]
VT --> LIMIT["Ограничивайте параллелизм к внешним сервисам<br/>через Semaphore, не размером пула"]
Рис. 3: Выбор способа выполнения начиная с JDK 21. Сначала проведите черту — «менять способ ожидания ввода-вывода, распараллеливать процессор» — затем отдайте работу, ограниченную вводом-выводом, виртуальным потокам, а процессорную — обычному пулу
На стороне, ограниченной процессором, есть одна оговорка. Executors.newFixedThreadPool ограничивает число потоков, но его очередь не ограничена. В долгоживущем сервисе, где подачи постоянно обгоняют обработку, ограничены только потоки числом ядер — задачи, скапливающиеся в очереди, и их данные продолжают есть память. В такой конфигурации либо используйте ThreadPoolExecutor напрямую, чтобы настроить ограниченную очередь плюс политику отказа, либо поставьте контроль допуска вроде Semaphore на стороне подачи, чтобы можно было дать обратное давление (тот же принцип, что в разговоре об очередях в разделе 4).
3.2. Не злоупотребляйте виртуальными потоками
Виртуальные потоки — лёгкие потоки, отвязанные от потоков ОС: во время блокирующей операции в JDK (ввод-вывод, блокировка, сон и тому подобное в стандартной библиотеке) они отпускают поток ОС, поэтому одна JVM может держать миллионы их. При этом они отпускают его не при каждом виде блокировки. Если виртуальный поток блокируется, выполняя нативный код (JNI) или внешнюю функцию, он остаётся прикреплённым к своему потоку-носителю. То, что исправил JDK 24 (JEP 491, ниже), — pinning из-за synchronized; pinning на нативной границе остаётся, поэтому нагрузка большого числа виртуальных потоков операциями, которые долго блокируются через JNI-драйвер или API устройства, исчерпает потоки-носители. Но как подчёркивает официальное руководство, они не «более быстрые потоки». Скорость выполнения кода не меняется — они дают масштаб (пропускную способность).3
Есть три дисциплины их использования.3
- Не помещайте их в пул. Виртуальные потоки дёшевы и одноразовы; «число задач = число виртуальных потоков» — правильное состояние. Кладка виртуальных потоков в
newFixedThreadPool— ошибка: используйте формуtry (var executor = Executors.newVirtualThreadPerTaskExecutor()). - Ограничивайте параллелизм через
Semaphore. Ограничение вроде «не больше 10 одновременных соединений к внешнему API» выражайте семафором, а не размером пула. - Не используйте их для работы, ограниченной процессором. Платформенные потоки примерно по числу ядер по-прежнему правильный инструмент для распараллеливания вычислений.
Заметьте, что внутри виртуального потока выполняется обычный синхронный код. Вместо того чтобы переписывать код так, как это делает async/await в .NET, философия проектирования виртуальных потоков — позволить запускать прямолинейный код «один поток на запрос» без изменений в огромном масштабе.2
3.3. Частое недоразумение — «пулинг не нужен» относится только к виртуальным потокам
Не читайте дисциплину «не помещайте их в пул» как «в Java нет пула потоков (или он неэффективен)». Реальность противоположна: пулы Java — зрелая часть стандартной библиотеки с JDK 5 (2004). Тонко настраиваемый универсальный пул ThreadPoolExecutor (создаётся через различные фабрики Executors), ForkJoinPool с кражей работы (его общий экземпляр commonPool() — цель выполнения по умолчанию для parallel stream и CompletableFuture) и ScheduledThreadPoolExecutor для периодического выполнения — по-прежнему главные игроки для работы, ограниченной процессором.
Пул по сути — оптимизация, основанная на том, что «создавать и держать поток ОС дорого, поэтому переиспользуйте его». Виртуальные потоки снимают эту предпосылку, делая стоимость создания почти нулевой, так что переиспользовать их больше незачем — точное понимание не в том, что пулинг стал неэффективным, а в том, что потоки стали достаточно лёгкими, чтобы оптимизация пулинга была не нужна. А под виртуальными потоками планировщик JDK гоняет набор потоков-носителей (потоков ОС) примерно по числу ядер как ForkJoinPool с кражей работы.2 Иначе говоря, форма «обработать огромный объём конкурентной работы небольшим пулом потоков ОС» сохраняется; только управление этим пулом перешло из рук разработчика в JVM. Справедливо сказать, что Java приходит к той же точке, что async/await в .NET, который возвращает поток в пул в момент await, не меняя формы кода.
4. Сокращение общего изменяемого состояния — разбиение, неизменяемость, конкурентные коллекции и очереди
Состязание возникает только когда есть и «несколько потоков», и «общие изменяемые данные». Число потоков задают требования, поэтому то, что может срезать проектирование, — общая часть. Есть три семейства приёмов — разбиение, сделать вещи неизменяемыми и передавать данные — и вот как их писать на Java.
Разбейте. Для параллельной агрегации вместо того, чтобы каждый поток писал в общую переменную итога, пусть каждый поток строит частичный результат и сливайте их в конце. reduce / collect у parallel stream дают именно эту форму как каркас, и LongAdder, о котором ниже, тоже реализация стратегии разбиения — внутри дробит на ячейки, чтобы размазать состязание, и суммирует их при чтении. Сокращать, как часто вы пишете в общее состояние, нужно раньше, чем писать синхронизацию правильно.
Сделайте неизменяемым. Соберите данные через record и неизменяемые коллекции (List.copyOf / Map.copyOf), которые после построения больше не переписываются, и вы можете делиться ими без синхронизации. Для конфигурации и справочных данных стандартный шаблон: когда нужно заменить, постройте новый объект и подмените ссылку volatile. Однако «выглядит только для чтения» и «является неизменяемым» — разные вещи. Методы доступа record возвращают сырые ссылки своих компонентов, и копия List.copyOf тоже поверхностная (она не дублирует объекты элементов), поэтому если элементы изменяемы, любой, у кого есть псевдоним, может переписать содержимое, и состязание остаётся. Делиться без синхронизации безопасно только когда весь граф объектов — включая элементы — неизменяем. Если участвуют изменяемые элементы, либо передайте глубокую копию, либо тоже сдвиньте элементы к record / неизменяемым типам.
Используйте составные операции конкурентных коллекций. Для шаблона ConcurrentHashMap «создать, если нет, затем вставить» используйте computeIfAbsent. Этот метод выполняет весь вызов атомарно, и если ключа нет, функция отображения вызывается ровно один раз внутри этого одного вызова.8 Гарантия отличается от ConcurrentDictionary.GetOrAdd в .NET (чья фабрика при состязании может отработать больше одного раза) — пункт, который легко путают те, кто переходит между двумя языками. Это, однако, не «ровно один раз за жизнь ключа». Если функция возвращает null или бросает, отображение не регистрируется, и функция снова выполняется при следующем вызове (то же, если запись удалили после регистрации). Если ваша инициализация не терпит дублированных побочных эффектов, спроектируйте так, чтобы функция успешно возвращала не-null. Ценой атомарности часть обновлений от других потоков блокируется, пока идёт вычисление, поэтому держите функцию отображения короткой и никогда не обновляйте эту же карту изнутри функции (обнаруженное рекурсивное обновление может бросить IllegalStateException).8
// Стандартный шаблон счётчика частоты: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
Передавайте данные через очередь. Направьте поток данных между потоками через BlockingQueue. У ArrayBlockingQueue с заданной ёмкостью put блокируется, когда она полна, давая естественное обратное давление — та же форма, что ограниченный канал в издании .NET. Даже в эпоху виртуальных потоков этот проект с ясной границей между производителем и потребителем остаётся действенным.
5. Дисциплина блокировок — synchronized и ReentrantLock
5.1. На чём блокироваться и чего не делать, держа блокировку
Мыслите единицу блокировки не как «участок кода», а как «данные». Сопоставьте один объект блокировки каждому набору изменяемых данных, который хотите защитить, и берите ту же блокировку в каждом месте, которое трогает эти данные — реальность гоночной ошибки обычно в том, что это сопоставление где-то развалилось. Избегайте synchronized(this) и synchronized(SomeClass.class), потому что внешний код может заблокировать тот же объект; вместо этого сопоставьте защищаемые данные один к одному с private final Object lock = new Object();, который никогда не выставляется наружу.
Ещё две дисциплины поверх этого. Во-первых, не делайте ничего медленного и ничего, что трогает внешний мир, держа блокировку. Ввод-вывод, вызов слушателей или запуск неизвестного кода, пока блокировка ещё удерживается, и удлиняет удержание, и рискует тем, что вызываемый попытается взять другую блокировку, создав циклическое ожидание из рис. 2. Во-вторых, зафиксируйте порядок захвата нескольких блокировок. Там, где берёте две и больше блокировок, сделайте правилом, что каждый поток берёт их в одном порядке, а для мест, где порядок гарантировать нельзя, подготовьте путь «отпустить и повторить, если не получилось» через tryLock(timeout), о котором ниже.
synchronized достаточно для «короткого простого исключения». Переходите к ReentrantLock, когда нужны следующие вещи.
- Захват с таймаутом через
tryLock(timeout)(превращение вечного зависания в отказ, который можно залогировать и обработать) - Политики справедливости, несколько
Conditionили когда хотите разнести захват и освобождение блокировки по разным методам
Используя ReentrantLock, никогда не ломайте шаблон try сразу после lock() и unlock() в блоке finally (в Java нет эквивалента RAII из C++, поэтому этот шаблон — вся дисциплина).
5.2. Виртуальные потоки и pinning — что изменилось в JDK 24
Когда виртуальные потоки только появились (JDK 21-23), было ограничение: блокировка внутри блока synchronized прикрепляла виртуальный поток к его потоку ОС (он не мог отпустить поток ОС, теряя выгоду масштаба), и частые или долгие места блокировки рекомендовалось заменять на ReentrantLock.3 Это ограничение снято, когда JEP 491 в JDK 24 переписал реализацию монитора, и synchronized больше не прикрепляет виртуальные потоки.4 Если вы на JDK 24 или новее, механически заменять synchronized как контрмеру pinning больше не нужно. Стоит проверить, не застряли ли более старые внутренние руководства на предупреждении эпохи JDK 21.
5.3. Куда встают атомики и volatile
Атомарные обновления одной переменной берут на себя AtomicInteger / AtomicLong / AtomicReference (или, для статистики, которую только инкрементируют с высокой частотой, устойчивый к состязанию LongAdder). volatile гарантирует видимость и упорядочение (happens-before), не атомарность составных операций.5 Тот же вывод, что в изданиях .NET и C++, держится и в Java: атомики для флагов и одиночных значений, блокировки для составного состояния, и не пытайтесь заставить volatile сделать это в одиночку.
6. Проектирование остановки — прерывание как общий язык
6.1. Этикет interrupt
Остановка и отмена в Java унифицированы вокруг прерывания. t.interrupt() ставит статус прерывания целевого потока, и если цель заблокирована в sleep / wait / join или подобном, бросает InterruptedException, чтобы разбудить её сразу (при этом статус прерывания сбрасывается).6 Thread.stop / suspend / resume, принудительные механизмы прошлого, принципиально небезопасны, поэтому их вызов теперь приводит к UnsupportedOperationException.6
flowchart TB
OWNER["Вызывающий останавливает - вызывает t.interrupt"] --> ST["Статус прерывания установлен"]
ST --> A["Поток считает:<br/>опрашивает Thread.interrupted в цикле"]
ST --> B["Заблокирован в sleep / wait / join:<br/>срабатывает InterruptedException и будит сразу<br/>статус сброшен"]
A --> E["Убирает за собой и заканчивает сам"]
B --> C{"Что делает catch?"}
C -->|"Может закончить сам"| E
C -->|"Не может закончить - напр. внутри библиотеки"| R["Thread.currentThread.interrupt<br/>восстанавливает статус и оставляет сигнал"]
R --> E
Рис. 4: Кооперативная остановка через прерывание. Проглатывание InterruptedException уничтожает сигнал остановки — поймав его, выбор либо «закончить», либо «восстановить»
В практике нужно помнить ровно одну дисциплину: не пишите код, который ловит InterruptedException и ничего не делает. Если можете закончить в пределах своей ответственности — закончите там; если нет — восстановите статус через Thread.currentThread().interrupt() и передайте сигнал вызывающему (см. FAQ).
6.2. Двухэтапная остановка ExecutorService
API остановки ExecutorService сидят поверх модели прерывания. shutdown() перестаёт принимать новые задачи и даёт уже поданным задачам дойти до конца; shutdownNow() пытается остановить выполняющиеся задачи. Как спецификация интерфейса это best-effort, и явно задокументировано, что стандартная реализация (например ThreadPoolExecutor) обычно отменяет через Thread.interrupt() — значит задача, которая не отвечает на прерывание, не остановится даже с shutdownNow, а если вы используете собственную реализацию Executor, нужно смотреть в её документации, как она отменяет (посылает ли прерывание вообще).1 Стандартный шаблон остановки, который показывает официальная документация, — следующий двухэтапный шаблон.1
/** Истина, когда остановка завершена. Не переходите к освобождению общих ресурсов, пока это ложь. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // Этап 1: перестать принимать новые задачи и ждать завершения
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// Этап 2: запросить отмену. Задачи, снятые до запуска,
// возвращаются, поэтому пометьте их Future отменёнными, чтобы разбудить ждущих get()
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
return false; // Остановка не завершена. Сообщите в форме, отличимой от успеха
}
}
return true;
} catch (InterruptedException ex) {
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
Thread.currentThread().interrupt(); // Восстановить и статус прерывания этого потока
return false; // Этот путь тоже может оставить остановку незавершённой
}
}
flowchart TB
S["shutdown<br/>перестаёт принимать новые задачи"] --> W1{"awaitTermination<br/>ждёт завершения"}
W1 -->|"успевает к сроку"| DONE["Остановка завершена"]
W1 -->|"таймаут"| NOW["shutdownNow<br/>посылает прерывание выполняющимся задачам<br/>ответит ли — зависит от задачи"]
NOW --> W2{"awaitTermination<br/>ждёт снова"}
W2 -->|"завершается"| DONE
W2 -->|"всё ещё не закончено"| LOG["Зафиксировать как аномалию<br/>подозревать задачу, игнорирующую прерывания"]
Рис. 5: Двухэтапная остановка ExecutorService. Поэтапный проект «вежливо подождать → запросить через прерывание → если всё ещё не закончилось, наблюдать как аномалию»
У отмены Runnable, которые возвращает shutdownNow(), есть одно ограничение. Возвращается объект, который сидел в очереди выполнения — для обычного submit это тот самый FutureTask, отданный вызывающему, но для задач, поданных через обёртку вроде ExecutorCompletionService, это обёртка внутри очереди, другой объект, чем Future вызывающего. В такой конфигурации отмена выше не завершит Future вызывающего, поэтому спроектируйте так, чтобы держать свой список Future в момент подачи и отменять их при остановке (или возвращать снятые задачи владельцу).
close() (AutoCloseable), доступный с JDK 19, упаковывает «вызвать shutdown и ждать завершения» в форму, которую можно писать через try-with-resources, и try (var executor = ...) в сочетании с виртуально-потоковым newVirtualThreadPerTaskExecutor — современная базовая форма.1 Однако close() не замена двухэтапному шаблону выше. Поскольку он ждёт завершения без таймаута, если хотя бы одна задача не отвечает на прерывание или никогда не заканчивается, поток, который пытается закрыть его, блокируется навсегда. Это инструмент для областей, где задачи конечны и гарантированно доходят до конца (подали здесь, ждут здесь); для мест вроде пути остановки приложения, где нужно, чтобы всегда заканчивалось за ограниченное время, используйте вместо этого двухэтапный шаблон с таймаутом. Отмена отдельной задачи тоже идёт через прерывание, через Future.cancel(true).
7. Поток интерфейса — EDT в Swing
Настольные приложения, независимо от языка и фреймворка, следуют правилу, что интерфейс принадлежит исключительно потоку, который им управляет. В Swing этот исключительный поток — Event Dispatch Thread (EDT): методы компонентов Swing, как правило, не потокобезопасны, и прикосновение к ним из нескольких потоков приглашает взаимные помехи потоков и ошибки согласованности памяти. Запрашивайте обновления экрана из других потоков через SwingUtilities.invokeLater на EDT, и наоборот, поскольку долгие операции на EDT замораживают интерфейс, выносите тяжёлую работу в рабочий поток через SwingWorker или подобное.7 JavaFX следует той же форме: обновления интерфейса запрашиваются на потоке приложения через Platform.runLater.
8. Проверка и отладка — дампы потоков как оружие
Нельзя рассчитывать, что тесты найдут гоночные ошибки. Обычные тесты считают успехом запуск, в котором состязания случайно не случилось. Защиту мыслите тремя слоями.
Первая линия обороны — проектирование. На ревью проверяйте таблицей: какие изменяемые данные общие, какая блокировка защищает каждый пункт (сопоставление из раздела 5.1), уникален ли порядок захвата блокировок, не проглатывает ли какой-нибудь блок catch InterruptedException, и доходит ли путь остановки (shutdown/прерывание) до каждой задачи.
Во-вторых, хорошо используйте дампы потоков. В Java есть стандартный инструмент снять «состояние каждого потока в этот застывший миг»: jstack (или jcmd <pid> Thread.print) печатает трассировки стека, а параметр -l добавляет ещё сведения о блокировках.9 Заметьте, что этот традиционный формат дампа для платформенных потоков; виртуальные потоки вашего приложения он не включает. Когда трассируете заблокированный запрос в конфигурации с виртуальными потоками (раздел 3), используйте jcmd <pid> Thread.dump_to_file -format=json <file>, который умеет дампить и виртуальные потоки.2 Базовая процедура расследования зависания — снять два-три дампа с интервалом в несколько секунд и сверить, какой блокировки ждёт каждый простаивающий поток и кто эту блокировку держит. Если логируете таймауты tryLock(timeout) (раздел 5.2), можно даже автоматизировать триггер снятия дампа.
В-третьих, растрясите под нагрузкой. Нагрузочное тестирование — долгий прогон с параллелизмом больше числа ядер, рандомизация порядка обработки, вставка искусственных задержек — практический способ повысить шанс вытянуть «попадание» гонки на машине разработки. Перед выпуском хотя бы раз прогоните тест с объёмом данных и числом потоков продакшен-масштаба.
9. Куда движется конкурентность Java — структурированная конкурентность
Короткий взгляд на полшага вперёд, чтобы закончить. Опираясь на предпосылку виртуальных потоков, Structured Concurrency (StructuredTaskScope) — которая трактует несколько подзадач как одну единицу работы и структурирует распространение отказа и отмену — в разработке и по состоянию на август 2026 всё ещё функция предпросмотра. Она пересмотрена в форму API на основе StructuredTaskScope.open() в пятом предпросмотре JDK 25 (JEP 505) и продолжается шестым предпросмотром (JEP 525) в текущем JDK 26.1011 Тем временем Scoped Values, неизменяемый обмен контекстом, который решает проблемы ThreadLocal, финализированы в JDK 25.12 Принципы этой статьи — ясные границы задач, неизменяемый обмен, кооперативная остановка — совпадают и с направлением, в котором идут эти новые API.
10. Итог — контрольный список Java
- Не осталось ли в бизнес-коде
new Thread(построен ли он наExecutorService/ виртуальных потоках)? - Направляются ли работа, ограниченная вводом-выводом, и работа, ограниченная процессором, в разные механизмы выполнения (развилка на рис. 3)?
- Не помещаются ли виртуальные потоки в пул, и ограничивается ли параллелизм через
Semaphore? - Неизменяемы ли общие данные (
record/List.copyOf) или построены на инструментахjava.util.concurrent? - Нет ли
synchronized(this)или блокировки на публично открытом объекте? - Используете ли составные операции
ConcurrentHashMap(computeIfAbsentи т. д.) и держите ли функцию отображения короткой? - Не ожидаете ли атомарности от
volatile(используют ли счётчики классы Atomic /LongAdder)? - Нет ли ни одного блока catch, проглатывающего
InterruptedException? - Следует ли остановка
ExecutorServiceдвухэтапному шаблону с таймаутом (и ограничены ли места сclose()областями, где завершение задач гарантировано)? - Сведены ли обновления интерфейса Swing/JavaFX на EDT / поток приложения?
Java — один из языков, лучше всего оснащённых инструментами конкурентности, и появление виртуальных потоков открыло путь масштабировать прямолинейный синхронный код без переписывания. Именно поэтому правильно схватить разделение труда между инструментами — какой для пропускной способности, какой для исключения и что сигналит остановку — и есть суть проектирования многопоточности в Java.
Похожие статьи
- Практические лучшие практики многопоточности: издание .NET — что решить, прежде чем добавлять потоки
- Практические лучшие практики многопоточности: издание C++ — устранение аварий структурой через RAII и jthread
- Практические лучшие практики многопоточности: издание C — безопасная запись способом Win32 API
- Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait
Смежные области консультирования
KomuraSoft LLC занимается ревью проектирования многопоточности для бизнес-систем и пакетной обработки на Java, расследованием первопричин дефектов конкурентности вроде порчи общего состояния и симптомов «иногда не останавливается / зависает» (анализ дампов потоков) и техническими консультациями по внедрению виртуальных потоков.
Справочные ссылки
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). О том, что shutdown() даёт уже поданным задачам дойти до конца, переставая принимать новые; о том, что shutdownNow() пытается остановить выполняющиеся задачи и возвращает список задач, ожидавших выполнения, хотя типичная реализация отменяет через Thread.interrupt() и не даёт гарантии сверх best-effort, так что задача, не отвечающая на прерывание, не завершится; о том, что завершения можно ждать через awaitTermination; о том, что close() (AutoCloseable, с Java 19) вызывает shutdown и ждёт завершения, пригодно с try-with-resources; и о том, что двухэтапная остановка shutdown → awaitTermination → shutdownNow показана как пример использования. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
OpenJDK, JEP 444: Virtual Threads. О том, что виртуальные потоки стали официальной функцией в JDK 21; о том, что это лёгкие потоки, которые резко снижают усилия по написанию, сопровождению и наблюдению за конкурентными приложениями с высокой пропускной способностью; о философии проектирования масштабировать прямолинейный синхронный код «один запрос — один поток» без изменений; о том, что планировщик виртуальных потоков JDK — ForkJoinPool с кражей работы в режиме FIFO, с параллелизмом по умолчанию, равным числу доступных процессоров; и о том, что добавлен новый формат дампа потоков, включающий виртуальные потоки, как jcmd Thread.dump_to_file (в виде простого текста и JSON), тогда как традиционные дампы потоков виртуальные потоки не включают. ↩ ↩2 ↩3 ↩4 ↩5
-
Oracle Java SE Core Libraries, Virtual Threads. О том, что виртуальные потоки — лёгкие потоки, реализуемые средой выполнения Java, которые отпускают поток ОС во время блокирующего ввода-вывода; о том, что это функция масштаба (пропускной способности), а не скорости (задержки), и она не подходит для процессорно-интенсивной обработки; о том, что виртуальные потоки никогда не помещают в пул, а используют по одному на задачу (newVirtualThreadPerTaskExecutor); о том, что для ограничения параллелизма используют Semaphore, а не пул потоков; о том, что блокировка внутри synchronized по состоянию на JDK 21 вызывала pinning к потоку ОС, для чего частые или долгие места советовали заменять на ReentrantLock; и о том, что pinning можно обнаружить через -Djdk.tracePinnedThreads. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. О том, что реализация монитора JVM в JDK 24 переписана для поддержки виртуальных потоков, так что блокировка внутри блока или метода synchronized больше не прикрепляет виртуальный поток к его потоку-носителю; и о том, что контрмера эпохи JDK 21-23 «заменить synchronized на ReentrantLock» в принципе больше не нужна. ↩ ↩2
-
Oracle, The Java Tutorials, Memory Consistency Errors. О том, что ошибки согласованности памяти возникают, когда несколько потоков имеют несогласованные представления одних и тех же данных; о том, что ключ к их избежанию — отношение happens-before (гарантия, что запись в память одним оператором видна другому оператору); и о том, что synchronized, volatile и Thread.start / join среди прочих создают отношения happens-before. ↩ ↩2 ↩3
-
Oracle, Thread (Java SE 21 & JDK 21 API). О том, что Thread.stop / suspend / resume принципиально небезопасны (блокировки освобождаются в несогласованном состоянии и сломанные объекты становятся видимы; suspend может пригласить взаимную блокировку), поэтому объявлены устаревшими к удалению и теперь при вызове бросают UnsupportedOperationException; о том, что interrupt() ставит статус прерывания и будит поток, заблокированный в sleep / wait / join, бросая InterruptedException (что сбрасывает статус прерывания); и о различии в том, как interrupted() и isInterrupted() обращаются с этим статусом. ↩ ↩2 ↩3
-
Oracle, The Java Tutorials, The Event Dispatch Thread. О том, что код обработки событий Swing выполняется на Event Dispatch Thread (EDT); о том, что методы большинства объектов Swing не потокобезопасны, так что вызов их из нескольких потоков приглашает взаимные помехи потоков и ошибки согласованности памяти, а значит доступ к компонентам Swing следует, как правило, выполнять на EDT; о том, что задачи на EDT из других потоков запрашивают через SwingUtilities.invokeLater / invokeAndWait; и о том, что задачи, выполняющиеся на EDT, должны заканчиваться быстро. ↩ ↩2
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). О том, что весь вызов метода computeIfAbsent выполняется атомарно, и функция отображения при отсутствии ключа вызывается ровно один раз; о том, что во время вычисления часть операций обновления других потоков блокируется, поэтому её следует держать короткой и простой; о том, что функции отображения запрещено изменять эту карту саму, а обнаруженное рекурсивное обновление приводит к IllegalStateException; и о том, что операция извлечения (get) не блокируется, а между обновлением для данного ключа и последующим извлечением выполняется отношение happens-before. ↩ ↩2 ↩3
-
Oracle, The jstack Command (Java SE 21 Tools Reference). О том, что jstack печатает трассировки стека (имя класса, имя метода, номер строки) каждого потока в указанном процессе Java; о том, что параметр -l включает подробный вывод с дополнительными сведениями о блокировках; и о том, что его используют вместе с другими диагностическими инструментами вроде jcmd. ↩
-
OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). О том, что API структурированной конкурентности трактует группу связанных подзадач как одну единицу работы и структурирует распространение ошибок и отмену; о том, что StructuredTaskScope пересмотрен в форму, открываемую через статический фабричный метод (open); и о том, что по состоянию на JDK 25 это пятый предпросмотр, ещё не финализированная функция. ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). О том, что структурированная конкурентность продолжается как шестой предпросмотр и в JDK 26 — то есть по состоянию на август 2026 всё ещё функция предпросмотра в текущем JDK, для использования которой нужно включить функции предпросмотра. ↩
-
OpenJDK, JEP 506: Scoped Values. О том, что Scoped Values финализированы в JDK 25; и о том, что это механизм безопасно и эффективно делиться неизменяемыми контекстными данными внутри потоков и между ними, дающий решение проблем ThreadLocal (изменяемость, управление жизненным циклом и стоимость наследования). ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Практические рекомендации по многопоточности: издание C — безопасное письмо в духе Win32 API
Устоявшийся подход к многопоточности на C с Win32 — создание потоков через _beginthreadex, SRW-блокировки и условные переменные, функции ...
Практические лучшие практики многопоточности: издание C++ — устранять аварии структурой с RAII и jthread
В C++ многопоточность — мир, где гонка данных есть неопределённое поведение. Статья разбирает ловушку деструктора std::thread, проектиров...
Когда не стоит переводить Windows-приложение в веб: таблица решений и «разделение» как реальный выход
Запросов на перевод корпоративных Windows-приложений в веб становится всё больше, но для приложений с интеграцией оборудования, локальной...
Win32 Thread Pool API — параллелизм без создания потоков через CreateThreadpoolWork
По всему нативному коду разбросаны вызовы CreateThread? Статья разбирает Thread Pool API Win32, переработанный в Vista, — четыре объекта ...
Приложения, которые ломаются после выхода из сна — как работают события питания Windows и как строить бизнес-приложения, которые это переживают
Открыли ноутбук — и соединения бизнес-приложения мертвы: причина в проекте, который не учитывал сон. Статья разбирает поток уведомлений W...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Если теперь есть виртуальные потоки, пул потоков (ExecutorService) больше не нужен?
- Зависит от сценария. Виртуальные потоки — механизм для запуска огромного числа задач, в которых преобладает ожидание ввода-вывода; они не делают код быстрее, а повышают пропускную способность. Для работы, ограниченной вводом-выводом, используйте один виртуальный поток на задачу (Executors.newVirtualThreadPerTaskExecutor) и никогда не помещайте виртуальные потоки в пул. С другой стороны, для распараллеливания вычислений, которые загружают процессор под завязку, по-прежнему подходит пул платформенных потоков, ограниченный примерно числом ядер (или parallel stream). А если нужно ограничить число одновременных соединений с внешним сервисом, рекомендация эпохи виртуальных потоков — ограничивать это Semaphore, а не размером пула.
- Что выбирать: synchronized или ReentrantLock?
- Для короткого простого исключения synchronized достаточно, и код остаётся сжатым. Выбирайте ReentrantLock, когда нужны возможности вроде захвата с таймаутом через tryLock, политики справедливости или нескольких Condition. Есть историческая оговорка при сочетании с виртуальными потоками: в JDK 21-23 была проблема, при которой блокировка внутри блока synchronized прикрепляла виртуальный поток к его потоку ОС, поэтому частые или долгие места блокировки рекомендовалось заменять на ReentrantLock. JDK 24 (JEP 491) переписал реализацию монитора и снял это ограничение. Начиная с JDK 24 заменять synchronized из-за pinning не нужно.
- Делает ли добавление volatile что-то потокобезопасным?
- Нет. volatile в Java создаёт отношение happens-before между записью в эту переменную и её чтением, гарантируя видимость (что другие потоки видят последнюю запись) и упорядочение, но не гарантирует атомарность составной операции вроде «прочитать, вычислить, записать обратно». Инкрементируйте volatile int-счётчик через ++ из нескольких потоков — сложения теряются. Для счётчиков используйте AtomicInteger / AtomicLong (или LongAdder для высокочастотной агрегации), а для совместной защиты нескольких переменных — блокировку. volatile уместен почти только в ситуациях вроде простого флага состояния — где один поток пишет, а остальные только читают.
- Можно ли перехватывать InterruptedException и игнорировать его?
- Нет. Прерывание — стандартный сигнал Java для остановки и отмены, и его проглатывание создаёт поток, который не останавливается. К моменту, когда бросается InterruptedException, статус прерывания уже сброшен, поэтому если вы не можете закончить работу сами, либо восстановите статус через Thread.currentThread().interrupt(), чтобы оставить сигнал вызывающему, либо пробросьте исключение как есть. Пустой блок catch, который ничего не делает, — классическая причина ошибок, когда остановка не срабатывает или shutdownNow игнорируется.
- Разве нельзя остановить поток через Thread.stop?
- Нет, нельзя. Thread.stop принципиально небезопасен (освобождает блокировки, оставляя их в несогласованном состоянии и открывая сломанные объекты другим потокам), поэтому давно объявлен устаревшим, а вызов в современном Java бросает UnsupportedOperationException. То же относится к Thread.suspend / resume. Единственный законный способ остановить поток — кооперативная остановка через прерывание. Если вы используете ExecutorService, shutdown лишь перестаёт принимать новые задачи и ждёт завершения — он не посылает прерывание выполняющимся задачам. Останавливать выполняющиеся задачи пытается shutdownNow, и это best-effort (стандартная реализация работает через прерывание).
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.