Перейти к основному содержимому

Разница между потоком и виртуальным потоком в Java

· 5 мин. чтения

1. Введение

В этом руководстве мы покажем разницу между традиционными потоками в Java и виртуальными потоками, представленными в Project Loom .

Далее мы поделимся несколькими вариантами использования виртуальных потоков и API-интерфейсов, представленных в проекте.

Прежде чем мы начнем, мы должны отметить, что этот проект находится в активной разработке. Мы запустим наши примеры на виртуальной машине loom с ранним доступом: openjdk-15-loom+4-55_windows-x64_bin.

Более новые версии сборок могут свободно изменять и нарушать текущие API. При этом в API уже произошли серьезные изменения, поскольку ранее использовавшийся класс java.lang.Fiber был удален и заменен новым классом java.lang.VirtualThread .

2. Общий обзор потоков и виртуальных потоков

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

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

Затем, как правило, мы не хотим блокировать эти потоки, и это приводит к использованию неблокирующих API-интерфейсов ввода-вывода и асинхронных API-интерфейсов, которые могут загромождать наш код.

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

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

В конечном итоге нам не нужно было бы обращаться к NIO или API-интерфейсам Async. Это должно привести к более читаемому коду, который легче понять и отладить. Тем не менее, продолжение потенциально может заблокировать несущий поток , в частности, когда поток вызывает собственный метод и оттуда выполняет блокирующие операции.

3. Новый API построителя потоков

В Loom мы получили новый API-интерфейс конструктора в классе Thread , а также несколько фабричных методов. Давайте посмотрим, как мы можем создавать стандартные и виртуальные фабрики и использовать их для выполнения нашего потока:

Runnable printThread = () -> System.out.println(Thread.currentThread());

ThreadFactory virtualThreadFactory = Thread.builder().virtual().factory();
ThreadFactory kernelThreadFactory = Thread.builder().factory();

Thread virtualThread = virtualThreadFactory.newThread(printThread);
Thread kernelThread = kernelThreadFactory.newThread(printThread);

virtualThread.start();
kernelThread.start();

Вот результат вышеуказанного запуска:

Thread[Thread-0,5,main]
VirtualThread[<unnamed>,ForkJoinPool-1-worker-3,CarrierThreads]

Здесь первая запись — это стандартный вывод toString потока ядра.

Теперь мы видим в выводе, что у виртуального потока нет имени, и он выполняется в рабочем потоке пула Fork-Join из группы потоков CarrierThreads .

Как мы видим, независимо от базовой реализации, API один и тот же, а это означает, что мы можем легко запускать существующий код в виртуальных потоках .

Кроме того, нам не нужно изучать новый API, чтобы использовать их.

4. Виртуальная композиция потоков

Это продолжение и планировщик , которые вместе составляют виртуальный поток. Теперь наш планировщик пользовательского режима может быть любой реализацией интерфейса Executor . Приведенный выше пример показал нам, что по умолчанию мы запускаем ForkJoinPool .

Теперь, аналогично потоку ядра, который может быть выполнен на ЦП, затем припаркован, перепланирован обратно, а затем возобновил свое выполнение, продолжение — это единица выполнения, которая может быть запущена, затем припаркована (уступлена), перепланирована обратно и возобновлена. его выполнение таким же образом, с которого оно было остановлено, и по-прежнему управляется JVM вместо того, чтобы полагаться на операционную систему.

Обратите внимание, что продолжение представляет собой низкоуровневый API, и что программисты должны использовать API более высокого уровня, такие как API Builder, для запуска виртуальных потоков.

Однако, чтобы показать, как это работает под капотом, теперь мы запустим наше экспериментальное продолжение:

var scope = new ContinuationScope("C1");
var c = new Continuation(scope, () -> {
System.out.println("Start C1");
Continuation.yield(scope);
System.out.println("End C1");
});

while (!c.isDone()) {
System.out.println("Start run()");
c.run();
System.out.println("End run()");
}

Вот результат вышеуказанного запуска:

Start run()
Start C1
End run()
Start run()
End C1
End run()

В этом примере мы запустили наше продолжение и в какой-то момент решили остановить обработку. Затем, как только мы повторно запустили его, наше продолжение продолжилось с того места, где оно остановилось. По выводу мы видим, что метод run() был вызван дважды, но продолжение было запущено один раз, а затем продолжило свое выполнение во втором запуске с того места, где оно было остановлено.

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

Итак, наш основной поток создал новый кадр стека в своем стеке вызовов для метода run() и приступил к выполнению. Затем, после завершения продолжения, JVM сохранила текущее состояние своего выполнения.

Затем основной поток продолжил свое выполнение, как если бы метод run() вернулся и продолжил цикл while . После второго вызова метода run продолжения JVM восстановила состояние основного потока до точки, в которой продолжение завершилось и завершило выполнение.

5. Вывод

В этой статье мы обсудили разницу между потоком ядра и виртуальным потоком. Далее мы показали, как можно использовать новый API-интерфейс конструктора потоков от Project Loom для запуска виртуальных потоков.

Наконец, мы показали, что такое продолжение и как оно работает «под капотом». Мы можем дополнительно изучить состояние Project Loom, проверив виртуальную машину раннего доступа . В качестве альтернативы мы можем изучить больше уже стандартизированных API параллелизма Java .