JEP 389: Foreign Linker API (Incubator)
Foreign Linker API, статус Incubator (инкубационный модуль)
| Ответственный | Maurizio Cimadamore |
| Тип | Feature |
| Область | JDK |
| Статус | Closed / Delivered |
| Выпуск | 16 |
| Компонент | core-libs |
| Обсуждение | panama dash dev at openjdk dot java dot net |
| Трудоёмкость | L |
| Длительность | L |
| Связан с | JEP 393: Foreign-Memory Access API (Third Incubator) |
| JEP 412: Foreign Function & Memory API (Incubator) | |
| Рецензенты | Brian Goetz, Jorn Vernee, Paul Sandoz |
| Создан | 2020/07/20 11:19 |
| Обновлён | 2022/03/02 17:08 |
| Задача | 8249755 |
Аннотация
Добавить API, который даёт статически типизированный доступ к нативному коду на чистой Java. Вместе с Foreign-Memory API (JEP 393) этот API значительно упростит привязку к нативной библиотеке, при которой иначе легко допустить ошибку.
История
Foreign-Memory Access API, на котором основан этот JEP, впервые был предложен в JEP 370 и в конце 2019 года запланирован в Java 14 как API в статусе Incubator. Затем его обновили в JEP 383 и JEP 393, запланированных в Java 15 и 16 соответственно. Foreign-Memory Access API и Foreign Linker API вместе составляют основные результаты проекта Panama.
Цели
-
Простота использования: заменить JNI более совершенной моделью разработки на чистой Java.
-
Поддержка C: на первом этапе цель этой работы — высококачественное, полностью оптимизированное взаимодействие с библиотеками на C на платформах x64 и AArch64.
-
Универсальность: Foreign Linker API и его реализация должны быть достаточно гибкими, чтобы со временем поддержать другие платформы (например, 32-битную x86) и внешние функции, написанные на языках, отличных от C (например, C++, Fortran).
-
Производительность: Foreign Linker API должен обеспечивать производительность, сравнимую с JNI или выше.
Что не является целью
Целью не является:
- отказаться от JNI, реализовать его заново или улучшить;
- предоставить инструмент для автоматической генерации кода на Java из заголовочных файлов нативного кода;
- изменить или улучшить способ упаковки и развёртывания Java-приложений, которые взаимодействуют с нативными библиотеками (например, многоплатформенные JAR-файлы).
Мотивация
Java поддерживает вызовы нативных методов через Java Native Interface (JNI) начиная с Java 1.1, но этот путь всегда был сложным и хрупким. Чтобы обернуть нативную функцию с помощью JNI, нужно разработать несколько артефактов: API на Java, заголовочный файл на C и реализацию на C. Даже с помощью инструментов разработчикам на Java приходится работать с несколькими наборами инструментов, чтобы поддерживать согласованность нескольких платформозависимых артефактов. Это достаточно сложно даже для стабильных API, а если нужно следить за API, которые ещё разрабатываются, обновление всех этих артефактов при каждом изменении API становится значительной нагрузкой на сопровождение. Наконец, JNI в основном касается кода, но код всегда обменивается данными, а в доступе к нативным данным JNI помогает мало. Поэтому разработчики часто прибегают к обходным путям (например, к прямым буферам или sun.misc.Unsafe), из-за которых код приложения сложнее сопровождать или он даже становится менее безопасным.
За эти годы появилось множество фреймворков, восполняющих пробелы JNI, в том числе JNA, JNR и JavaCPP. JNA и JNR динамически генерируют обёртки по объявлению интерфейса, заданному пользователем; JavaCPP генерирует обёртки статически на основе аннотаций на объявлениях JNI-методов. Хотя эти фреймворки часто заметно удобнее, чем JNI, ситуация всё ещё далека от идеальной, особенно в сравнении с языками, в которых взаимодействие с нативным кодом поддерживается полноценно. Например, пакет ctypes в Python может динамически оборачивать нативные функции без всякого связующего кода. Другие языки, например Rust, предоставляют инструменты, которые автоматически выводят нативные обёртки из заголовочных файлов C/C++.
В конечном счёте разработчики на Java должны иметь возможность (в большинстве случаев) просто использовать любую нативную библиотеку, которая полезна для конкретной задачи, — а мы видели, как текущее положение дел этому мешает. Этот JEP устраняет этот перекос, добавляя эффективный и поддерживаемый API — Foreign Linker API, — который обеспечивает поддержку внешних функций без промежуточного связующего кода на JNI. Для этого внешние функции предоставляются в виде method handle, которые можно объявлять и вызывать в коде на чистой Java. Это значительно упрощает написание, сборку и распространение библиотек и приложений на Java, которые зависят от внешних библиотек. Кроме того, Foreign Linker API вместе с Foreign-Memory Access API образуют прочную и эффективную основу, на которую могут надёжно опираться сторонние фреймворки взаимодействия с нативным кодом — как существующие, так и будущие.
Описание
В этом разделе мы подробнее рассмотрим, как с помощью Foreign Linker API реализуется взаимодействие с нативным кодом. Описанные в этом разделе абстракции будут предоставлены как Incubator-модуль с именем jdk.incubator.foreign, в одноимённом пакете, рядом с существующим Foreign Memory Access API.
Поиск символов
Первая составляющая любой поддержки внешних функций — механизм поиска символов в нативных библиотеках. В традиционных сценариях Java/JNI это делается методами System::loadLibrary и System::load, которые внутри сводятся к вызовам dlopen. Foreign Linker API предоставляет простую абстракцию поиска по библиотеке (library lookup) — класс LibraryLookup (аналогичный lookup для method handle), который позволяет искать именованные символы в заданной нативной библиотеке. Получить объект поиска по библиотеке можно тремя способами:
-
LibraryLookup::ofDefaultвозвращает объект поиска по библиотеке, который видит все символы, загруженные вместе с VM. -
LibraryLookup::ofPathсоздаёт объект поиска по библиотеке, связанный с библиотекой, найденной по заданному абсолютному пути. -
LibraryLookup::ofLibraryсоздаёт объект поиска по библиотеке, связанный с библиотекой с заданным именем (для этого может понадобиться правильно задать переменнуюjava.library.path).
Получив объект поиска, клиент может с помощью метода lookup(String) получать дескрипторы символов библиотеки — глобальных переменных или функций. Этот метод возвращает новый LibraryLookup.Symbol, который представляет собой просто прокси для адреса памяти и имени.
Например, следующий код ищет функцию clang_getClangVersion, предоставляемую библиотекой clang:
LibraryLookup libclang = LibraryLookup.ofLibrary("clang");
LibraryLookup.Symbol clangVersion = libclang.lookup("clang_getClangVersion");
Одно принципиальное различие между механизмом загрузки библиотек в Foreign Linker API и в JNI состоит в том, что загруженные библиотеки JNI связаны с загрузчиком классов. Кроме того, чтобы сохранить целостность загрузчиков классов, одну и ту же библиотеку JNI нельзя загрузить более чем в один загрузчик классов. Описанный здесь механизм внешних функций проще: Foreign Linker API позволяет клиентам обращаться к нативным библиотекам напрямую, без промежуточного кода на JNI. Важно, что Foreign Linker API никогда не передаёт объекты Java в нативный код и обратно. Поэтому библиотеки, загруженные через LibraryLookup, не привязаны ни к какому загрузчику классов и могут (пере)загружаться столько раз, сколько нужно.
Компоновщик C
Интерфейс CLinker лежит в основе поддержки внешних функций в этом API.
interface CLinker {
MethodHandle downcallHandle(LibraryLookup.Symbol func,
MethodType type,
FunctionDescriptor function);
MemorySegment upcallStub(MethodHandle target,
FunctionDescriptor function);
}
Эта абстракция играет двойную роль. Во-первых, для downcall-вызовов (например, вызовов из Java в нативный код) метод downcallHandle позволяет представлять нативные функции как обычные объекты MethodHandle. Во-вторых, для upcall-вызовов (например, вызовов из нативного кода обратно в код на Java) метод upcallStub позволяет преобразовать существующий MethodHandle (который может указывать на какой-либо метод Java) в MemorySegment, который затем можно передать нативной функции как указатель на функцию. Обратите внимание: хотя абстракция CLinker в основном нацелена на поддержку взаимодействия с языком C, её понятия достаточно общие, чтобы в будущем их можно было применить и к другим внешним языкам.
И downcallHandle, и upcallStub принимают экземпляр FunctionDescriptor — совокупность разметок памяти, которая полностью описывает сигнатуру внешней функции. Интерфейс CLinker определяет множество констант разметки, по одной для каждого основного примитивного типа C. Эти разметки можно комбинировать с помощью FunctionDescriptor, чтобы описать сигнатуру функции на C. Например, функцию на C, которая принимает char* и возвращает long, можно описать таким дескриптором:
FunctionDescriptor func
= FunctionDescriptor.of(CLinker.C_LONG, CLinker.C_POINTER);
Разметки в этом примере соответствуют разметке, подходящей для используемой платформы, поэтому они зависят от платформы: например, C_LONG на Windows будет 32-битной разметкой значения, а на Linux — 64-битной. Для конкретной платформы доступны отдельные наборы платформозависимых констант разметки (например, CLinker.Win64.C_LONG).
Разметки, определённые в классе CLinker, удобны, поскольку они описывают типы C, с которыми мы хотим работать. Кроме того, через атрибуты разметки они содержат скрытую информацию, которую компоновщик внешних функций использует, чтобы вычислить последовательность вызова для заданного дескриптора функции. Например, два типа C, int и float, могут иметь похожую разметку памяти (оба являются 32-битными значениями), но обычно передаются через разные регистры процессора. Атрибуты, прикреплённые к специфичным для C разметкам в классе CLinker, гарантируют, что аргументы и возвращаемые значения обрабатываются правильно.
И downcallHandle, и upcallStub также принимают (напрямую или косвенно) экземпляр MethodType. Тип метода описывает сигнатуры Java, которые клиенты будут использовать при работе со сгенерированными дескрипторами downcall-вызовов или заглушками upcall-вызовов. Типы аргументов и возвращаемого значения в экземпляре MethodType проверяются на соответствие соответствующим разметкам. Например, среда выполнения компоновщика проверяет, что размер Java-типа-носителя, связанного с данным аргументом или возвращаемым значением, равен размеру соответствующей разметки. Соответствие примитивных разметок Java-типам-носителям может различаться от платформы к платформе (например, на Linux/x64 C_LONG соответствует long, а на Windows — int), но разметки указателей (C_POINTER) всегда связаны с типом-носителем MemoryAddress, а структуры (разметки которых определяются GroupLayout) всегда связаны с типом-носителем MemorySegment.
Downcall-вызовы
Предположим, мы хотим вызвать следующую функцию, определённую в стандартной библиотеке C:
size_t strlen(const char *s);
Для этого нужно:
- найти символ
strlen; - описать сигнатуру функции на C с помощью разметок из класса
CLinker; - выбрать сигнатуру Java, которая будет наложена на нативную функцию (именно с этой сигнатурой будут работать клиенты нативного method handle);
- создать по этой информации нативный method handle для downcall-вызова с помощью
CLinker::downcallHandle.
Вот пример того, как это сделать:
MethodHandle strlen = CLinker.getInstance().downcallHandle(
LibraryLookup.ofDefault().lookup("strlen"),
MethodType.methodType(long.class, MemoryAddress.class),
FunctionDescriptor.of(C_LONG, C_POINTER)
);
Функция strlen входит в стандартную библиотеку C, которая загружается вместе с VM, поэтому для её поиска можно просто использовать объект поиска по умолчанию. Остальное довольно просто. Единственная тонкость — как описать size_t: обычно этот тип имеет размер указателя, поэтому на Linux можно использовать C_LONG, а на Windows пришлось бы использовать C_LONG_LONG. На стороне Java size_t представляется типом long, а указатель — параметром MemoryAddress.
Получив нативный method handle для downcall-вызова, его можно использовать как любой другой method handle:
try (MemorySegment str = CLinker.toCString("Hello")) {
long len = strlen.invokeExact(str.address()); // 5
}
Здесь мы используем один из вспомогательных методов CLinker, чтобы преобразовать строку Java в сегмент памяти вне кучи, содержащий строку C, завершённую NULL. Затем мы передаём этот сегмент в method handle и сохраняем результат в переменной Java типа long.
Заметьте, что всё это удалось сделать без какого-либо промежуточного нативного кода: весь код взаимодействия можно выразить на Java (низкоуровневой).
Upcall-вызовы
Иногда полезно передать код на Java какой-либо нативной функции в виде указателя на функцию. Этого можно добиться с помощью поддержки upcall-вызовов в компоновщике внешних функций. Чтобы показать это, рассмотрим следующую функцию, определённую в стандартной библиотеке C:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
Эта функция сортирует содержимое массива с помощью пользовательской функции сравнения compar, которая передаётся как указатель на функцию. Чтобы вызвать функцию qsort из Java, сначала нужно создать для неё нативный method handle для downcall-вызова:
MethodHandle qsort = CLinker.getInstance().downcallHandle(
LibraryLookup.ofDefault().lookup("qsort"),
MethodType.methodType(void.class, MemoryAddress.class, long.class,
long.class, MemoryAddress.class),
FunctionDescriptor.ofVoid(C_POINTER, C_LONG, C_LONG, C_POINTER)
);
Как и ранее, мы используем C_LONG и long.class для отображения типа C size_t, а MemoryAddess.class — и для первого параметра-указателя (указателя на массив), и для последнего параметра (указателя на функцию).
На этот раз, чтобы вызвать method handle qsort для downcall-вызова, нам нужен указатель на функцию, который передаётся последним параметром. Здесь пригодится поддержка upcall-вызовов в абстракции компоновщика внешних функций: она позволяет создать указатель на функцию из существующего method handle. Сначала напишем статический метод, который сравнивает два элемента int, переданные как указатели:
class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(),
addr1.toRawLongValue()) -
MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(),
addr2.toRawLongValue());
}
}
Затем создадим method handle, указывающий на эту функцию сравнения:
MethodHandle comparHandle
= MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class,
MemoryAddress.class,
MemoryAddress.class));
Теперь, когда у нас есть method handle для нашей функции сравнения на Java, можно создать указатель на функцию. Так же как для downcall-вызовов, мы описываем сигнатуру указателя на внешнюю функцию с помощью разметок из класса CLinker:
MemorySegment comparFunc
= CLinker.getInstance().upcallStub(comparHandle,
FunctionDescriptor.of(C_INT,
C_POINTER,
C_POINTER));
);
Наконец, у нас есть сегмент памяти comparFunc, базовый адрес которого указывает на заглушку, через которую можно вызвать нашу функцию сравнения на Java. Теперь у нас есть всё необходимое, чтобы вызвать method handle qsort для downcall-вызова:
try (MemorySegment array = MemorySegment.allocateNative(4 * 10)) {
array.copyFrom(MemorySegment.ofArray(new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 }));
qsort.invokeExact(array.address(), 10L, 4L, comparFunc.address());
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
}
Этот код создаёт массив вне кучи, копирует в него содержимое массива Java, а затем передаёт этот массив в handle qsort вместе с функцией сравнения, полученной от компоновщика внешних функций. В качестве побочного эффекта после вызова содержимое массива вне кучи будет отсортировано в соответствии с нашей функцией сравнения, написанной на Java. Затем мы извлекаем из сегмента новый массив Java, содержащий отсортированные элементы.
Этот сложный пример показывает все возможности абстракции компоновщика внешних функций: полное двустороннее взаимодействие кода и данных через границу между Java и нативным кодом.
Альтернативы
Продолжать использовать JNI или другие сторонние фреймворки для взаимодействия с нативным кодом.
Риски и допущения
-
Реализации JIT потребуют доработки, чтобы использование нативных method handles, полученных из API, было как минимум столь же эффективным и так же поддавалось оптимизации, как использование существующих нативных методов JNI.
-
Разрешение вызовов внешних функций всегда означает ослабление некоторых требований безопасности, обычно связанных с платформой Java. (Так происходит уже сейчас при вызове нативных методов JNI, хотя разработчики могут об этом не знать.) Например, Foreign Linker API никак не может проверить, совпадает ли количество аргументов в дескрипторе функции с количеством аргументов компонуемого символа. Чтобы помочь в диагностике некоторых из самых частых причин сбоев, могут быть добавлены дополнительные средства отладки, подобные существующему параметру
-Xcheck:jni. -
Поскольку Foreign Linker API небезопасен по своей природе, получение экземпляра компоновщика внешних функций — привилегированная операция с ограниченным доступом, для которой нужен флаг
-Dforeign.restricted=permit.
Зависимости
-
API, описанный в этом JEP, — важный шаг к поддержке взаимодействия с нативным кодом, которая является целью проекта Panama. Этот API во многом опирается на Foreign-Memory Access API, описанный в JEP 370 и JEP 383.
-
Работа, описанная в этом JEP, вероятно, позволит затем создать инструмент
jextract, который по заголовочным файлам заданной нативной библиотеки автоматически генерирует нативные method handles, нужные для взаимодействия с этой библиотекой. Это ещё больше снизит накладные расходы на использование нативных библиотек из Java.