Цель этой статьи — изучить известные факты о грядущем расширении модели многопоточности Java.

Нет, не беспокойтесь, текущая модель многопоточности Java остается, но за кулисами что-то хорошее уже стучится в виртуальную дверь.

Да, мы говорим о JEP-425: Virtual Threads.

Преодоление текущих ограничений параллелизма

Давайте сначала посмотрим на текущую модель многопоточности Java. Она обеспечивает реализацию класса Thread. Поток можно рассматривать как единица параллелизма Java, который может выполнять так называемые Runnable задачиЭкземпляр класса Thread также является объектом, но за кулисами происходит нечто большее.

Например: каждый вновь созданный поток получает свой собственный стек, выделенный для хранения локальных переменных, вызовов методов, ссылок, которые помещаются сверху во время выполнения внутри текущей области действия потока.

Поток завершится после завершения задачи вызовом метода join(). Он может быть сопоставлен 1:1 с потоком платформы, управляемым базовой системой, которая управляет планированием выполняемых инструкций. 

Поскольку базовая платформа не может создавать неограниченное количество потоков платформы, становится очевидным, что текущую модель потоков Java необходимо использовать с умом и отслеживать порядок создания новых потоков.

Ограничивающий фактор в основном вызван доступными ресурсами (ЦП, память и т. д.), хотя сама Java может вызывать иное ощущение.

try(ExecutorService executor = Executors.newSingleThreadExecutor(THREAD_FACTORY)){
   executor.execute(() -> … );
}

Пример 1. Однопоточный исполнитель, выполняющий задачу Runnable.

За последние десятилетия параллельная программа, написанная на Java, была способна выполнять Runnable задачи параллельно, то есть одновременно. В настоящее время Java уже предоставляет концепции Executors (исполнителей - пример 1.) или Thread Pools (пулов потоков - пример 2.), которые помогают разработчикам управлять доступными ресурсами платформы и избегать нежелательного использования системных ресурсов, например, новых вызовов Thread() и start().

try( ExecutorService executor = Executors.newFixedThreadPool(10, THREAD_FACTORY)){
   executor.submit(() -> … );   
}

Пример 2. Пул фиксированных инициированных потоков, запускающих Callable задачу.

Начиная с версии Java SE 8, Java также содержит концепцию ComputableFeature (пример 3.), которая помогает выполнять асинхронные задачи в изолированных потоках и по умолчанию запускает общий пул потоков. Чтобы быть более точным, он использует общий ForkJoinPool (рисунок 1). 

Платформа ForkJoin стала еще одним большим улучшением в выпуске Java SE 7. Его цель состояла в том, чтобы облегчить возможность правильного использования всех доступных процессорных ядер, но он мог приводить к некоторым отрицательным моментам, вызванным, например, невольным использованием исполнителей (Пример 3).

record ComputableTask(AtomicInteger counter, int failedCycle) implements Runnable {
   @Override
   public void run() {
      // May thrown an exception
       readFileContent(counter, failedCycle);
       System.out.printf("""
               DONE: thread: '%s', cycle: '%d', failedCycle:'%d'
               """, Thread.currentThread().getName(), counter.get(), failedCycle);
   }
}
...
completableFuture.thenRun(new ComputableTask(counter, failedCycle));
... 
Example output:
DONE: thread: 'main', cycle: '1', failedCycle:'2'
DONE: thread: 'ForkJoinPool.commonPool-worker-1', cycle: '2', failedCycle:'2'
FINISHED: cycles:'100'

Пример 3. Использование ComputableFuture может иметь недостатки, такие как невозможность прерывания выполнения, отладка или осмысленная трассировка StackTrace.

Честно говоря, поскольку платформа Java очень многопоточная, поэтому говоря о параллелизме, мы должны также учитывать сборщики мусора, отладчики, профилировщики или другие технологии, на которые влияет «игра с потоками».

Встречаем виртуальные потоки

Хорошо, это то, что мы имеем на данный момент. 

Скоро произойдет что-то захватывающее. Предстоит следующее крупное расширение модели параллелизма. Это виртуальные потоки. 

Я объясню, что такое виртуальные потоки, откуда они берутся и почему они нам нужны! Мотивация может быть очевидной. Освежим основы. 

Идея thread-sharing (совместного использования потоков), представленная пулом потоков (ForJoinPool, pool и т. д.), между задачами может помочь улучшить пропускную способность, но по сравнению со стилем thread-per-request (поток на запрос) она может иметь существенные недостатки. Идея thread-per-request позволяет сделать код удобным для сопровождения, понятным и отлаживаемым. Этот стиль позволяет выполнять и наблюдать за задачей от начала до конца (основную причину легко определить). Совместное использование потоков усложняет все это.

...
var threadFactory = new ThreadFactory() {
  ... 
  @Override
  public Thread newThread(Runnable r) {
    var t = new Thread(threadGroup, r, "t-" + counter.getAndIncrement());
    t.setDaemon(true);
    return t;
  }
};
...
var executor = Executors.newFixedThreadPool(THREADS_NUMBER, threadFactory);
for (int i = 0; i < EXECUTION_CYCLES; i++) {
  executor.submit(new ThreadWorker(i, MAX_CYCLES, ALLOCATION_SIZE));
}
...

Пример 4. Текущий подход поток на запрос с фиксированным размером пула и фабрикой.

Но вот и хорошие новости. Виртуальные потоки стремятся поддерживать стиль «поток на запрос», чтобы внести ясность в выполнение кода и сохранить понятную структуру потока. Подход Virtual Threads выглядит многообещающе, поскольку он пытается использовать ресурсы операционной системы (переносимые потоком платформы, рисунок 1) и поддерживать понятный код (сравните примеры 4 и 5).

Существует два способа создания виртуального потока:

  • Executors.newVirtualThreadPerTaskExecutor(threadFactory) (пример 4).

  • Executors.newVirtualThreadPerTaskExecutor().

Оба создают новый виртуальный поток для каждой задачи.

Рисунок 1. Общий пул потоков ForkJoin используется совместно с виртуальными потоками и даже пользовательской фабрикой, и каждый виртуальный поток принадлежит к группе «VirtualThread».
Рисунок 1. Общий пул потоков ForkJoin используется совместно с виртуальными потоками и даже пользовательской фабрикой, и каждый виртуальный поток принадлежит к группе «VirtualThread».

Виртуальный поток является общим (не привязанным к процессору, рисунок 1) и передается через поток платформы (привязанный к процессору). Поэтому пользователь не должен делать каких-либо предположений о его назначении потоку платформы. Эти виртуальные потоки дешевы и должны создаваться для краткосрочной задачи, и их никогда не следует объединять в пул из-за дизайна (Рисунок 3.).

var threadFactory = Thread.ofVirtual()
               .name("ForkJoin-custom-factory-", 0)
               .factory();
var counter = new AtomicInteger(0);
var failedCycle = new Random().nextInt(CYCLE_MAX - 1) + 1;
try (var executor = Executors.newThreadPerTaskExecutor(threadFactory)) {
  for (int i = 0; i < EXECUTION_CYCLES; i++) {
    executor.submit(new ComputableTask(counter, failedCycle));
  }
}

Пример 5. В Java SE 19 предложен метод newThreadPerTaskExecutor, который запускает поток для каждой выполняемой задачи, и фабрика потоков, обслуживающая виртуальный поток.

Виртуальные потоки позволяют выполнять сотни задач одновременно (!), которые в противном случае могли бы привести к сбоям JVM или исключениям из-за нехватки памяти, используя общую модель потоков (пример 4. Например, с THREAD_NUMBER = 10_000).

Несколько вещей, которые нужно запомнить

Виртуальный поток всегда работает как поток демона с NORM_PRIORITY, что означает, что использование установщика не имеет никакого эффекта. Поскольку виртуальные потоки содержатся в активных потоках, они не могут быть частью какой-либо ThreadGroup. Использование Thread.getThreadGroup возвращает VirtualThreads.

Виртуальный поток не имеет разрешения при работе с Security Manager, который в любом случае уже устарел (JEP-411, Java SE 17, ссылка 4).

Как уже упоминалось, виртуальный поток ведет себя почти так же, как обычные потоки, что означает, что они могут использовать локальные потоки и локальные наследуемые переменные потока (осторожно, поскольку виртуальный поток никогда не следует объединять в пул)

Запомните еще одну вещь

В Java SE 19 также есть еще одно очень важное улучшение. ExecutorService    теперь расширяет интерфейс AutoCloseable и рекомендуется использовать конструкцию try-with-resource. Это хорошо сочетается с целью удаления финализации (JEP-421, ссылка 3). 

Дополнительным расширением, связанным с предстоящей реализацией Virtual Thread, являются события Java Flight Recorder.

Рисунок 2.: Предстоящие события Java Flight Recorder для виртуальных потоков
Рисунок 2.: Предстоящие события Java Flight Recorder для виртуальных потоков

Дарксайдеров почти нет

Возможны некоторые недостатки. Один из них связан с тем, что VirtualThread планирует использовать общий пул потоков, который также используется другими процессами, работающими в JVM, такими как инфраструктура ForkJoin (рис. 1). Это может гипотетически вызвать исключение нехватки памяти при попытке выделить стек потока или привести приложение к невозможности создания потока. 

Другой проблемой является потенциальная несовместимость с существующей реализацией параллелизма, поскольку, например, ThreadGroup всегда возвращает значение VirtualThreads, но дело в том, что его нельзя уничтожить, возобновить или остановить. Эти методы всегда вызывают исключение. ThreadMXBean предназначен для использования только для потоков платформы и некоторых других…

Вывод

Рисунок 3.: ComputableTaskEvent, созданное задачей. Он показывает использование виртуальных потоков. Виртуальные потоки обслуживаются фабрикой (пример 5).
Рисунок 3.: ComputableTaskEvent, созданное задачей. Он показывает использование виртуальных потоков. Виртуальные потоки обслуживаются фабрикой (пример 5).

Концепция виртуальных потоков выглядит очень многообещающе. Она не только помогает увеличить пропускную способность приложения за счет одновременного запуска гораздо большего количества одновременных задач (рис. 3), но также обеспечивает основу для «теоретически» легкого рефакторинга уже существующего кода (пример 5. стиль потока на запрос), см. раздел «Дарксайдеров почти нет».

В конце концов, JEP-425 все еще находится в стадии активной разработки, и мы должны ждать предстоящих результатов в Java SE 19.

Чтобы протестировать примеры с текущим состоянием, вы можете перейти на GitHub проект (ссылка 5).

Рисунок 4.: Традиционный подход «запрос на поток», демонстрирующий его ограничения по сравнению с изображением 3.
Рисунок 4.: Традиционный подход «запрос на поток», демонстрирующий его ограничения по сравнению с изображением 3.

Рекомендуем прочитать

  1. Project Loom Early-Access Build 19-loom+5-429 (2022/4/4)

  2. JEP-425: Virtual Threads (Preview)  

  3. JEP-421: Deprecate Finalization for Removal 

  4. JEP-411: Deprecate the Security Manager for Removal 

  5. GitHub Java 19 Examples

Комментарии (5)


  1. akurilov
    05.05.2022 23:58
    +2

    Это же те самые green threads, которые были в Яве на заре её существования. Интересная история получается - пришли к тому же самому, от чего ушли.



  1. ageres
    06.05.2022 00:41

    Ощущается некоторая нехватка дисклеймера, чего-то вроде "Мы это сделали для старого кода. Не используйте в новом!".


  1. akurilov
    06.05.2022 12:31
    +1

    Это не project loom случаем?


  1. mayorovp
    06.05.2022 14:52
    +5

    Столько понаписано, но так и не сказано чем виртуальный поток отличается от обычного и за счёт чего он выходит дешевле...