1. Запуск асинхронной задачи: supplyAsync и runAsync
Самый распространённый способ запустить асинхронную задачу — использовать CompletableFuture.supplyAsync. Этот метод принимает лямбду или метод, который возвращает результат. Например, мы хотим имитировать загрузку данных с сервера:
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// Имитация долгой операции (например, загрузки файла)
sleep(1000);
return "Данные с сервера";
});
System.out.println("Задача запущена!");
// ... здесь можно делать что-то еще, пока задача выполняется
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
}
runAsync: когда результат не нужен
Если ваша задача ничего не возвращает (например, просто пишет в лог, отправляет уведомление), используйте runAsync:
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
sleep(500);
System.out.println("Операция завершена!");
});
runAsync всегда возвращает CompletableFuture<Void>, потому что результат не предполагается.
2. thenApply, thenAccept, thenRun: в чем разница?
Когда асинхронная задача завершилась, обычно хочется что-то сделать с результатом. Для этого придуманы методы‑«обработчики»:
- thenApply — преобразует результат и возвращает новый результат.
- thenAccept — принимает результат, ничего не возвращает (используется для побочных эффектов).
- thenRun — не принимает результат, ничего не возвращает (просто выполняет действие после завершения задачи).
thenApply: обработка и преобразование результата
Если вам нужно преобразовать результат предыдущей задачи, используйте thenApply. Например, загрузили строку, а теперь хотим узнать её длину:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Java");
CompletableFuture<Integer> lengthFuture = future.thenApply(s -> {
System.out.println("Вычисляем длину строки...");
return s.length();
});
// lengthFuture теперь содержит Integer — длину строки "Java"
lengthFuture.thenAccept(len -> System.out.println("Длина: " + len));
Что происходит:
- future содержит строку "Java".
- thenApply преобразует строку в её длину (int).
- thenAccept выводит результат.
thenAccept: действие с результатом (ничего не возвращает)
Если нужно просто что-то сделать с результатом (например, вывести его на экран), а возвращать ничего не надо — используйте thenAccept:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Привет, мир!");
future.thenAccept(result -> {
System.out.println("Результат: " + result);
});
thenAccept — это как «потребитель»: он съедает результат и делает с ним что-то полезное.
thenRun: действие без результата
Если вы хотите просто выполнить какое-то действие после завершения задачи, но результат вам не нужен, используйте thenRun:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Готово!");
future.thenRun(() -> {
System.out.println("Загрузка завершена!");
});
Обратите внимание: внутри thenRun вы не можете получить результат предыдущей задачи — он просто игнорируется.
3. Цепочки вызовов: строим pipeline из задач
Самая большая сила CompletableFuture — это умение строить цепочки вычислений. Каждый метод (thenApply, thenAccept, thenRun) возвращает новый CompletableFuture, к которому можно снова добавить обработчик.
Пример: многоступенчатая обработка
Давайте доработаем наше приложение: загрузим данные, преобразуем их, выведем результат и напишем в лог, что всё завершено.
CompletableFuture.supplyAsync(() -> {
System.out.println("Шаг 1: Загружаем данные...");
sleep(500);
return "Java";
})
.thenApply(data -> {
System.out.println("Шаг 2: Преобразуем данные...");
return data.toUpperCase();
})
.thenAccept(result -> {
System.out.println("Шаг 3: Выводим результат: " + result);
})
.thenRun(() -> {
System.out.println("Шаг 4: Всё завершено!");
});
Выход в консоль:
Шаг 1: Загружаем данные...
Шаг 2: Преобразуем данные...
Шаг 3: Выводим результат: JAVA
Шаг 4: Всё завершено!
Обратите внимание:
Каждый следующий шаг начинается только после завершения предыдущего. Это позволяет строить настоящие «конвейеры» обработки данных.
4. Асинхронные варианты: thenApplyAsync, thenAcceptAsync, thenRunAsync
По умолчанию обработчики (thenApply, thenAccept, thenRun) выполняются в том же потоке, в котором завершилась предыдущая задача. Иногда это не очень удобно — если обработка тяжелая, лучше вынести её в отдельный поток.
Для этого придуманы асинхронные версии:
- thenApplyAsync
- thenAcceptAsync
- thenRunAsync
В чём разница?
- Без Async: обработчик может выполниться в том же потоке, что и предыдущая задача (например, если задача была завершена в ForkJoinPool, обработчик там же).
- С Async: обработчик гарантированно будет выполнен в другом потоке из ForkJoinPool (или вашего Executor’а).
Пример: сравним обычный и асинхронный обработчик
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Загрузка... [" + Thread.currentThread().getName() + "]");
return "Hello";
});
future.thenApply(result -> {
System.out.println("thenApply: [" + Thread.currentThread().getName() + "]");
return result + " World";
});
future.thenApplyAsync(result -> {
System.out.println("thenApplyAsync: [" + Thread.currentThread().getName() + "]");
return result + " Async World";
});
Типичный вывод:
Загрузка... [ForkJoinPool.commonPool-worker-1]
thenApply: [ForkJoinPool.commonPool-worker-1]
thenApplyAsync: [ForkJoinPool.commonPool-worker-2]
Вывод:
Асинхронный обработчик выполняется в другом потоке.
Когда использовать Async-методы?
- Если обработка ресурсоёмкая (например, сложные вычисления, работа с сетью).
- Если не хотите блокировать поток, в котором завершилась предыдущая задача.
- Если хотите явно управлять потоками (например, передать свой Executor как второй аргумент).
5. Полезные нюансы
Таблица: сравнение методов thenApply, thenAccept, thenRun
| Метод | Использует результат? | Возвращает значение? | Для чего использовать |
|---|---|---|---|
|
Да | Да | Преобразование результата |
|
Да | Нет | Побочные эффекты (вывод, логирование) |
|
Нет | Нет | Просто действие после завершения задачи |
|
Да | Да | То же, но в другом потоке |
|
Да | Нет | То же, но в другом потоке |
|
Нет | Нет | То же, но в другом потоке |
Вопрос: как строить длинные цепочки?
Можно вызывать методы друг за другом, как конструктор LEGO:
CompletableFuture.supplyAsync(() -> "42")
.thenApply(Integer::parseInt)
.thenApply(x -> x * 2)
.thenAccept(x -> System.out.println("Результат: " + x));
Выход:
Результат: 84
Каждый следующий шаг получает результат предыдущего, может его изменить или просто использовать.
6. Типичные ошибки при работе с thenApply, thenAccept, thenRun
Ошибка №1: Путаница в типах возвращаемых значений.
thenApply должен возвращать значение, которое пойдёт дальше по цепочке. Если вы случайно используете thenApply, но не возвращаете результат, следующая операция получит null (или вообще не скомпилируется). Для побочных эффектов используйте thenAccept или thenRun.
Ошибка №2: Попытка использовать результат в thenRun.
Внутри thenRun нет доступа к результату предыдущей задачи. Если вы хотите использовать результат, выбирайте thenApply или thenAccept.
Ошибка №3: Блокировка основного потока.
Если вы вызываете get() или join() в главном потоке, вы теряете все преимущества асинхронности: поток будет ждать завершения задачи, как в старом добром синхронном коде. Лучше использовать неблокирующие цепочки и колбэки.
Ошибка №4: Необработка ошибок.
Если в цепочке возникает исключение, а вы не добавили обработчик (exceptionally, handle, whenComplete), оно «потеряется», и задача может завершиться с ошибкой, которую вы не увидите. Всегда обрабатывайте ошибки в цепочках.
Ошибка №5: Неожиданное выполнение в другом потоке.
Асинхронные методы (thenApplyAsync и др.) могут выполняться в другом потоке. Если вы обращаетесь к переменным, не защищённым от многопоточного доступа, могут возникнуть гонки данных.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ