⏱️ Раздел 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 без указания изолированного пула.

Связанные вопросы