Сервер на восьми ядрах принимает поток заявок, и почти каждая заявка половину времени ждёт ответа внешнего сервиса. Интуиция подсказывает держать побольше рабочих потоков, но сколько именно и кто будет ими управлять. В Java за порядок отвечает ExecutorService из пакета java.util.concurrent, появившийся в Java 5 в 2004 году, неблокирующие цепочки строит CompletableFuture из Java 8, а в Java 21 добавились виртуальные потоки. Разберём все три механики с кодом, расчётами и граблями, на которые наступает почти каждый.
Пул потоков ExecutorService снимает с приложения ручное создание и остановку потоков
Прямое управление через new Thread выглядит просто до первого происшествия на бою. Поток в JVM это обёртка над потоком операционной системы: он живёт собственным жизненным циклом, требует памяти под стек, по умолчанию около мегабайта, и дорого стоит как при создании, так и при переключении контекста. Когда приложение порождает поток на каждую заявку, память заканчивается раньше, чем сами заявки.
ExecutorService разворачивает модель наоборот: разработчик описывает задачу и передаёт её в пул, а пул решает, запустить задачу сразу, поставить в очередь или отклонить. Метод submit отправляет задачу и возвращает Future, invokeAll ждёт целого набора, invokeAny возвращает результат самой быстрой. Задачей служит объект Runnable, который ничего не возвращает, или Callable, который отдаёт значение и умеет завершаться ошибкой, поэтому в прикладном коде живут почти исключительно Callable.
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Callable<Integer>> jobs = new ArrayList<>();
for (int i = 1; i <= 20; i++) {
int n = i;
jobs.add(() -> n * n);
}
long total = 0;
for (Future<Integer> f : pool.invokeAll(jobs)) {
total += f.get();
}
System.out.println(total);
Двадцать квадратов посчитаются четырьмя потоками, итог составит 2870. invokeAll блокирует вызвавший поток до готовности набора, поэтому в запросах его держат под таймаутом.
Аккуратная остановка идёт в три шага: shutdown прекращает приём новых задач, awaitTermination даёт текущим доработать в пределах лимита, shutdownNow прерывает застрявшие. С Java 19 интерфейс реализует AutoCloseable, и пул открывают в try-with-resources, получая гарантированное ожидание завершения.
Фабричные методы Executors закрывают типовые сценарии без кастомной настройки пула
Рядовому сервису вручную конфигурировать исполнитель обычно не нужно, у класса Executors есть готовые фабрики под характер нагрузки, различающиеся политикой создания и повторного использования потоков:
- newFixedThreadPool создаёт пул фиксированного размера, где потоки переживают задачу и берут следующую из очереди, вариант для стабильной фоновой нагрузки;
- newCachedThreadPool заводит новый поток под каждую задачу при занятых старых и держит освободившиеся 60 секунд, годится для редких всплесков;
- newSingleThreadExecutor даёт одну рабочую нить с гарантированным порядком выполнения, когда задачи обязаны идти строго по очереди;
- newScheduledThreadPool умеет запускать задачи с задержкой и по расписанию, это основа таймеров и плановых заданий;
- newWorkStealingThreadPool собирает пул на базе ForkJoinPool, где каждый поток держит собственную очередь и ворует задачи у соседей;
- newVirtualThreadPerTaskExecutor с Java 21 заводит отдельный виртуальный поток под каждую задачу и не переиспользует его вовсе.
У кэшируемого пула нет верхней границы: под наплывом он породит тысячи потоков, каждая со своим стеком, и сервер утонет в переключениях контекста. Фиксированный ограничивает аппетит, но требует ответа на вопрос размера, и правило из руководства JDK подсказывает его: задачи ждут ввод-вывод, значит потоков берут заметно больше числа ядер, задачи считают, значит по числу ядер. Веб-сервису, где заявка девяносто процентов времени спит на сетевом вызове, хватает ста-двухсот потоков на восемь ядер.
Тонкая настройка ThreadPoolExecutor начинается с понимания четырёх его параметров
Когда фабрик не хватает, строят ThreadPoolExecutor вручную. Конструктор принимает corePoolSize, maximumPoolSize, keepAliveTime, рабочую очередь, фабрику потоков и обработчик отказов. Логика роста неочевидна: новые потоки появляются сверх corePoolSize только при заполненной очереди. Отсюда сюрпризы: небольшая очередь при большом максимуме даёт агрессивный рост, а неограниченная LinkedBlockingQueue забирает весь наплыв и не даёт пулу вырасти выше ядра.
Обработчик отказов включается, когда очередь полна и потоки на максимуме. По умолчанию AbortPolicy бросает RejectedExecutionException, CallerRunsPolicy заставляет поток-отправитель выполнить задачу самому и работает как естественный клапан против лавины, DiscardPolicy молча роняет задачу, DiscardOldestPolicy вытесняет из очереди самую старую. Для пользовательских запросов выбирают осмысленный отказ.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 64, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(256),
new ThreadPoolExecutor.CallerRunsPolicy());
Такой пул держит восемь потоков в спокойном режиме, дорастает до шестидесяти четырёх при наплыве и устраивает отправителю обратное давление. Числа полезно сверять с формулой Литтла: устойчивая пропускная способность равна числу одновременных заявок, делённому на среднее время обработки. Заявка занимает полсекунды, исполнитель даёт двести обработчиков, значит потолок составит четыреста заявок в секунду, дальше начнёт расти очередь.
CompletableFuture строит цепочки преобразований вместо ожидания результата в лоб
Классический Future из Java 5 умеет одно: отдать результат через get, блокируя вызвавший поток. Стоит скомбинировать несколько вызовов, как код превращается в лес вложенных ожиданий. CompletableFuture из Java 8 переворачивает модель: программа описывает, что делать с результатом, когда он появится.
Цепочка собирается из методов с говорящими именами. thenApply преобразует значение, как map. thenCompose принимает функцию, возвращающую другой CompletableFuture, и разворачивает его, как flatMap. thenCombine сливает два независимых будущих результата. exceptionally ловит ошибку и подставляет запасное значение, handle обрабатывает оба исхода, whenComplete добавляет побочный эффект, не меняя результат.
CompletableFuture<Report> report = CompletableFuture
.supplyAsync(() -> orderService.load(orderId), ioPool)
.thenCompose(order -> itemsService.fetchAsync(order))
.thenCombine(customerService.fetchAsync(orderId),
(items, customer) -> assembler.build(items, customer))
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> Report.fallback(orderId));
ioPool в первом вызове передан не случайно. Без явного исполнителя все асинхронные шаги едут в общий ForkJoinPool.commonPool, параллельность которого по умолчанию равна числу процессоров без одного, и блокирующий вызов там это короткий путь к голоданию всего процесса.
Набором результатов управляют allOf и anyOf: первый завершается после всех этапов, второй после первого. С Java 9 появились orTimeout, завершающий цепочку ошибкой TimeoutException по лимиту, и completeOnTimeout, подставляющий запасное значение без исключения. Разница между get и join: первый бросает проверяемые InterruptedException и ExecutionException, второй непроверяемое CompletionException, поэтому внутри стримов пишут join, а на границе модуля get.
Виртуальные потоки Java 21 меняют цену блокировки и привычные правила игры
Виртуальные потоки, ставшие частью Java 21 осенью 2023 года, устроены по образцу виртуальной памяти: JVM отображает огромное число лёгких потоков на малое число потоков операционной системы. Когда такой поток упирается в блокирующий вызов, среда снимает его с несущего платформенного потока и отдаёт носитель другой задаче. Блокировка перестаёт быть дорогой, и серверу выгодно вернуться к простой модели поток на заявку вместо асинхронных цепочек.
Документация JDK говорит прямо: виртуальные потоки не делают код быстрее, они дают масштаб. Стек такого потока живёт в куче и растёт по мере надобности, а не резервирует мегабайты заранее. Сто тысяч задач займут в куче пару сотен мегабайт, тогда как платформенные запросили бы около сотни гигабайт адресного пространства под одни стеки, и сервер, тонувший на пуле в двести потоков, получает десятки тысяч одновременных подключений на тех же восьми ядрах.
Thread.startVirtualThread(() -> sendNotification(orderId));
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(500));
return "готово " + id;
});
}
}
Десять тысяч задач одновременно существуют в памяти, дремля на паузах и не занимая носителей, а try-with-resources в конце сам вызовет close и дождётся всех.
Главное правило руководства по внедрению звучит непривычно: виртуальные потоки не пулят. Каждый из них представляет конкретную задачу, а не драгоценный ресурс, поэтому пулы для них не строят: число виртуальных потоков просто равно числу одновременных задач, как число строк в списке равно числу имён. Отсюда же совет писать простой синхронный код с блокирующими вызовами вместо реактивных цепочек, которые на виртуальных потоках выигрыша не дают.
Пиннинг виртуальных потоков и другие подводные камни на практике
У виртуальных потоков есть слабое место. Если поток блокируется внутри секции synchronized или нативного метода, среда не может снять его с несущего потока, и тот простаивает. Это называют пиннингом. Короткие секции над значениями в памяти не страшны, а синхронизация вокруг обращения к базе способна свести выигрыш на нет.
В JDK 21 проблему отслеживают двумя способами. Флаг -Djdk.tracePinnedThreads=full печатает полную трассировку при каждой такой блокировке, вариант short ограничивается проблемными кадрами, а JDK Flight Recorder ловит событие jdk.VirtualThreadPinned с порогом по умолчанию двадцать миллисекунд. Лечение точечное: в горячих местах synchronized меняют на ReentrantLock, который не мешает снятию с носителя.
private final ReentrantLock lock = new ReentrantLock();
void transfer(Account from, Account to, long amount) {
lock.lock();
try {
ledger.write(from, to, amount);
} finally {
lock.unlock();
}
}
В марте 2025 года в JDK 24 пришла переработка по JEP 491, после которой synchronized перестал зажимать несущий поток, и зона пиннинга сузилась до нативных вызовов. Для новых проектов это снимает половину ограничений, но код, который остаётся на 21 LTS, по-прежнему стоит проверять трассировкой. Второй камень это ThreadLocal: кэш дорогих объектов в локальной переменной потока задумывался для маленького пула, а при миллионах короткоживущих потоков превращается в фабрику одноразовых тяжёлых объектов. Хрестоматийный пример SimpleDateFormat, заменяемый иммутабельным DateTimeFormatter, безопасным сразу для всех потоков.
Наблюдаемость и контроль помогают выкатить многопоточный код без сюрпризов
Асинхронный код хуже поддаётся отладке, поэтому наблюдаемость закладывают заранее. Обычный дамп через jstack видит платформенные потоки, а для виртуальных с Java 21 работает jcmd с ключом Thread.dump_to_file и форматом JSON, где видны даже короткоживущие задачи. Flight Recorder отмечает жизненный цикл виртуальных потоков событиями jdk.VirtualThreadStart и jdk.VirtualThreadEnd, выключенными по умолчанию, и событием jdk.VirtualThreadSubmitFailed о нехватке ресурсов.
Перед выкаткой невелик чек-лист, экономящий недели разбирательств. Сверить размер пула с расчётом по формуле Литтла. Убедиться, что у блокирующих вызовов есть собственный исполнитель, а не общий commonPool. Прогнать сценарий отказа внешнего сервиса и посмотреть, что очереди не растут бесконечно. Задать осмысленный обработчик отказов вместо молчаливой потери. И устроить нагрузочный прогон с реальным профилем, а не с равномерным генератором.
Прагматичный итог. Новому серверному коду на Java 21 и выше разумно сразу писаться в модели поток на заявку поверх виртуальных потоков, а ExecutorService держать для вычислительных пулов. CompletableFuture остаётся незаменимым там, где нужно скомбинировать несколько асинхронных источников с таймаутами. Эти механики не соперники, они закрывают разные этажи одной задачи, и владение обеими отличает зрелого инженера от того, кто бежит за модой.