JavaRush /Курси /JAVA 25 SELF /Вступ до CompletableFuture

Вступ до CompletableFuture

JAVA 25 SELF
Рівень 55 , Лекція 0
Відкрита

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 та інших.

flowchart LR A[Запуск завдання асинхронно] --> B[Обробка результату] B --> C[Наступна операція] C --> D[Обробка помилок]

Так 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(), якщо тільки це не крайній випадок (наприклад, у тестах).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ