Потоки, горутины и async/await в Java: пишем конкурентный код правильно

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Конкурентность в 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 теряет контекст).
  • Не решает проблему блокирующих операций в самом начале.

Сравнение подходов: когда что выбирать?

Давайте сведем все в одну таблицу (в виде списка, чтобы было наглядно):

Характеристика Кла

Блоги

Рекомендаций по книгам