Как стать автором
Обновить
200.43

Тестируем JEP 491 вместе с Деном Вегой

Время на прочтение5 мин
Количество просмотров2.2K

Один из девелопер адвокатов 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 и всего, что с ним связано.

Теги:
Хабы:
+17
Комментарии2

Публикации

Информация

Сайт
t.me
Дата регистрации
Численность
11–30 человек