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 — иначе программа завершится раньше, чем задача выполнится. В реальных приложениях (например, в web-серверах) это не проблема, но в консольных демках — учитывайте это.

Ошибка № 4: Путают thenAccept и thenApply.
thenAccept — для «побочных эффектов» (ничего не возвращает), thenApply — для преобразования результата (возвращает новый результат).

Ошибка № 5: Смешивают асинхронный и синхронный код без нужды.
Если вы начали писать асинхронно — не тяните обратно в синхрон через get()/join(), если только это не крайний случай (например, в тестах).

1
Задача
JAVA 25 SELF, 55 уровень, 0 лекция
Недоступна
Сердце Мыслителя: Асинхронная Трансформация Энергии
Сердце Мыслителя: Асинхронная Трансформация Энергии
1
Задача
JAVA 25 SELF, 55 уровень, 0 лекция
Недоступна
Финансовый Страж: Управление Рисками в Асинхронных Транзакциях
Финансовый Страж: Управление Рисками в Асинхронных Транзакциях
Комментарии (3)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Sagil Уровень 51
16 ноября 2025
55
Andrey Уровень 1
29 октября 2025
55+
I'll kick them all Уровень 5
15 октября 2025
55