В чём разница между thenApply() и thenApplyAsync()
Оба метода — thenApply() и thenApplyAsync() — принимают функцию Function
🟢 Junior Level
Оба метода — thenApply() и thenApplyAsync() — принимают функцию Function<T, U> для преобразования результата одного этапа в другой. Главное различие между ними заключается в том, в каком потоке будет выполнена эта функция:
thenApply()(синхронное исполнение этапа): выполняется в том же потоке, который завершил предыдущий этап (или в вызывающем потоке, если результат уже был готов).thenApplyAsync()(асинхронное исполнение этапа): отправляет задачу на исполнение в пул потоков (ForkJoinPool.commonPool()по умолчанию или переданный кастомныйExecutor).
Пример в коде
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
System.out.println("Supply: " + Thread.currentThread().getName());
return "Hello";
});
// 1. thenApply: выполняется в том же потоке воркера (без переключения контекста)
cf.thenApply(s -> {
System.out.println("thenApply: " + Thread.currentThread().getName());
return s + " World";
});
// 2. thenApplyAsync: задача уходит в очередь пула и берётся новым свободным потоком
cf.thenApplyAsync(s -> {
System.out.println("thenApplyAsync: " + Thread.currentThread().getName());
return s + " World";
});
🟡 Middle Level
Когда что использовать
thenApply: для быстрых CPU-трансформаций
Если преобразование тривиально (парсинг строки, маппинг в DTO, конвертация типов, чтение полей), переключение потока не только избыточно, но и вредно. Вызов thenApply отрабатывает за доли наносекунд прямо в регистрах процессора и L1-кэше текущего ядра:
userFuture
.thenApply(User::getEmail)
.thenApply(String::trim)
.thenApply(String::toLowerCase);
thenApplyAsync: для тяжёлых вычислений или блокирующего I/O
Если этап вычисления сам по себе ресурсоёмкий (шифрование, сжатие, расчет хэшей, блокирующий поход в сеть/диск), выполнение его в потоке предыдущего этапа заблокирует или замедлит родительский пул. В этом случае этап изолируют в отдельном пуле:
userFuture.thenApplyAsync(
user -> cryptoService.generateHeavySignature(user),
computeExecutor // Выделенный пул для CPU-bound задач
);
Сравнительная таблица характеристик
| Характеристика | thenApply |
thenApplyAsync |
|---|---|---|
| Поток исполнения | Поток завершителя или текущий поток | Поток из Executor |
| Накладные расходы | Минимальные (~2–5 нс, прямой вызов) | Заметные (~1–5 мкс на очередь и переключение контекста) |
Контекст ThreadLocal |
Может сохраняться (если поток тот же) | Гарантированно теряется (другой поток) |
| Риск блокировки пула | Высокий (при тяжёлых операциях) | Изолирован (при использовании своего Executor) |
🔴 Senior Level
Тонкости выбора потока в thenApply
Поведение синхронного метода thenApply недетерминировано и зависит от многопоточных таймингов в рантайме:
CompletableFuture<String> cf = new CompletableFuture<>();
// Поток А (клиентский поток, например HTTP-handler в Tomcat)
cf.thenApply(data -> process(data));
// Поток Б (воркер, например слушатель RabbitMQ)
cf.complete("payload");
- Случай 1 (Late registration): Если Поток Б вызвал
complete()до того, как Поток А вызвалthenApply(), то Поток А обнаружитresult != nullи вызоветprocess()внутри себя синхронно прямо в теле вызоваthenApply(). - Случай 2 (Early registration): Если Поток А вызвал
thenApply()до того, как Поток Б вызвалcomplete(), колбэк кладётся в стекCompletion. Когда Поток Б завершит фьючерс, он извлечёт колбэк и вызоветprocess()внутри Потока Б!
[!WARNING] Если
process()содержит блокирующий код, в случае 2 он намертво заблокирует системный поток брокера или EventLoop! Именно поэтому для любых неопределённых по времени операций обязателенthenApplyAsync(..., ioExecutor).
Проблема потери контекста (MDC и SecurityContext)
В микросервисной архитектуре критически важно сквозное логирование (SLF4J MDC traceId, spanId) и контекст безопасности (SecurityContextHolder):
ThreadLocalпривязан к конкретному потоку ОС.- При вызове
thenApplyAsyncзадача переходит в другой поток пула, гдеThreadLocalпуст! Логи теряютtraceId, а вызовы методов падают с ошибками авторизации (Unauthorized).
Решение на уровне Senior/Lead:
Оборачивать передаваемый Executor в контекстно-зависимый декоратор (например, TaskDecorator в Spring или OpenTelemetry Context.taskWrapping):
public class MdcCopyingExecutor implements Executor {
private final Executor delegate;
public MdcCopyingExecutor(Executor delegate) { this.delegate = delegate; }
@Override
public void execute(Runnable command) {
Map<String, String> context = MDC.getCopyOfContextMap();
delegate.execute(() -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
if (context != null) MDC.setContextMap(context);
try {
command.run();
} finally {
if (previous != null) MDC.setContextMap(previous);
else MDC.clear();
}
});
}
}
4 Tricky Questions
1. Может ли thenApply() выполниться в главном потоке (main), даже если исходный supplyAsync() был запущен в фоновом кастомном пуле?
Ответ:
Да, может!
Если задача в фоновом пуле завершилась очень быстро (ещё до того, как поток main дошёл до строчки .thenApply(...)), вызываемый thenApply() увидит, что поле result уже заполнено. В этой ситуации рантайм CompletableFuture не ставит задачу в очередь пула, а немедленно вызывает переданную функцию синхронно прямо в текущем вызывающем потоке (то есть в main).
2. При каких условиях thenApplyAsync() без передачи Executor будет создавать новый поток ОС на каждую задачу вместо использования пула?
Ответ:
В среде, где параллелизм общего пула ForkJoinPool.commonPool() оказывается меньше или равен 1.
Это происходит:
- В одноядерных контейнерах (Docker/Kubernetes с лимитом
resources.limits.cpu <= 1). - При явной установке JVM-параметра
-Djava.util.concurrent.ForkJoinPool.common.parallelism=0. В этом случае полеCompletableFuture.USE_COMMON_POOLстановитсяfalse, и методscreenExecutor(null)начинает подставлять внутреннийThreadPerTaskExecutor, который на каждый асинхронный вызов создаёт и запускает новый системный потокnew Thread(task).start(), что может мгновенно привести кOutOfMemoryError: unable to create new native thread.
3. Почему JIT-компилятор (C2) способен заинлайнить цепочку из нескольких thenApply, но никогда не сможет сделать этого для thenApplyAsync?
Ответ:
Метод thenApply при вызове в одном потоке представляет собой прямое вычисление функций. JIT-компилятор видит поток управления, может применить устранение виртуальных вызовов (Devirtualization), заинлайнить тело лямбд и оптимизировать доступ к памяти через Scalar Replacement.
В случае thenApplyAsync метод вынужден аллоцировать узел задачи в куче, положить его в очередь задач пула потоков (WorkQueue), выполнить межпоточную синхронизацию (memory barrier / volatile writes) и переключить контекст ядра. JIT не может устранить границу между потоками.
4. Допустим, мы вызвали cf.thenApplyAsync(fn). В каком пуле выполнится fn, если cf был создан через CompletableFuture.supplyAsync(supplier, customExecutor)?
Ответ:
Функция fn выполнится в ForkJoinPool.commonPool(), а не в customExecutor!
Параметр Executor не наследуется автоматически дочерними этапами конвейера от родительских этапов. Если метод thenApplyAsync() вызван без аргумента Executor, он всегда обращается к стандартному пулу JVM по умолчанию. Чтобы операция осталась в customExecutor, его необходимо передавать явно в каждый асинхронный шаг: thenApplyAsync(fn, customExecutor).
🎯 Шпаргалка для интервью
Главное за 60 секунд
thenApplyвыполняется синхронно в потоке завершителя или в вызывающем потоке. Минимальный оверхед (~2–5 нс), подходит для лёгких преобразований данных.thenApplyAsyncотправляет задачу в пул потоков (ForkJoinPool.commonPool()или переданныйExecutor). Требует переключения контекста (~1–5 мкс), необходим для тяжёлых CPU или блокирующих задач.Executorне наследуется цепочкой: вызов*Asyncбез параметров всегда идёт вcommonPool.- При переходе в
*AsyncконтекстThreadLocal(MDC, Spring Security) теряется, если пул не оснащён декоратором.
Типичные ошибки на интервью
- Полагать, что
thenApplyAsyncвсегда лучше и “более асинхронный” (для лёгких операций это неоправданный расход ресурсов на переключение контекста). - Думать, что
thenApplyвсегда выполняется в отдельном фоновом потоке. - Забывать передавать кастомный пул в
thenApplyAsyncпри выполнении блокирующих сетевых запросов.