Всем привет!

Многопоточность в Java развивается очень быстро, а многие всё ещё ограничиваются обычными потоками и ключевым словом synchronized. Сегодня я хочу рассказать именно о виртуальных потоках: как с ними работать, почему они меняют подход к многопоточности и какие задачи решают лучше традиционных механизмов. Буду объяснять просто и понятно, чтобы материал был полезен как новичкам, которые только знакомятся с виртуальными потоками, так и опытным разработчикам, которые хотят понять современные практики и возможности Project Loom.

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

Виртуальные потоки управляются JVM, что делает их намного более эффективными по сравнению с традиционными потоками, за управление которыми отвечает операционная система.

Виртуальные потоки выполняются поверх так называемых carrier threads — это обычные потоки (платформенные), взятые из Fork/Join Pool.

Благодаря этому они автоматически наследуют преимущества:

  • оптимизированного планировщика;

  • work-stealing алгоритмов;

  • интеллектуального распределения нагрузки.

Внутренний планировщик виртуальных потоков в JVM основан на ForkJoinPool, работающем в режиме FIFO (First-In, First-Out).

ВАЖНО: этот планировщик отдельный, он не совпадает с общим пулом ForkJoinPool.commonPool(), который, например, используется для parallel streams (и работает в режиме LIFO).

Два типа потоков в Java

Платформенные потоки

Платформенные потоки — это те самые потоки, которые Java использует с момента своего появления.

Характеристики платформенных потоков:

  • Это тяжёлые потоки, которые выполняются напрямую операционной системой.

  • Каждый Java-поток соответствует одному потокy ядра (kernel thread) — это модель “one-to-one”.

  • Планирование, переключение контекста и управление полностью осуществляются операционной системой.

  • Именно на них много лет строилась вся модель конкурентности Java.

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

Виртуальные потоки — это user-mode threads, или легковесные потоки, добавленные в Java начиная с JDK 21.

Их ключевые особенности:

  • Управляются полностью JVM, без участия ОС в планировании.

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

  • Они не привязаны напрямую к потокам ядра.

  • Вместо этого множество виртуальных потоков разделяют небольшой пул carrier threads — это обычные платформенные потоки.

Таким образом, JVM может эффективно мультиплексировать (переключать) огромное количество виртуальных потоков на сравнительно малое количество системных потоков.

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

Виртуальные потоки отличаются следующими ключевыми особенностями:

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

Планирование.
Их планирует JVM, а не операционная система, что снижает накладные расходы и позволяет эффективнее использовать CPU.

Работа с блокировками.
Блокирующие операции (ввод/вывод, sleep и т. д.) не замедляют виртуальные потоки: при блокировке они освобождают carrier thread, и другие потоки продолжают выполняться.

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

Благодаря этому виртуальные потоки становятся более масштабируемой и производительной моделью конкурентности.

Виртуальные потоки Project Loom стали стабильной функцией, начиная с JDK 21. Чтобы начать их использовать, убедитесь, что у вас установлен JDK 21.

Создание виртуальных потоков в Java

Для простых случаев метод Thread.startVirtualThread() предлагает прямой подход. Он принимает Runnable, поэтому мы можем передать лямбда-выражение, и переданный код выполнится:

Thread.startVirtualThread(() -> System.out.println("Привет всем!"));

Однако, если запустить этот код, то ничего не будет выведено, почему?
Виртуальные потоки в Java по умолчанию являются daemon-потоками. Это означает, что когда основной поток (тот, который создал виртуальный поток) завершает работу, JVM завершает все оставшиеся daemon-потоки. Пример:

public static void main(String[] args) throws InterruptedException {
    Thread vThread = Thread.startVirtualThread(() -> 
        System.out.println("Вот теперь я погнал")
    );
    vThread.join();
}

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

Thread newThread = Thread.ofVirtual()
        .start(() -> System.out.println("Всем привет"));
newThread.join();

Чтобы создать поток без немедленного запуска, используйте следующее:

Thread unstartedThread = Thread.ofVirtual()
        .unstarted(() -> System.out.println("Всем привет"));

unstartedThread.start();
unstartedThread.join();

Также вы можете создавать виртуальные потоки через executors:

try (ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> future = virtualExecutor.submit(Main::sendMessage);
}

Группы потоков

  • Все виртуальные потоки в Java всегда принадлежат одной и той же группе потоков — default thread group.

  • API не позволяет указать собственную ThreadGroup при создании виртуального потока.

  • Более того, виртуальные потоки специально ограничены в этом плане: метод Thread.ofVirtual() не принимает ThreadGroup, и попытка изменить группу вручную тоже невозможна — это сделано для упрощения модели и повышения безопасности.

  • Платформенные потоки по-прежнему могут работать в разных группах, но виртуальные — нет.

Это прямое намеренное решение проектировщиков Project Loom: виртуальные потоки не должны использовать ThreadGroup, потому что сама концепция ThreadGroup считается устаревшей и практически не применяемой в современной Java.

Закон Литтла

Закон Литтла — фундаментальный принцип, описывающий связь между тремя ключевыми параметрами производительности: пропускной способностью, конкурентностью и временем ответа. Для стабильной системы он выражается формулой:

λ = N / d

где

  • λ — пропускная способность (сколько задач завершается в секунду)

  • N — среднее число одновременно обрабатываемых задач

  • d — среднее время обработки одной задачи

Закон универсален: он не различает время работы и ожидания и применим к любым системам - от потоков в Java до кассы самообслуживания

Используя виртуальные потоки, мы фактически можем увеличить N в законе Литтла, что повышает параллелизм и, следовательно, пропускную способность. Это особенно полезно, когда задержку d трудно уменьшить из-за характера работы

Когда лучше применять виртуальные потоки

Если у приложения тысячи или десятки тысяч параллельных задач — виртуальные потоки сильно помогают:

  • высоконагруженных веб-серверов

  • сервисов, обрабатывающих массу параллельных I/O-операций (Input/Output)

  • микросервисной архитектуры, где каждый запрос → поток

Виртуальные потоки особенно эффективны, когда задачи больше ожидают результата, чем используют CPU:

  • сетевые вызовы

  • работа с БД

  • задержки, таймауты

  • файловые операции

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

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

В основе виртуальных потоков лежит альтернативная реализация java.lang.Thread, которая хранит стековые фреймы в куче, собираемой GC.
В отличие от обычных потоков, использующих большие непрерывные блоки памяти ОС, виртуальные потоки:

  • занимают изначально всего несколько сотен байт,

  • динамически увеличивают и уменьшают размер стека,

  • значительно экономят ресурсы.

Операционная система не знает о виртуальных потоках — она видит только платформенные потоки.

Чтобы выполнить код виртуального потока, JVM монтирует его на платформенный поток — carrier thread, который берётся из специализированного ForkJoinPool.

Во время монтирования:

  • часть стека виртуального потока копируется с кучи на стек carrier-потока,

  • carrier-поток временно выполняет код виртуального потока.

Одна из ключевых инноваций — то, как виртуальные потоки обрабатывают блокировку.

Когда виртуальный поток достигает операции, которая обычно блокирует (например, ожидание I/O):

  • виртуальный поток размонтируется с carrier-потока,

  • его стек копируется обратно в кучу,

  • carrier-поток освобождается для других задач.

Эта логика встроена почти во все блокирующие точки JDK — именно поэтому виртуальные потоки такие лёгкие.

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

Когда виртуальный поток вызывает future.get(), Thread.sleep(), делает запрос в сеть, читает из базы — то есть выполняет блокирующую I/O операцию — происходит следующее:

1. Виртуальный поток "отцепляется" от carrier-потока.

Его стековые фреймы сохраняются в куче (heap).

2. Carrier-поток освобождается

Он становится свободен и может выполнять другие виртуальные потоки.

3. Виртуальный поток спит в heap и не занимает ОС-ресурсы

Он занимает всего несколько сот байт памяти, не удерживает ОС-поток.

4. Когда I/O готово — виртуальный поток снова монтируется на любой свободный carrier-thread

И продолжает с того места, где остановился.

Управление ограничениями ресурсов с помощью Rate Limiting

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

Например вы можете использовать Semaphore:

public class RateLimitExample {

    private static final int MAX_PARALLEL = 10;
    private static final Semaphore gate = new Semaphore(MAX_PARALLEL);

    public static void main(String[] args) throws Exception {
        List<String> jokes = fetchRest(50);
        jokes.stream().limit(3).forEach(j -> System.out.println(" " + j));
    }

    private static List<String> fetchRest(int n) {
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = IntStream.range(0, n)
                    .mapToObj(i -> pool.submit(RateLimitExample::sendRestRequest))
                    .toList();
            return futures.stream()
                    .map(RateLimitExample::join)
                    .toList();
        }
    }

    private static String sendRestRequest() throws Exception {
        try {
            gate.acquire();
            return emulateRest();
        } finally {
            gate.release();
        }
    }

    private static String emulateRest() throws InterruptedException {
        Thread.sleep(1000);
        return "Эмуляция вызова";
    }

    private static <T> T join(Future<T> f) {
        try {
            return f.get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new CompletionException(e);
        } catch (ExecutionException e) {
            throw new CompletionException(e.getCause());
        }
    }
}

Мы ограничиваем параллелизм искусственно (семафором), но это безопасно, потому что блокировка виртуального потока почти бесплатна. Обычный поток блокировать дорого — он удерживает память, стек, планировочные ресурсы. Виртуальный — нет.

Мы создаём семафор с 10 разрешениями.

Представь, что у тебя есть:

  • 100 человек (виртуальные потоки)

  • 10 стульев (семафор = 10 разрешений)

Чтобы выполнить задачу (вызвать API), нужно сесть на стул.

Правила:

  • если есть свободный стул - человек садится и делает запрос

  • если нет - человек стоит и ждёт

  • когда человек встал - стул освобождается, и садится следующий

При этом:

  • людей может быть 10, 100, 1000 - без разницы

  • стульев всегда 10

  • одновременно сидят максимум 10

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

Пиннинг

В контексте виртуальных потоков пиннинг означает ситуацию, когда виртуальный поток привязывается к своему переносчику (carrier thread — платформенный поток, на котором он выполняется). Когда поток pinned, он не может быть размонтирован с carrier thread, даже если выполняет блокирующие операции — он фактически “монополизирует” этот carrier thread на всё время блокировки.

Пиннинг происходит в двух основных случаях:

1. synchronized блоки или методы

Когда виртуальный поток входит в synchronized блок или метод, он становится pinned к carrier thread. Пока выполняется этот код, carrier не может использоваться для других задач.

public class PinnedExample {
    private static final Object lock = new Object();

    public static void main(String[] args) {
        List<Thread> threadList = IntStream.range(0, 10)
                .mapToObj(i -> Thread.ofVirtual().unstarted(() -> {
                    if (i == 0) {
                        System.out.println(Thread.currentThread());
                    }
                    synchronized (lock) {
                        try {
                            Thread.sleep(25);
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    }
                    if (i == 0) {
                        System.out.println(Thread.currentThread());
                    }
                })).toList();

        threadList.forEach(Thread::start);

        threadList.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

2. Нативные методы или foreign functions

Когда виртуальный поток вызывает нативный метод или функцию FFI, он также pinned.

Как уменьшить влияние пиннинга

  • Вместо synchronized используйте ReentrantLock, который позволяет виртуальным потокам размонтироваться при блокировке.

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

public class NoPinningExample {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {

        var threadList = IntStream.range(0, 10)
                .mapToObj(i -> Thread.ofVirtual().unstarted(() -> {
                    if (i == 0) {
                        System.out.println(Thread.currentThread());
                    }

                    lock.lock();
                    try {
                        Thread.sleep(25);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    } finally {
                        lock.unlock();
                    }

                    if (i == 0) {
                        System.out.println(Thread.currentThread());
                    }

                })).toList();

        threadList.forEach(Thread::start);

        threadList.forEach(thread -> {
            try {
                thread.join();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

Начиная с JDK 24 - synchronized больше не приводит к пиннингу. Виртуальные потоки смогут размонтироваться даже внутри синхронизированных секций

Итог

Виртуальные потоки — это современный, лёгкий и масштабируемый способ работы с конкурентностью в Java. Они позволяют запускать тысячи и даже миллионы задач без перегрузки системы, автоматически освобождая ресурсы при блокировках и эффективно используя базовые потоковые мощности JVM. Благодаря этому виртуальные потоки идеально подходят для сервисов, которые выполняют большое количество ожиданий — сетевых вызовов, работы с БД, I/O.

Всем спасибо за внимание!)

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