Практики многопоточности на Java: что считать нормой в эпоху виртуальных потоков

· Обновлено: · · Многопоточность, Java, Бизнес-приложения, Расследование сбоев, Проектирование

История изменений (1 обновлений, последнее 31 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан по текущему навыку технического перевода как полный перевод японского оригинала.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.22175940)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Практики многопоточности на Java: что считать нормой в эпоху виртуальных потоков. KomuraSoft LLC. https://comcomponent.com/ru/blog/multithreading-best-practices-java/

DOI (зарегистрированный архив)
10.5281/zenodo.22175940
DOI (последняя зарегистрированная версия)
10.5281/zenodo.22175941

«Хотим распараллелить пакетную обработку бизнес-системы на 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
  • Виртуальные потоки — инструмент пропускной способности, а не ускорения вычислений. CPU-bound параллелизм по-прежнему делают платформенными потоками примерно по числу ядер: фиксированный пул или parallel stream.3
  • Блокировку берите на private final объекте или на отдельном ReentrantLock. synchronized(this) и блокировка на публично доступном объекте легко сталкиваются с чужим кодом. Если нужен захват с тайм-аутом (tryLock), это ReentrantLock.
  • В JDK 21–23 блокировка внутри synchronized прикрепляла виртуальные потоки к потоку ОС; в JDK 24 (JEP 491) это сняли. Отличайте старые предупреждения от нынешней реальности.34
  • volatile даёт видимость и порядок, но не атомарность. Счётчики — AtomicInteger / LongAdder, составное состояние — блокировка.5
  • Единственный правильный способ остановить поток — кооперативное завершение через прерывание (interrupt). Thread.stop / suspend / resume сейчас бросают UnsupportedOperationException. Не глушите InterruptedException: восстановите статус или пробросьте дальше.6
  • ExecutorService останавливайте двухэтапным шаблоном: shutdown → awaitTermination → shutdownNow. shutdownNow — best-effort (в стандартной реализации через прерывание), поэтому задачи должны на прерывание отвечать.1
  • UI в Swing принадлежит исключительно EDT (Event Dispatch Thread). Обновления из других потоков поручайте через SwingUtilities.invokeLater.7

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 28, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Почему многопоточность сложна: состояние гонки, взаимная блокировка и модель памяти

Проблемы, которые приносит многопоточность, в любом языке сводятся к двум видам.

Состояние гонки (race condition) — ошибка, при которой результат зависит от того, в каком порядке несколько потоков доходят до одного и того же участка кода. Классический пример — общий счётчик: одно выражение count++ на деле распадается на три шага — прочитать, прибавить, записать обратно. Если два потока входят в эти три шага одновременно, инкремент одного затирается записью другого и пропадает. Результат меняется от запуска к запуску, и какой именно получится — предсказать нельзя.

Поток BОбщая переменная countПоток AПоток BОбщая переменная countПоток Acount = 10Два инкремента, но count = 11инкремент потока A потерянЧтение (10)Чтение (10)Сложение у себя (11)Сложение у себя (11)Запись (11)Запись (11)

Рис. 1: Классическая гонка, в которой теряется инкремент общего счётчика. Если другой поток вклинивается между тремя шагами count++, более поздняя запись затирает более раннюю.

Взаимная блокировка (deadlock) — состояние, в котором два потока ждут блокировку, которую держит другой, и никто не может продвинуться. Поток A держит блокировку 1 и ждёт блокировку 2; поток B держит блокировку 2 и ждёт блокировку 1 — этого достаточно, чтобы оба остановились навсегда.

ждёт освобождения блокировки 2ждёт освобождения блокировки 1Поток Aдержит блокировку 1Поток Bдержит блокировку 2

Рис. 2: Циклическое ожидание при взаимной блокировке. Как только стрелки ожидания замыкаются в кольцо, все потоки внутри этого кольца останавливаются навсегда.

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

2.1. Что специфично для Java: модель памяти и happens-before

Поверх этого специфика Java в том, как видны общие данные: это задают отношения happens-before в модели памяти Java (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. Отделить задачу от того, как она выполняется

В Java принцип «не создавайте потоки сами» несёт ExecutorService. Он отделяет работу (Runnable / Callable) от способа выполнения — сколько потоков, какая очередь — и оставляет создание, повторное использование и уничтожение потоков библиотеке.1

Начиная с JDK 21 выбор способа выполнения стал простой развилкой из двух вариантов.23

В основном ждёт ввода-выводаHTTP, БД, файлыСчёт, который грузит процессорЕсть задача, которую хотите выполнить конкурентноЧем занята задача?Виртуальные потокиExecutors.newVirtualThreadPerTaskExecutor()один на задачу, в пул не кластьФиксированный пул платформенных потоковExecutors.newFixedThreadPool (примерно по числу ядер)или parallel streamОграничение параллелизма к внешним сервисам —Semaphore, а не размер пула

Рис. 3: Выбор способа выполнения начиная с JDK 21. Сначала проведите черту: «менять способ ожидания ввода-вывода, распараллеливать процессор». I/O-bound работу отдайте виртуальным потокам, CPU-bound — обычному пулу.

На CPU-bound стороне есть одна оговорка. Executors.newFixedThreadPool ограничивает число потоков, но очередь у него неограниченная. В долгоживущем сервисе, где постановка задач постоянно обгоняет обработку, ядрами ограничены только потоки: задачи, которые копятся в очереди, и их данные продолжают есть память. В такой схеме либо собирайте ThreadPoolExecutor напрямую — очередь с ёмкостью плюс политика отказа, — либо поставьте на стороне постановщика ограничение входа вроде Semaphore, чтобы можно было дать backpressure (тот же принцип, что у очередей в разделе 4).

3.2. Как не ошибиться с виртуальными потоками

Виртуальные потоки — лёгкие потоки, отвязанные от потоков ОС. Во время блокирующей операции JDK (ввод-вывод, блокировка, sleep и тому подобное в стандартной библиотеке) они отпускают поток ОС, поэтому одна JVM может держать миллионы таких потоков. Но отпускают его не при любой блокировке. Если виртуальный поток блокируется в нативном коде (JNI) или при вызове foreign function, он остаётся прикреплённым к своему потоку-носителю (carrier). JDK 24 (JEP 491, ниже) снял pinning из-за synchronized; pinning на нативной границе остаётся. Если на большое число виртуальных потоков повесить долгую блокировку через JNI-драйвер или API устройства, потоки-носители закончатся. И как подчёркивает официальное руководство, это не «быстрые потоки». Скорость выполнения кода не меняется — они дают масштаб (пропускную способность).3

Дисциплина использования — три пункта.3

  1. Не кладите их в пул. Виртуальные потоки дешёвые и одноразовые; правильное состояние — «число задач = число виртуальных потоков». Кладка виртуальных потоков в newFixedThreadPool — ошибка. Форма такая: try (var executor = Executors.newVirtualThreadPerTaskExecutor()).
  2. Ограничивайте параллелизм через Semaphore. Ограничение вроде «не больше 10 одновременных соединений к внешнему API» выражайте семафором, а не размером пула.
  3. Не используйте их для CPU-bound работы. Для распараллеливания вычислений по-прежнему лучше платформенные потоки примерно по числу ядер.

Заметьте: внутри виртуального потока выполняется обычный синхронный код. В отличие от async/await в .NET, код переписывать не нужно. Идея виртуальных потоков — запускать прямолинейный код «один поток на запрос» как есть и в огромном масштабе.2

3.3. Не путайте: «пул не нужен» относится только к виртуальным потокам

Из правила «не кладите их в пул» не следует, что «в Java нет пула потоков (или он неэффективен)». Наоборот: пулы Java — зрелая часть стандартной библиотеки с JDK 5 (2004). Универсальный пул с тонкой настройкой ThreadPoolExecutor (его создают фабрики Executors), work-stealing пул ForkJoinPool (общий экземпляр commonPool() — исполнение по умолчанию для parallel stream и CompletableFuture) и ScheduledThreadPoolExecutor для периодического запуска — для CPU-bound работы они и сейчас главные.

Пул изначально — оптимизация вида «создавать и держать поток ОС дорого, поэтому переиспользуйте его». Виртуальные потоки почти обнуляют стоимость создания и снимают эту предпосылку, так что переиспользовать их больше незачем. Точная формулировка не «пулинг стал неэффективным», а «потоки стали достаточно лёгкими, чтобы эта оптимизация была не нужна». При этом под виртуальными потоками планировщик JDK как раз крутит набор потоков-носителей (потоков ОС) примерно по числу ядер как work-stealing 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 блокируется, когда очередь полна, и получается естественный backpressure — та же схема, что bounded-канал в статье по .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, не ломайте шаблон: сразу после lock() — try, в finally — unlock() (в 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 сведены к прерыванию (interruption). t.interrupt() ставит статус прерывания целевого потока; если цель заблокирована в sleep / wait / join или подобном, бросает InterruptedException и будит её сразу (при этом статус прерывания сбрасывается).6 Thread.stop / suspend / resume, когда-то принудительные средства, по сути небезопасны, поэтому их вызов теперь приводит к UnsupportedOperationException.6

Может закончить самНе может (например, внутри библиотеки)Кто останавливает: t.interrupt()Статус прерывания установленПоток считает:в цикле проверяет Thread.interrupted()Заблокирован в sleep / wait / join:бросается InterruptedException и поток сразу просыпается(статус сброшен)Делает зачистку и заканчивает самЧто делает catch?Thread.currentThread().interrupt()восстанавливает статус и оставляет сигнал

Рис. 4: Кооперативное завершение через прерывание. Если глушить InterruptedException, сигнал остановки пропадает: поймав его, выбор либо «закончить», либо «восстановить».

В практике достаточно одной дисциплины: не пишите код, который ловит InterruptedException и ничего не делает. Если можете закончить в пределах своей ответственности — закончите там; если нет — восстановите статус через Thread.currentThread().interrupt() и передайте сигнал вызывающему (см. FAQ).

6.2. Двухэтапное завершение ExecutorService

API остановки ExecutorService сидят поверх модели прерывания. shutdown() перестаёт принимать новые задачи и даёт уже принятым доработать; shutdownNow() пытается остановить выполняющиеся задачи. Как спецификация интерфейса это best-effort, и явно сказано, что стандартная реализация (например ThreadPoolExecutor) обычно отменяет через Thread.interrupt(). Значит, задача, которая не отвечает на прерывание, не остановится даже с shutdownNow. Если вы используете собственную реализацию Executor, смотрите в её документации, как она отменяет (посылает ли прерывание вообще).1 Устоявшийся шаблон остановки из официальной документации — следующий двухэтапный.1

/** true, если остановка завершена. Пока false, к освобождению общих ресурсов переходить нельзя. */
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;                   // Этот путь тоже может оставить остановку незавершённой
    }
}
успевает к срокутайм-аутзавершаетсявсё ещё не законченоshutdown()перестаёт принимать новые задачиawaitTerminationждёт завершенияОстановка завершенаshutdownNow()шлёт interrupt выполняющимся задачам(ответит ли — зависит от задачи)awaitTerminationждёт сноваЗафиксировать как аномалию(подозревать задачу, которая не отвечает на прерывание)

Рис. 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. Поток UI: EDT в Swing

В настольных приложениях, независимо от языка и фреймворка, действует правило: интерфейс принадлежит исключительно потоку, который им управляет. В Swing этот поток — Event Dispatch Thread (EDT). Методы компонентов Swing, как правило, не потокобезопасны: обращение из нескольких потоков даёт интерференцию потоков и ошибки согласованности памяти. Обновления экрана из других потоков поручайте EDT через SwingUtilities.invokeLater. Наоборот, долгая работа на 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 <файл>, который умеет дампить и виртуальные потоки.2 Базовая процедура расследования зависания: снять два-три дампа с интервалом в несколько секунд и сверить, какой блокировки ждёт каждый простаивающий поток и кто эту блокировку держит. Если логируете тайм-ауты tryLock(timeout) (раздел 5.2), триггер снятия дампа можно даже автоматизировать.

В-третьих, растрясите под нагрузкой. Стресс-тест — долгий прогон с параллелизмом больше числа ядер, рандомизация порядка обработки, вставка искусственных задержек — практический способ повысить шанс поймать гонку на машине разработки. Перед выпуском хотя бы раз прогоните испытание с объёмом данных и числом потоков боевого масштаба.

9. Что дальше в конкурентности Java: структурированная конкурентность

На полшага вперёд, чтобы закончить. Опираясь на виртуальные потоки, Structured Concurrency (StructuredTaskScope) трактует несколько подзадач как одну единицу работы и структурирует распространение отказа и отмену. На август 2026 это всё ещё preview. В пятом preview JDK 25 (JEP 505) API перевели на форму StructuredTaskScope.open(), и в текущем JDK 26 она продолжается шестым preview (JEP 525).1011 Тем временем Scoped Values — неизменяемый обмен контекстом, который закрывает проблемы ThreadLocal, — финализированы в JDK 25.12 Принципы этой статьи (явные границы задач, неизменяемый обмен, кооперативная остановка) совпадают с тем направлением, куда идут эти новые API.

10. Итог: контрольный список Java

  1. Не осталось ли в прикладном коде new Thread (построен ли он на ExecutorService / виртуальных потоках)?
  2. Разведены ли I/O-bound и CPU-bound по разным механизмам выполнения (развилка на рис. 3)?
  3. Не кладутся ли виртуальные потоки в пул, и выражен ли лимит параллелизма через Semaphore?
  4. Неизменяемы ли общие данные (record / List.copyOf) или они опираются на инструменты java.util.concurrent?
  5. Нет ли synchronized(this) или блокировки на публично доступном объекте?
  6. Используете ли составные операции ConcurrentHashMap (computeIfAbsent и т. д.) и держите ли функцию отображения короткой?
  7. Не ждёте ли атомарности от volatile (счётчики — атомарные классы / LongAdder)?
  8. Нет ли ни одного catch, который глушит InterruptedException?
  9. Следует ли остановка ExecutorService двухэтапному шаблону с тайм-аутом (и ограничены ли места с close() областями, где доработка задач гарантирована)?
  10. Сведены ли обновления UI в Swing/JavaFX на EDT / поток приложения?

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

Похожие статьи

Смежные области консультирования

В KomuraSoft LLC мы делаем ревью многопоточного проектирования бизнес-систем и пакетной обработки на Java, разбираем дефекты конкурентности вроде порчи общего состояния и симптомов «иногда не останавливается / зависает» (анализ дампа потоков) и консультируем по внедрению виртуальных потоков.

Справочные ссылки

  1. 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

  2. OpenJDK, JEP 444: Virtual Threads. О том, что виртуальные потоки стали официальной возможностью в JDK 21; что это лёгкие потоки, которые резко снижают усилия на написание, сопровождение и наблюдение за конкурентными приложениями с высокой пропускной способностью; о философии масштабировать прямолинейный синхронный код «один запрос — один поток» без переписывания; о том, что планировщик виртуальных потоков JDK — work-stealing ForkJoinPool в режиме FIFO, с параллелизмом по умолчанию, равным числу доступных процессоров; и о том, что добавлен новый формат дампа потоков, включающий виртуальные потоки, как jcmd Thread.dump_to_file (простой текст и JSON), тогда как традиционные дампы потоков виртуальные потоки не включают. ↩ ↩2 ↩3 ↩4 ↩5

  3. Oracle Java SE Core Libraries, Virtual Threads. О том, что виртуальные потоки — лёгкие потоки, которые реализует среда выполнения Java и которые отпускают поток ОС во время блокирующего ввода-вывода; что это возможность масштаба (пропускной способности), а не скорости (задержки), и она не подходит для процессорно-интенсивной обработки; что виртуальные потоки никогда не кладут в пул, а используют по одному на задачу (newVirtualThreadPerTaskExecutor); что для ограничения параллелизма берут Semaphore, а не пул потоков; что по состоянию на JDK 21 блокировка внутри synchronized вызывала pinning к потоку ОС, и для частых или долгих мест советовали заменять на ReentrantLock; и что pinning можно обнаружить через -Djdk.tracePinnedThreads. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. О том, что в JDK 24 реализацию монитора JVM переписали под виртуальные потоки, так что блокировка внутри блока или метода synchronized больше не прикрепляет виртуальный поток к потоку-носителю; и о том, что мера эпохи JDK 21–23 «заменить synchronized на ReentrantLock» в принципе больше не нужна. ↩ ↩2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. О том, что ошибки согласованности памяти возникают, когда несколько потоков имеют несогласованные представления одних и тех же данных; что ключ к их избежанию — отношение happens-before (гарантия, что запись в память одним оператором видна другому оператору); и что synchronized, volatile и Thread.start / join среди прочего создают отношения happens-before. ↩ ↩2 ↩3

  6. Oracle, Thread (Java SE 21 & JDK 21 API). О том, что Thread.stop / suspend / resume по сути небезопасны (блокировки отпускаются в несогласованном состоянии и становятся видны повреждённые объекты; suspend может привести к взаимной блокировке), поэтому объявлены устаревшими к удалению и сейчас при вызове бросают UnsupportedOperationException; что interrupt() ставит статус прерывания и будит поток, заблокированный в sleep / wait / join, бросая InterruptedException (при этом статус прерывания сбрасывается); и о том, как по-разному interrupted() и isInterrupted() обращаются с этим статусом. ↩ ↩2 ↩3

  7. Oracle, The Java Tutorials, The Event Dispatch Thread. О том, что код обработки событий Swing выполняется на Event Dispatch Thread (EDT); что методы большинства объектов Swing не потокобезопасны, так что вызов их из нескольких потоков даёт интерференцию потоков и ошибки согласованности памяти, а значит доступ к компонентам Swing следует, как правило, выполнять на EDT; что задачи на EDT из других потоков поручают через SwingUtilities.invokeLater / invokeAndWait; и что задачи на EDT должны заканчиваться быстро. ↩ ↩2

  8. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). О том, что весь вызов computeIfAbsent выполняется атомарно, и функция отображения при отсутствии ключа вызывается ровно один раз; что во время вычисления часть операций обновления других потоков блокируется, поэтому вычисление следует держать коротким и простым; что функции отображения запрещено изменять эту же карту, а обнаруженное рекурсивное обновление приводит к IllegalStateException; и что операция чтения (get) не блокируется, а между обновлением для данного ключа и последующим чтением выполняется отношение happens-before. ↩ ↩2 ↩3

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). О том, что jstack печатает трассировки стека (имя класса, имя метода, номер строки) каждого потока в указанном процессе Java; что параметр -l включает подробный вывод с дополнительными сведениями о блокировках; и что его используют вместе с другими диагностическими инструментами вроде jcmd. ↩

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). О том, что API структурированной конкурентности трактует группу связанных подзадач как одну единицу работы и структурирует распространение ошибок и отмену; что StructuredTaskScope переведён на форму, которую открывают статическим фабричным методом (open); и что по состоянию на JDK 25 это пятый preview, ещё не финализированная возможность. ↩

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). О том, что структурированная конкурентность продолжается как шестой preview и в JDK 26 — то есть на август 2026 это всё ещё preview в текущем JDK, и для использования нужно включить preview-возможности. ↩

  12. OpenJDK, JEP 506: Scoped Values. О том, что Scoped Values финализированы в JDK 25; и что это механизм безопасно и эффективно делиться неизменяемыми контекстными данными внутри потоков и между ними — решение проблем ThreadLocal (изменяемость, управление временем жизни и стоимость наследования). ↩

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Если есть виртуальные потоки, пул потоков (ExecutorService) уже не нужен?
Зависит от задачи. Виртуальные потоки нужны, чтобы запускать сразу очень много задач, где преобладает ожидание ввода-вывода: они не ускоряют сам код, а повышают пропускную способность. Для I/O-bound работы берите один виртуальный поток на задачу (Executors.newVirtualThreadPerTaskExecutor) и не кладите виртуальные потоки в пул. Для распараллеливания вычислений, которые загружают процессор, по-прежнему лучше пул платформенных потоков примерно по числу ядер (или parallel stream). Если нужно ограничить число одновременных обращений к внешнему сервису, в эпоху виртуальных потоков это делают через Semaphore, а не размером пула.
Что выбирать: synchronized или ReentrantLock?
Для короткой и простой синхронизации хватает synchronized, и код остаётся коротким. ReentrantLock берите, когда нужны захват с тайм-аутом через tryLock, политика справедливости или несколько Condition. Историческая оговорка для виртуальных потоков: в JDK 21–23 блокировка внутри synchronized прикрепляла виртуальный поток к потоку ОС (pinning), поэтому частые или долгие блокировки советовали заменять на 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, который ничего не делает, — типичная причина, почему shutdown не срабатывает и shutdownNow игнорируется.
Разве нельзя остановить поток через Thread.stop?
Нельзя. Thread.stop по сути небезопасен: он отпускает блокировки в несогласованном состоянии, и другим потокам становятся видны повреждённые объекты. Поэтому он давно объявлен устаревшим, а в современном Java вызов бросает UnsupportedOperationException. То же относится к Thread.suspend / resume. Единственный корректный способ остановить поток — кооперативное завершение через прерывание (interrupt). Если вы используете ExecutorService, shutdown только перестаёт принимать новые задачи и ждёт, пока уже принятые доработают: выполняющимся задачам он прерывание не шлёт. Останавливать выполняющиеся задачи пытается shutdownNow, и это best-effort (в стандартной реализации — через прерывание).

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог