Конкурентность в Java: от классических потоков до горутин и async/await
Java — это язык, который с самого рождения умеет работать с несколькими задачами одновременно. Но мир не стоит на месте. Сегодня мы поговорим о конкурентности в Java — от старых добрых потоков (Thread) до виртуальных потоков (горутин из Project Loom) и асинхронного подхода с async/await (на примере CompletableFuture).
Если вы пишете на Java и хотите, чтобы ваш сервер не «тормозил» под нагрузкой, а код оставался читаемым, — эта статья для вас. Поехали!
Почему конкурентность важна для современного Java-разработчика?
Представьте, что ваш сайт обрабатывает 10 000 запросов в секунду. Если каждый запрос выполняется последовательно, пользователи будут ждать вечность. Конкурентность в Java позволяет выполнять множество задач «одновременно», эффективно используя ресурсы процессора.
Современные приложения — это не просто CRUD. Это микросервисы, обработка потоков данных, WebSocket, real-time аналитика. Без понимания многопоточности Java вы рискуете написать код, который либо упадет с OutOfMemoryError, либо будет работать как улитка.
Уровень 1: Классические потоки (Thread) — фундамент
Поток в Java — это самая низкоуровневая абстракция для выполнения кода параллельно. Каждый поток — это отдельный «легковесный» процесс внутри операционной системы.
Вот как выглядит простейший пример создания потока:
public class ThreadExample {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("Привет из потока: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
thread.start();
System.out.println("Главный поток продолжает работу");
}
}
Плюсы:
- Простота создания — new Thread(() -> { ... }).start();
- Прямое управление жизненным циклом.
Минусы:
- Создание потока — дорогая операция (накладные расходы на стек, контекстное переключение).
- Ограничение по количеству: 10 000 потоков могут убить вашу JVM.
- Сложность синхронизации: deadlock, race condition.
Классический подход хорош для небольших задач, но для high-load приложений он не годится. Именно здесь на помощь приходят виртуальные потоки в Java.
Уровень 2: Виртуальные потоки (Project Loom) — горутины в Java
Если вы слышали про горутины в Go, то виртуальные потоки Java — это почти то же самое. Они появились в Java 21 (Project Loom) и полностью меняют правила игры.
Виртуальные потоки — это «легковесные» потоки, которыми управляет JVM, а не операционная система. Их можно создавать миллионами, и они потребляют минимум памяти.
Пример создания виртуального потока:
public class VirtualThreadExample {
public static void main(String[] args) throws InterruptedException {
// Создаем 100 000 виртуальных потоков
var threads = new Thread[100_000];
for (int i = 0; i < threads.length; i++) {
threads[i] = Thread.startVirtualThread(() -> {
// Имитация работы с блокирующим I/O
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
for (Thread t : threads) {
t.join();
}
System.out.println("Все виртуальные потоки завершены!");
}
}
Потрясающе, правда? 100 000 потоков без падения JVM. Конкурентность в Java с виртуальными потоками становится такой же простой, как в Go или Erlang.
Когда использовать виртуальные потоки?
- Блокирующие I/O операции (чтение файлов, запросы к БД, вызовы REST API).
- Микросервисы с тысячами одновременных подключений.
- Любой код, где много ожидания (sleep, wait, блокировки).
Когда НЕ использовать?
- CPU-bound задачи (сложные вычисления) — здесь виртуальные потоки не дадут выигрыша.
- Если у вас уже есть хорошо отлаженный код на CompletableFuture — миграция может быть неоправданной.
Уровень 3: Async/await через CompletableFuture
А что если вы не хотите работать с потоками вообще? Добро пожаловать в мир асинхронного программирования в Java. Здесь мы используем CompletableFuture — мощный инструмент для цепочек асинхронных операций.
По сути, это аналог async/await из JavaScript или C#, только без синтаксического сахара (пока что, но в Java есть предложения добавить его).
Вот пример асинхронного вызова API с обработкой результата:
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
public class AsyncAwaitExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// Асинхронный вызов, который вернет результат через 2 секунды
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(2000); // Имитация долгой операции
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "Результат асинхронной операции";
});
// Цепочка обработки
CompletableFuture<String> processedFuture = future
.thenApply(result -> result.toUpperCase())
.thenApply(result -> "Обработано: " + result);
// Блокируем основной поток, чтобы получить результат (как await)
String finalResult = processedFuture.get();
System.out.println(finalResult);
}
}
Здесь supplyAsync запускает задачу в ForkJoinPool (общем пуле потоков), а thenApply создает цепочку обработки — это и есть наш async/await в Java.
Плюсы CompletableFuture:
- Не блокирует потоки во время ожидания.
- Удобные комбинации: thenCombine, allOf, anyOf.
- Обработка ошибок через exceptionally.
Минусы:
- Код становится сложнее читать при длинных цепочках.
- Сложно отлаживать (stack trace теряет контекст).
- Не решает проблему блокирующих операций в самом начале.
Сравнение подходов: когда что выбирать?
Давайте сведем все в одну таблицу (в виде списка, чтобы было наглядно):