Что такое CompletableFuture и чем он отличается от Future
CompletableFuture
🟢 Junior Level
CompletableFuture<T> — это класс, появившийся в Java 8 в пакете java.util.concurrent, который реализует интерфейсы Future<T> и CompletionStage<T>. Он представляет собой дескриптор результата асинхронного вычисления, который может быть завершён явно (вручную) установкой значения или исключения.
Главные отличия от классического Future
Интерфейс Future<T>, существовавший с Java 5, обладал критическим недостатком: он был чисто пассивным и блокирующим. Чтобы получить результат или среагировать на окончание задачи, вызывающий поток был обязан вызвать блокирующий метод get() или опрашивать isDone() в цикле.
| Характеристика | Future<T> (Java 5) |
CompletableFuture<T> (Java 8+) |
|---|---|---|
| Получение результата | Блокирующий get() |
Неблокирующие колбэки (thenAccept, thenApply) |
| Композиция задач | Невозможна без блокировки потока | Построение цепочек (пайплайнов) любой сложности |
| Ручное завершение | Нельзя завершить извне | Метод complete(value) / completeExceptionally(ex) |
| Обработка ошибок | try-catch вокруг get() |
Декларативные методы exceptionally(), handle() |
| Комбинирование | Нет поддержки | Методы allOf(), anyOf(), thenCombine() |
Базовый пример
// ❌ Старый подход с Future: блокирует поток main
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "Данные из БД";
});
String result = future.get(); // ТЕКУЩИЙ ПОТОК ЗАБЛОКИРОВАН НА 1 СЕКУНДУ
// ✅ Подход с CompletableFuture: реактивный и неблокирующий
CompletableFuture.supplyAsync(() -> "Данные из БД")
.thenApply(data -> data + " -> обогащено")
.thenAccept(finalResult -> System.out.println("Готово: " + finalResult));
// Поток main не блокируется и может сразу выполнять другую полезную работу
🟡 Middle Level
Архитектура и ключевые интерфейсы
CompletableFuture объединяет две ключевые концепции:
Future<T>: контракт для проверки готовности (isDone()), отмены (cancel()) и ожидания (get(),join()).CompletionStage<T>: контракт обещания (Promise), описывающий шаг асинхронного конвейера. Каждый этап вычисляет значение при завершении другого этапа без активного ожидания.
Жизненный цикл и перевод в завершённое состояние
В отличие от FutureTask, который переводится в состояние завершения исключительно исполняющим потоком Callable/Runnable, CompletableFuture можно завершить вручную из любого потока:
CompletableFuture<String> promise = new CompletableFuture<>();
// В фоновом потоке / по приходу вебсокета / по сообщению из Kafka:
new Thread(() -> {
try {
String data = networkService.fetch();
promise.complete(data); // Успешное завершение
} catch (Exception ex) {
promise.completeExceptionally(ex); // Завершение с ошибкой
}
}).start();
// Клиентский код подписывается на событие без блокировки:
promise.thenAccept(System.out::println);
Синхронные и асинхронные методы трансформации
Для каждого типа действия CompletionStage предлагает три варианта метода:
thenApply(Function fn): функция выполняется в том же потоке, который завершил предыдущий этап (или в вызывающем потоке, если этап уже был завершён).thenApplyAsync(Function fn): функция отправляется на исполнение в стандартный пулForkJoinPool.commonPool().thenApplyAsync(Function fn, Executor executor): функция передаётся в указанный кастомный пул потоков.
CompletableFuture.supplyAsync(() -> "User123", customThreadPool)
// Выполняется в потоке из customThreadPool
.thenApply(id -> id.toLowerCase())
// Выполняется в ForkJoinPool.commonPool()
.thenApplyAsync(id -> repository.findUser(id))
// Выполняется в специализированном ioExecutor
.thenAcceptAsync(user -> notifyService.send(user), ioExecutor);
Типичные ошибки новичков
- Блокировка пула
commonPool: запуск долгих сетевых вызовов черезsupplyAsync(() -> httpCall())без указания своегоExecutor. В стандартном пуле количество потоков ограничено (Runtime.getRuntime().availableProcessors() - 1), и блокирующий I/O быстро парализует все асинхронные задачи в JVM. - Проглатывание исключений: если к пайплайну не прикреплены
exceptionally(),handle()или не вызван терминальныйjoin(), исключение останется незамеченным внутри объекта, не попав ни в консоль, ни в логгер.
🔴 Senior Level
Внутреннее устройство: Lock-free Treiber Stack
Под капотом CompletableFuture реализован без тяжелых мьютексов (synchronized или ReentrantLock). Он полагается на атомарные операции CAS (Unsafe / VarHandle в зависимости от версии Java):
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
volatile Object result; // Хранит либо T, либо AltResult (исключение/NIL)
volatile Completion stack; // Стек зависимых этапов (Treiber Stack)
static final class AltResult {
final Throwable ex;
AltResult(Throwable x) { this.ex = x; }
}
static final AltResult NIL = new AltResult(null); // Маркер для null-результата
}
- Поле
result:null: вычисление ещё не завершено.T: вычисление успешно завершилось объектом типаT.NIL: вычисление успешно завершилось со значениемnull.AltResult: вычисление завершилось аварийно (содержитThrowable).
- Поле
stack(Lock-free Treiber Stack):- При добавлении подписчиков (
thenApply,thenAccept) создаётся узелCompletion. - Если
result == null, новый узел CAS-операцией проталкивается в голову стекаstack. - Когда задача завершается (
complete()), поток атомарно через CAS переводитresultизnullв финальное состояние, извлекает стек зависимостей и выполняет (или шедулит) все зарегистрированные колбэки через методpostComplete(). - Если на момент вызова
thenApplyполеresultуже неnull, узел в стек не кладётся, а действие исполняется немедленно прямо в вызывающем потоке.
- При добавлении подписчиков (
Правило выбора потока при выполнении синхронных колбэков
Один из самых частых источников гонок и неочевидных багов — предположение о том, какой поток выполняет thenApply():
CompletableFuture<String> cf = new CompletableFuture<>();
// Поток А (клиентский)
cf.thenApply(s -> {
System.out.println(Thread.currentThread().getName());
return s.toUpperCase();
});
// Поток Б (воркер)
cf.complete("hello");
- Сценарий 1: Поток Б вызвал
complete("hello")ДО того, как Поток А зарегистрировалthenApply. В этом случае Поток А выполнит лямбду в своём собственном потоке синхронно прямо внутри вызоваthenApply. - Сценарий 2: Поток А зарегистрировал
thenApplyДО того, как Поток Б завершил фьючер. В этом случае Поток Б, вызываяcomplete(), распакует стекCompletionи выполнит лямбду в контексте Потока Б. - Сценарий 3: Вызван
thenApplyAsync(...). Лямбда гарантированно отправляется как задача в пул потоков (воркер пула).
Ограничения CompletableFuture и отличие от реактивных потоков
Хотя CompletableFuture реализует модель Promise/Stage, он рассчитан на одиночные значения (0 или 1 элемент).
- Он не предназначен для обработки потоков данных (data streams) с множественными элементами (в отличие от
Flow.Publisher, RxJava или Project ReactorFlux). - В нём отсутствует концепция обратного давления (Backpressure): производитель не может регулировать темп в зависимости от возможностей потребителя.
4 Tricky Questions
1. В каком потоке выполнится вызов .thenApply(fn), если родительский CompletableFuture уже завершён на момент регистрации колбэка?
Ответ:
Колбэк выполнится синхронно в вызывающем потоке (в том потоке, который вызывает .thenApply()).
Внутри CompletableFuture метод проверяет: если result != null, регистрация в стек зависимостей пропускается, и функция fn вызывается немедленно в текущем потоке. Если же родительский фьючер ещё не готов, колбэк положат в стек зависимостей, и его выполнит тот поток, который вызовет .complete() (или поток завершения асинхронного воркера). Это означает, что поведение синхронного метода неделикатно зависит от таймингов многопоточного выполнения.
2. Почему вызов future.cancel(true) у CompletableFuture не прерывает (interrupt) поток, исполняющий асинхронную задачу?
Ответ:
В стандартном FutureTask вызов cancel(true) вызывает Thread.interrupt() у исполняющего потока. Однако у CompletableFuture метод cancel(boolean mayInterruptIfRunning) реализован формально: параметр mayInterruptIfRunning полностью игнорируется!
Вызов cancel(true) лишь атомарно устанавливает состояние AltResult(CancellationException) на самом экземпляре CompletableFuture. Поток, который крутит лямбду внутри ForkJoinPool или кастомного Executor, продолжит работать до естественного завершения, если только задача сама явно не проверяет статус отмены через внешние флаги.
3. Что произойдёт при многократном вызове complete(T) или одновременном вызове complete(val) и completeExceptionally(ex) из разных потоков?
Ответ:
CompletableFuture гарантирует потокобезопасную идемпотентность завершения за счёт CAS-операции над полем result:
// Первый вызов установит результат и вернёт true:
boolean first = cf.complete("Success"); // true
// Второй вызов ничего не сделает и вернёт false:
boolean second = cf.complete("Other"); // false
// Исключение не запишется:
boolean third = cf.completeExceptionally(new RuntimeException()); // false
Только первый вызов переведёт объект в терминальное состояние и запустит зависимый стек вычислений. Все последующие попытки завершить фьючер игнорируются, состояние объекта остаётся неизменным.
4. В чём принципиальная разница между .get() и .join() с точки зрения сигнатуры и обработки исключений?
Ответ:
- Метод
get()объявлен в интерфейсеFuture<T>и бросает проверяемые исключения (checked exceptions):InterruptedException(если поток был прерван во время ожидания) иExecutionException(заворачивает любое исключение из асинхронной цепочки). Требует обязательного блокаtry-catch. - Метод
join()объявлен вCompletableFutureи бросает непроверяемое исключение (unchecked exception)CompletionException, в полеcauseкоторого находится оригинальная ошибка (а при отмене бросаетCancellationException). Он специально спроектирован для удобного использования внутри лямбда-выражений и Stream API, где checked-исключения запрещены сигнатурами функциональных интерфейсов.
🎯 Шпаргалка для интервью
Главное за 60 секунд
CompletableFuture— это реализацияFuture+CompletionStage, появившаяся в Java 8 для неблокирующего асинхронного программирования.- Позволяет связывать задачи в цепочки (
thenApply,thenCompose), комбинировать результаты (thenCombine,allOf) и декларативно обрабатывать ошибки (exceptionally,handle). - По умолчанию асинхронные методы без передачи экзекутора используют
ForkJoinPool.commonPool(). Для любых блокирующих I/O операций обязательно передавать выделенныйExecutorService. - Внутренняя структура: lock-free стек зависимостей (Treiber Stack) и
volatile Object result, обновляемые через CAS.
Типичные ошибки на интервью
- Утверждать, что
thenApply()всегда выполняется в отдельном фоновом потоке. (Нет! Без суффикса*Asyncон выполняется либо в потоке завершителя, либо в вызывающем потоке). - Считать, что
cf.cancel(true)прерывает поток черезThread.interrupt(). (Параметр игнорируется, задача в пуле не прерывается). - Использовать
CompletableFutureдля непрерывных потоковых данных (для этого нужен Reactive Streams / RxJava / Reactor).