Сервер на восьми ядрах принимает поток заявок, и почти каждая заявка половину времени ждёт ответа внешнего сервиса. Интуиция подсказывает держать побольше рабочих потоков, но сколько именно и кто будет ими управлять. В 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 есть готовые фабрики под характер нагрузки, различающиеся политикой создания и повторного использования потоков:

  1. newFixedThreadPool создаёт пул фиксированного размера, где потоки переживают задачу и берут следующую из очереди, вариант для стабильной фоновой нагрузки;
  2. newCachedThreadPool заводит новый поток под каждую задачу при занятых старых и держит освободившиеся 60 секунд, годится для редких всплесков;
  3. newSingleThreadExecutor даёт одну рабочую нить с гарантированным порядком выполнения, когда задачи обязаны идти строго по очереди;
  4. newScheduledThreadPool умеет запускать задачи с задержкой и по расписанию, это основа таймеров и плановых заданий;
  5. newWorkStealingThreadPool собирает пул на базе ForkJoinPool, где каждый поток держит собственную очередь и ворует задачи у соседей;
  6. 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 остаётся незаменимым там, где нужно скомбинировать несколько асинхронных источников с таймаутами. Эти механики не соперники, они закрывают разные этажи одной задачи, и владение обеими отличает зрелого инженера от того, кто бежит за модой.