
Один из девелопер адвокатов Spring Framework, Ден Вега, на днях написал пост в одну запрещенную соцсеть, в котором изучал работу JEP 491 — Synchronize Virtual Threads without Pinning. Автор провел незатейливый эксперимент, но эффект от JEP обнаружен не был. Репозиторий с кодом эксперимента прилагался. Автор предложил выяснить, в чем тут дело... и я выяснил.

Напомню, в чем заключается суть JEP. Ранее, synchronized
блок 'пришивал' виртуальный тред к треду носителю. Иногда, это приводило к достаточно необычным эффектам, в частности, про это был один из переводов в нашем блоге. Начиная с Java 24 такого поведения нет и synchronized
можно использовать с виртуальными тредами без опасений.
Ден Вега попробовал протестировать это поведение на практике, закономерно предполагая, что теперь synchronized
внутри виртуальных тредов не вызовет истощение пула тредов-носителей. Код прост, как три копейки. В цикле многократно создаются виртуальные потоки, которые:
1. Выполняют некоторую вычислительную работу
2. Делают Thread.sleep()
внутри synchronized
блока.
Код эксперимента
package dev.danvega;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class Application {
private static final int NUM_THREADS = 5000; // Increased threads
private static final long BLOCKING_TIME_MS = 5; // Shorter sleep inside lock
private static final int CPU_WORK_ITERATIONS = 10000; // Work *outside* lock
private static final Object lock = new Object();
private static final AtomicInteger completedTasks = new AtomicInteger(0);
private static volatile double busyWorkSink = 0; // To prevent dead code elimination
// Simple CPU-bound work
private static void doCpuWork() {
double result = 0;
for (int i = 0; i < CPU_WORK_ITERATIONS; i++) {
result += Math.sin(i) * Math.cos(i);
}
// Use the result to prevent optimization
busyWorkSink += result;
}
public static void main(String[] args) throws InterruptedException {
System.out.println("Running with Java version: " + System.getProperty("java.version"));
System.out.printf("Launching %,d virtual threads...\n", NUM_THREADS);
System.out.printf("Each thread does CPU work (%,d iterations), then acquires lock and blocks for %d ms.\n",
CPU_WORK_ITERATIONS, BLOCKING_TIME_MS);
ThreadFactory factory = Thread.ofVirtual().factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
Instant start = Instant.now();
for (int i = 0; i < NUM_THREADS; i++) {
executor.submit(() -> {
try {
// This work can run concurrently if carrier threads are available.
doCpuWork();
synchronized (lock) {
// Short sleep *inside* the lock
// JDK 21: Pins carrier, making it unavailable for others' doCpuWork()
// JDK 24: Unmounts carrier, allowing it to run others' doCpuWork()
Thread.sleep(Duration.ofMillis(BLOCKING_TIME_MS));
}
completedTasks.incrementAndGet();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Thread interrupted: " + e.getMessage());
} catch (Exception e) {
System.err.println("Error in task: " + e.getMessage());
e.printStackTrace(); // Print stack trace for unexpected errors
}
});
}
executor.shutdown();
boolean finished = executor.awaitTermination(5, TimeUnit.MINUTES);
Instant end = Instant.now();
Duration duration = Duration.between(start, end);
if (finished) {
System.out.printf("All %,d tasks completed.\n", completedTasks.get());
System.out.printf("Total execution time: %.3f seconds\n", duration.toMillis() / 1000.0);
} else {
System.err.printf("Executor timed out after 5 minutes. Tasks completed: %,d\n", completedTasks.get());
}
}
System.out.println("----------------------------------------");
// Print sink value to ensure work wasn't optimized away
// System.out.println("Sink value (ignore): " + busyWorkSink);
}
}
Что ожидаем
Автор описал свои ожидания как раз внутри synchronized
блока.
synchronized (lock) {
// Short sleep *inside* the lock
// JDK 21: Pins carrier, making it unavailable for others' doCpuWork()
// JDK 24: Unmounts carrier, allowing it to run others' doCpuWork()
Thread.sleep(Duration.ofMillis(BLOCKING_TIME_MS));
}
В Java 21 каждый synchronized блок будет пинить виртуальный поток к потоку-носителю, что приведет к одновременному исполнению задач по количеству потоков-носителей.
В Java 24 таких 'приколов' не будет и почти все
Thread.sleep()
будут выполняться буквально параллельно. ВообщеThread.sleep()
в виртуальном потоке должен парковать этот виртуальный поток и не занимать никаких ресурсов.
Что получили
А получили идентичное поведение, никакой значимой разницы:
Running with Java version: 21.0.6
Launching 5,000 virtual threads...
Each thread does CPU work (10,000 iterations), then acquires lock and blocks for 5 ms.
All 5,000 tasks completed.
Total execution time: 32.334 seconds
----------------------------------------
Running with Java version: 24
Launching 5,000 virtual threads...
Each thread does CPU work (10,000 iterations), then acquires lock and blocks for 5 ms.
All 5,000 tasks completed.
Total execution time: 31.592 seconds
----------------------------------------
Более того, результат не зависит от количества потоков носителей, которые можно указывать через свойство -Djdk.virtualThreadScheduler.parallelism=1
. Хотя казалось бы, чем меньше одновременных вирутальных потоков шедулятся на потоки-носители, тем быстрее мы должны по этим потокам вылететь.
А собственно, почему?

А разгадка проста
На самом деле, речь тут в банальном баге. Смотрим еще раз на synchronized
блок. Что он нам обещает? Он гарантирует, что код внутри этого блока будет выполняться эксклюзивно для переданного lock
. Перечитайте еще раз эту фразу и подумайте пол минуты. Потом посмотрите на место объявления этого lock
. Вне зависимости от того, пинятся треды или нет, каждый Thread.sleep()
в данном случае выполняется эксклюзивно.
Так что, эффект от пиннинга тредов здесь не заметить.
А как надо?
Чтобы заметить эффект, нам нужны synchronized
, но чтобы они были не взаимоисключающие. Сделать это достаточно просто, если для каждого synchronized
создавать новый lock
. При этом, хочу подчеркнуть, писать такой код в реальных приложениях нельзя. Это делает совершенно бессмысленным использование synchronized
блоков. Мы же пишем такой код, чтобы зафиксировать отсутствие или наличие закрепления потоков.
Ну что, замеряем?
final Object lock = new Object(); // <-- больше не глобальный. Но только для нужд эксперимента.
executor.submit(() -> {
try {
// This work can run concurrently if carrier threads are available.
doCpuWork();
synchronized (lock) {
// Short sleep *inside* the lock
// JDK 21: Pins carrier, making it unavailable for others' doCpuWork()
// JDK 24: Unmounts carrier, allowing it to run others' doCpuWork()
Thread.sleep(Duration.ofMillis(BLOCKING_TIME_MS));
}
completedTasks.incrementAndGet();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Thread interrupted: " + e.getMessage());
} catch (Exception e) {
System.err.println("Error in task: " + e.getMessage());
e.printStackTrace(); // Print stack trace for unexpected errors
}
});
Java 21
Running with Java version: 21.0.6
Launching 5,000 virtual threads...
Each thread does CPU work (10,000 iterations), then acquires lock and blocks for 5 ms.
All 5,000 tasks completed.
Total execution time: 34.133 seconds
----------------------------------------
Стало даже хуже! Но это и не удивительно — потоки пинятся на единственный поток-носитель + дополнительный оверхед при создании локов.
Java 24
Running with Java version: 24
Launching 5,000 virtual threads...
Each thread does CPU work (10,000 iterations), then acquires lock and blocks for 5 ms.
All 5,000 tasks completed.
Total execution time: 0.640 seconds
----------------------------------------
Феноминально! JEP задействовался.
Итоги
Да особо никаких итогов и нет, кроме того, что JEP действительно работает, и это очень важно. Пининг потоков мог привести к неожиданным дедлокам, но в Java 24 такой опасности нет. Будьте внимательнее, а вот Дену Веге я закинул пулл-реквест. Итоговый код можно посмотреть в репозитории нашего сообщества.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.