1. Проблема синхронного коду
Уявімо: у вас є програма, яка має завантажити дані з інтернету або прочитати великий файл. Ви пишете щось на кшталт:
String data = readFromFile("bigfile.txt");
System.out.println("Дані: " + data);
Усе було б добре, але якщо файл великий або мережа повільна, програма просто підвісає на рядку читання. Користувач дивиться на «підвислий» інтерфейс, сервер не може обслуговувати інші запити, а програміст... сумує.
Таку ситуацію називають блокуванням: потік (наприклад, головний потік вашого застосунку) змушений чекати, поки операція завершиться. А якщо таких операцій багато — усе, привіт, лаги та низька продуктивність.
Це якби ви прийшли до кав’ярні, зробили замовлення — і мусили стояти біля стійки, доки вам не приготують каву. А інші клієнти стоять за вами й також чекають, поки бариста закінчить із вами. Неефективно, правда?
Асинхронність: як вона рятує світ
Асинхронне програмування — це підхід, за якого тривалі операції (наприклад, читання файлу, запит до сервера, звернення до бази даних) виконуються у фоновому потоці, а основний потік продовжує працювати: обслуговувати користувачів, приймати нові запити, реагувати на події.
Тобто ви робите замовлення (запускаєте завдання), йдете займатися своїми справами, а коли кава готова (завдання завершилося), вам просто кажуть: «Готово!»
У Java до появи CompletableFuture із цим було не надто зручно. Подивімося, як усе розвивалося.
2. Історичні підходи: Future та його обмеження
У Java 5 з’явився інтерфейс Future — перша спроба зробити роботу з асинхронними завданнями бодай трохи зручнішою. Він давав змогу доручити завдання пулу потоків і згодом отримати результат.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Обережно: потік заблокується, доки завдання не виконається!
Здавалося б, ідея гарна, але на практиці з’ясувалося, що Future — це як стара поштова скринька: лист ви надіслали, але щоб дізнатися, чи надійшла відповідь, потрібно весь час зазирати всередину.
Він не вміє сповіщати, коли результат готовий, не підтримує ланцюжків дій на кшталт «зроби це, а потім те», не дає зручно обробляти помилки. Усе впирається в той самий блокувальний виклик get(), через який асинхронність перетворюється назад на очікування.
3. Поява CompletableFuture: новий стиль асинхронності
У Java 8 на зміну застарілому Future прийшов справжній герой асинхронності — CompletableFuture. Цей клас із пакета java.util.concurrent став універсальним інструментом для всіх, хто втомився чекати результатів «вручну» і хоче писати асинхронний код красиво, компактно й зрозуміло.
CompletableFuture вміє майже все. Він може запускати завдання в інших потоках, вибудовувати з них ланцюжки — наприклад, спочатку обчислити результат, потім його обробити, а далі зробити щось іще. Він легко комбінує кілька завдань: можна дочекатися завершення всіх одразу або лише першого, яке встигне. Помилки теж обробляються елегантно — без зайвих try-catch. А увесь стиль роботи стає ближчим до функціонального: замість нудних викликів і очікувань з’являються виразні методи на кшталт thenApply, thenAccept та інших.
Так CompletableFuture перетворив асинхронність із важкого ремесла на зручний і гнучкий інструмент, з яким код нарешті дихає вільно.
4. Найпростіший приклад: перший крок у світ CompletableFuture
Погляньмо на мінімальний приклад асинхронного завдання:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// Запускаємо завдання асинхронно
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// Отримуємо результат (блокує потік!)
try {
int result = future.get();
System.out.println("Результат: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
Цей код уже виконує обчислення в окремому потоці — основний потік не блокується в момент запуску завдання. Але виклик get() усе ж блокує потік, доки результат не буде готовий.
А як НЕ блокувати потік?
Усе просто: використовуйте методи‑колбеки, які викликаються, коли завдання завершиться:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("Результат: " + result));
System.out.println("Я не блокуюся і можу робити щось ще!");
Висновок:
- thenAccept — це «підпишись на результат»: коли завдання виконається, виклич цей код.
- Основний потік не чекає виконання завдання, а продовжує працювати.
Візуалізація (псевдокод подій)
[Головний потік] --> [Запуск завдання]
| |
v v
[Робить щось] [Фоновий потік рахує 2+2]
| |
v v
[Друкує "Я не блокуюся..."]
| |
v v
[Коли пораховано — викликається thenAccept]
5. Як це виглядає в застосунку?
Уявімо, що ви розробляєте консольний застосунок, де користувач може замовити завантаження даних (наприклад, із бази або з сервера), а поки дані завантажуються — програма не «підвисає», а продовжує приймати команди.
Приклад: імітація тривалої операції
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("Починаємо завантаження даних...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// Імітація тривалого завантаження
try {
Thread.sleep(2000); // 2 секунди
} catch (InterruptedException e) {
return "Помилка завантаження";
}
return "Дані успішно завантажено!";
});
// Підписуємося на результат
dataFuture.thenAccept(result -> System.out.println("Результат: " + result));
// Програма продовжує працювати
System.out.println("Поки дані завантажуються, я можу робити щось ще!");
// Щоб програма не завершилася завчасно (лише для демо!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
Що побачите в консолі:
Починаємо завантаження даних...
Поки дані завантажуються, я можу робити щось ще!
[за 2 секунди]
Результат: Дані успішно завантажено!
6. Корисні нюанси
Трохи про потоки під капотом
Коли ви пишете CompletableFuture.supplyAsync(...), завдання за замовчуванням виконується в так званому ForkJoinPool — це спеціальний пул потоків, який Java використовує для паралельних завдань. Якщо вам потрібно більше контролю (наприклад, свій ExecutorService), його можна передати другим параметром:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
Але для простих завдань достатньо й стандартного пулу.
Отримання результату: get(), join(), thenAccept
- get() — блокує потік, доки результат не буде готовий (кидає checked‑винятки).
- join() — теж блокує, але кидає unchecked‑винятки (RuntimeException).
- thenAccept(), thenApply() та інші — НЕ блокують, а викликають передану функцію, коли результат готовий.
У реальних асинхронних застосунках намагайтеся уникати get()/join() у головному потоці!
7. Типові помилки на перших кроках із CompletableFuture
Помилка № 1: Використання get() або join() у головному потоці.
Так ви знову блокуєте програму й втрачаєте всі переваги асинхронності. Натомість використовуйте thenAccept, thenApply та інші методи для обробки результату.
Помилка № 2: Забули обробити помилку.
Якщо в асинхронному завданні виникне виняток, він не «вискочить» в основний потік. Без обробки через exceptionally або handle ви просто не дізнаєтеся, що щось пішло не так.
Помилка № 3: Не дочекалися завершення програми.
У демо‑прикладах часто доводиться «пригальмовувати» main‑потік через Thread.sleep — інакше програма завершиться раніше, ніж завдання виконається. У реальних застосунках (наприклад, у веб‑серверах) це не проблема, але в консольних демо‑прикладах — враховуйте це.
Помилка № 4: Плутають thenAccept і thenApply.
thenAccept — для «побічних ефектів» (нічого не повертає), thenApply — для перетворення результату (повертає новий результат).
Помилка № 5: Змішують асинхронний і синхронний код без потреби.
Якщо ви почали писати асинхронно — не тягніть назад у синхрон через get()/join(), якщо тільки це не крайній випадок (наприклад, у тестах).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ