Jactl эмулирует виртуальные потоки в Java 8 без Loom

Jactl, встраиваемый скриптовый язык для Java-приложений, спроектированный так, чтобы скрипты компилировались в байткод и не блокировали поток выполнения на долгих операциях. Его создатель, Джеймс Кроуфорд, в посте от 28 августа 2026 года объясняет, как устроен внутренний механизм, решающий эту задачу на Java 8 и Java 11, версиях, для работы на которых Jactl создавался ещё до того, как в Java 21 появились виртуальные потоки (Virtual Threads), расширение из проекта Loom. В реактивных приложениях (например, построенных на Vert.x) пул потоков цикла событий разбирает очередь событий, и, по описанию автора, ни одно событие не должно блокировать поток: если блокирующая операция, обращение к базе, удалённый вызов, займёт поток цикла событий, рано или поздно все потоки окажутся заняты ожиданием, и обработка новых событий встанет. Виртуальные потоки в Java 21+ решают это на уровне JVM, сохраняют весь стек вызовов с локальными переменными и восстанавливают его после завершения блокирующей операции. Но в Java 8, по словам автора, сохранить стек вызовов средствами языка или байткода нельзя, поэтому Jactl потребовался собственный механизм.

Кроуфорд описывает решение как цепочку исключений. Когда скрипт вызывает долгую операцию, в примере поста это sleep(), вместо того чтобы заблокировать поток, функция бросает исключение специального класса, он называется Continuation (буквально «продолжение» выполнения), а событие для возобновления работы регистрируется отдельно, и поток цикла событий освобождается для других задач. Пока это исключение разворачивает стек вызовов, сгенерированный компилятором Jactl код в каждом кадре стека перехватывает Continuation, сохраняет в нём значения своих локальных переменных, в двух массивах: long[] для примитивов и Object[] для всего остального, вместе с меткой места вызова, а затем бросает новый Continuation, связанный с только что пойманным. К моменту, когда исключение доходит до низа стека Jactl, исходный стек вызовов заменяется цепочкой объектов Continuation, которая целиком описывает состояние выполнения скрипта. Автор отдельно отмечает: само по себе бросание исключений дёшево, а дорога обычно генерация трассировки стека, раз Continuation её не заполняет, механизм остаётся эффективным.

Компилятор Jactl заранее вычисляет, какие функции способны бросить Continuation, это «асинхронные» функции, и прослеживает это свойство вверх по цепочке вызовов, так что в любой точке кода известно, может ли вызов привести к Continuation, даже если асинхронная функция спрятана на много уровней вложенности. Каждый вызов асинхронной функции компилятор оборачивает в try/catch, а сама асинхронная функция неявно получает первым аргументом объект Continuation: при первом вызове он null, а при возобновлении после приостановки в нём приходит сохранённое состояние, и сгенерированный код через switch и goto переходит ровно туда, где выполнение было прервано, восстановив локальные переменные из массивов. Когда долгая операция завершается, вызывается метод continueExecution(result) у первого Continuation в цепочке: он находит сохранённый MethodHandle нужной функции и вызывает её; а поскольку стек вызовов уже не совпадает с исходным, после завершения функции управление возвращается не в исходного «родителя», а обратно в continueExecution(), которая достаёт из цепочки следующий Continuation, и так далее, пока цепочка не закончится и приложение не получит финальный результат через колбэк. Если во время возобновления старой цепочки скрипт снова упирается в долгую операцию, в примере поста это повторная приостановка внутри checkInventory(), новая цепочка Continuation для этой приостановки достраивается так, что к её концу подклеивается остаток старой цепочки, и после её отработки продолжается разбор оставшихся старых звеньев.

Раз состояние скрипта в любой момент сводится к цепочке Continuation, а её можно сериализовать в массив байт, Jactl добавил отдельную функцию checkpoint(): скрипт вызывает её в контрольных точках, чтобы сохранить своё состояние. Дальше это состояние можно записать на диск, в базу данных или реплицировать по сети на другой экземпляр приложения и в любой момент возобновить, например, если исходный хост, на котором выполнялся скрипт, вышел из строя. Компилятор Jactl генерирует код сериализации в байты для каждого встроенного типа и пользовательского класса, а сам Jactl предоставляет приложению хуки для сохранения и репликации состояния и для его последующего восстановления, то есть это готовый строительный блок для отказоустойчивости скриптуемого приложения.

Чтобы оценить накладные расходы механизма, автор собрал бенчмарк на JMH с планировщиком событий Vert.x: скрипт на Jactl обрабатывает пакеты по 200 заказов, для каждого заказа считает скидку по объёму, 20% при количестве от 100 единиц, 10%, от 50, 5%, от 20, и суммирует итоги по категориям, а функция проверки склада checkInventory() в части прогонов вызывает sleep(0): операция с нулевой задержкой мгновенно приостанавливает и тут же возобновляет скрипт, что позволяет измерить именно накладные расходы самой приостановки и возобновления на разной глубине стека вызовов. Бенчмарк сравнивал пропускную способность при 0, 1, 2, 5 и 10 таких приостановках на один прогон скрипта. В тексте поста результаты приведены только в виде графика «Throughput vs number of suspend/resume operations» (буквально, «пропускная способность в зависимости от числа операций приостановки и возобновления»), без конкретных числовых значений; вывод автора, что в этом бенчмарке влияние каждой приостановки и возобновления оказалось небольшим. Он также прямо оговаривает: в реальных сценариях относительное влияние зависит от того, сколько работы выполняет скрипт, насколько глубоко вложен стек вызовов и сколько локальных переменных (включая параметры) есть на каждом его уровне, а измеренные накладные расходы включают в себя ещё и расходы самого планировщика Vert.x на постановку в очередь скриптов и событий возобновления.

По заключению автора, для реактивных приложений на старых версиях Java подход на основе Continuation даёт удобный и эффективный способ предложить кастомизацию через скрипты, не беспокоясь о блокировке потоков цикла событий, причём скрипты пишутся с обычными «блокирующими» на вид вызовами внутри, без async/await, Future или Promise: со стороны скрипта Jactl даёт ровно ту же модель программирования, которую виртуальные потоки Java 21 дают Java-программам. На современных версиях Java этот встроенный механизм можно отключить флагом JactlContext.async(false) и отдать управление настоящим виртуальным потокам JVM. В постскриптуме Кроуфорд добавляет, что позже узнал о более ранних библиотеках с похожей идеей для произвольного Java-кода, Apache Javaflow и Java Continuations Library (последняя больше не поддерживается); они используют инструментирование байткода, а не кодогенерацию компилятором, но, по его наблюдению, полагаются на ту же идею, бросать исключение и перехватывать его в каждом кадре стека, чтобы записать локальное состояние.

Ключевые факты

  • Jactl, встраиваемый скриптовый язык для Java-приложений; его создатель Джеймс Кроуфорд в посте от 28 августа 2026 года объясняет, как язык получает эффект виртуальных потоков Java 21 (Loom) на Java 8 и 11, где их не существует.
  • Механизм, цепочка исключений: блокирующая операция (в примере, sleep()) бросает исключение Continuation; каждый кадр стека Jactl перехватывает его, сохраняет локальные переменные в двух массивах (long[] для примитивов, Object[] для остального) и пробрасывает дальше новый, связанный с предыдущим Continuation, так собирается цепочка, полностью описывающая состояние выполнения.
  • Компилятор помечает функции, способные бросить Continuation, как асинхронные, отслеживает это свойство по всей цепочке вызовов и оборачивает такие вызовы в try/catch; при возобновлении сгенерированный код переходит по switch и goto ровно туда, где выполнение было прервано, восстановив локальные переменные.
  • Поскольку состояние сводится к сериализуемой в байты цепочке Continuation, Jactl добавил функцию checkpoint(): состояние скрипта можно сохранить на диск, в базу данных или реплицировать по сети и возобновить, например, после падения хоста.
  • Бенчмарк на JMH и Vert.x (пакеты по 200 заказов, 0, 1, 2, 5 и 10 приостановок sleep(0) на прогон) описан в посте подробно, но результат пропускной способности показан только на графике-изображении, числовых значений в тексте нет, есть лишь вывод автора, что влияние оказалось небольшим.

Почему это важно

До Java 21 у реактивных, построенных на цикле событий Java-приложений (например, на Vert.x) не было встроенного способа приостановить выполнение долгой операции и потом восстановить его, не блокируя рабочий поток. Виртуальные потоки (Virtual Threads), расширение Java 21 из проекта Loom, решили эту задачу на уровне самой JVM: они сохраняют весь стек вызовов вместе с локальными переменными и восстанавливают его, когда блокирующая операция завершается. Jactl, встраиваемый скриптовый язык для Java-приложений, создавался ещё до Java 21 и должен был работать в том числе на Java 8 и 11, где сохранить стек вызовов средствами языка или байткода нельзя. Пост его создателя показывает, как та же задача решается полностью на уровне компилятора и рантайма скриптового языка, цепочкой исключений, которая перехватывает и потом восстанавливает состояние выполнения покадрово, без какой-либо поддержки со стороны JVM. Побочный эффект того же механизма, раз состояние скрипта сводится к сериализуемой цепочке объектов, из неё бесплатно получается ещё и механизм чекпоинтинга для отказоустойчивости.

Кому это важно

В первую очередь, разработчикам, которые встраивают скриптовые движки в Java-приложения (плагины, пользовательские правила, кастомизация бизнес-логики), особенно если приложение реактивное и должно работать на Java 8 или 11. Также, инженерам, проектирующим собственные DSL или скриптовые рантаймы поверх JVM и ищущим готовый паттерн приостановки и возобновления выполнения без отдельного потока операционной системы на каждый скрипт. И тем, кому интересна сама техника, захват состояния выполнения через цепочку исключений и покадровое сохранение стека: применительно к Jactl это подробно разобранный пример техники, которая переносится на любой JVM-язык, компилируемый в байткод.

Как это применить

Jactl можно встроить в Java-приложение как скриптовый движок: на Java 8 и 11 механизм на основе Continuation работает из коробки и гарантирует, что скрипт не заблокирует поток цикла событий, даже если внутри скрипта вызовы выглядят как блокирующие (sleep, обращение к базе, удалённый вызов), писать их можно в обычном последовательном стиле, без async/await, Future или Promise. На Java 21 и новее этот встроенный механизм можно отключить флагом JactlContext.async(false) и отдать обработку блокирующих вызовов настоящим виртуальным потокам JVM. Отдельно от неблокирующего выполнения, вызовом checkpoint() внутри скрипта можно зафиксировать его состояние в контрольной точке и сохранить на диск, в базу данных или реплицировать на другой узел, это готовый строительный блок для восстановления скрипта после сбоя хоста. Автор не приводит в посте ни номера версии, ни даты релиза, ни истории изменений этой возможности Jactl, оценивать её зрелость и границы применимости стоит по собственному коду и тестам, а не по этому объясняющему посту.

Можно ли доверять

Источник, официальный блог проекта Jactl, а текст написан от первого лица его создателем, Джеймсом Кроуфордом: это первичное авторское описание собственных инженерных решений, а не пересказ со стороны, так что описанию механизма и кода можно доверять. Слабое место, бенчмарк: методика описана подробно (JMH, планировщик Vert.x, пакеты по 200 заказов, 0, 1, 2, 5 и 10 приостановок на прогон), но сами числа пропускной способности в тексте поста не приведены, они показаны только на графике-изображении, а не как цифры в тексте. Пост также нигде не сравнивает Continuation-механизм напрямую с производительностью настоящих виртуальных потоков Java 21, заявленная эквивалентность касается модели программирования, доступной автору скрипта, а не измеренного сравнения быстродействия.

Риски и подводные камни

Подход требует, чтобы компилятор Jactl отслеживал «асинхронность» по всей цепочке вызовов и оборачивал каждый такой вызов в try/catch с генерацией кода для сохранения и восстановления локальных переменных, это специально построенный механизм компилятора, а не готовая библиотечная возможность. Единственный бенчмарк в посте, синтетический: sleep(0) не ждёт реального времени, поэтому измеряет накладные расходы именно самого механизма приостановки и возобновления, а не поведение под настоящей задержкой ввода-вывода; по собственной оговорке автора, реальное влияние на другой нагрузке зависит от объёма работы скрипта, глубины стека вызовов и числа локальных переменных на каждом его уровне, вывод «влияние небольшое» не переносится автоматически на произвольный код. Сами числа пропускной способности из бенчмарка в тексте поста не даны, только на графике, сравнить их напрямую не с чем. Автор также уточняет, что механизм чекпоинтинга генерирует код сериализации в байты для каждого встроенного и пользовательского типа Jactl, то есть распространяется на все объекты, с которыми работает скрипт. В постскриптуме он упоминает более ранние библиотеки с похожей идеей для произвольного Java-кода, Apache Javaflow и Java Continuations Library, и уточняет, что вторая из них больше не поддерживается.

«Пока исключение при броске не заполняет трассировку стека, это на самом деле очень эффективно.»

— Джеймс Кроуфорд, создатель Jactl