Как создать CompletableFuture, который уже завершён с результатом
В асинхронном программировании часто возникает ситуация, когда результат вычисления известен заранее (например, значение взято из кэша в оперативной памяти, является дефолтным f...
🟢 Junior Level
В асинхронном программировании часто возникает ситуация, когда результат вычисления известен заранее (например, значение взято из кэша в оперативной памяти, является дефолтным fallback-объектом или возвращается в юнит-тестах). Чтобы не нарушать сигнатуру метода, возвращающего CompletableFuture<T>, используют специальные фабричные методы.
Основные фабричные методы
CompletableFuture.completedFuture(T value)(Java 8+): Возвращает новый экземплярCompletableFuture, который уже находится в завершённом состоянии с указанным значением.CompletableFuture<String> cachedFuture = CompletableFuture.completedFuture("Данные из Redis"); // Результат доступен немедленно, без ожидания и без запуска потоков: String result = cachedFuture.join(); // Возвращает "Данные из Redis" мгновенноCompletableFuture.failedFuture(Throwable ex)(Java 9+): Возвращает экземпляр, который уже завершён с ошибкой.CompletableFuture<User> errorFuture = CompletableFuture.failedFuture( new UserNotFoundException("Пользователь не найден") );completedStage(T value)иfailedStage(Throwable ex)(Java 12+): Возвращают интерфейсCompletionStage<T>, защищая объект от ручного изменения или принудительного перезаписывания вызывающей стороной.
🟡 Middle Level
Паттерн “Условная асинхронность” (Conditional Asynchrony)
Главный сценарий использования completedFuture в боевом коде — прозрачное кэширование, когда при попадании в кэш I/O-операция не инициируется вовсе:
public CompletableFuture<ProductDetails> getProductDetails(String productId) {
// 1. Проверяем локальный кэш (Caffeine / Guava / HashMap)
ProductDetails cached = localCache.getIfPresent(productId);
if (cached != null) {
// Мгновенный синхронный возврат без накладных расходов на пулы потоков
return CompletableFuture.completedFuture(cached);
}
// 2. Если в кэше нет — уходим в асинхронный I/O через пул потоков
return CompletableFuture.supplyAsync(() -> remoteCatalogService.fetch(productId), ioExecutor)
.thenApply(product -> {
localCache.put(productId, product);
return product;
});
}
Критическая ловушка: контекст исполнения колбэков
Если вызвать .thenApply(...) или .thenAccept(...) у обычного (ещё исполняющегося) фьючера, колбэк выполнится в том потоке, который его завершит.
Но если вызвать .thenApply(...) на уже завершённом через completedFuture объекте:
- Колбэк выполнится СИНХРОННО в вызывающем потоке прямо внутри вызова
thenApply!
// ОПАСНО: если heavyCalculation() тяжёлый, он заблокирует текущий поток!
CompletableFuture.completedFuture(userData)
.thenApply(user -> heavyCpuCalculation(user));
// БЕЗОПАСНО: если нужна изоляция потоков, ВСЕГДА используйте *Async версии
CompletableFuture.completedFuture(userData)
.thenApplyAsync(user -> heavyCpuCalculation(user), computeExecutor);
Создание завершённого фьючера с ошибкой в Java 8
В Java 8 не было метода failedFuture(). Для создания фьючера с ошибкой использовалась явная инициализация:
// Java 8 идиома для failed future:
CompletableFuture<String> failed = new CompletableFuture<>();
failed.completeExceptionally(new IllegalStateException("Ошибка валидации"));
🔴 Senior Level
Внутреннее состояние памяти и оптимизация аллокаций
При вызове CompletableFuture.completedFuture(value):
- Выделяется экземпляр
CompletableFutureв heap. - Поле
resultинициализируется значением(value == null) ? NIL : value(гдеNIL— специальный синглтонAltResult). - Поле
stack(Treiber stack) остаётсяnull. - Никакие системные потоки, очереди задач или
ForkJoinPoolне задействуются. Время выполнения метода составляет порядка 2–5 наносекунд.
Highload-оптимизация: устранение аллокаций для Void
В микросервисах с высокой нагрузкой (сотни тысяч RPS) методы часто возвращают CompletableFuture<Void>. Постоянное создание CompletableFuture.completedFuture(null) создаёт избыточное давление на Young Generation сборщика мусора (GC).
Паттерн оптимизации (как в Netty и Reactor):
public final class AsyncUtils {
// Единый переиспользуемый завершённый синглтон на всё приложение:
public static final CompletableFuture<Void> COMPLETED_VOID =
CompletableFuture.completedFuture(null);
private AsyncUtils() {}
}
// Использование:
public CompletableFuture<Void> logEventAsync(Event event) {
if (!event.isImportant()) {
return AsyncUtils.COMPLETED_VOID; // 0 аллокаций в куче!
}
return CompletableFuture.runAsync(() -> kafkaProducer.send(event), ioExecutor);
}
Архитектурная защита: completedStage (Java 12)
Класс CompletableFuture имеет фатальный архитектурный изъян при возврате из публичных API библиотек: любой клиент может вызвать cf.obtrudeValue(...) или cf.complete(...) и нарушить внутреннее состояние сервиса.
Для защиты инкапсуляции в Java 12 добавили CompletableFuture.completedStage(value):
public CompletionStage<Session> authenticate(Credentials creds) {
if (creds.isAnonymous()) {
// Возвращает MinimalStage — специальную внутреннюю легковесную реализацию,
// у которой методы complete() и obtrude() отсутствуют или бросают UnsupportedOperationException
return CompletableFuture.completedStage(Session.ANONYMOUS);
}
return fetchSessionAsync(creds);
}
4 Tricky Questions
1. Что произойдёт, если передать null в CompletableFuture.completedFuture(null)? Допустимо ли это?
Ответ:
Это абсолютно валидная операция, NullPointerException не выбрасывается.
Внутри CompletableFuture значение null мапится на внутренний статический синглтон NIL (экземпляр AltResult с пустым полем Throwable). При последующем вызове get() или join() фьючер распаковывает NIL обратно в чистый null. Это стандартный способ вернуть завершённый CompletableFuture<Void>.
2. Если метод вернул CompletableFuture.completedFuture(x), и вызывающий код применил цепочку из 10 операций .thenApply(...) без суффикса Async, в каком потоке они выполнятся и чем это чревато в реактивных серверах (например, Netty или Vert.x)?
Ответ: Все 10 колбэков выполнятся последовательно и строго синхронно в том самом потоке, который вызвал метод и построил цепочку. Если этот код выполняется внутри сетевого потока ввода-вывода (Netty EventLoop thread), и хотя бы одна операция в цепочке окажется ресурсоёмкой (парсинг JSON, криптография, валидация), EventLoop будет заблокирован. Это вызовет лавинообразный рост задержек (Latency Spike) для всех остальных сотен сетевых соединений, обслуживаемых этим EventLoop-потоком.
3. Чем реализация CompletableFuture.completedStage(T) отличается от простого приведения (CompletionStage<T>) CompletableFuture.completedFuture(T)?
Ответ:
При простом касте (CompletionStage<T>) completedFuture вызывающий код может выполнить обратное нисходящее приведение типа (Downcasting) обратно к CompletableFuture и вызвать деструктивные методы complete(), cancel() или obtrudeValue().
Метод completedStage() возвращает приватный JDK-класс MinimalStage. Попытка выполнить каст (CompletableFuture<T>) stage завершится ClassCastException. Кроме того, у MinimalStage метод toCompletableFuture() возвращает независимую копию, мутация которой не повлияет на исходный объект.
4. Что произойдёт, если к CompletableFuture.failedFuture(new MyException()) применить метод .thenApply(fn)? Вызовется ли функция fn?
Ответ:
Функция fn не вызовется.
Любой фьючер, находящийся в состоянии ошибки, немедленно переводит все зависимые этапы thenApply, thenAccept, thenCompose в то же самое состояние ошибки, минуя исполнение функциональных интерфейсов. Ошибка будет распространяться вниз по цепочке до первого найденного восстанавливающего обработчика (exceptionally, handle или whenComplete).
🎯 Шпаргалка для интервью
Главное за 60 секунд
- Для создания готового
CompletableFutureиспользуются:completedFuture(T)(успех),failedFuture(Throwable)(ошибка, с Java 9),completedStage(T)(неизменяемый интерфейс, с Java 12). - Применяется в кэшировании (Conditional Asynchrony), моках в юнит-тестах и заглушках (fallback).
- Критический нюанс: любые синхронные колбэки (
thenApply,thenAccept), навешенные наcompletedFuture, выполняются в вызывающем потоке. Если обработка тяжелая — обязательно используйте*Asyncс кастомным пулом. - Для
CompletableFuture<Void>в highload-системах рекомендуется завести статическую константуCOMPLETED_VOID, чтобы не создавать лишний мусор для GC.
Типичные ошибки на интервью
- Думать, что
completedFutureзапускает задачу вForkJoinPool(он отрабатывает синхронно на месте за пару наносекунд). - Не знать о методе
failedFutureиз Java 9 и вручную писать громоздкиеnew CompletableFuture() + completeExceptionally(). - Игнорировать блокировку вызывающего потока при цепочке вызовов
thenApplyнад уже готовым результатом.