⏱️ Розділ 19 · Питання #11

У чому різниця між 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. Випадок 1 (Late registration): Якщо Потік Б викликав complete() до того, як Потік А викликав thenApply(), то Потік А виявить result != null і викличе process() всередині себе синхронно прямо в тілі виклику thenApply().
  2. Випадок 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 при виконанні блокуючих мережевих запитів.

Пов’язані теми