Project Panama: как сделать Java «ближе к железу»?
В феврале в нашей новосибирской Точке кипения прошел тринадцатый митап JUGNsk, где Владимир Иванов, ведущий инженер Oracle из группы разработки виртуальной Java-машины HotSpot, рассказал, что сейчас приходит на смену JNI при взаимодействии Java-кода с native-кодом. Читайте далее о FFI (Foreign Function Interface), jextract, Memory Access API и Foreign ABI (Application Binary Interface).
Статья подготовлена по мотивам доклада, видеозапись которого с сессией вопросов-ответов можно найти тут. Это не просто расшифровка, а доработанная самим Владимиром версия исходного материала.
В целом, доклад посвящен довольно специфической теме: взаимодействию с платформенными (native) библиотеками из Java.
Как все начиналось
Видеозапись доклада Java Native Runtime with Charles Nutter
Разработка Java ведется в рамках OpenJDK, и любые нововведения в язык и платформу создаются в специализированных OpenJDK проектах. Для примера: лямбда-выражения и Stream API пришли из Project Lambda, система модулей (JPMS) создавалась в рамках Project Jigsaw, работа в Project Valhalla ведется над inline-типами (и всем, что с этим связано). Project Panama — один из таких мегапроектов, в рамках которого ведется разработка новых механизмов работы с платформенными библиотеками (и не только).
Текущее состояние (или «моментальный срез») проекта само по себе не столь интересно. Поэтому постараюсь не просто рассказать, где сейчас находится проект, но и дать некоторую перспективу: как проект начинался, что в нем происходило в последние годы, где он находится сейчас и куда планируется двигаться дальше.
Бывает, со стороны смотрят на проект и не понимают, что поменялось за последний год. Предположим, год назад рассказывали про Value Types, а теперь они называются по-другому — Inline Types. Со стороны может показаться, что это все изменения. Но если вглядеться в детали и проследить изменения год за годом, объем работы впечатляет.
Возвращаясь к Panama: вдохновителем проекта стал Charles Nutter (@headius), который на конференции JVMLS 2013 сделал большой доклад про библиотеку JNR (Java Native Runtime), активно используемую JRuby для работы с платформой. В докладе он сравнил производительности JNR, JNI и JNA (еще одной библиотеки для взаимодействия с native-кодом).
Производительность всех вариантов вызовов (через чистый JNI, через JNR и т.д.) была далека от прямого вызова native-функции, и повысить ее на уровне сторонней библиотеки было уже нельзя — необходима была помощь со стороны виртуальной машины Java (JVM). Идея Charles Nutter заключалась в том, чтобы создать новый API на замену JNI.
К слову, в то время разработка Java велась на основе JSR.
Призыв Чарльза был услышан, и в марте 2014 года было инициировано обсуждение нового OpenJDK-проекта. Предложение получило достаточный отклик в сообществе, так что датой старта проекта можно считать 18 марта 2014 года, когда John Rose инициировал официальное голосование о создании Project Panama.
Анонсированная «область ответственности» для проекта была выбрана гораздо шире, чем предлагал Чарльз. Для JNR было бы достаточно добавить низкоуровневый способ делать прямые вызовы библиотечных функций. Для прямого доступа в память уже был sun.misc.Unsafe. И для JNR было достаточно чего-то похожего: по входному адресу какой-либо native-функции виртуальная машина просто генерировала бы прямой вызов по этому адресу. Для целей JRuby (работы с интерфейсом POSIX) этого было более чем достаточно. Но не для Java-платформы и чего-то, рассматриваемого как замена JNI. Так что с самого начала для проекта были заявлены амбициозные цели: не просто работа с native-кодом, но и поддержка полностью программируемых представлений данных в памяти (memory layout) для более тесного взаимодействия. Проект не ограничивали жесткими рамками.
Не зря было выбрано «говорящее» название, напоминающее о Панамском проливе: проект Panama должен был сделать Java и native-код «ближе» друг к другу.
У проекта Panama есть свой репозиторий: http://hg.openjdk.java.net/panama/dev/
На текущий момент два основных направления работы — это замена JNI и Vector API.
Вот четыре основных ветки, которые можно отнести к FFI:
Но прежде чем говорить о деталях и особенностях нового FFI, я сначала расскажу о проблематике работы с native-кодом в Java: почему текущие решения выглядят именно так, что можно сделать лучше, а что исправить уже не получится.
Взаимодействие между Java и native-кодом
У native-кода есть своя область памяти и свои структуры данных. Java-код работает с Java-кучей. Если мы хотим, чтобы Java и native-код взаимодействовали, нам нужно найти способ ссылаться из одной области в другую. Т.е. native-код должен иметь возможность работать с Java-кучей, а Java-код — с native-кучей.
Какие тут могут быть проблемы? В native-коде указатели «стабильны», сами по себе, без ведома работающего с ними кода они не меняются. А Java-куча управляется сборщиком мусора (GC): объекты могут перемещаться по мановению волшебной палочки. Иными словами, в процессе освобождения памяти сборщик мусора вправе перемещать любой объект в памяти и все указатели на него должны быть корректно обновлены. По дизайну такая операция абсолютно прозрачна для Java-кода и им никак не контролируется.
Для памяти вне Java-кучи обновления не нужны, потому что структуры данных не двигаются. А если извне есть ссылки в Java-кучу, такого рода изменения нужно учитывать и обрабатывать.
Одно из решений — это фиксирование положения объекта в Java-куче (object pinning), но это накладывает серьезные ограничения на реализацию GC и с точки зрения производительности имеет ряд неприятных последствий.
Альтернативный подход (который используется в JNI) заключается в работе с Java-объектами через таблицу стабильных указателей (handles), про которую знает GC.
При перемещении объекта сборщик мусора обновляет соответствующий слот таблицы, и native-код начинает работать с новым адресом. Но это требует определенных изменений на стороне native-кода: нельзя просто добавить дополнительный уровень косвенности без его ведома, требуется координация с GC.
В общих чертах дизайн взаимодействия native-кода и Java-кода на уровне данных выглядит следующим образом:
JNI
JNI появился практически сразу, начиная с версии 1.1 (технически API присутствовал и в 1.0, но не был частью стандарта.)
С точки зрения разработчика взаимодействие с JNI выглядит следующим образом:
В Java-коде специальным образом (ключевое слово «native») объявляются методы и javac (java compiler) создает class-файл и header-файл. На основе header разработчик вручную пишет реализацию native-функций, компилирует с помощью компилятора С/C++ и получает на выходе готовую библиотеку. Таким образом получаем class-файл и библиотеку.
Для примера давайте посмотрим, как это выглядит на простом примере getpid — native-функции, которая не принимает никаких аргументов, а возвращает идентификатор текущего процесса.
class LibC {
static native long getpid();
}
jlong JNICALL Java_LibC_getpid(JNIEnv* env, jclass c) {
return getpid();
}
Про JNI говорят много нехороших вещей, но, тем не менее, это оказался очень мощный API, предоставляющий богатый набор примитивов для взаимодействия
Например, вызов Java-метода:
jlong JNICALL Java_... (JNIEnv* env,
jclass cls,
jobject obj) {
jmethodID mid = env->GetMethodID(cls, "m", "(I)J");
jlong result = env->CallLongMethod(obj, mid, 10);
Или доступ к полю в Java-объекте:
jlong JNICALL Java_...(JNIEnv* env,
jclass cls,
jobject obj) {
jfieldID fid = env->GetFieldID(cls, "f", "J");
jlong result = env->GetLongField(obj, fid);
jlong result = env->SetLongField(obj, fid, 10);
Детали реализации вызова через JNI
C точки зрения реализации, виртуальной машине полезно различать вызовы Java-кода, исполнение которого она полностью контролирует, и вызов native-кода, контроль которого в общем случае невозможен (за исключением строго определенных мест). Для JVM вызов native-кода — это «черный ящик».
Для стабильной работы виртуальной машине требуется иметь возможность оперативно остановить работу приложения в любой момент. Для этой цели JVM в сгенерированном коде выбирает и помечает места, в которых исполнение может быть безопасно остановлено (т.н. safe point).
В Java-коде виртуальная машина может эти точки расставлять по своему усмотрению. Поскольку у JVM нет возможности модифицировать native-код, при его вызове поток автоматически уходит в safe point (на всем протяжении вызова).
Инициируя safe point, виртуальная машина ждет остановки всех потоков, исполняющих Java-код, но это не относится к потокам, исполняющим native-код. Native-код может продолжать исполняться параллельно с работой сборщика мусора.
Подводя итоги:
- Плюсом JNI можно назвать хоть и не бесшовную, но хорошую интеграцию с Java. Этот API сфокусирован на Java, а не на native-коде, т.е. через него можно получить доступ к массе деталей Java-приложения.
- Минус: неудобство использования. Нужно вручную писать «обертки» и учитывать дополнительные расходы на вызов. Вполне можно представить доступ к данным вне Java-кучи доступ в виде JNI вызова, но это получается слишком накладно. (В этом основная причина популярности sun.misc.Unsafe.)
Critical JNI
Со временем реализация JNI в HotSpot была расширена. Например, появился Critical JNI.
Посмотрим на простой пример:
jint JNICALL Java_…(JNIEnv *env, jclass c, jobject arr) {
jint len = (*env)->GetArrayLength(end, arr);
jbyte* a = (*env)->GetPrimitiveArrayCritical(env, arr, 0);
…
return sum;
}
Предположим, у нас есть метод, в который в качестве параметра передается ссылка на массив из Java-кучи. Через JNI мы хотим получить доступ к содержимому этого массива. Сначала требуется узнать его длину, потом взять указатель на начало и только после этого можно начинать работать с данными.
Для небольших массивов эти два вызова через JNIEnv дают запредельный рост накладных расходов: по сравнению с вызовом пустого метода через JN, накладные расходы увеличиваются на порядок!
Связано это с тем, что, находясь в native-контексте, каждое обращение через JNIEnv требует проверки текущего состояния JVM (safepoint check). Иначе, например, может оказаться, что в это же время сборщик мусора куда-то передвигает наш массив.
Critical JNI решает эту проблему для примитивных массивов: JVM в процессе вызова извлекает всю нужную нам информацию (длина + указатель) и явно передает их в качестве аргументов.
jint JNICALL JavaCritical_…(jint length, jbyte* first) {
…
return sum;
}
Получаем существенную экономию. Наиболее ярко она проявляется для маленьких массивов, так как на больших объемах данных объем работы обычно доминирует над накладными расходами на вызовы.
У Critical JNI есть ряд ограничений. Это всего лишь небольшая надстройка (не являющаяся частью публичного API), которая не решает общих проблем самого JNI.
Как было отмечено выше, JNI — «Java-центричный» интерфейс. Как нам вызвать native-код, представления о вызовах в котором серьезно отличаются от Java? Например, printf:
int printf(const char *format, …)
На стороне Java-кода мы можем представить vararg-аргумент в виде Object[], но придется вручную взять все аргументы, распаковать их при необходимости, разложить на стек в соответствии с системным ABI и только после этого вызвать native-функцию. Написать JNI-«обертку» для printf вручную крайне непросто.
Следующий вопрос: что делать с вызовами из native-кода обратно в Java? Классический пример — qsort:
void qsort(
void* base,
size_t nel,
size_t width,
int (*cmp)(const void*, const void*));
qsort получает в качестве аргумента указатель на функцию, используемую при сравнении элементов. Native-код делает вызов по указателю и передает необходимые аргументы, а мы хотим, чтобы в итоге исполнился Java-код. Как это сделать с помощью JNI? Однозначно нетривиально.
Библиотека JNR (Java Native Runtime)
JNR решает часть проблем. JNI фактически ограничивался инструментами, предоставляемыми виртуальной машиной. Все остальное нам нужно было делать вручную. JNR поднимает все взаимодействия на уровень Java-кода.
public interface LibC {
@pid_t long getpid();
}
LibC lib = LibraryLoader
.create(LibC.class)
.load("c");
libc.getpid()
Т.е. пользователю для getpid вообще не нужно больше писать native-код: набросал интерфейс, добавил аннотаций, и библиотека все делает за нас. И это неплохо работает.
Как я упоминал, JRuby использует JNR для взаимодействия с платформенными библиотеками. Преимущество JNR — автоматическое связывание native-методов. Пользователю не надо писать native-код. Все «грязную» работу за него делает библиотека. Среди недостатков я бы выделил этап ручного написания интерфейса. Он не очень хорошо масштабируется. На практике библиотеки имеют достаточно обширный интерфейс, и его перевод на Java требует существенного объема. Кроме того, JNR — это библиотека, которая все равно базируется на JNI. Как говорил Charles Nutter, мы не можем быть быстрее JNI, хотя в некоторых случаях нам бы этого очень хотелось.
Куда двигаться дальше?
Можем ли мы в новом проекте быть быстрее JNI?
Выше я уже упоминал, что написание интерфейса для реальных библиотек крайне трудоемко. А миграция на новые версии становится еще более трудоемкой, сопоставимой с написанием интерфейса с нуля. В качестве примера рассмотрим популярную библиотеку TensorFlow:
C-интерфейс, который считается стандартом де-факто, актуален. У TensorFlow есть также Java-интерфейс, но он предоставляет ограниченный набор функциональности.
В цифрах C-интерфейс — это порядка 250 сущностей, которые нужно не только написать, но и «обернуть» аннотациями. Если вы что-то сделаете неправильно, скорее всего, вы получите труднодиагностируемую ошибку в процессе исполнения (crash), либо, что еще хуже, некорректный результат.
При миграции на новую версию вам нужно отследить все изменения в интерфейсе библиотеки и внести адаптировать Java-интерфейс. Даже ограниченный объем Java API, который предоставляет TensorFlow, — это уже более 10 тыс. строк native-кода и Java-кода. Поэтому имеют практический смысл более высокоуровневые механизмы работы с библиотеками. Так мы можем упростить работу с платформенными библиотеками для разработчика.
JNI нам мешает далеко не во всех случаях. Зачастую он достаточно быстр для большинства сценариев. Но вот вручную описать интерфейсы крайне трудозатратно.
Первая фаза проекта (2015 — 2018)
Работа над FFI началась в конце 2014 года и активно развернулась в 2015.
Результатом этой работы стало появление нового инструмента jextract, который на основе header-файла библиотеки автоматически создавал все необходимые интерфейсы. Т.е. в процессе разработки мы можем взять header-файл, извлечь из него интерфейсы, написать свое Java-приложение с использованием этих интерфейсов, скомпилировать и запустить. А все связывание интерфейсов с соответствующими частями native-библиотеки происходит «на лету» — в процессе исполнения. Нужные реализации создаются на основе мета-информации, собранной jextract.
Рассмотрим это на примере getpid — аналога «hello world» для native-кода.
Для unistd.h jextract создает Java-интерфейс unistd (.h → .class):
В аннотациях содержится вся необходимая информация, чтобы в процессе исполнения можно было найти и вызвать нужную native-функцию, а потом обработать возвращаемый результат. Все происходит автоматически: jextract генерирует интерфейс, а во время исполнения по нему создается реализация. Разработчику ничего не требуется делать.
Использовать это можно следующим образом:
// Load native library
Library lib = NativeLibrary.load("c");
// Weave a class for the unistd interface,
// and return an instance
unistd unistd = NativeLibrary.create(lib, unistd.class)
// Call the system getpid() function
int pid = unistd.getpid();
Загружаем библиотеку и получаем реализацию ранее созданного интерфейса на её основе. unistd — это интерфейс, который jextract «извлек» из unistd.h. В итоге мы получаем объект, через который можем вызывать функции, объявленные в unistd.h.
Native-библиотеки содержат код, но на практике взаимодействие с ним подразумевает передачу данных. Поэтому вместе с jextract был добавлен новый API для работы с данными. Также появился класс Pointer, который представлял собой «типизированный» указатель: адрес плюс тип хранимого значения.
Класс Pointer предоставляет набор базовых операций: cast (привести к другому типу), offset (смещение указателя на константу), get/set (чтение/запись по адресу). По сути получился указатель в терминах C.
Также, в дополнение к Pointer, API содержит специальный класс Array, который представляет массив фиксированной длины. В принципе в C массивы — это просто синтаксический сахар поверх обычных указателей. Но есть ряд тонких различий, что и мотивировало появление новой сущности.
Для управления ресурсами появился класс Scope, представляющий некоторый контекст. Освобождение ресурсов происходит при закрытии контекста (Scope.close ()), и try-with-resources позволяет удобно с ними работать в лексическом контексте: при выходе из контекста все использованные в данном контексте ресурсы будут автоматически освобождены. Кроме того, каждый Pointer «привязан» к некоторому Scope. На каждый доступ по указателю осуществляется проверка, не закрыт ли соответствующий Scope. Иначе бросается исключение, сигнализирующее ошибку доступа.
@NativeHeader(declarations=
"strlen=(u64:u8)i32")
interface Strings {
int strlen(Pointer buf);
}
…
var lib = Libraries.bind(MethodHandles.lookup(),
Strings.class);
try (var scope = Scope.newNativeScope()) {
var strPtr = scope.allocateCString("Hello");
lib.strlen(strPtr);
}
В этом примере мы передаем Java-строку в native-код. Поскольку представление строки в Java и native-коде различаются, требуется конвертация и— Scope предоставляет такую возможность (allocateCString ()).
Переосмысление подхода
К 2018 году проект пришел в достаточно стабильное состояние. Был опробован ряд популярных библиотек, с которыми новые инструменты работали без особых проблем. Понятно, что не все было идеально, но это был период активного экспериментирования: каковы должны быть ключевые сценарии использования.
В таком контексте полученный результат был очень даже хорош. С точки зрения удобства использования это был гигантский шаг вперед по сравнению с JNI и существенное улучшение на фоне JNR.
Но если смотреть на проект как на пирамиду, то результат получился монолитным.
Не было четкого разделения на базовые и вспомогательные компоненты. Не было новых низкоуровневых платформенных API, которые можно было бы использовать, к примеру, в сторонних библиотеках (например, для поддержки C++ в JavaCPP).
Всё, за исключением jextract и Pointer API, было деталью реализации.
Это накладывало серьезные ограничения на дальнейшие шаги: если ставить вопрос об интеграции в JDK, то получалось «либо все, либо ничего».
John Rose (JVM Architect) в обсуждениях описал это так: «мы пытались получить все сразу, в случайном порядке, не выстраивая четкую пирамиду».
Наконец, пришло время структурировать дальнейшую разработку. В результате произошло разделение: jextract оформился как самостоятельный высокоуровневый инструмент, базирующийся на двух низкоуровневых платформенных API:
- Memory Access API для работы с памятью;
- Foreign ABI API для вызовов native-кода.
С точки зрения разработчика сценарий использования jextract остался прежним:
jextract на основе header-файла создает Java-интерфейс, а затем разработчик уже компилирует его как часть своей программы.
$ jextract … --source unistd.h
$ javac … unistd_h.java Getpid.java
$ cat Getpid.java
… unistd_h.getpid() ...
Но в новой реализации jextract генерирует не просто интерфейсы, а уже интерфейс с полной реализацией. Если заглянуть в unistd_h.java, то мы увидим там не просто интерфейс с аннотациями, как раньше, а реальный код, который исполняется и делает все то же самое, что раньше генерировалось на этапе исполнения.
pid_t getpid(void);
public final class unistd_h {
[...]
public static final MethodHandle getpid = RuntimeHelper.downcallHandle(
LIBRARIES, "getpid",
"()I",
FunctionDescriptor.of(C_UINT),
false);
public static final int getpid() {
try {
return (int)getpid.invokeExtract();
} catch (Throwable ex) {
throw new AssertionError(ex);
}
}
[...]
}
Рассмотрим, каким образом данная реализация вызывает getpid:
- System ABI
abi = SystemABI.getInstance();
У нас есть интерфейс System ABI, который используется для создания MethodHandle, с помощью которых можно взаимодействовать с native-кодом. - MemoryAddress
entry = LibraryLookup.ofDefault().lookup("getpid");
Мы можем загружать библиотеки через LibraryLookup и делать поиск entry point по имени, на выходе получая адрес этого entry point. - MethodType
type = MethodType.methodType(int.class);
Чтобы создать MethodHandle для native-функции, нам нужно описать, как он будет выглядеть на уровне Java-кода. Для этого мы используем MethodType. Все MethodHandle объекты динамически типизированы, несмотря на то, что на уровне системы типов Java эта информация отсутствует. - FunctionDescriptor
desc = FunctionDescriptor.of(MemoryLayouts.C_INT);
Также требуется описание native-функции, которую мы хотим вызвать. Для этих целей используется новый класс FunctionDescriptor. В случае getpid требуется описать только тип возвращаемого значения, и для стандартных типов C уже есть набор готовых констант. - MethodHandle
getpid = abi.downcallHandle(entry, type, desc);
Теперь на основе Java-типа (MethodType), native-типа (FunctionDescriptor) и адреса native-функции (MemoryAddress) SystemABI.downcallHandle () может сконструировать объект MethodHandle, реализующий вызов соответствующей native-функции.
Native Method Handles
Реализация базируется на новом типе MethodHandle (NativeMethodHandle) и расширяет существующий java.lang.invoke API.
«Making native calls from the JVM», John Rose
В Java 7 в байт-код JVM была добавлена новая инструкция — invokedynamic, и вместе с ней появился новый java.lang.invoke API для работы с методами на уровне Java-кода. С точки зрения реализации java.lang.invoke, взаимодействие с native-кодом не сильно отличается от взаимодействия с Java-кодом, т.е. нам не составляет большого труда добавить поддержку native-функций.
// int pid = mh.invokeExact();
$ java … -XX:+PrintInlining ...
...
@ 8 LambdaForm$MH::invokeExact_MT (29 bytes) force inline by annotation
@ 11 Invokers::checkExactType (17 bytes) force inline by annotation
@ 1 MethodHandle::type (5 bytes) accessor
@ 15 Invokers::checkCustomized (23 bytes) force inline by annotation
@ 1 MethodHandleImpl::isCompileConstant (2 bytes) (intrinsic)
@ 25 LambdaForm$NMH::invokeNative_I (27 bytes) force inline by …
@ 7 NativeMethodHandle::internalNativeEntryPoint (8 bytes) force inline …
@ 23 MethodHandle::linkToNative(L)I (0 bytes) direct native call
Интерфейс между JVM и Java-кодом — это MethodHandle.linkToNative, специальный метод-адаптер, которому, наряду с аргументами функции, передается и адрес вызываемой native-функции (native entry point).
Посмотрим на это с точки зрения производительности. В машинном коде, сгенерированном JIT-компилятором JVM, вызов через MethodHandle был оптимизирован в прямой вызов:
callq 0x1057b2eb0; native method entry
Фактически мы получили то, о чем просил Charles Nutter.
Если через JNI мы тратим на вызов getpid 12 — 13 нс, то при должной поддержке JIT-компилятора мы можем этот linkToNative call превратить в прямой вызов entry point. Понятно, он будет небезопасен. Но мы ранее обсуждали какие шаги делает JVM при вызове native-код: поток переводится в специальное состояние и с точки зрения виртуальной машины не должен быть остановлен при переходе в safe point.
А при прямом вызове этого работа не происходит. Соответственно, JVM будет думать, что поток исполняет Java-код и ждать его остановки.
Такая оптимизация работает для простых случаев вроде getpid. Но в более сложных ситуациях возможны проблемы. Например, если у вас есть блокирующий вызов внутри native-кода, JVM может просто «зависнуть», т.к. будет ожидать остановки потока, который заблокирован на неопределенное время.
И блокировки — не единственный источник проблем: в области сборщиков мусора с низким временем отклика (low latency GC) минимальные гарантированные паузы JVM были серьезно сокращены. Работа касалась не только GC, но и всей JVM, так что теперь можно гарантировать паузы порядка десятков миллисекунд, а говорить и о интервалах менее 1 миллисекунды. А за это время JVM должна не только достичь безопасного состояния, но и выполнить всю необходимую работу, чтобы не нарушить гарантии на паузы.
Universal Adapter
Мы рассмотрели подход с методом-адаптером linkToNative. Но параллельно разрабатывался альтернативный механизм — т.н. Universal Adapter. Это полностью программируемый API, который позволяет описывать произвольные native-вызовы.
У обычных Java-методов сигнатура фиксирована: в месте объявления метода четко описано, аргументы какого типа они принимают и результат какого типа возвращают. Для нужд java.lang.invoke и MethodHandle API этого недостаточно. Требуется через единый интерфейс иметь возможность вызывать любой метод. Из обычного Java-кода этого сделать нельзя, поэтому в спецификацию языка и платформы были добавлены так называемые signature-polymorphic методы, у которых может быть произвольная сигнатура (определяется в месте использования).
Проблема с linkToNative, который также является signature-polymorphic, в том, что его поведение описывается Java-сигнатурой. Предположим, мы хотим описать native-функцию с переменным числом аргументов (varargs). Нам потребуется описать, как именно в стеке располагаются параметры, что сделать в терминах Java-сигнатур теоретически возможно, но крайне неудобно. Другими словами Java-сигнатуры хороши для описания вызовов Java-методов, а для varargs нам лучше изъясняться в терминах C (или даже ABI платформы).
Universal Adapter API решает эту проблему. Он представляет из себя низкоуровневый механизм для описания состояния стека и регистров до и после вызова. Фактически Universal Adapter в качестве аргументов получает полное состояние регистров и вершины стека, а после возврата из вызова обрабатывает результат.
Хотя Universal Adapter — это очень мощный механизм, он плохо подходит для оптимизации JIT-компилятором. JIT-компилятору требуется много низкоуровневых деталей о вызове, чтобы подобрать оптимальный код. Так что с точки зрения производительности Universal Adapter оказался на порядок медленнее JNI.
Foreign ABI: В поиске низкоуровневого API
Описанные два подхода (linkToNative и Universal Adapter) не конкурируют, а взаимно дополняют друг друга. В идеале хотелось бы всегда демонстрировать максимальную производительность. Но, с практической точки зрения, имея возможность вызвать любой код, целесообразно сфокусироваться на оптимизации некоторых популярных случаев. Например, мы оптимизируем в JIT-компиляторе 90% наиболее часто используемых случаев, а для корректной работы остальных 10% будем использовать более простой альтернативный механизм, но пожертвовав скоростью вызова.
На текущий момент унифицировать linkToNative и Universal Adapter возможности нет, т.к. они предоставляют два абсолютно разных API. Нельзя в процессе работы с конкретным MethodHandle выбрать, какой интерфейс использовать. Это требуется указать в момент создания MethodHandle. Поэтому работа, которая идет сейчас в проекте, направлена на унификацию интерфейса между двумя механизмами. Цель — иметь возможность в момент JIT-компиляции выбрать конкретный механизм реализации вызова: есть ли возможность оптимизировать вызов или целесообразнее воспользоваться универсальным адаптером?
Далее я еще расскажу про вызовы из native-кода в Java-код (upcall), а пока переключимся на Memory Access API.
Memory Access API
Memory Access API — это низкоуровневый, безопасный и в то же время эффективный API для доступа в память (как в Java-кучу, так и вне её). Он пришел на смену Pointer API как интерфейс взаимодействия с данными и больше не завязан на семантику языка C.
API предоставляет четыре ключевые абстракции:
- MemorySegment: описывает область памяти;
- MemoryAddress: адрес в MemorySegment;
- MemoryLayout: описание раскладки структур данных в памяти;
- MemoryHandles: фабрика VarHandle«ов для доступа к данным через MemoryAddress.
Ключевой особенностью нового API является безопасность его использования. В отличие от sun.misc.Unsafe, новый API не должен допускать критических ошибок, ведущих к прекращению работы всей JVM. Вместо этого в случаях неправильного использования ошибки должны оставаться на уровне приложения или приводить к исключительным ситуациям (с возможностью их перехвата и обработки).
Например, при доступе к данным из многих потоков без должной синхронизации можно получить ошибки именно на уровне логики приложения (при чтении семантически некорректного значения или при неправильной интерпретации данных). С точки зрения среды исполнения это приводит к остановке всего Java-процесса.
В процессе исполнения Memory Access API предоставляет две ключевые гарантии.
Во-первых, контроль времени жизни ресурсов. Гарантируется, что память, к которой осуществляется доступ, все еще доступна. После закрытия соответствующего MemorySegment объекта память может быть освобождена, но все попытки получить к ней доступ приведут к исключительным ситуациям.
Во-вторых, для каждого обращения происходит проверка границ доступа. Попытка доступа вне границ сегмента также приведет к исключительной ситуации.
Разберем простой пример (в комментарии приведет эквивалентный код на C):
// struct { int x; int y; } points[5];
// for (int i = 0; i < 5; i++) { points[i].x = i; }
VarHandle intHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
VarHandle xHandle = MemoryHandles.withStride(intHandle, 8);
try (MemorySegment points = MemorySegment.allocateNative(4 * 2 * 5)) {
MemoryAddress base = points.baseAddress();
for (long i = 0 ; i < 5 ; i++) {
xHandle.set(base, i, (int)i); // safety checks
}
} // points.close() frees memory
В примере объявляется массив из пяти элементов. Каждый элемент — это структура из двух элементов типа int (две координаты). Затем, перебирая элементы массива, в x-координату записывается определенное значение.
Чтобы написать эквивалентный код на основе Memory Access API, мы начинаем с того, что конструируем VarHandle, осуществляющий доступ к x-компоненте в i-ом элементе массива. В дальнейшем мы просто переиспользуем этот VarHandle. На вход он получает индекс элемента в массиве и записывает в него значение, переданное в другом аргументе.
VarHandle intHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
VarHandle xHandle = MemoryHandles.withStride(intHandle, 8);
VarHandle были добавлены в Java 9, и они очень похожи на MethodHandle. Есть даже возможность конвертировать VarHandle в эквивалентный MethodHandle. Но, по сравнению с MethodHandle, VarHandle API получился чуть выше уровнем и явно предоставляет 4 типа доступа к памяти.
В итоге мы имеем VarHandle объект, реализующий индексированный доступ к элементам массива. На основе комбинаторов для MemoryAddress можно строить достаточно сложные вычисления смещения.
После выхода у нас память освобождается за счет try-with-resources. Даже если созданный MemorySegment каким-либо образом покинет лексический контекст, он будет неявно закрыт при выходе из него. Любые попытки последующего доступа будут завершаться исключением. Т.е. проблема доступа к ресурсу после его освобождения (use after free) решена с помощью дополнительной проверки в процессе исполнения.
MemoryLayout
Memory Access API предоставляет возможность декларативно описывать раскладку структур данных в памяти. До этого, как мы видели в примерах, jextract добавлял специальные аннотации, в которых раскладка кодировалась с помощью специального языка (LDL = Layout Definition Language). LDL был построен на строках, и описания получались достаточно компактными, но достаточно неудобными для написания и последующего чтения.
На смену LDL пришел MemoryLayout с эквивалентной функциональностью:
struct Point {
int x;
int y;
} pts[5];
Теперь
MemoryLayout.ofSequence(5,
MemoryLayout.ofStruct(
MemoryLayout.ofValueBits(32)
.withName("x"),
MemoryLayout.ofValueBits(32)
.withName("y")
)
);
Структура — группа из двух элементов, каждый размером 32 бита — описывается с помощью MemoryLayout.ofStruct. Для удобства элементы структур можно именовать.
Это упрощает создание VarHandle на основе такого описания:
// struct { int x; int y; } points[5];
SequenceLayout seq = MemoryLayout.ofSequence(5,
MemoryLayout.ofStruct(
MemoryLayout.ofValueBits(32, ByteOrder.nativeOrder()).withName("x"),
MemoryLayout.ofValueBits(32, ByteOrder.nativeOrder()).withName("y")));
// i -> points[i].x
VarHandle seqXHandle = seq.varHandle(int.class,
MemoryLayout.PathElement.sequenceElement(),
MemoryLayout.PathElement.groupElement("x"));
// for (int i = 0; i < 5; i++) { points[i].x = i; }
try (MemorySegment points = MemorySegment.allocateNative(seq)) {
MemoryAddress base = points.baseAddress();
long size = seq.elementCount().getAsLong();
for (long i = 0; i < size; i++) {
seqXHandle.set(base, i, (int) i);
}
}
На основе такой раскладки с именованными по