⏱️ Раздел 19 · Вопрос #3

Как создать CompletableFuture, который уже завершён с результатом

В асинхронном программировании часто возникает ситуация, когда результат вычисления известен заранее (например, значение взято из кэша в оперативной памяти, является дефолтным f...


🟢 Junior Level

В асинхронном программировании часто возникает ситуация, когда результат вычисления известен заранее (например, значение взято из кэша в оперативной памяти, является дефолтным fallback-объектом или возвращается в юнит-тестах). Чтобы не нарушать сигнатуру метода, возвращающего CompletableFuture<T>, используют специальные фабричные методы.

Основные фабричные методы

  1. CompletableFuture.completedFuture(T value) (Java 8+): Возвращает новый экземпляр CompletableFuture, который уже находится в завершённом состоянии с указанным значением.
    CompletableFuture<String> cachedFuture = CompletableFuture.completedFuture("Данные из Redis");
    
    // Результат доступен немедленно, без ожидания и без запуска потоков:
    String result = cachedFuture.join(); // Возвращает "Данные из Redis" мгновенно
    
  2. CompletableFuture.failedFuture(Throwable ex) (Java 9+): Возвращает экземпляр, который уже завершён с ошибкой.
    CompletableFuture<User> errorFuture = CompletableFuture.failedFuture(
        new UserNotFoundException("Пользователь не найден")
    );
    
  3. 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):

  1. Выделяется экземпляр CompletableFuture в heap.
  2. Поле result инициализируется значением (value == null) ? NIL : value (где NIL — специальный синглтон AltResult).
  3. Поле stack (Treiber stack) остаётся null.
  4. Никакие системные потоки, очереди задач или 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 над уже готовым результатом.

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