Чи можна вручну завершити CompletableFuture результатом
Для цього використовуються методи:
🟢 Junior Level
Так, можна. На відміну від класичного java.util.concurrent.Future, який являв собою виключно пасивний дескриптор запущеного завдання, CompletableFuture реалізує концепцію Promise (Обіцянка): його можна створити порожнім і завершити вручну з будь-якого потоку в будь-який момент часу.
Для цього використовуються методи:
complete(T value)— переводитьCompletableFutureу завершений стан із вказаним значенням.completeExceptionally(Throwable ex)— завершуєCompletableFutureаварійно з винятком.
// 1. Створюємо порожній CompletableFuture (результату ще немає)
CompletableFuture<String> promise = new CompletableFuture<>();
// 2. У фоновому потоці або обробнику подій завершуємо його:
new Thread(() -> {
try {
String data = loadDataFromSocket();
promise.complete(data); // Передаємо результат
} catch (Exception ex) {
promise.completeExceptionally(ex); // Або прокидаємо помилку
}
}).start();
// 3. Викликаючий код підписується або очікує на результат:
promise.thenAccept(data -> System.out.println("Received: " + data));
Основне правило
Метод complete(value) спрацьовує лише один раз. Якщо CompletableFuture вже був завершений (викликом complete(), винятком, скасуванням або фоновим розрахунком), повторні виклики complete() просто ігноруються і повертають false.
🟡 Middle Level
1. Значення, що повертається, і потокобезпечність complete()
Метод complete(T value) повертає boolean:
true— якщо поточний виклик дійсно перевівCompletableFutureу завершений стан.false— якщоCompletableFutureвже був завершений раніше іншим потоком або операцією.
CompletableFuture<String> cf = new CompletableFuture<>();
boolean first = cf.complete("Success"); // true (переможець)
boolean second = cf.complete("Another one"); // false (проігноровано)
System.out.println(cf.join()); // Виведе "Success"
Перехід стану є суворо потокобезпечним та атомарним (базується на CAS-операціях на рівні JVM). Стани гонитви даних виключені: перший потік, що завершив, перемагає, всі наступні отримують відмову.
2. Завершення з помилкою: completeExceptionally()
Якщо асинхронний процес завершився збоєм (таймаут сокета, збій валідації, помилка протоколу):
CompletableFuture<User> userFuture = new CompletableFuture<>();
// Завершуємо аварійно:
userFuture.completeExceptionally(new UserNotFoundException("User 42 not found"));
// Будь-яка спроба отримати результат викине CompletionException:
userFuture.exceptionally(ex -> {
log.error("Failed to load user: {}", ex.getMessage());
return User.ANONYMOUS;
});
3. Java 9: completeAsync()
У Java 9 з’явився метод completeAsync(Supplier<? extends T> supplier, Executor executor), який дозволяє асинхронно обчислити значення для ручного завершення на вказаному пулі потоків:
CompletableFuture<String> cf = new CompletableFuture<>();
// Завершить cf результатом supplier у пулі customExecutor:
cf.completeAsync(() -> heavyCalculation(), customExecutor);
4. Небезпечний метод: obtrudeValue() та obtrudeException()
В API є методи obtrudeValue(T value) та obtrudeException(Throwable ex). Вони примусово перезаписують результат CompletableFuture, навіть якщо він уже був завершений:
CompletableFuture<String> cf = CompletableFuture.completedFuture("Original");
cf.obtrudeValue("Hacked");
System.out.println(cf.join()); // "Hacked"
[!CAUTION] Чому
obtrudeValueкатегорично не рекомендується використовувати в production: Він грубо порушує інваріант незмінності (immutability) завершеного стану. Стадії, які вже встигли виконатися за вихідним значенням"Original", не будуть перезапущені. Нові ж підписники отримають"Hacked". Це неминуче призведе до розсинхронізації даних та непередбачуваних race conditions. Цей метод призначений виключно для утиліт відновлення після фатальних помилок або для тестування.
🔴 Senior Level
Внутрішній механізм завершення: Treiber Stack та postComplete()
Що фізично відбувається всередині JVM при виклику cf.complete(value)?
// Спрощений вихідний код OpenJDK:
public boolean complete(T value) {
boolean triggered = RESULT.compareAndSet(this, null, (value == null) ? NIL : value);
if (triggered)
postComplete(); // Виклик колбеків!
return triggered;
}
- Атомарний CAS: Поле
volatile Object resultпереводиться зnullу значення (або маркерNILпри передачіnull, абоAltResultпри помилці). postComplete(): Якщо потік виграв CAS, він бере на себе відповідальність за виштовхування всіх зареєстрованих залежних стадій (Completion) із внутрішнього неблокуючого стека (Treiber stack).
Критична пастка: В якому потоці виконуються колбеки?
Якщо до моменту виклику cf.complete("data") до нього вже були прив’язані синхронні стадії (наприклад, .thenApply(func) або .thenAccept(consumer)), ці стадії виконаються синхронно прямо в потоці, який викликав complete()!
// Потік Netty EventLoop обробляє вхідний TCP-пакет:
void onChannelRead(ByteBuf buf) {
String message = parse(buf);
// УВАГА: якщо до future прив'язаний важкий ланцюжок thenApply:
future.complete(message);
// ВСІ синхронні колбеки виконаються прямо ТУТ, у потоці Netty!
// Потік введення-виведення виявиться заблокованим обчисленнями бізнес-логіки!
}
Рішення: Для ізоляції потоків завжди використовуйте асинхронні версії колбеків із зазначенням спеціалізованого пулу потоків: thenApplyAsync(func, businessExecutor).
Захист від несанкціонованого завершення (Encapsulation)
Якщо сервіс повертає CompletableFuture<Data>, викликаючий код може зловжити API та викликати .complete(...), зламавши внутрішній стан сервісу:
// Сервіс:
public CompletableFuture<Data> fetchData() { ... }
// Зловмисний або некоректний клієнтський код:
CompletableFuture<Data> f = service.fetchData();
f.complete(new FakeData()); // Сервер ще працює, а клієнт уже підмінив результат!
Патерни захисту (Інваріант надійності):
- Повернення інтерфейсу
CompletionStage:public CompletionStage<Data> fetchData() { return internalFuture; // У CompletionStage методів complete() немає! }Застереження: Клієнт може викликати
stage.toCompletableFuture().complete(...). - Використання
minimalCompletionStage()(Java 9+):public CompletionStage<Data> fetchData() { return internalFuture.minimalCompletionStage(); }В отриманого об’єкта виклик
.toCompletableFuture()викинеUnsupportedOperationException, роблячи підміну результату фізично неможливою. - Використання
copy()(Java 9+):public CompletableFuture<Data> fetchData() { return internalFuture.copy(); // Завершення копії не завершить оригінал! }
4 Tricky Questions
1. В якому потоці виконуються колбеки (thenApply, thenAccept), якщо cf.complete("done") викликаний вручну?
Відповідь:
Якщо залежна стадія (thenApply, thenAccept) була зареєстрована до того, як було викликано complete(), вона буде виконана в тому самому потоці, який викликав complete() (всередині методу postComplete()).
Якщо ж complete() вже відпрацював, а залежна стадія реєструється пізніше, вона виконається синхронно в потоці, що викликав thenApply(). Щоб гарантувати виконання колбека в ізольованому пулі потоків незалежно від таймінгу завершення, необхідно використовувати асинхронні методи thenApplyAsync(..., executor).
2. Що відбувається, якщо два потоки одночасно викликають cf.complete("A") і cf.completeExceptionally(new RuntimeException("B"))?
Відповідь:
Відбувається конкурентна гонитва CAS (Compare-And-Swap) за поле result.
- Рівно один із потоків встигне атомарно змінити
resultзnullна своє значення ("A"абоAltResult(ex)). Цей потік отримає значенняtrue, що повертається. - Другий потік виявить, що поле
resultвже не дорівнюєnull, його CAS завершиться невдачею, метод повернеfalse, а передане ним значення або виняток буде повністю проігноровано. Жодних винятків гонки (типуIllegalStateException) викинуто не буде.
3. Якщо викликати cf.obtrudeValue("new") після того, як cf.complete("old") вже спрацював, чи перезапустяться раніше прикріплені до cf стадії?
Відповідь: Ні, раніше прикріплені стадії не перезапустяться.
У момент першого виклику complete("old") внутрішній стек залежностей (Completion stack) повністю спустошується та обнуляється під час роботи postComplete(). При виклику obtrudeValue("new") поле result перезаписується новим значенням, але стек підписників уже порожній. У результаті всі стадії, що залежали від cf, залишаться зі старим результатом "old", у той час як прямі виклики cf.join() або нові зареєстровані підписники отримають "new". Це призводить до порушення цілісності графа обчислень.
4. Як спроєктувати API, що повертає асинхронний результат, щоб споживач не міг вручну завершити ваш CompletableFuture?
Відповідь:
У Java 9+ найкращим рішенням є повернення CompletableFuture.minimalCompletionStage():
public CompletionStage<Order> createOrderAsync(...) {
CompletableFuture<Order> internalPromise = new CompletableFuture<>();
// ... логіка ініціалізації ...
return internalPromise.minimalCompletionStage();
}
Інтерфейс CompletionStage в принципі не оголошує методів завершення (complete, obtrudeValue). А реалізація minimalCompletionStage захищена від обходу: якщо викликаючий код спробує викликати .toCompletableFuture(), він отримає екземпляр MinimalStage, будь-який модифікуючий метод якого викидає UnsupportedOperationException.
Альтернатива — повернути захисну копію через internalPromise.copy(): якщо клієнт викличе complete() на копії, оригінал продовжить виконуватися без змін.
🎯 Шпаргалка для інтерв’ю
Основні методи завершення
| Метод | Поведінка | Коли застосовувати |
|---|---|---|
complete(value) |
Завершує зі значенням, якщо ще не був завершений. Повертає true/false. |
Основний метод реалізації патерну Promise / callback-адаптерів. |
completeExceptionally(ex) |
Завершує з помилкою, якщо ще не був завершений. Повертає true/false. |
Передача помилок з асинхронних драйверів або обробників подій. |
completeAsync(supplier, exec) |
Планує асинхронний розрахунок значення в заданому пулі (Java 9+). | Ледача передача обчислень без блокування поточного потоку. |
obtrudeValue(value) |
Примусово перезаписує результат, навіть якщо CF вже завершений. | ⚠️ Табу в production. Тільки для recovery-утиліт і тестів. |
Ключові правила
- Перший перемагає:
complete()ідемпотентний з точки зору наступних викликів — тільки перший виклик переводить стадію у фінальний стан. - Небезпека I/O блокування: Потік, що викликає
complete(), сам виконує синхронні стадіїthenApply. Не викликайтеcomplete()в I/O потоках (Netty EventLoop, Swing EDT), якщо до future прив’язана важка бізнес-логіка. - Інкапсуляція: Не віддавайте клієнтам назовні сирий екземпляр
CompletableFuture, якщо ви не хочете, щоб вони завершили його замість вас. ПовертайтеminimalCompletionStage()абоcopy().
Антипатерни
- ❌ Використовувати
obtrudeValue()для повторного використання об’єкта: призводить до розсинхронізації графа колбеків. - ❌ Ігнорувати значення
complete(), що повертається, при координації потоків: якщо метод повернувfalse, результат не був прийнятий.