Я люблю стректрейсы и понятный линейный код. И соответственно не люблю реактивщину. Все примеры будут нереактивными с последовательным понятным кодом.
Примеры запускались на доступной сегодня jdk.
openjdk version "19-loom" 2022-09-20 OpenJDK Runtime Environment (build 19-loom+6-625) OpenJDK 64-Bit Server VM (build 19-loom+6-625, mixed mode, sharing)
Не забываем про --enable-preview флажок.
В этой jdk доступны такие методы для экспериментирования с виртуальными потоками:
/** * Creates a virtual thread to execute a task and schedules it to execute. * * <p> This method is equivalent to: * <pre>{@code Thread.ofVirtual().start(task); }</pre> * * @param task the object to run when the thread executes * @return a new, and started, virtual thread * @throws UnsupportedOperationException if preview features are not enabled * @see <a href="#inheritance">Inheritance when creating threads</a> * @since 19 */ @PreviewFeature(feature = PreviewFeature.Feature.VIRTUAL_THREADS) public static Thread startVirtualThread(Runnable task) { ... }
и
/** * Creates an Executor that starts a new virtual Thread for each task. * The number of threads created by the Executor is unbounded. * * <p> This method is equivalent to invoking * {@link #newThreadPerTaskExecutor(ThreadFactory)} with a thread factory * that creates virtual threads. * * @return a new executor that creates a new virtual Thread for each task * @throws UnsupportedOperationException if preview features are not enabled * @since 19 */ @PreviewFeature(feature = PreviewFeature.Feature.VIRTUAL_THREADS) public static ExecutorService newVirtualThreadPerTaskExecutor() { .... }
Не очень много, но для экспериментов хватит.
Общий код запуска тестов:
@BenchmarkMode(Mode.AverageTime) @Warmup(iterations = 1) @Measurement(iterations = 2) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Benchmark) public class BenchmarkThreading { public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(BenchmarkThreading.class.getSimpleName()) .forks(1) .build(); new Runner(opt).run(); } //тут тесты }
Производительность

Для начала проверим самое простое. Создание потоков. Убедимся что виртуальные потоки работают так как и ожидается.
@Benchmark public void testCreateVirtualThread(Blackhole blackhole) { for (int i=0; i<100; ++i) { int finalI = i; Thread.startVirtualThread(() -> blackhole.consume(finalI)); } } @Benchmark public void testCreateThread(Blackhole blackhole) { for (int i = 0; i < 1000; ++i) { int finalI = i; var thread = new Thread(() -> blackhole.consume(finalI)); thread.start(); } }
Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCreateThread avgt 199158,959 us/op BenchmarkThreading.testCreateVirtualThread avgt 53,674 us/op
Результат получился ожидаемый и не удивительный. Виртуальные потоки создаются на порядки быстрее обычных как и ожидается.
А что они нам дадут в более-менее реальных примерах использования? Нормальная программа на Джаве не создает потоки в нагруженных участках кода, а использует пулы и экзекуторы.
Попробуем экзекутором выполнить микрозадачи:
@Benchmark public void testVirtualExecutorSmallTask(Blackhole blackhole) { try(var executor = Executors.newVirtualThreadPerTaskExecutor()){ for (int i = 0; i < 100; ++i) { int finalI = i; executor.submit(() -> blackhole.consume(finalI)); } } } @Benchmark public void testCachedExecutorSmallTask(Blackhole blackhole) throws InterruptedException { try(var executor = Executors.newCachedThreadPool()){ for (int i = 0; i < 100; ++i) { int finalI = i; executor.submit(() -> blackhole.consume(finalI)); } } } @Benchmark public void testFixedExecutorSmallTask(Blackhole blackhole) throws InterruptedException { try(var executor = Executors.newFixedThreadPool(20)){ for (int i = 0; i < 100; ++i) { int finalI = i; executor.submit(() -> blackhole.consume(finalI)); } } }
Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCachedExecutorSmallTask avgt 2 1233,639 us/op BenchmarkThreading.testFixedExecutorSmallTask avgt 2 2156,590 us/op BenchmarkThreading.testVirtualExecutorSmallTask avgt 2 96,231 us/op
Результат тоже хорош. За исключение того что с размером fixed пула я не угадал. Ну ладно, на практике в продакшен коде типовой мидл тоже никогда не угадает.
А что если сделать тест еще ближе к реальности? В нормальном коде в поток выносят операции занимающее какое-то значимое количество времени.
На моей тестовой машине Blackhole.consumeCPU(100_000_000) занимает около 200мс что можно принять разумным временем на задачу которую уже можно отправлять в отдельный поток.
@Benchmark public void testVirtualExecutorNormalTask(Blackhole blackhole) { try(var executor = Executors.newVirtualThreadPerTaskExecutor()){ for (int i = 0; i < 100; ++i) { executor.submit(() -> Blackhole.consumeCPU(100_000_000)); } } } @Benchmark public void testCachedExecutorNormalTask(Blackhole blackhole) throws InterruptedException { try(var executor = Executors.newCachedThreadPool()){ for (int i = 0; i < 100; ++i) { executor.submit(() -> Blackhole.consumeCPU(100_000_000)); } } } @Benchmark public void testFixedExecutorNormalTask(Blackhole blackhole) throws InterruptedException { try(var executor = Executors.newFixedThreadPool(20)){ for (int i = 0; i < 100; ++i) { executor.submit(() -> Blackhole.consumeCPU(100_000_000)); } } }
Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCachedExecutorNormalTask avgt 2 5249759,575 us/op BenchmarkThreading.testFixedExecutorNormalTask avgt 2 5247051,750 us/op BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 5246058,750 us/op
Разницы нет. Это было ожидаемо. На такой нагрузке работа с потоками занимает пренебрежимо малое время по сравнению с бизнес логикой. Не загромождая статью исходниками покажу результат для других значений Blackhole.consumeCPU(ххх)
10_000_000 или 20мс на задачу Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCachedExecutorNormalTask avgt 2 553018,934 us/op BenchmarkThreading.testFixedExecutorNormalTask avgt 2 564500,005 us/op BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 530236,755 us/op 1_000_000 или 2мс на задачу Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCachedExecutorNormalTask avgt 2 65124,411 us/op BenchmarkThreading.testFixedExecutorNormalTask avgt 2 54710,276 us/op BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 53285,513 us/op 100_000 или 0.2мс на задачу Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCachedExecutorNormalTask avgt 2 14088,289 us/op BenchmarkThreading.testFixedExecutorNormalTask avgt 2 8267,134 us/op BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 5792,022 us/op 10_000 или 0.02мс на задачу Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCachedExecutorNormalTask avgt 2 2377,223 us/op BenchmarkThreading.testFixedExecutorNormalTask avgt 2 2757,024 us/op BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 664,795 us/op
Разница становится явно видна на совсем маленьких задачах. Там где менеджмент потоков начинает занимать значимое время от всей остальной логики.
Можно сделать вывод что в типовом нормальном Джава коде плюсов по производительности от простого включения виртуальных потоков мы не заметим. Если вы у себя её заметили, то стоит покопаться по коду поискать где вы используете потоки для слишком маленьких задач.
Зато мы получаем возможность кидать в отдельный поток просто все что угодно. Разница для микрозадач колоссальна. Это откроет некоторые возможности более удобно писать код и лучше утилизировать все доступные ядра во вроде бы однопоточном коде. Может быть наконец-то появится смысл в .parallelStream() при использовании виртуальных потоков внутри.
И как обычно пойдет куча ошибок с созданием слишком большого и ненужного числа виртуальных потоков, со всеми радостями отладки без стектрейсов потом. Исследовать непойманное исключение в логах в котором нет ни одной строчки твоего кода это очень увлекательный процесс.
АПД: Следующий тест производительности добавлен по просьбе из комментариев. Спасибо комментатору, который напомнил мне что никогда нельзя доверять мысленным тестам производительности и всегда надо всё проверять на бенчмарками.
Я для этого теста буду использовать немного другие параметры экзекутора. Для максимального приближения к реальности. Допустим есть http сервер с пулом принимающим соединения в 100 потоков и выполняющий в них задачи с паузами в 100мс. У меня на машине всего 4 ядра, значит при нагрузке более 4% скорость работы теста определяется быстродействием кода, а не чем-то еще.
Экзекуторы:
Executors.newVirtualThreadPerTaskExecutor() Executors.newCachedThreadPool() Executors.newFixedThreadPool(100))
Тест:
@Benchmark public void testCachedExecutorNormalTask() { try (var executor = Executors.newCachedThreadPool()) { for (int i = 0; i < 100; ++i) { executor.submit(() -> { try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); } Blackhole.consumeCPU(1_000_000); }); } } }
Результаты:
100мс ожидания + 20мс работы. Нагрузка пула около 15%, что очень блико к реальной продакшен нагрузке хорошо сконфигуренного сервера. Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCachedExecutorNormalTask avgt 2 677262,310 us/op BenchmarkThreading.testFixedExecutorNormalTask avgt 2 659306,103 us/op BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 679307,987 us/op 100мс ожидания + 2мс работы. Тут скорость уже перестала зависеть от скорости работы кода. Benchmark Mode Cnt Score Error Units BenchmarkThreading.testCachedExecutorNormalTask avgt 2 196600,036 us/op BenchmarkThreading.testFixedExecutorNormalTask avgt 2 191974,914 us/op BenchmarkThreading.testVirtualExecutorNormalTask avgt 2 196900,749 us/op
Результаты показывают что при типичной нагрузке на типичный пул http сервера виртуальные потоки не дают никакого заметного ускорения. Если цифры как следует выкрутить в сторону уменьшения аналогично предыдущим тестам мы получим ускорение. Но положа руку на сердце у кого из вас АПИшка работает на порядки быстрее чем в этом тесте?
А что с памятью?

Уже давно ходят слухи что потоки в Джаве очень прожорливы до памяти. Я читал версии что каждый поток стоит мегабайты памяти просто так на создание. И виртуальные потоки всех нас спасут от покупки дополнительной памяти в наши кластера.
Исследовать расход памяти в Джаве на что-то это довольно неоднозначный процесс. Предлагаю тривиально оценить расход памяти на какое-то число созданных, запущенных и ничего не делающих потоков. Это довольно типовая ситуация когда основная часть потоков висит на IO и ждет данных. Обычно именно таких потоков хочется побольше для удобства разработки.
Приложение для оценки простейшее:
public static void main(String[] args) { for(int i=0; i<100; ++i) { var thread = new Thread(() -> { Blackhole.consumeCPU(1); try { Thread.sleep(100_000); } catch (InterruptedException e) { throw new RuntimeException(e); } }); thread.start(); } System.exit(0); }
JDK17 LTS. Тех кто еще не обновился мне уже даже не жалко. Давно пора обновиться было.
openjdk version "17.0.3" 2022-04-19 OpenJDK Runtime Environment Temurin-17.0.3+7 (build 17.0.3+7) OpenJDK 64-Bit Server VM Temurin-17.0.3+7 (build 17.0.3+7, mixed mode, sharing)
Никаких особых ключей запуска: -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics -Xmx4G
71 Thread (reserved=75855304, committed=4_793_800) (thread #71) (stack: reserved=75497472, committed=4435968) (malloc=178456 #744) (arena=179376 #245) 1_016 Thread (reserved=1069151112, committed=65_549_192) (thread #1016) (stack: reserved=1066401792, committed=62799872) (malloc=1526056 #7146) (arena=1223264 #2040) 10_018 Thread (reserved=10532726584, committed=643_856_184) (thread #10018) (stack: reserved=10505682944, committed=616812544) (malloc=15013392 #70316) (arena=12030248 #20051)
Видна хорошая закономерность с расходом около 64 килобайт памяти на пустой поток.
Виртуальные потоки в этом месте память под себя не требуют, и будут занимать что-то схожее с типичным Джава объектом размером в десятки-сотни байт. Можно упрощенно считать что это 0 по сравнению с 64 килобайтами на классический поток.
Выводы
Отрицательно:
Нас ждут увлекательные баталии в код ревью о новых практиках написания кода.
Количество ошибок с многопоточностью заметно возрастет.
Нейтрально:
Виртуальные потоки не дадут никакого ускорения в типичном джава приложении без переписывания кода.
Виртуальные потоки не уменьшат потребление памяти нормально сделанным приложением. 64 килобайта * 1_000 типовых потоков, это неинтересно.
Положительно:
Виртуальные потоки дадут возможность по-новому писать код. Паралелим все что не запрещено математикой.
.parallelStream обретает смысл.
Виртуальные потоки дадут возможность более эффективно утилизировать доступные ядра. Без выделения больших независимых кусков кода и реактивщины.
