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

У чому різниця між thenApply() і thenApplyAsync()

Обидва методи трансформують результат CompletableFuture, але в різних потоках:

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Обидва методи трансформують результат CompletableFuture, але в різних потоках:

  • thenApply() — виконується в тому ж потоці, який завершив попередній CF
  • thenApplyAsync() — виконується в іншому потоці (ForkJoinPool або вказаний Executor)
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    System.out.println("Supply: " + Thread.currentThread().getName());
    return "Hello";
});

// thenApply — той же потік
cf.thenApply(s -> {
    System.out.println("thenApply: " + Thread.currentThread().getName());
    return s + " World";
});

// thenApplyAsync — інший потік (ForkJoinPool)
cf.thenApplyAsync(s -> {
    System.out.println("thenApplyAsync: " + Thread.currentThread().getName());
    return s + " World";
});

🟡 Middle Level

Коли що використовувати

thenApply — для легких операцій:

// Легка трансформація — немає сенсу переключати потік
cf.thenApply(s -> s.toUpperCase())
  .thenApply(s -> s.trim())
  .thenAccept(System.out::println);

thenApplyAsync — для важких операцій:

// Важка обробка — краще в іншому потоці
cf.thenApplyAsync(s -> heavyProcessing(s), executor)
  .thenAccept(System.out::println);

З своїм Executor:

ExecutorService executor = Executors.newFixedThreadPool(10);

cf.thenApplyAsync(s -> transform(s), executor);

Типові помилки

  1. thenApplyAsync без Executor: ```java // Використовує ForkJoinPool.commonPool() cf.thenApplyAsync(s -> s.toUpperCase());

// ⚠️ commonPool має обмежене число потоків // Для I/O операцій — погано

// ✅ Свій Executor cf.thenApplyAsync(s -> s.toUpperCase(), ioExecutor);


---

## 🔴 Senior Level

### Internal Implementation

**thenApply:**
```java
public <U> CompletableFuture<U> thenApply(Function<? super T,? extends U> fn) {
    return uniApplyStage(null, fn);
}

// Виконується в потоці, який завершив попередній CF
// Немає переключення потоку — мінімальний overhead

thenApplyAsync:

public <U> CompletableFuture<U> thenApplyAsync(Function<? super T,? extends U> fn) {
    return uniApplyStage(asyncPool, fn);  // asyncPool = ForkJoinPool.commonPool()
}

public <U> CompletableFuture<U> thenApplyAsync(
    Function<? super T,? extends U> fn, Executor executor
) {
    return uniApplyStage(screenExecutor(executor), fn);
}

// asyncPool — потік з ForkJoinPool.commonPool()
// або вказаний executor

Архітектурні Trade-offs

thenApply thenApplyAsync
Той же потік Інший потік
~5 ns overhead ~1μs overhead
Для легких операцій Для важких/блокуючих
Немає переключення контексту Переключення потоку

Edge Cases

1. ThreadLocal:

// thenApply — ThreadLocal зберігається
ThreadLocal<String> context = new ThreadLocal<>();
context.set("value");

cf.thenApply(s -> {
    String ctx = context.get();  // OK — той же потік
    return s + ctx;
});

// thenApplyAsync — ThreadLocal втрачений
cf.thenApplyAsync(s -> {
    String ctx = context.get();  // null! — інший потік
    return s;
});

2. Blocking operations:

// ❌ thenApply з блокуючою операцією
cf.thenApply(s -> {
    Thread.sleep(1000);  // блокує потік ForkJoinPool!
    return s;
});

// ✅ thenApplyAsync з окремим Executor
cf.thenApplyAsync(s -> {
    Thread.sleep(1000);
    return s;
}, ioExecutor);

Продуктивність

thenApply:
- Overhead: ~5 ns
- No thread switch

thenApplyAsync (commonPool):
- Overhead: ~1μs
- Thread switch + queue

thenApplyAsync (custom executor):
- Overhead: ~2-5μs
- Thread switch + queue

Для легких операцій: thenApply значно швидший thenApplyAsync (немає overhead на планування задачі
в пул), але точне співвідношення залежить від JVM, навантаження і заліза.

When NOT to use thenApplyAsync

  • ThreadLocal контекст — Async може змінити потік, ThreadLocal втрачений
  • Тривіальна трансформація (getLength, toString) — overhead на scheduling > користі

Production Experience

Pipeline:

// thenApply для легких трансформацій
fetchDataAsync()
    .thenApply(data -> parseJson(data))       // легка
    .thenApply(obj -> validate(obj))          // легка
    .thenApplyAsync(valid -> transform(valid), transformExecutor)  // важка
    .thenAcceptAsync(result -> save(result), ioExecutor);  // I/O

Best Practices

// ✅ thenApply для легких операцій
cf.thenApply(s -> s.toUpperCase());

// ✅ thenApplyAsync для важких/блокуючих
cf.thenApplyAsync(s -> heavyProcessing(s), executor);

// ✅ Свій Executor для I/O
cf.thenApplyAsync(s -> ioOperation(s), ioExecutor);

// ❌ thenApplyAsync без причини (overhead)
// ❌ thenApply з блокуючими операціями
// ❌ commonPool для I/O

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • thenApply — в тому ж потоці що і попередній CF, ~5 ns overhead
  • thenApplyAsync — у ForkJoinPool.commonPool() або вказаному Executor, ~1μs overhead
  • thenApply для легких CPU трансформацій, thenApplyAsync для важких/блокуючих
  • ThreadLocal втрачається при Async — інший потік
  • Без Executor thenApplyAsync використовує ForkJoinPool.commonPool()

Часті уточнюючі питання:

  • Коли thenApply, а коли thenApplyAsync? — thenApply для легких (toUpperCase), thenApplyAsync для важких (heavyProcessing, I/O)
  • thenApplyAsync без Executor — який пул? — ForkJoinPool.commonPool(), для I/O це thread pool starvation
  • Чому thenApply швидший? — Немає переключення потоку, queue, scheduling overhead
  • ThreadLocal з thenApplyAsync? — Втрається, т.к. інший потік. Потрібна явна передача контексту

Червоні прапорці (НЕ говорити):

  • «thenApplyAsync завжди кращий — він асинхронний» — +~1μs overhead, для легких операцій надмірний
  • «thenApply блокує потік» — він виконується в потоці завершеного CF, без додаткової блокування
  • «thenApplyAsync без Executor безпечний для I/O» — використовує commonPool, thread pool starvation

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

  • [[12. Який пул потоків використовується за замовчуванням для async методів]]
  • [[13. Як вказати свій Executor для CompletableFuture]]
  • [[4. У чому різниця між thenApply() і thenCompose()]]
  • [[15. Чому важливо уникати блокуючих операцій в CompletableFuture]]