Скрипты на JVM без лишнего оверхеда: Jumper против LuaJ, Groovy, Jython и других

в 14:24, , рубрики: groovy, java, jvm, jython, Lua, встраиваемые скрипты, конфигурация, Песочница, скриптовый язык

Предисловие

Сразу хочу сказать, что в этой статье я не буду подробно расписывать, что Sun и Oracle сделали с Java, JavaScript и JSON. Если кратко, то просто будем принимать то, что исторически сложилось.

А сложилось так. В 1995 году Sun выпустила Java и апплеты — Java‑код прямо в странице браузера. В том же году Netscape переименовала свой браузерный язык LiveScript в JavaScript: это была маркетинговая сделка с Sun, а не родство языков. Общего у них синтаксис фигурных скобок и слово в названии. В 2001 году Дуглас Крокфорд вытащил из JavaScript синтаксис объектных литералов и назвал его JSON — формат по происхождению чисто джаваскриптовый.

Дальше Oracle купила Sun (2010), браузеры выпилили плагины NPAPI (Chrome в 2015, Firefox в 2017), и Java из браузера ушла: апплеты объявлены устаревшими в Java 9 и удалены в Java 11. JavaScript в браузере остался, а через Node.js заехал ещё и на сервер. Торговая марка «JavaScript» при этом до сих пор принадлежит Oracle, и в 2024 году сообщество Deno подало заявку на её аннулирование — это, пожалуй, единственное, что эти два языка сейчас связывает.

С этого момента языки окончательно разошлись по ориентации. Java — статическая типизация, серверы, Android, большие кодовые базы. JavaScript — динамика, браузер, асинхронщина и JSON как родной формат данных. Каждый развивался под свою платформу, и всё, что хорошо ложится на одну, на другой тянется с оверхедом. Это и есть исходная точка для всего, что ниже.

Как появился Jumper

Проект появился не с пустого места, чтобы создать очередной языка программирования, а из‑за накопительного эффекта.

Первый сигнал я увидел, когда разбирал устройство игры Infinitode 2. Уровни там описаны на Lua, и я подумал, что это хорошая практика: логика уровня лежит рядом с данными, правится без пересборки игры. Через несколько месяцев я ради интереса сделал мод на Minecraft с базовым Lua API на LuaJ, чтобы работать с полями клиента прямо из игры. Спустя время желание работать с Lua пропало окончательно: ruby‑подобный синтаксис с end, массивы с первого элемента, отсутствие гибкости в самом коде и необходимость постоянно менять код мода под каждую новую возможность скрипта. Каждый новый метод API — это ещё одна обёртка на стороне Java.

Потом было затишье, пока я снова не встретился с модами, которые реализовывали скрипты в игре. Где‑то JavaScript на Rhino, где‑то Python на Jython, а где‑то и вовсе псевдоязык на yml с условиями в строках. После этого стало ясно, что это системная проблема: для JVM нет нативного скриптового языка для лёгкой интеграции. Есть решения для других платформ, и интеграция каждого из них в JVM карается оверхедом — на боксинге чисел, на границе вызова Java, на размере дистрибутива, на самом синтаксисе, который ни один Java‑разработчик не считает родным.

Тут важное уточнение про Groovy и Kotlin Script. Это замечательные варианты, но они хороши для скриптов сборки, а не для встраиваемых скриптов. Kotlin Script тянет за собой весь компилятор Kotlin (62 МБ в зависимостях) и собирает каждый скрипт секунды. Groovy платит за динамику анбоксингом примитивов и метаклассами на каждом вызове, а в статическом режиме с @CompileStatic это уже не скрипт, а Java с аннотациями. Цифры будут во второй части.

Цель языка была проста: быть для JVM, но с удобствами скриптов. Так и появился Jumper — лёгкий, компилируемый в байткод JVM, с синтаксисом Java, но с гибкостью скриптов: таблицы как в Lua и динамическая типизация там, где производительность не критична.

Что такое Jumper

Jumper — скриптовый язык для JVM: синтаксис Java, лёгкость Lua. Один jar на 0.6 МБ без зависимостей, Java 21+, MIT. Три вида файлов: .jmp — скрипт, .jmc — конфиг, .jma — политика доступа. Внутри два уровня исполнения: интерпретатор дерева (Tier 0) и свой генератор байткода JVM (Tier 1, без ASM), который компилирует каждую функцию в отдельный класс, как только она становится горячей.

Вот скрипт целиком, чтобы было видно, как это выглядит:

import java.util.ArrayList;

dyn greet(name) { return "Hello, " + name; }
dyn names = new ArrayList();

names.add("Jumper");
names.add("Java");

dyn point = { x: 1, y: 2 };

for (dyn n : names) println(greet(n) + " " + (point.x + point.y));

Если вы пишете на Java, тут нечему учиться. ArrayList — это настоящий java.util.ArrayList, import — обычный импорт Java‑класса, цикл for (: ) тот же. Новых слов два: dyn и таблица в фигурных скобках.

Типизация по выбору

dyn — динамическая переменная, как local в Lua или let в JavaScript. Рядом с ней живут int, long, double, boolean, String и имена классов, и это настоящие типы: примитивы не упаковываются, ошибки типов ловятся при разборе.

dyn fib(n) {                    // динамика: n может быть чем угодно
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

int fib(int n) {                // статика: Tier 1 делает из этого метод с сигнатурой (I)I
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

Правило одно: платишь за динамику только там, где её используешь. Любой dyn можно заменить на int или имя класса, ничего больше не трогая, и этот код исполняется как Java. Остальное остаётся скриптом. Смешивать можно как угодно: double умножить на dyn — получится double, компилятор это выводит сам.

Слово var специально не используется: в Java var — это вывод типа с фиксацией, а dyn — по‑настоящему динамическая переменная. Разное поведение — разное слово.

Таблицы, массивы, классы

Таблица — это объект из Lua или JavaScript: ключи и значения, доступ через точку или скобки, можно добавлять поля на ходу.

dyn server = { host: "0.0.0.0", port: 25565, tags: ["foo", "bar"] };
server.motd = "A Jumper server";
println(server.port + 1);        // 25566
println(server["host"]);         // 0.0.0.0
println(len(server.tags));       // 2

Массивы с нуля, как в Java, а не с единицы. Внутри таблицы хранятся по формам (shapes, как hidden classes в V8): пока ключи одни и те же, поле читается по смещению, а не по хешу строки.

Классы — как в Java, но без лишних слов: тип возврата обязателен, конструктор — единственный член без типа, поля можно типизировать.

class Vec {    
  double x, y;    
  Vec(double x, double y) { this.x = x; this.y = y; }  
  
  double len() { return Math.sqrt(x * x + y * y); }    
  Vec add(Vec o) { return new Vec(x + o.x, y + o.y); }
}

class Entity {    
  Vec pos = new Vec(0.0, 0.0);    
  dyn data = {};                // а это поле динамическое   
  
  void tick(double dt) { 
    pos = pos.add(new Vec(dt, 0)); 
  }
}

Классы запечатаны: набор полей известен после разбора, Tier 1 генерирует для экземпляра настоящий JVM‑класс с настоящими полями double x, y. Поэтому e.pos.x в горячем цикле — это getfield, а не поиск по имени. Есть наследование, super, static, лямбды (x) -> x * 2, switch со стрелками, try/catch/finally, модули через import "utils.jmp";.

JVM — это и есть стандартная библиотека

Своей стандартной библиотеки у языка нет, и это принципиально. Строки — java.lang.String, коллекции — java.util, математика — Math. Java‑класс импортируется и используется напрямую, включая массивы и лямбды в функциональные интерфейсы:

import java.util.HashMap;
import java.util.concurrent.TimeUnit;

dyn m = new HashMap();
m.put("a", 1);
dyn a = new int[]{3, 1, 2};  // настоящий int[]

java.util.Arrays.sort(a);
dyn list = new java.util.ArrayList();
list.add(5); list.add(3);
list.sort((x, y) -> x - y);           // Comparator из лямбды
list.forEach((x) -> println(x));      // Consumer из лямбды

println(TimeUnit.SECONDS.toMillis(2));
println(String.valueOf(42).length());

Вызов Java‑метода из скрипта стоит столько же, сколько из Java: по бенчмаркам ArrayList.get в цикле на 2 миллиона элементов — 1.05 x от Java. Для встраиваемого языка это главное свойство: скрипт постоянно дёргает код хоста, и если граница дорогая, то дорого всё.

Чего в языке нет

Чтобы не было сюрпризов: нет generics (коллекции принимают dyn), нет приведений (int) x — вместо этого int(x), нет instanceof — есть isa, нет char и float — есть int, long, double. Класс скрипта не может наследовать Java‑класс, но может реализовать функциональный интерфейс лямбдой. Если кому‑то из этого списка что‑то нужно — это тема для обсуждения в комментариях, а не ограничение архитектуры.

Конфигурации:.jmc

После создания скриптовой базы я обратил внимание на то, что было у меня под носом всё это время, ещё до того, как я занялся скриптами, — конфигурации. У JVM свой формат фактически один — java.util.Properties, он с JDK 1.0. Всё остальное пришло с других платформ, и у каждого формата была своя родная среда:

  • XML — стандарт W3C 1998 года для документов и обмена данными в вебе. В Java прижился через J2EE, Ant и Maven, но как формат конфигов никогда не задумывался.

  • JSON — синтаксис объектных литералов JavaScript (2001), формат обмена браузера с сервером. Комментариев нет, потому что и не должно было быть: его писали машины для машин.

  • YAML — 2001 год, сообщество Perl и Python, а популярным его сделал Ruby on Rails. В мир Java пришёл через Spring Boot и Bukkit.

  • HOCON — единственный, кто родился на JVM: Typesafe (ныне Lightbend) сделала его для Scala, Akka и Play. Но это формат одной экосистемы, за её пределами его почти нет.

  • TOML — 2013 год, Том Престон‑Вернер из GitHub, «очевидный минимальный язык конфигов». Родной для Rust (Cargo) и Python (pyproject), в Java‑мире держится на Forge‑модах.

При этом каждый из них в JVM‑приложении проходит один и тот же путь: файл читается, разбирается сторонней библиотекой в дерево объектов и живёт в памяти в виде Map и List. А у Jumper уже были парсер, таблицы и массивы, то есть ровно то же дерево, только с литералами в Java‑синтаксисе. Так почему бы не сделать и конфиги? Так появился формат .jmc. Всё как в скриптах, только сильно урезано: синтаксис от Java, таблицы от Lua, но нет функций, циклов, классов, импортов и доступа к Java. Остались выражения, ссылки на переменные выше по файлу и if/else — ровно то, что нужно для удобного конфигурирования и чего нет ни в одном формате из списка выше.

// server.jmc
boolean dev = false;
int port = dev ? 25566 : 25565;

dyn server = {    
  host: "0.0.0.0",    
  port: port,    
  maxPlayers: dev ? 5 : 100,    
  motd: "A Jumper server"
};

dyn database = {    
  url: "jdbc:mysql://localhost:3306/mc",
  pool: { min: 2, max: dev ? 2 : 10 }
};

dyn worlds = ["world", "world_nether", "world_the_end"];
if (dev) {    
  server.motd = server.motd + " [dev]";
}

Значение конфига — таблица переменных верхнего уровня в порядке объявления, то есть вспомогательные dev и port попадут в неё рядом с server и database: хост берёт нужные секции по имени, лишние ключи ему не мешают. Со стороны Java это одна строка: Config.load(path). Файл исполняется без какого‑либо доступа к Java, так что конфиг от пользователя не может сделать ничего, кроме как описать данные. Для чистых данных есть быстрый путь — однопроходный читатель без токенов и дерева, который считает выражения прямо по ходу чтения; всё, что он не умеет, уходит на полный парсер с тем же результатом. Цифры против Jackson, Gson, fastjson2, SnakeYAML и остальных — во второй части, там есть чем удивить и есть где честно признать паритет.

Безопасность:.jma

Осталось решить вопрос безопасности: отдавать скриптам полный контроль над JVM нельзя. Скрипт, который видит Runtime.exec и ClassLoader, — это не плагин, а удалённое выполнение кода с доставкой через папку plugins/.

Решение — белый список. Файл .jma (Jumper Access) перечисляет, какие пакеты, классы, методы и поля Java скрипту видны. Всё, что не перечислено, закрыто. Своего формата у политики нет: это обычный скрипт Jumper, который сам исполняется под политикой «виден только Policy». Вот политика из примера сервера в репозитории:

// scripts.jma - политика доступа для скриптов в scripts/
Policy.allowPackage("example.jumper.api");          // API для скриптов: сервер, игроки, мир
Policy.allowClass("example.jumper.api.ScriptPlayer")
      .denyMethod("kick");                          // ...кроме кика: это для своих плагинов
Policy.denyClass("example.jumper.api.Players");     // внутренняя кухня плагина
Policy.allowPackage("java.util");                   // коллекции
Policy.allowPackage("java.lang");                   // String, Math, Integer... но не Class/Runtime/System/Thread
Policy.allowMethod("java.lang.System", "currentTimeMillis");
Policy.maxTableSize(100000);                        // while (true) list.add(x) остановится здесь, а не на -Xmx

Как это работает:

  1. Политику задаёт хост, а не скрипт. Access.load(path) в Java‑коде, дальше new Interpreter().access(policy). Скрипт не может ни прочитать политику, ни поменять. Без политики скрипт видит всю JVM — это осознанное решение: свой скрипт автоматизации не надо запирать, чужой плагин — надо.

  2. Правила складываются от общего к частному. Правило на класс сильнее правила на пакет, правило на метод сильнее правила на класс и действует на переопределения: закрыли Entity.setHealth — закрыт и Zombie.setHealth.

  3. Проверка идёт на связывании, а не на каждом вызове. Большинство нарушений ловится ещё при разборе: скрипт с import java.lang.reflect.Method; просто не запустится. Остальное — в момент, когда invokedynamic привязывает метод к месту вызова. Горячий цикл после этого бежит без проверок, поэтому песочница не стоит производительности. Нарушение — SecurityException, которое скрипт не может поймать своим catch.

  4. Пути выхода из песочницы не открываются пакетным правилом. allowPackage("java.lang") даёт String и Math, но не Class, ClassLoader, Runtime, System, Thread, не java.lang.reflect, java.lang.invoke, java.lang.foreign, не порождатели потоков из java.util.concurrent. Каждый такой класс надо назвать явно, а у Class даже после этого доступны только безобидные getName и подобные: никакое правило не откроет forName или getClassLoader. getClass() закрыт по умолчанию.

  5. Объекты хоста подчиняются той же политике. Если хост передал в скрипт объект, а его класс не открыт, скрипт не сможет вызвать у него ничего. Выбраться наружу через возвращаемое значение тоже нельзя: методы, возвращающие ClassLoader, отфильтрованы жёстко.

  6. Зависание тоже атака. Interpreter.cancellable(true) включает проверку на обратных рёбрах циклов и на входе в функции, и сторож хоста может снять скрипт через cancel(). Без флага проверок в циклах нет совсем. maxTableSize страхует от бесконечно растущей таблицы. А вот лимита на общий объём памяти нет и не будет: это задача -Xmx и того, кто принимает скрипты.

Песочницу проверяли не только своими тестами. Независимый тест на проникновение нашёл выход со стандартной политикой allowPackage("java.lang") + allowPackage("java.util"): пакетное правило открывало и подпакеты (java.lang.foreign, java.lang.management, java.util.logging), а new java.util.Formatter("/path") писал в произвольный файл без всякого java.io. Все пять находок закрыты в 0.11.1 и покрыты регрессионными тестами. Рассказываю об этом не потому, что горжусь дырой, а потому, что песочница, которую никто не ломал, ничего не стоит.

Как это встраивается

Два пути. Стандартный javax.script, если у вас уже есть код под JSR-223 (имена движка jumper и jmp):

ScriptEngine jumper = new ScriptEngineManager().getEngineByName("jumper");
jumper.put("name", "world");
jumper.eval("println("Hello, " + name);");

И свой API, когда нужны политика, отмена и вызов функций скрипта из Java. Так выглядит загрузка одного скрипта в примере сервера из репозитория:

Access policy = Access.load(serverFolder.resolve("scripts.jma"));
Interpreter jj = new Interpreter()       
    .access(policy)            // без этой строки скрипт видит всю JVM
    .cancellable(true);        // сторож сможет снять зависший скрипт

jj.define("server", new ScriptServer(ctx));   // объект хоста, виден скрипту как `server`

Script script = jj.script(Files.readString(file)); // разбор: здесь политика проверяет имена
script.run();                                      // верхний уровень

if (script.has("onTick")) {    
  script.invoke("onTick", tick);
  // функция скрипта из Java
}

А сам скрипт на стороне пользователя выглядит так — это реальный welcome.jmp из того же примера, его можно править на живом сервере и перезагружать командой:

import java.util.HashMap;

dyn visits = new HashMap();  // состояние скрипта, живёт до перезагрузки

void onJoin(dyn p) {    
  int n = visits.getOrDefault(p.name(), 0) + 1;
  visits.put(p.name(), n);
  p.send(n == 1 ? "Welcome, " + p.name() + "!" : "Welcome back, " + p.name() + " (visit " + n + ")");
  server.broadcast(p.name() + " joined, " + server.players().size() + " online");
}

void onQuit(dyn p) {    
  server.log(p.name() + " left after " + visits.get(p.name()) + " visit(s)");
}

Инструменты тоже есть: языковой сервер (LSP) встроен в тот же jmp.jar, на нём работают плагины для VS Code и Neovim, а для IntelliJ IDEA есть отдельный плагин. Подсветка, автодополнение по Java‑классам хоста, переход к определению, и редактор знает политику: закрытый класс подсвечивается ещё до запуска.

Подключается через JitPack: com.github.jumper-lang:jumper:<тег>. Репозиторий, вики и сайт документации — в конце статьи.

Почему Jumper

Всё, что выше, — слова. Дальше цифры, и сначала о том, как они получены, потому что бенчмарк без методики — это реклама.

Как мерили

  • 28 бенчмарков в восьми группах: циклы и арифметика, вызовы функций (моно‑, поли‑ и мегаморфные), массивы и таблицы, строки, ООП, интероп с Java, «как в жизни» (nbody, binary_trees, game_tick).

  • 14 движков: Java и Kotlin как потолок, Kotlin Script, три режима Jumper (типизированный, динамический, интерпретатор без JIT), Groovy в двух режимах, LuaJ в двух режимах, Nashorn, Rhino, JRuby, Jython. Каждый в своём сильнейшем режиме: LuaJ с компиляцией в байткод, Rhino в компилирующем режиме, Nashorn с optimistic types, JRuby с JIT с первого вызова и invokedynamic.

  • Каждая ячейка — отдельный процесс JVM, прогрев до стабилизации, медиана из 9 измерений. Разбор и компиляция скрипта в замер не входят.

  • Каждый бенч возвращает чек‑сумму, и она сверяется с Java. Все 392 ячейки сошлись: ни одного движка, который быстро считает не то.

  • Стенд: Windows, Amazon Corretto 21.0.4, -Xms2g -Xmx2g -XX:+UseParallelGC -Xss16m. JDK 21, а не 25, потому что Groovy 4 не читает class‑файлы JDK 25. Graal.js в основной таблице нет: без GraalVM он работает интерпретатором, а на GraalVM — отдельный стенд, про него ниже.

Бенчмарк лежит в репозитории, каждый бенч — это 14 файлов на 14 языках плюс описание отклонений. Если вы считаете, что какой‑то движок запущен не в лучшем режиме или бенч написан не идиоматично — pull request с цифрами приветствуется.

Скрипты: отношение к Java

Скрипты на JVM без лишнего оверхеда: Jumper против LuaJ, Groovy, Jython и других - 1

Среднее геометрическое отношения к Java по 28 бенчам (1.00 x = скорость Java, меньше — быстрее):

движок

geomean

Java

1.00 x

Kotlin

1.02 x

Kotlin Script (разогретый)

1.02 x

Jumper, типизированный

1.03 x

Jumper, динамический

1.15 x

Groovy @CompileStatic

1.80 x

Nashorn

4.15 x

JRuby

10.95 x

Groovy динамический

12.50 x

LuaJ (luajc)

14.84 x

Rhino

14.95 x

Jumper, интерпретатор

22.29 x

Jython

43.01 x

LuaJ интерпретатор

44.18 x

Типизированный Jumper неотличим от Java в пределах погрешности стенда (она около 13% на одной ячейке). На 21 бенче из 28 он в пределах 1.15 x, а на пяти быстрее Java: полиморфный вызов через четыре класса 0.68 x, виртуальный вызов в трёхуровневой иерархии 0.79 x, switch 0.89 x. Это не магия: классы Jumper запечатаны, и место вызова знает все возможные цели, а javac такой гарантии дать не может.

Где отстаёт: создание объектов (object_alloc_churn 1.31 x, oop_construct 1.27 x) и обратный вызов из Java в скрипт (interop_callback 1.21 x). Экземпляр класса Jumper всё ещё несёт 24 байта базы таблицы сверх полей, и на 15 миллионах короткоживущих объектов это видно.

Динамический Jumper — тот же код с dyn вместо типов и таблицами вместо классов — в 1.15 x от Java. Ближайший динамический движок, Nashorn, в 3.6 раза медленнее его; JRuby и динамический Groovy — в десять. Это и есть цена динамики, которую вы платите только там, где сами выбрали dyn.

Граница Java

Скрипты на JVM без лишнего оверхеда: Jumper против LuaJ, Groovy, Jython и других - 2

Для встраиваемого языка главный вопрос не «как быстро он считает фибоначчи», а «сколько стоит вызвать метод хоста». ArrayList.add и get в цикле на 2 миллиона элементов с 15 проходами чтения:

движок

interop_method

interop_collections (HashMap)

Jumper, типизированный

1.05 x

1.53 x

Jumper, динамический

1.18 x

1.08 x

Nashorn

1.12 x

6.63 x

Groovy @CompileStatic

3.31 x

1.73 x

Groovy динамический

6.29 x

1.94 x

JRuby

18.0 x

7.95 x

LuaJ (luajc)

36.2 x

19.6 x

Jython

91.6 x

21.0 x

Rhino

242 x

91.7 x

У LuaJ вызов Java‑метода идёт через рефлексию и преобразование каждого аргумента — отсюда 36 раз. Именно это я и почувствовал в своём моде: любое обращение скрипта к игре стоило дороже самой работы. Jumper вызывает Java через invokedynamic с inline‑кешем по классу получателя, и после первого вызова это обычный вызов метода, который C2 инлайнит.

Антирекорды

Несколько ячеек, по которым видно, что у каждого движка есть своя «больная точка», о которой лучше знать до того, как вы его встроили:

  • Groovy, switch по восьми плотным меткам: 71 x даже с @CompileStatic, 91 x без. Groovy реализует switch через isCase на каждую метку, а не через tableswitch.

  • Groovy динамический, двенадцать классов в одном месте вызова: 552 x, 28 секунд на итерацию против 51 мс у Java. Инлайн‑кеш переполняется, и каждый вызов идёт через метакласс. У Jumper та же ячейка — 0.99 x.

  • Rhino, HashMap из скрипта: 92 x; обычный массив на 15 миллионов чисел не влез в 2 ГБ, потому что каждый элемент упакован.

  • Nashorn, таблица со строковыми ключами: 78 x. При этом на численных циклах Nashorn ровно 1.0 x — он был хорошим движком, пока его не выбросили из JDK.

  • Jython: нет JIT совсем, 43 x в среднем, 262 x на полях объектов, и это Python 2.

А на GraalVM?

Честное ограничение. На GraalVM CE 25 типизированный Jumper — 2.21 x от Java, Graal.js — 2.30 x, динамический Jumper — 2.61 x. Циклы, арифметика, строки и аллокация там те же 1.0–1.3 x, а вот места вызова методов и граница Java дорожают в 10–45 раз: компилятор Graal не сворачивает цепочки MethodHandle, на которых построен Tier 1, так, как это делает C2. Jumper заточен под HotSpot. Если ваш сервер живёт на GraalVM, это стоит знать до того, как вы поверите таблице выше.

Конфиги

Скрипты на JVM без лишнего оверхеда: Jumper против LuaJ, Groovy, Jython и других - 3

Один и тот же конфиг сервера (114 значений: секции, списки, 40 строк сообщений) в каждом формате, чек‑сумма загруженного совпадает у всех 21 участников. Холодный старт — первая загрузка в свежей JVM с загрузкой классов библиотеки, разбор — текст в памяти на разогретой JVM:

библиотека

формат

холодный старт, мс

разбор, мкс

аллокация, КБ

Jumper

jmc

25.0

7.1

1.8

java.util.Properties

properties

21.8

22.1

37.7

fastjson2 (POJO)

JSON

214

6.4

9.4

fastjson2

JSON

177

8.0

12.8

DSL‑JSON

JSON

93

12.4

15.1

Jackson

JSON

224

12.6

14.1

kotlinx.serialization

JSON

95

14.7

19.6

Gson

JSON

71

18.8

20.6

Moshi

JSON

59

27.2

27.0

NightConfig

TOML

56

31.7

76

Jackson TOML

TOML

235

31.7

31.3

XML DOM (JDK)

XML

60

79.3

101

Jackson YAML

YAML

253

133

260

Typesafe Config

HOCON

75

151

233

SnakeYAML

YAML

103

186

377

Jankson

JSON5

64

189

219

Configurate

YAML

276

229

340

tomlj

TOML

145

657

1252

Сначала паритет, который надо признать: по чистой скорости разбора Jumper в одной группе с fastjson2, а не впереди: 7.1 мкс против 6.4–8.0. Jackson медленнее в 1.8 раза, Gson в 2.6, YAML в 20–30, tomlj в 90. Но микросекунды разбора — не то, за что платит сервер. Он платит за холодный старт и за мусор на каждом reload, и тут картина другая: 25 мс против 177–261 у Jackson и fastjson2 (быстрее только Properties, который уже лежит в CDS‑архиве JDK) и 1.8 КБ мусора против 9–15 у JSON‑парсеров и 260–380 у YAML. В таблице Jumper читает текст каждый раз; у него есть и режим шаблона неизменного текста (0.7 мкс на повторное чтение), но это кеш результата, и сравнивать его с парсерами нечестно.

Размер дистрибутива

Скрипты на JVM без лишнего оверхеда: Jumper против LuaJ, Groovy, Jython и других - 4

Сумма jar‑ов, которые придётся положить рядом с вашим приложением: Jumper 0.6 МБ (один jar, вместе с языковым сервером для редакторов), Rhino 1.2, LuaJ 1.7, Nashorn 2.4, Groovy 7.4, JRuby 34, Jython 48, Graal.js 62, Kotlin Script 62.

Четыре тезиса

Вы не обязаны ничего портировать. Jumper не просит ломать обратную совместимость и в срочном порядке переписывать то, что работает. Легаси пусть живёт на старом стеке. Но новый плагин, новый мод, новый конфиг с логикой можно делать уже на новом, и оба стека спокойно живут в одном процессе.

Не нужно переключать мозг. Скрипт, конфиг и политика написаны на одном синтаксисе, и это синтаксис вашего основного языка. Ни Lua с единицы, ни Python 2, ни отступы YAML, ни псевдоязык в строках JSON. И в редакторе одна подсветка, одно автодополнение по тем же Java‑классам.

За производительность отвечаете вы. Типизация — это ручка, а не режим движка. Написали dyn — получили гибкость и 1.15 x. Поставили int и double в горячем цикле — получили Java. Никакой перекомпиляции, никаких аннотаций, одна замена слова в том месте, где профилировщик показал проблему.

За безопасность отвечаете вы. Политика — это файл, который пишет хост, а не настройка «безопасный режим вкл/выкл». Свой скрипт автоматизации запускается без политики и видит всю JVM. Чужой плагин получает ровно то, что вы перечислили в .jma, и этот список лежит рядом с ним в том же синтаксисе, что и сам плагин.

FAQ

Зачем ещё один язык, если есть Groovy? Groovy — отличный язык для сборки и тестов, но как встраиваемый он либо медленный (динамический режим, 8% от Java), либо требует аннотаций и всё равно проваливается на switch и на границе Java. Плюс 7 МБ зависимостей и нет песочницы из коробки. Jumper делался ровно под одну задачу — скрипты внутри Java‑приложения — и больше ни под что.

Это безопасно запускать чужие скрипты? С политикой .jma — да, в тех пределах, которые описаны выше: белый список классов, закрытая рефлексия и загрузчики, отмена зависшего скрипта, лимит на размер таблицы. Чего нет: лимита на общую память и прерывания блокирующих Java‑вызовов (если вы открыли скрипту CountDownLatch.await, это ваш выбор). Без политики скрипт равен Java‑коду в том же процессе.

Почему нет generics и приведений типов? Потому что это скрипт. Generics в Java стираются компилятором и существуют только для проверки; в Jumper их роль играет dyn, а проверка типа делается там, где значение попадает в типизированную переменную. Приведения чисел есть, но в виде функций int(x), long(x), double(x) — явно и без скобочной неоднозначности в парсере.

Можно ли наследовать Java‑класс из скрипта? Сейчас нет, и парсер скажет об этом понятной ошибкой. Функциональный интерфейс реализуется лямбдой, а для всего остального хост обычно даёт скрипту свой API‑объект, как в примере с server. Реализация произвольного Java‑интерфейса объектом скрипта — в планах.

А отладка? Ошибки времени выполнения несут номер строки скрипта, ошибки разбора — строку и колонку, и редактор показывает их до запуска. Пошагового отладчика нет: код Tier 1 — это настоящие JVM‑классы, их можно сбросить на диск (JMP_DUMP) и смотреть javap, но точки останова в IDE пока не ставятся.

Сколько стоит старт? Разбор скрипта — миллисекунды, компиляция функции в байткод идёт при первом вызове или заранее для всех сразу (Jit.compileAll), если первый тик сервера не должен за это платить. Для сравнения: Kotlin Script на первом скрипте поднимает весь компилятор и тратит секунды.

Как подключить? JitPack: com.github.jumper-lang:jumper:<тег> в Gradle или Maven, Java 21+. Или просто jmp.jar из релизов — в нём CLI, REPL, языковой сервер и движок javax.script.

Что известно из недоделок? Замыкания захватывают переменную цикла, а не её значение на итерации (как var в JavaScript, а не let); обход — функция‑обёртка, описано в спецификации. Создание объектов на 20–30% дороже Java. На GraalVM вызовы заметно дороже, чем на HotSpot. Всё это есть в трекере репозитория, и туда же можно нести всё, что вы найдёте после этой статьи.

Ссылки

Автор: Padej

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js