У чому різниця між 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при виконанні блокуючих мережевих запитів.