Всем привет!

Многопоточность в Java не стоит на месте, а многие до сих пор используют только synchronized и создают потоки через new Thread(). С этого дня я запускаю серию уроков по современной многопоточности: как её правильно строить, в чём преимущества новых подходов по сравнению со старыми и что из классики всё ещё стоит использовать. Постараюсь объяснять максимально просто и наглядно, чтобы уроки были полезны и стажёрам, которые только начинают разбираться в теме, и опытным разработчикам, которым интересно узнать современный стиль работы с потоками.

Поехали!

Так как статья предназначена для "самых маленьких", начну немного с основ)

Что такое поток?


В Java поток — это отдельная линия выполнения внутри программы. Потоки работают параллельно, разделяют общую память и могут обращаться к одним и тем же данным. При этом у каждого потока есть свой стек и свои локальные переменные, поэтому он может выполнять задачи независимо от других. Это позволяет программе делать несколько вещей одновременно.

Параллелизм vs конкурентность

Термины параллелизм и конкурентность часто используют как синонимы. Однако это разные понятия.

Параллелизм — это когда несколько задач выполняются действительно одновременно. Например, если у вас дома две стиральные машины — вы можете запустить две стирки параллельно.

Конкурентность — это когда задачи выполняются по очереди, но так, что создаётся ощущение одновременности. Например, если у вас одна стиральная машина: вы загрузили стирку, пока она идёт — убираете квартиру, потом проверяете стирку, затем отвечаете на сообщения. Всё происходит «в одно время», хотя физически — по очереди.

Параллелизм зависит от количества ресурсов (ядер, машинок), а конкурентность — от того, как вы организуете работу.

Запуск потоков

Независимо от того, какой способ создания потока мы выбираем, всё начинается с вызова метода start у объекта Thread. Это очень важный шаг, потому что вызов start делает гораздо больше, чем просто выполнение метода run. Он выполняет подготовительные действия, такие как выделение системных ресурсов. После этого метод start вызывает run, но уже в новом потоке выполнения.

Важно: нельзя вызывать run напрямую. Если сделать так, то метод run выполнится в текущем потоке, а не в новом.

public static void main(String[] args) throws Exception {
    new Thread(() -> {
        System.out.println(Thread.currentThread().getName());
    }).run();

    new Thread(() -> {
        System.out.println(Thread.currentThread().getName());
    }).start();
}

Например в этом коде вместо Thread-0 и Thread-1 мы получаем main и Thread-1

Понимание скрытых расходов

Современные веб-приложения часто используют модель «один поток на запрос»: каждому запросу выделяется поток, который обрабатывает его от начала до конца. Но увеличивать число потоков бесконечно нельзя.

Хотя кажется, что больше потоков = выше пропускная способность, на практике это не всегда так. Один поток занимает около 2 МБ памяти вне кучи, и при тысячах запросов это становится серьёзным ограничением. В итоге слишком большое количество потоков может не помочь, а наоборот — снизить эффективность приложения.

Пропускная способность (throughput) — это просто количество обработанных запросов в единицу времени, и она растёт не только за счёт увеличения числа потоков, но и за счёт правильной архитектуры и эффективного использования ресурсов.

Как узнать, сколько потоков сможет выдержать ваша система?
Есть небольшой тест, который сможет показать, сколько потоков сможет выдержать ваша система:

public static void main(String[] args) {
    var threadCount = new AtomicInteger(0);
    try {
        while (true) {
            var thread = new Thread(() -> {
                threadCount.incrementAndGet();
                LockSupport.park();
            });
            thread.start();
        }
    } catch (OutOfMemoryError error) {
        System.out.println("Лимит ваших потоков: " + threadCount);
    }
}

Как эффективно применить ресурсы

Давайте рассмотрим пример кода. Есть 4 отдельных сущности:

public record Client(Long id, String name) {} //Клиент
public record City(String name, int coordinate) {} //Город
public record Item(String name, double price) {} //Товар
public record Order(clientId: Long, double price, String city) {} //Заказ

И есть метод:

 public static Order makeOrder(Long clientId) {
        Client client = getClient(clientId); // Вызов бд = блокировка потока
        Item item = getItem(client);    // Вызов смежной системы = блокировка потока
        City city = getCityFromYandex(client); // Вызов смежной системы = блокировка потока
        anyWork(); // любой другой метод
        return createOrder(clientId, item, city);
    }

Здесь рассматривается пример плохого кода. Каждый метод ждет ответ.
Где-то это блок от бд, где-то от смежной системы. Если предположить, что каждый метод выполняется 500мс, то выполнение calculateCredit выполнится за 2.5 секунды.

Напишем класс, который будет проверять время выполнения потока:

public class ThreadTimer {
    public static <T> T measure(Callable<T> task) throws Exception {
        long startTime = System.nanoTime();
        try {
            return task.call();
        } finally {
            long endTime = System.nanoTime();
            long duration = (endTime - startTime) / 1_000_000;
            System.out.println("Время выполнения: " + duration + " мс");
        }
    }
}

Также добавим наши методы, вместо реальных вызовов бд и смежных систем будем заставлять поток спать:

public static Client getClient(Long personId) {
    delay(500);
    return new Client(personId, "Mikhail");
}

public static Item getItem(Client person) {
    delay(500);
    return new Item("iphone", 1000);
}
public static City getCityFromYandex(Client person) {
    delay(500);
    return new City("SPB", 52);
}
public static void anyWork() {
    delay(500);
}

public static Order createOrder(Long personId, Item item, City liabilities) {
    delay(500);
    return new Order(personId, item.price(), liabilities.name());
}

public static void delay(int milliseconds) {
    try {
        Thread.sleep(milliseconds);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException(e);
    }
}

ВАЖНО: При прерывании поток получает InterruptedException, но сам флаг прерывания при этом сбрасывается. Чтобы не потерять информацию о том, что поток был прерван, нужно снова установить флаг — для этого вызывают Thread.currentThread().interrupt().
Такой шаблон гарантирует, что вышестоящий код сможет корректно увидеть прерывание.

Теперь запускаем код:

public static void main(String[] args) throws Exception {
    ThreadTimer.measure(() -> CalculateOrderMethods.makeOrder(1L));
}

Время выполнения: 2554 мс

Как и ожидалось, время выполнения 2.5 секунды.

Мы же с вами видим, что методы getItem getCityFromYandex и anyWork не связаны друг с другом, так давайте же этим воспользуемся:

public static Order makeOrderWithThreads(Long clientId) throws InterruptedException {
    var client = getClient(clientId);

    var itemRef = new AtomicReference<Item>();
    var t1 = new Thread(() -> {
        Item item = getItem(client);
        itemRef.set(item);
    });

    var cityRef = new AtomicReference<City>();
    Thread t2 = new Thread(() -> {
        City city = getCityFromYandex(client);
        city.set(liabilities);
    });

    var t3 = new Thread(OrderMethods::anyWork);

    t1.start();
    t2.start();
    t3.start();

    t1.join();
    t2.join();
    var order = createOrder(clientId, itemRef.get(), cityRef.get());
    t3.join();

    return order;
}

Запускаем код:

public static void main(String[] args) throws Exception {
    ThreadTimer.measure(() -> CalculateOrderMethods.makeOrderWithThreads(1L));
}

Время выполнения: 1522 мс

Отлично, мы сократили время выполнения с 2.5 до 1.5 секунд

Как работает этот код:

  1. Используется AtomicReference для безопасного обмена данными между потоками.

  2. Потокобезопасное присваивание результатов, доступных главному потоку.

  3. Независимая работа выполняется параллельно, не влияя на расчёт заказа

  4. Все три потока запускаются одновременно, сокращая общее время выполнения.

  5. Ждём завершения потоков item и city перед расчётом заказа.

  6. Заказ рассчитывается с использованием результатов параллельных операций.

  7. Обеспечивается завершение независимой работы до выхода метода.

Мы использовали AtomicReference из стандартного пакета Java Concurrency — потокобезопасную структуру, содержащую ссылку на один объект. При работе с несколькими потоками это критически важно.

Но не слишком ли мы добавили много кода? Статья же была про современный подход. И тут вы совершенно правы, давайте рассмотрим вариант с Executor Framework

Executor Framework

Чтобы избежать опасностей, связанных с созданием потоков «на лету», лучше использовать один из фреймворков для работы с параллелизмом в Java, например ExecutorService, который предоставляет более структурированный способ управления потоками. Кроме того, использование ExecutorService не только контролирует количество одновременно работающих потоков, но и позволяет эффективно переиспользовать их, управляя их жизненным циклом. Это помогает избавиться от накладных расходов, возникающих при создании и завершении потоков вручную.

Новый пример кода:

public static Order makeOrderWithExecutor(Long clientId) throws ExecutionException, InterruptedException {
    try (ExecutorService executor = Executors.newFixedThreadPool(5)) {
        Client client = getClient(clientId);
        Future<Item> itemFuture = executor.submit(() -> getItem(client));
        Future<City> cityFuture = executor.submit(() -> getCityFromYandex(client));
        executor.submit(OrderMethods::anyWork);
        return createOrder(clientId, itemFuture.get(), cityFuture.get());
    }
}

Запускаем:

public static void main(String[] args) throws Exception {
    ThreadTimer.measure(() -> CalculateOrderMethods.makeOrderWithExecutor(1L));
}

Время выполнения: 1524 мс

Время выполнение оказалось таким же, как и у кода выше, хотя кода стало гораздо меньше и он стал чище.

Разберём, что здесь было сделано:

  1. Создаётся пул потоков, который автоматически завершится, когда выполнение блока try закончится.

  2. Запускаются оба важных задания, результаты которых нам нужны.

  3. Отправляется дополнительная работа, которая не должна блокировать основной поток.

  4. Ожидаются результаты ключевых задач и производится их обработка.

Future — это обещание результата, который будет доступен позже.

Представь, что ты заказал пиццу.
Тебе дают номер заказа — это и есть Future.
Пицца ещё не готова, но у тебя уже есть квитанция, по которой ты потом можешь:

  • узнать, готова ли пицца (isDone()),

  • подождать, пока её приготовят (get()),

  • или вообще отменить заказ (cancel()).

Future позволяет запустить задачу в другом потоке и получить её результат, когда он будет готов — не блокируя основной поток прямо сейчас.

Решение задачи с CompletableFuture

CompletableFuture — класс, упрощающий асинхронное программирование за счёт акцента на композицию и удобство использования.

CompletableFuture позволяет писать код асинхронно в декларативном и функциональном стиле, без излишнего количества callback-ов. Его богатый API позволяет легко цеплять несколько асинхронных операций, улучшая читаемость и поддержку кода.

Решение с CompletableFuture выглядит следующим образом:

    public static Order makeOrderWithCompletableFuture(Long clientId) throws InterruptedException, ExecutionException {
        // Запускаем все задачи параллельно
        CompletableFuture<Client> clientFuture = supplyAsync(() -> getClient(clientId));
        CompletableFuture<Item> itemFuture = clientFuture.thenApplyAsync(OrderMethods::getItem);
        CompletableFuture<City> cityFuture = clientFuture.thenApplyAsync(OrderMethods::getCityFromYandex);
        CompletableFuture<Void> anyWorkFuture = runAsync(OrderMethods::anyWork);

        // Ждём завершения всех и создаём заказ
        return allOf(clientFuture, itemFuture, cityFuture, anyWorkFuture)
                .thenApply(v -> {
                    try {
                        Client client = clientFuture.get();
                        Item item = itemFuture.get();
                        City city = cityFuture.get();
                        return createOrder(client.id(), item, city);
                    } catch (InterruptedException | ExecutionException e) {
                        Thread.currentThread().interrupt();
                        throw new RuntimeException(e);
                    }
                })
                .get();
    }

Время выполнения: 1539 мс

Как это работает:

  • Асинхронно запускается независимая работа (anyWork) — она выполняется параллельно с остальными задачами.

  • Асинхронно загружается клиент (getClient) — эта задача тоже сразу стартует.

  • Как только клиент готов, параллельно запускаются загрузка товара (getItem(client)) и города (getCityFromYandex(client)), используя уже готовый объект клиента.

  • Когда все результаты готовы (клиент, товар, город, любая работа), создаётся итоговый заказ.

  • Основной поток блокируется на .get(), пока все задачи не завершатся и заказ не будет готов.

Виртуальные потоки

Project Loom — ключевой шаг к современному «первоклассному» подходу к конкурентности в Java. Он представил новый тип потоков — виртуальные потоки, открывая новую эру конкурентности в Java.

Виртуальные потоки очень лёгкие и могут создаваться по требованию с минимальными накладными расходами по сравнению с классическим созданием потоков. Их можно создавать миллионами, что открывает новые возможности для высококонкурентных и масштабируемых приложений.

Коротко плюсы виртуальных потоков в Java:

  1. Тысячи потоков без проблем с памятью — каждый занимает минимум ресурсов.

  2. Простая модель один поток на задачу — пишешь код как для обычных потоков, без коллбеков.

  3. Легко масштабировать I/O-heavy приложения — миллионы соединений без сложных пулов.

  4. Совместимы с существующим API — работают с ExecutorService, CompletableFuture и др.

Давайте внедрим виртуальные потоки в наш пример с ExecutorService:

public  static Order makeOrderWithVirtualThread(Long clientId) throws ExecutionException, InterruptedException {
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
        Client client = getClient(clientId);
        Future<Item> itemFuture = executor.submit(() -> getItem(client));
        Future<City> cityFuture = executor.submit(() -> getCityFromYandex(client));
        executor.submit(OrderMethods::anyWork);
        return createOrder(clientId, itemFuture.get(), cityFuture.get());
    }
}

Время выполнения: 1520 мс

Так как виртуальные потоки полностью совместимы со старым API, нам достаточно вызвать Executors.newVirtualThreadPerTaskExecutor() и мы уже получим новый функционал с виртуальными потоками.

Итог

Сегодня мы познакомились с современным подходом к многопоточности в Java.

  • Поняли, что потоки — это не только Thread и synchronized, а удобные и мощные инструменты для параллельной и конкурентной работы.

  • Разобрались, в чём разница между конкурентностью и параллелизмом, и как эффективно запускать несколько задач одновременно.

  • Посмотрели, как ExecutorService, Future и CompletableFuture помогают писать асинхронный код без лишнего хаоса и повторных вызовов.

  • Узнали, что виртуальные потоки позволяют создавать тысячи потоков без больших затрат памяти и упрощают работу с I/O-heavy задачами.

Если тебе было интересно, дальше мы будем разбирать многопоточность ещё глубже:
будем смотреть правильное использование CompletableFuture, оптимизацию виртуальных потоков.

То есть, впереди будет ещё более увлекательно и наглядно, а главное — очень практично для реальной работы в Java.

Комментарии (1)


  1. RomTec
    24.11.2025 22:05

    Конкурентность — это когда задачи выполняются по очереди, но так, что создаётся ощущение одновременности. Например, если у вас одна стиральная машина: вы загрузили стирку, пока она идёт — убираете квартиру, потом проверяете стирку, затем отвечаете на сообщения. Всё происходит «в одно время», хотя физически — по очереди.

    ... у вас одна стиральная машина: вы загрузили стирку, пока она идёт — убираете квартиру ...

    Вот здесь неувязка - два исполнителя (но разных) и два параллельных процесса - в одном стиралка стирает, в другом хозяин(ка) убирает.

    А вот дальше похоже на конкурентность, т.к. исполнитель один и именно он переключается на разные подпроцессы - убирает квартиру, проверяет стирку, отвечает на сообщения