В яких випадках краще використовувати CompletableFuture, а в яких — реактивне програмування
Критичний вододіл між CF та Reactive Streams:
🟢 Junior Level
Суть відмінності у двох реченнях
CompletableFutureпризначений для обробки одиночного асинхронного результату (скалярного значення: 0 або 1 елемент) у межах моделі «запит — відповідь».- Реактивне програмування (Project Reactor, RxJava, Akka/Pekko) спроєктоване для безперервної обробки асинхронних потоків даних ($0 \dots N$ або нескінченне число елементів) з підтримкою управління швидкістю передачі (backpressure).
// CompletableFuture: одиночний запит — одиночна відповідь (1 результат)
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> userService.findById(userId));
userFuture.thenAccept(user -> log.info("User loaded: {}", user.getName()));
// Project Reactor (Flux): асинхронний потік подій (0..N результатів)
Flux<StockPrice> priceStream = stockExchangeClient.streamLivePrices("AAPL");
priceStream
.filter(p -> p.value() > 150.0)
.subscribe(price -> log.info("Price alert: {}", price));
Просте емпіричне правило
- Потрібно асинхронно зробити 2–3 мережевих виклики до зовнішніх API, паралельно сходити в БД і скомбінувати результат в один DTO $\rightarrow$
CompletableFuture(вбудований у JDK, не вимагає сторонніх залежностей, простий у читанні). - Потрібна потокова обробка (Server-Sent Events, WebSockets, безперервні метрики, вивантаження мільйонів рядків із реактивного драйвера БД) або продюсер генерує дані швидше, ніж консьюмер встигає їх обробити $\rightarrow$ Реактивне програмування.
🟡 Middle Level
Архітектурні відмінності: Eager vs Lazy, Пуш vs Зворотний тиск
1. Потужність множини (Cardinality)
CompletableFuture<T>: суворо одне майбутнє значення або виняток. Після завершення його стан незмінний (immutable). Неможливо виразити «потік даних у часі».Mono<T>: $0$ або $1$ елемент (найближчий реактивний аналогCompletableFuture).Flux<T>: $0 \dots \infty$ елементів.
2. Модель виконання: Eager (Hot) vs Lazy (Cold)
CompletableFutureзавжди «гарячий» (Hot / Eager). Обчислення починає виконуватися одразу в момент викликуCompletableFuture.supplyAsync(...)або передачі завдання в Executor, незалежно від того, чи підписався хтось на результат черезthenApplyабоjoin.- Реактивні пайплайни за замовчуванням «холодні» (Cold / Lazy). Декларативний опис пайплайна (
flux.map(...).filter(...)) нічого не виконує, доки на нього явно не викличуть.subscribe()(або фреймворк типу Spring WebFlux не зробить це за вас). Потік можна перезапускати заново для кожного нового підписника.
3. Backpressure (Зворотний тиск)
Критичний вододіл між CF та Reactive Streams:
[Producer] ---- (100 000 елементів/сек) ---> [Queue] ---> [Consumer] (1 000 елементів/сек)
|
У CompletableFuture: | У Reactive Streams (pull-push):
Черга зростає -> OOM! | Consumer надсилає request(10)
| Producer сповільнюється
- У
CompletableFutureконсьюмер не може сказати джерелу: «Пригальмуй, віддай мені лише наступні 10 елементів». Джерело нестримно пушить завдання вThreadPoolExecutor. Якщо консьюмер повільніший за продюсера, черга завдань переповнюється, викликаючиRejectedExecutionExceptionабоOutOfMemoryError. - У Reactive Streams діє протокол
Subscription.request(n): консьюмер сам запитує у джерела порцію елементів, яку готовий обробити.
4. Поширення скасування (Cancellation Propagation)
- У
CompletableFuture.cancel(mayInterruptIfRunning)скасування локальне: завершує поточний вузол графа зCancellationException. Обчислювальний потік при цьому не переривається, а вищерозташовані етапи (upstream) не сповіщаються про скасування. - У Reactive Streams виклик
Subscription.cancel()каскадно піднімається вгору по всьому ланцюжку операторів (upstream), зупиняє генератор, звільняє TCP-сокети, скасовує таймаути та звільняє ресурси.
Порівняльна таблиця
| Критерій | CompletableFuture | Project Reactor / RxJava |
|---|---|---|
| Одиниця даних | Одне скалярне значення ($T$) | Потік подій ($0 \dots \infty$) |
| Стандарт | Java Core (JDK 8+) | Reactive Streams Specification (Flow у Java 9) |
| Модель виконання | Hot (Eager) | Cold (Lazy) за замовчуванням |
| Backpressure | Відсутній | Вбудований на рівні протоколу (request(n)) |
| Скасування (Cancellation) | Локальне, не перериває upstream | Каскадне вгору по графу (upstream) |
| Багатство операторів | Близько 50 методів | Понад 200 операторів (часові вікна, буфери, retry) |
| Складність налагодження | Середня | Висока (втрата стек-трейсів, складнощі контексту) |
🔴 Senior Level
Еволюція архітектури: Reactive vs CompletableFuture vs Virtual Threads (Java 21+)
Історично реактивне програмування впроваджувалося в Java з двох головних причин:
- Неблокуючий I/O (Non-blocking / Event-loop) для утилізації системних ресурсів: збереження 1 потоку ОС на тисячу мережевих з’єднань замість 1 потоку на з’єднання (Thread-per-request).
- Потокова композиція та Backpressure для обробки стрімів подій.
З релізом Java 21 (Project Loom / Virtual Threads) баланс кардинально змінився:
// Традиційний Spring WebFlux (Reactor):
public Mono<DashboardDto> getDashboardReactive(Long userId) {
return userClient.getUser(userId)
.zipWith(orderClient.getOrders(userId))
.map(tuple -> new DashboardDto(tuple.getT1(), tuple.getT2()));
}
// Сучасна Java 21 (Virtual Threads + Structured Concurrency):
public DashboardDto getDashboardVirtualThreads(Long userId) throws InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<User> userSub = scope.fork(() -> userClient.getUserBlocking(userId));
Supplier<List<Order>> ordersSub = scope.fork(() -> orderClient.getOrdersBlocking(userId));
scope.join().throwIfFailed(); // Блокування віртуального потоку дешеве (~1 КБ стека)
return new DashboardDto(userSub.get(), ordersSub.get());
}
}
Чому Virtual Threads витісняють Reactive у 80% типових Enterprise-задач
- Збереження імперативного стилю: Знову працюють стандартні
try-catch, звичайніfor-цикли, налагоджувачі IDE, звичнийThreadLocalі зрозумілі стек-трейси без реактивних обгорток. - Усунення “Coloring Problem” (червоно-сині функції): У реактивному стеку повернення
Mono/Fluxзаражає всі шари застосунку — від контролера до репозиторію. У моделі Virtual Threads будь-який метод залишається звичайним синхронним викликом.
Ніша, де Reactive залишається незамінним
Віртуальні потоки вирішують проблему дешевизни очікування I/O, але вони не вміють керувати швидкістю потоку даних. Реактивний стек залишається кращим при:
- Необхідності Backpressure у високонавантажених шлюзах (API Gateway, Rate Limiting, обробка черг із контролем пам’яті консьюмера).
- Складній часовій математиці над потоками: ковзні часові вікна (
window(Duration)), об’єднання швидких і повільних потоків подій у часі (sample,debounce,throttle), стрімінг котирувань, IoT-телеметрія. - Довгоживучих дуплексних з’єднаннях із потоковою віддачею: Server-Sent Events (SSE), WebSockets, gRPC Streaming.
4 Tricky Questions
1. Що станеться, якщо джерело видасть 1 000 000 елементів на секунду у CompletableFuture vs Reactive Stream?
Відповідь:
В архітектурі на CompletableFuture кожен елемент потребуватиме планування завдання в пул потоків (наприклад, через thenApplyAsync). Оскільки консьюмер не має механізму гальмування продюсера (немає backpressure), внутрішня черга пулу потоків почне стрімко зростати. Це неминуче призведе або до відмови в обслуговуванні (RejectedExecutionException), або до катастрофічного вичерпання купи (OutOfMemoryError: Java heap space).
У реактивному потоці (Flux / Flow.Publisher) консьюмер спочатку запитує тільки ту кількість елементів, яку встигає обробити (наприклад, request(32)). Продюсер призупиняє генерацію/читання з сокета на рівні TCP receive buffer, доки консьюмер не споживе порцію і не запитає наступну. Якщо джерело некероване (гарячий потік датчиків), реактивний пайплайн дозволяє явно задати стратегію переповнення: onBackpressureDrop() (відкидати надлишки), onBackpressureLatest() (зберігати тільки останнє значення) або буфер з обмеженням розміру.
2. Чи є CompletableFuture «гарячим» (hot) чи «холодним» (cold) джерелом порівняно з Reactor Mono?
Відповідь:
CompletableFuture є суворо «гарячим» (Hot). Він представляє собою обіцянку результату обчислення, яке вже запущене (або завершене). Якщо ви створили екземпляр CompletableFuture.supplyAsync(task), завдання вже відправлене в executor, незалежно від того, чи прикріпите ви до нього колбеки. Якщо три різні методи викличуть .thenApply() на одному й тому самому CompletableFuture, фонове завдання виконається рівно один раз, а результат буде доставлений усім трьом підписникам.
Mono в Project Reactor за замовчуванням «холодний» (Cold). Конструкція Mono.fromCallable(task) або Mono.defer(...) лише оголошує рецепт обчислення. Якщо на нього ніхто не викличе .subscribe(), фонова робота взагалі ніколи не почнеться. Якщо на такий холодний Mono підпишуться три незалежні консьюмери, завдання виконається три рази (по разу для кожного підписника), якщо потік явно не зроблений гарячим через .share() або .publish().autoConnect().
3. Як працює скасування (cancellation) у CompletableFuture vs Reactive Streams? Чи можна зупинити вже запущений HTTP-запит?
Відповідь:
У CompletableFuture.cancel(true) семантика скасування вкрай обмежена:
- Прапорець
mayInterruptIfRunningігнорується в стандартній реалізації JDK. - Виклик
cancel()просто записує в поточну стадію спеціальний внутрішній результатAltResult(CancellationException). Потік, що виконує мережевий запит або обчислення, не переривається і продовжує працювати до фізичного завершення. - Жоден сигнал скасування не йде «вгору» до джерела даних.
У Reactive Streams скасування є фундаментальною частиною протоколу. Коли підписник викликає Subscription.cancel(), сигнал проходить каскадом вгору (upstream) по всьому ланцюжку операторів. Кожен оператор закриває свої буфери, таймери та ресурси, а саме джерело (наприклад, реактивний WebClient на базі Netty) закриває мережевий сокет/канал TCP, миттєво припиняючи читання даних і звільняючи мережевий буфер ОС.
4. Якщо в Java 21 з’явилися Virtual Threads, чи повністю вони витісняють реактивне програмування та CompletableFuture?
Відповідь:
Ні, не повністю. Virtual Threads закривають головний біль класичної Java — вони роблять блокуючий I/O дешевим за пам’яттю та перемиканням контексту. Тому в класичних веб-застосунках типу «отримав HTTP-запит $\rightarrow$ сходив у базу $\rightarrow$ смикнув REST API $\rightarrow$ віддав JSON» віртуальні потоки (у зв’язці зі стандартним синхронним кодом або StructuredTaskScope) роблять реактивний стек надлишковим ускладненням.
Проте реактивне програмування зберігає свою незамінність у наступних сферах:
- Стрімінг даних із контролем швидкості (Backpressure): віртуальні потоки не надають протоколу регулювання швидкості між незалежними потоками продюсера та консьюмера.
- Складна часова обробка подій: реактивні оператори типу
debounce(придушення деренчання),sample,bufferTimeout,windowскладно і неефективно писати на чистих потоках із блокуваннями. - Реактивні протоколи: RSocket, gRPC Streaming, WebSocket, SSE.
🎯 Шпаргалка для інтерв’ю
Матриця вибору архітектурного рішення
[ Яка природа завдання? ]
|
+----------------------------+----------------------------+
| |
[ Одиночний запит-відповідь ] [ Потік даних (0..N) ]
| |
Java 21+ доступна? Чи потрібен Backpressure?
/ \ / \
[Так] [Ні] [Так] [Ні]
| | | |
Синхронний код CompletableFuture Project Reactor Звичайний Stream /
на Virtual Threads (або CF + Custom Executor) (Flux / Mono) Channel / Queue
Ключові тези для співбесіди
- Потужність: CF — це скаляр ($0..1$). Реактивний стрім (
Flux) — це вектор/потік ($0..\infty$). - Backpressure: У CF відсутній повністю (небезпека OOM). У Reactive Streams це фундаментальна частина специфікації (
Subscription.request(n)). - Eager vs Lazy: CF гарячий (стартує одразу, повторне опитування бере кешований результат). Реактивний пайплайн холодний (не запуститься без
.subscribe(), перезапускається на кожну підписку). - Virtual Threads: Не вбивають реактивність, але звужують її нішу до реального стрімінгу, шин подій і систем із суворими вимогами до backpressure.
Антипатерни (Червоні прапорці на співбесіді)
- ❌ «Ми переписали CRUD-сервіс на Spring WebFlux, щоб він працював у 10 разів швидше» — WebFlux не прискорює одиночні виклики; він лише підвищує щільність з’єднань при повільному I/O ціною ускладнення стек-трейсів і втрати налагоджуваності.
- ❌ «CompletableFuture підтримує зворотний тиск, якщо вказати обмежену чергу в ThreadPoolExecutor» — це не backpressure, а груба відмова в обслуговуванні (
RejectedExecutionHandler). - ❌ «Virtual Threads роблять реактивне програмування застарілим мотлохом» — Virtual Threads вирішують проблему блокувань, але не дають операторів часових вікон, об’єднання потоків і backpressure.