По умолчанию поток Java выполняется в потоке платформы, предоставляемом операционной системой, в которой выполняется виртуальная машина Java. Для многих рабочих нагрузок это приемлемое решение. Однако потоки платформы не являются легковесными. На создание потока платформы выделяется множество ресурсов и времени. Это ограничивает количество потоков платформы, которые может обработать процессор. А приложениях с интенсивной нагрузкой это становится узким местом, ограничивая производительность приложения. Так, нередко программа выполняет такие операции, которые могут занять продолжительное время, например, обращение к сетевым ресурсам, чтение-запись файлов, обращение к базе данных и т.д. Такие операции могут серьезно нагрузить приложение. Особенно это актуально в высоконагруженных веб-приложениях, которые должны быть готовы обслуживать тысячи запросов в секунду, и где продолжительные операции могут блокировать интерфейс пользователя и негативно повлиять на опыт пользователей. В случае с обычными потоками платформы при выполнении продолжительных операций поток просто бы блокировался на время выполнения операции. Виртуальные потоки представляют решение этой проблемы.
Виртуальные потоки — это легковесные потоки, управляемые виртуальной машиной Java (JVM), а не операционной системой. Виртуальным потокам обычно требуется мало ресурсов, и одна виртуальная машина Java может поддерживать миллионы виртуальных потоков. Виртуальные потоки подходят для выполнения задач, которые большую часть времени блокируются, например, операции ввода-вывода - обращение к файлам, к базе данных, операции отправки сетевых запросов. Однако виртуальные потоки не предназначены для длительных операций, интенсивно использующих процессор.
Как работает виртуальный поток? Виртуальный поток прикрепляется к потоку платформы и начинает выполнять некоторый код. То есть для выполнения виртуального потока все равно нужен платформенный поток, который в данном случае еще нызвается потоком-носителем (carrier), так как условно "несет" в себе виртуальный поток. Когда код виртуального потока вызывает блокирующую операцию (например, чтение файла), JVM не блокирует несущий платформенный поток, к которому прикреплен виртуальный. Вместо этого среда выполнения открепляет виртуальный поток с несущего потока платформы и сохраняет его состояние в куче, пока выполняется блокирующая операция (например, чтение файла). В это время несущий поток платформы освобождается и может выполнять другие виртуальные потоки, другие действия. Как только блокирующая операция завершается (например, чтение файла завершено), JVM снова прикрепляет виртуальный поток обратно на доступный поток платформы для продолжения выполнения. Этот механизм позволяет эффективно утилизировать системные ресурсы, так как платформенные потоки не простаивают в ожидании, а постоянно выполняют полезную работу.
В общем случае это выглядит так. Например, приходит запрос к веб-приложению, и для обработки запроса надо считать файл. Для обработки запроса запускается виртуальный поток, который прикрепляется к потоку платформы. Далее этот поток видит, что надо считать файл, соответственно запускается операция чтения файла, которая может занять долгое время. Виртуальный поток ждет, пока будет считан файл, и открепляется от потока платформы, к которому он был прикреплен и в рамках которого он выполнялся. Однако поток платформы не ждет завершения чтения файла, а переключается на выполнение другой работы (при ее наличие) - к нему может быть прикрпелен другой виртуальный поток. А когда файл будет считан, виртуальный поток возобновляет обработку и для этого снова прикрепляется к потоку платформы. Таким образом, приложение может более эффективно использовать ресурсы процессора, представленные потоками платформы.
Основные преимущества виртуальных потоков
Масштабируемость: поскольку виртуальные потоки очень легковесны (занимают всего несколько сотен байт памяти по сравнению с мегабайтами для платформенных потоков), можно создавать миллионы таких потоков без существенной нагрузки на систему.
Простота кода: виртуальные потоки позволяют писать код в привычном стиле, используя тот же API
Совместимость: виртуальные потоки являются реализацией класса java.lang.Thread, что обеспечивает их совместимость с существующим кодом и библиотеками, которые
работают с потоками.
Виртуальные потоки имеют тот же API, что и потоки платформы. Для создания виртуальных потоков можно использовать ряд способов:
С помощью статического метода Thread.startVirtualThread(Runnable task), в который передается выполняемая задача Runnable
С помощью статического метода Thread.Builder.OfVirtual.ofVirtual(), который создает строитель виртуального потока / фабрики потока
Рассмотрим оба этих способа.
Самый простой способ создать и запустить виртуальный поток представляет статический метод Thread.startVirtualThread(), в который передается
объект Runnable:
public static Thread startVirtualThread(Runnable task)
Простейший пример:
class Program{
public static void main(String[] args) {
System.out.println("Main thread started...");
Runnable task = () -> {
System.out.println("Hello from a virtual thread");
};
Thread.startVirtualThread(task); // запускаем виртуальный поток
System.out.println("Main thread finished...");
}
}
В данном случае в виртуальном потоке просто выводим сообщение на консоль. Однако вполне возможно при выполнении программы мы не увидим этого сообщения:
Main thread started... Main thread finished...
По подобному консольному выводу не видно, чтобы виртуальный поток выполнялся. Все потому что, в отличие от платформенных потоков виртуальные потоки по умолчанию определяются как потоки-демоны,
которые выполняются в фоновом режиме. Однако поскольку виртуальные потоки используют тот же API, что и обычные, мы можем вызвать метод join(),
чтобы дождаться завершения виртуального потока:
class Program{
public static void main(String[] args) {
System.out.println("Main thread started...");
Runnable task = () -> {
System.out.println("Hello from a virtual thread");
};
var t = Thread.startVirtualThread(task); // запускаем виртуальный поток
try{
t.join(); // ждем завершения виртуального потока
}
catch(InterruptedException _){
System.out.println("Main thread interrupted");
}
System.out.println("Main thread finished...");
}
}
И теперь мы получим следующий консольный вывод:
Main thread started... Hello from a virtual thread Main thread finished...
Метод ofVirtual() возвращает строитель виртуального потока, который позволяет выполнить более тонкую настройку:
public static Thread.Builder.OfVirtual ofVirtual()
Сам строитель представляет объект типа Thread.Builder.OfVirtual, у которого мы можем вызвать ряд методов:
Thread.Builder.OfVirtual name(String name)
Устанавливает имя для потока
Thread.Builder.OfVirtual name(String prefix, long start)
Также устанавливает имя для потока и плюс принимает значение счетчика, которое добавляется к имени потока
Thread start(Runnable task)
Запускает виртуальный поток, возвращая его в виде объекта Thread
Thread unstarted(Runnable task)
Создает новый поток, но без его запуска. Для планирования выполнения потока у объекта Thread необходимо явным образом вызвать метод start().
ThreadFactory factory()
Возвращает фабрику создания потоков в виде объекта ThreadFactory
Используем этот метод для создания потока:
class Program{
public static void main(String[] args) {
System.out.println("Main thread started...");
Runnable task = () -> {
System.out.println("Hello from " + Thread.currentThread().getName());
};
var t = Thread.ofVirtual()
.name("MyTask") // устанавливаем имя
.start(task); // запускаем поток
try{
t.join(); // ждем завершения виртуального потока
}
catch(InterruptedException _){
System.out.println("Main thread interrupted");
}
System.out.println("Main thread finished...");
}
}
Консольный вывод:
Main thread started... Hello from MyTask Main thread finished...
Или можно сначала создать, а потом отдельно запустить:
class Program{
public static void main(String[] args) {
System.out.println("Main thread started...");
var t = Thread.ofVirtual()
.name("MyTask") // устанавливаем имя
.unstarted(() -> System.out.println("Hello from " + Thread.currentThread().getName()));
t.start(); // запускаем поток
try{
t.join(); // ждем завершения виртуального потока
}
catch(InterruptedException _){
System.out.println("Main thread interrupted");
}
System.out.println("Main thread finished...");
}
}
Для виртуальных потоков метод isVirtual() возвращает true
Стоит отметить, что по умолчанию у виртуальных потоков нет имени потока. Метод getName() возвращает пустую строку, если имя потока не задано.
Поэтому если необходимо разграничить несколько потоков на основе имени, следует для них устанавливать имя явным образом.
Виртуальные потоки являются потоками-демонами и, следовательно, не препятствуют началу последовательности завершения работы. Поэтому для них метод isDaemon() возвращает true
Виртуальные потоки имеют фиксированный приоритет потока, который нельзя изменить. Посмотрим на эти свойства потока:
class Program{
public static void main(String[] args) {
System.out.println("Main thread started...");
Runnable task = () -> {
Thread t = Thread.currentThread(); // получаем текущий поток
System.out.println("Name: " + t.getName()); // если имя не задано, то t.getName() вернет пустую строку
System.out.println("Is Vitual: " + t.isVirtual());
System.out.println("Is Daemon: " + t.isDaemon());
System.out.println("Priority: " + t.getPriority());
};
var thread = Thread.startVirtualThread(task);
try{
thread.join(); // ждем завершения виртуального потока
}
catch(InterruptedException _){
System.out.println("Main thread interrupted");
}
System.out.println("Main thread finished...");
}
}
Консольный вывод:
Main thread started... Name: Is Vitual: true Is Daemon: true Priority 5 Main thread finished...
Идеальные кандидаты для использования виртуальных потоков:
I/O-bound задачи (работа с подсистемой ввода-вывода): приложения с большим количеством блокирующих операций ввода-вывода, такие как веб-серверы, микросервисы, приложения для работы с базами данных
Высоконагруженные системы: системы, которым необходимо обрабатывать тысячи или миллионы одновременных подключений
Когда лучше воздержаться от виртуальных потоков:
CPU-bound задачи: для задач, интенсивно использующих процессор (например, сложные математические вычисления, обработка больших объемов данных в памяти), преимущества виртуальных потоков не так заметны. В этом случае количество потоков должно быть соизмеримо с количеством ядер процессора, и классические платформенные потоки подойдут лучше
Работа с synchronized-блоками: длительное удержание блокировок в synchronized-блоках (рассматриваются в следующих статьях) может "прибить" (pin) виртуальный поток к его несущему платформенному потоку, что не позволит последнему выполнять другие задачи и может привести к снижению производительности, поэтому следует с осторожностью использовать синхронизацию с synchronized
Вызовы нативных методов (JNI): аналогично synchronized, вызовы нативного кода могут блокировать несущий поток