⏱️ Розділ 19 · Питання #16

Що робить метод supplyAsync() і коли його використовувати

Метод CompletableFuture.supplyAsync() — це головний фабричний метод для запуску асинхронних обчислень, що повертають результат. Він приймає функціональний інтерфейс Supplier...


🟢 Junior Level

Метод CompletableFuture.supplyAsync() — це головний фабричний метод для запуску асинхронних обчислень, що повертають результат. Він приймає функціональний інтерфейс Supplier<T> (постачальник даних) та ініціює виконання завдання у фоновому пулі потоків.

Базовий приклад

// Запуск асинхронного завдання:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    // Код виконується у фоновому потоці
    return "Дані із зовнішнього сервісу";
});

// Неблокуюче споживання результату:
future.thenAccept(result -> System.out.println("Отримано: " + result));

Відмінність supplyAsync() від runAsync()

Метод Інтерфейс, що приймається Повернене значення Типове застосування
supplyAsync Supplier<T> (() -> T) CompletableFuture<T> Завантаження даних, обчислення, читання
runAsync Runnable (() -> void) CompletableFuture<Void> Відправка сповіщень, аудит, “fire-and-forget”
// supplyAsync — повертає значення T
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(42));

// runAsync — побічний ефект без результату
CompletableFuture<Void> auditFuture = CompletableFuture.runAsync(() -> sendAuditLog("User 42 logged in"));

🟡 Middle Level

Перевантаження методу та ізоляція пулів

Метод існує у двох варіантах:

  1. supplyAsync(Supplier<U> supplier): відправляє завдання в дефолтний системний пул ForkJoinPool.commonPool().
  2. supplyAsync(Supplier<U> supplier, Executor executor): відправляє завдання у вказаний користувацький Executor.
// Для I/O операцій ЗАВЖДИ передавайте виділений пул потоків:
ExecutorService ioExecutor = Executors.newFixedThreadPool(16);

CompletableFuture<List<Product>> productsFuture = CompletableFuture.supplyAsync(
    () -> catalogClient.getProducts(), 
    ioExecutor
);

Проблема перевірюваних винятків (Checked Exceptions)

Функціональний інтерфейс Supplier<T> оголошує метод T get(), який не декларує перевірюваних винятків (throws Exception). Якщо ваш метод викидає, наприклад, IOException чи SQLException, компілятор видасть помилку:

// ❌ ПОМИЛКА КОМПІЛЯЦІЇ: unhandled IOException
CompletableFuture.supplyAsync(() -> Files.readString(Path.of("config.json")));

// ✅ Рішення 1: традиційний try-catch із загортанням у RuntimeException
CompletableFuture.supplyAsync(() -> {
    try {
        return Files.readString(Path.of("config.json"));
    } catch (IOException e) {
        throw new UncheckedIOException(e); // Обернеться в CompletionException
    }
});

// ✅ Рішення 2: SneakyThrows (Lombok) або універсальний ThrowingSupplier
CompletableFuture.supplyAsync(rethrow(() -> Files.readString(Path.of("config.json"))));

🔴 Senior Level

Внутрішня будова: завдання AsyncSupply

Під капотом виклик CompletableFuture.supplyAsync(supplier, executor) влаштований наступним чином:

public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor) {
    return asyncSupplyStage(screenExecutor(executor), supplier);
}

static <U> CompletableFuture<U> asyncSupplyStage(Executor e, Supplier<U> f) {
    if (f == null) throw new NullPointerException();
    CompletableFuture<U> d = new CompletableFuture<U>();
    e.execute(new AsyncSupply<U>(d, f)); // Загортаємо у внутрішнє завдання
    return d;
}

Клас AsyncSupply успадковує ForkJoinTask<Void> і реалізує Runnable та AsynchronousCompletionTask:

static final class AsyncSupply<T> extends ForkJoinTask<Void> implements Runnable, AsynchronousCompletionTask {
    CompletableFuture<T> dep; 
    Supplier<? extends T> fn;

    AsyncSupply(CompletableFuture<T> dep, Supplier<? extends T> fn) {
        this.dep = dep; this.fn = fn;
    }

    public void run() {
        CompletableFuture<T> d; Supplier<? extends T> f;
        if ((d = dep) != null && (f = fn) != null) {
            dep = null; fn = null; // Зануляємо посилання для збирача сміття!
            if (d.result == null) {
                try {
                    d.completeValue(f.get()); // Успішний запис через CAS
                } catch (Throwable ex) {
                    d.completeThrowable(ex);  // Запис AltResult(ex)
                }
            }
            d.postComplete(); // Пробудження всіх підписників у стеку
        }
    }
}

Ключові архітектурні висновки:

  1. Посилання на dep та fn зануляються одразу після старту виконання, запобігаючи витокам пам’яті (Memory Leaks), якщо лямбда захопила великий контекст.
  2. Будь-який Throwable (включаючи Error та RuntimeException) перехоплюється і надійно фіксується в полі result, переводячи ф’ючер у стан винятку без краху потоку пулу.

4 Tricky Questions

1. Що станеться, якщо supplier.get() всередині supplyAsync поверне null? Чи вважається це помилкою?

Відповідь: Ні, це не вважається помилкою. Метод завершиться успішно. Всередині CompletableFuture значення null розпізнається та упаковується в спеціальний синглтон NIL (екземпляр AltResult із порожнім полем винятку). При подальшому виклику future.join() або передачі аргументу в thenAccept(val -> ...) значення NIL прозоро розпакується назад у звичайний null.


2. У чому різниця між executorService.submit(callable) та CompletableFuture.supplyAsync(supplier, executorService)?

Відповідь:

  • executorService.submit(Callable<T>) повертає класичний блокуючий Future<T> (Java 5). У нього немає методів композиції, комбінування та декларативної обробки помилок без блокування get(). Крім того, Callable може оголошувати перевірювані винятки (throws Exception).
  • CompletableFuture.supplyAsync(Supplier<T>, executor) повертає CompletableFuture<T> з підтримкою контракту CompletionStage (40+ методів неблокуючої композиції). Проте Supplier не дозволяє кидати checked-винятки без обгортки.

3. Якщо передане в supplyAsync() завдання зависло, і ми викликали future.cancel(true), чи перерветься виконання завдання в потоці?

Відповідь: Ні, завдання продовжить виконуватися до кінця! На відміну від FutureTask, у CompletableFuture.cancel(boolean mayInterruptIfRunning) прапорець mayInterruptIfRunning повністю ігнорується. Метод cancel() лише атомарно записує CancellationException у сам об’єкт CompletableFuture. Сигнал Thread.interrupt() на виконуючий потік воркера не надсилається. Якщо потрібна переривність, необхідно передавати зовнішні прапорці або використовувати класичний FutureTask.


4. Що станеться, якщо передати null як supplier: CompletableFuture.supplyAsync(null)?

Відповідь: Метод негайно викине NullPointerException у викликаючому потоці ще до постановки завдання в чергу екзекутора. Рантайм CompletableFuture виконує попередню сувору валідацію вхідних аргументів (if (f == null) throw new NullPointerException();), захищаючи конвеєр від створення невалідних порожніх завдань.


🎯 Шпаргалка для інтерв’ю

Головне за 60 секунд

  • supplyAsync(Supplier<T>) — асинхронний запуск завдання з поверненням значення T.
  • runAsync(Runnable) — асинхронний запуск завдання без повернення значення (Void).
  • Без другого аргументу використовується ForkJoinPool.commonPool(). Для будь-яких I/O операцій обов’язково передавати свій Executor.
  • Supplier не підтримує Checked Exceptions — їх потрібно загортати в RuntimeException.
  • Повернення null з лямбди абсолютно легальне і завершує ф’ючер успіхом.
  • future.cancel(true) не перериває потік виконання завдання supplyAsync.

Типові помилки на інтерв’ю

  • Плутати supplyAsync (повертає T) та runAsync (повертає Void).
  • Вважати, що cancel(true) надсилає Thread.interrupt() на потік, що виконує supplyAsync.
  • Запускати блокуючий код через supplyAsync без вказівки ізольованого пулу.

Пов’язані теми