Як створити CompletableFuture, який вже завершений з результатом
В асинхронному програмуванні часто виникає ситуація, коли результат обчислення відомий заздалегідь (наприклад, значення взято з кешу в оперативній пам'яті, воно є дефолтним fall...
🟢 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 / ConcurrentHashMap)
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 та Project 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над уже готовим результатом.