⏱️ Раздел 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 при выполнении блокирующих сетевых запросов.

Связанные вопросы