JEP 454: Foreign Function & Memory API
API для вызова внешних функций и работы с внешней памятью
| Ответственный | Maurizio Cimadamore |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 22 |
| Компонент | core-libs / java.lang.foreign |
| Обсуждение | panama dash dev at openjdk dot org |
| Связан с | JEP 472: Prepare to Restrict the Use of JNI |
| JEP 442: Foreign Function & Memory API (Third Preview) | |
| Рецензенты | Alex Buckley, Jorn Vernee |
| Одобрен | Alan Bateman |
| Создан | 2023/06/22 09:36 |
| Обновлён | 2024/10/07 14:18 |
| Задача | 8310626 |
Аннотация
Представить API, с помощью которого программы на Java могут взаимодействовать с кодом и данными за пределами среды выполнения Java. Эффективно вызывая внешние функции (т. е. код вне JVM) и безопасно обращаясь к внешней памяти (т. е. памяти, которой не управляет JVM), этот API позволяет программам на Java вызывать нативные библиотеки и обрабатывать нативные данные без хрупкости и опасностей JNI.
История
Foreign Function & Memory (FFM) API изначально был предложен как возможность в статусе Preview (предварительная версия) в JEP 424 (JDK 19), а затем доработан в JEP 434 (JDK 20) и JEP 442 (JDK 21). Этот JEP предлагает сделать FFM API окончательным с дальнейшими небольшими доработками на основе накопленного опыта и отзывов. В этой версии мы:
- предоставили новый параметр компоновщика, позволяющий клиентам передавать сегменты кучи в дескрипторы методов для нисходящих вызовов (downcall);
- ввели атрибут манифеста JAR-файла
Enable-Native-Access, позволяющий коду в исполняемых JAR-файлах вызывать ограниченные методы без необходимости использовать параметр командной строки--enable-native-access; - дали клиентам возможность программно строить дескрипторы функций языка C, не прибегая к платформозависимым константам;
- улучшили поддержку массивов переменной длины в нативной памяти; и
- добавили поддержку произвольных кодировок для нативных строк.
Цели
-
Продуктивность — заменить хрупкий механизм методов
nativeи Java Native Interface (JNI) лаконичным, читаемым API на чистом Java. -
Производительность — предоставить доступ к внешним функциям и памяти с накладными расходами, сравнимыми с JNI и
sun.misc.Unsafe, если не меньшими. -
Широкая поддержка платформ — обеспечить обнаружение и вызов нативных библиотек на каждой платформе, где работает JVM.
-
Единообразие — предоставить способы работы со структурированными и неструктурированными данными неограниченного размера в памяти разных видов (например, в нативной памяти, персистентной памяти и управляемой памяти кучи).
-
Корректность — гарантировать отсутствие ошибок использования памяти после освобождения (use-after-free), даже когда память выделяется и освобождается в нескольких потоках.
-
Целостность — позволить программам выполнять небезопасные операции с нативным кодом и данными, но по умолчанию предупреждать пользователей о таких операциях.
Что не является целью
Целью не является
- заново реализовать JNI поверх этого API или как-либо иначе изменить JNI;
- заново реализовать устаревшие Java API, такие как
sun.misc.Unsafe, поверх этого API; - предоставить инструменты, которые механически генерируют код на Java из заголовочных файлов нативного кода; или
- изменить способ упаковки и развёртывания Java-приложений, взаимодействующих с нативными библиотеками (например, с помощью мультиплатформенных JAR-файлов).
Мотивация
Платформа Java всегда предлагала богатую основу разработчикам библиотек и приложений, которые хотят выйти за пределы JVM и взаимодействовать с другими платформами. Java API удобно и надёжно предоставляют доступ к ресурсам вне Java, будь то доступ к удалённым данным (JDBC), вызов веб-сервисов (HTTP-клиент), обслуживание удалённых клиентов (каналы NIO) или взаимодействие с локальными процессами (сокеты домена Unix). К сожалению, разработчики на Java по-прежнему сталкиваются со значительными препятствиями при доступе к важному виду ресурсов вне Java: коду и данным на той же машине, что и JVM, но за пределами среды выполнения Java.
Внешняя память
Объекты, созданные с помощью ключевого слова new, хранятся в куче JVM, где они подлежат сборке мусора, когда больше не нужны. Однако стоимость и непредсказуемость сборки мусора неприемлемы для критичных к производительности библиотек, таких как Tensorflow, Ignite, Lucene и Netty. Им нужно хранить данные вне кучи, в памяти вне кучи (off-heap), которую они сами выделяют и освобождают. Доступ к памяти вне кучи также позволяет сериализовать и десериализовать данные, отображая файлы непосредственно в память, например с помощью mmap.
Исторически платформа Java предоставляла два API для доступа к памяти вне кучи:
-
API
ByteBufferпредоставляет прямые байтовые буферы — объекты Java, за которыми стоят области памяти вне кучи фиксированного размера. Однако максимальный размер области ограничен двумя гигабайтами, а методы чтения и записи памяти примитивны и подвержены ошибкам: они дают немногим больше, чем индексированный доступ к примитивным значениям. Ещё серьёзнее то, что память, на которой основан прямой байтовый буфер, освобождается только тогда, когда объект буфера удаляется сборщиком мусора, а разработчики не могут этим управлять. -
API
sun.misc.Unsafeпредоставляет низкоуровневый доступ к памяти в куче, который работает и для памяти вне кучи. ИспользованиеUnsafeбыстро (потому что его операции доступа к памяти являются встроенными функциями (intrinsics) JVM), допускает огромные области вне кучи (теоретически до 16 эксабайт) и даёт детальный контроль над освобождением (потому чтоUnsafe::freeMemoryможно вызвать в любой момент). Однако эта модель программирования слаба, потому что даёт разработчикам слишком много контроля. Библиотека в долго работающем приложении может со временем выделять несколько областей памяти вне кучи и работать с ними; данные в одной области могут указывать на данные в другой, и области должны освобождаться в правильном порядке, иначе висячие указатели приведут к ошибкам use-after-free.(Та же критика относится к API вне JDK, которые предлагают детальное выделение и освобождение памяти, оборачивая нативный код, вызывающий
mallocиfree.)
Подводя итог, опытные разработчики заслуживают API, который может выделять память вне кучи, работать с ней и разделять её так же гибко и безопасно, как память в куче. Такой API должен сочетать потребность в предсказуемом освобождении с необходимостью предотвращать преждевременное освобождение, которое может приводить к сбоям JVM или, что хуже, к незаметному повреждению памяти.
Внешние функции
JNI поддерживает вызов нативного кода (т. е. внешних функций) начиная с Java 1.1, но он неудовлетворителен по многим причинам.
-
JNI требует нескольких утомительных артефактов: Java API (методы
native), заголовочного файла C, полученного из Java API, и реализации на C, которая вызывает нужную нативную библиотеку. Разработчикам на Java приходится работать с несколькими наборами инструментов, чтобы поддерживать согласованность платформозависимых артефактов, что особенно обременительно, когда нативная библиотека быстро развивается. -
JNI может взаимодействовать только с библиотеками, написанными на языках (обычно C и C++), которые используют соглашение о вызовах операционной системы и процессора, для которых собрана JVM. Метод
nativeнельзя использовать для вызова функции, написанной на языке с другим соглашением. -
JNI не согласует систему типов Java с системой типов C. Код на Java представляет составные данные объектами, а код на C — структурами, поэтому любой объект Java, переданный в метод
native, приходится кропотливо распаковывать в нативном коде. Например, рассмотрим record-класс JavaPerson: чтобы передать объектPersonв методnative, нативный код должен с помощью C API из JNI извлечь из объекта поля (например,firstNameиlastName). В результате разработчики на Java иногда упаковывают свои данные в один объект (например, в массив байтов или прямой байтовый буфер), но чаще, поскольку передача объектов Java через JNI медленна, они используют APIUnsafe, чтобы выделить память вне кучи и передать её адрес в методnativeкакlong, — что делает код на Java катастрофически небезопасным!
За эти годы появилось множество фреймворков, заполняющих пробелы, оставленные JNI, в том числе JNA, JNR и JavaCPP. Эти фреймворки часто заметно лучше JNI, но ситуация всё равно далека от идеальной — особенно по сравнению с языками, в которых взаимодействие с нативным кодом поддерживается как полноценная возможность. Например, пакет Python ctypes может динамически оборачивать функции из нативных библиотек без какого-либо связующего кода. Другие языки, такие как Rust, предоставляют инструменты, которые механически создают нативные обёртки из заголовочных файлов C/C++.
В конечном счёте у разработчиков на Java должен быть поддерживаемый API, позволяющий им напрямую использовать любую нативную библиотеку, признанную полезной для конкретной задачи, без утомительного связующего кода и неуклюжести JNI. Две превосходные абстракции, на которые можно опереться, — это дескрипторы методов (method handles), то есть прямые ссылки на сущности, подобные методам, и дескрипторы переменных (variable handles), то есть прямые ссылки на сущности, подобные переменным. Предоставление нативного кода через дескрипторы методов, а нативных данных через дескрипторы переменных радикально упростило бы написание, сборку и распространение библиотек Java, зависящих от нативных библиотек. Кроме того, API, способный моделировать внешние функции (т. е. нативный код) и внешнюю память (т. е. данные вне кучи), дал бы прочную основу для сторонних фреймворков взаимодействия с нативным кодом.
Описание
Foreign Function & Memory API (FFM API) определяет классы и интерфейсы, с помощью которых клиентский код в библиотеках и приложениях может
- управлять выделением и освобождением внешней памяти
(MemorySegment,ArenaиSegmentAllocator), - работать со структурированной внешней памятью и обращаться к ней
(MemoryLayoutиVarHandle) и - вызывать внешние функции (
Linker,SymbolLookup,FunctionDescriptorиMethodHandle).
FFM API находится в пакете java.lang.foreign модуля java.base.
Пример
В качестве краткого примера использования FFM API приведём код на Java, который получает дескриптор метода для функции библиотеки C radixsort, а затем с его помощью сортирует четыре строки, изначально находящиеся в массиве Java (некоторые детали опущены).
// 1. Find foreign function on the C library path
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle radixsort = linker.downcallHandle(stdlib.find("radixsort"), ...);
// 2. Allocate on-heap memory to store four strings
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. Use try-with-resources to manage the lifetime of off-heap memory
try (Arena offHeap = Arena.ofConfined()) {
// 4. Allocate a region of off-heap memory to store four pointers
MemorySegment pointers
= offHeap.allocate(ValueLayout.ADDRESS, javaStrings.length);
// 5. Copy the strings from on-heap to off-heap
for (int i = 0; i < javaStrings.length; i++) {
MemorySegment cString = offHeap.allocateFrom(javaStrings[i]);
pointers.setAtIndex(ValueLayout.ADDRESS, i, cString);
}
// 6. Sort the off-heap data by calling the foreign function
radixsort.invoke(pointers, javaStrings.length, MemorySegment.NULL, '\0');
// 7. Copy the (reordered) strings from off-heap to on-heap
for (int i = 0; i < javaStrings.length; i++) {
MemorySegment cString = pointers.getAtIndex(ValueLayout.ADDRESS, i);
javaStrings[i] = cString.reinterpret(...).getString(0);
}
} // 8. All off-heap memory is deallocated here
assert Arrays.equals(javaStrings,
new String[] {"car", "cat", "dog", "mouse"}); // true
Этот код гораздо понятнее любого решения на основе JNI, поскольку неявные преобразования и обращения к памяти, которые были бы скрыты за вызовами методов native, теперь выражены непосредственно в коде на Java. Можно использовать и современные идиомы Java; например, потоки данных (streams) позволяют нескольким потокам параллельно копировать данные между памятью в куче и памятью вне кучи.
Сегменты памяти и арены
Сегмент памяти — это абстракция, за которой стоит непрерывная область памяти, расположенная вне кучи или в куче. Сегмент памяти может быть
- нативным сегментом, выделенным с нуля в памяти вне кучи (как будто через
malloc), - отображённым сегментом, обёрнутым вокруг области отображённой памяти вне кучи (как будто через
mmap), или - сегментом массива или буфера, обёрнутым вокруг области памяти в куче, связанной соответственно с существующим массивом Java или байтовым буфером.
Все сегменты памяти имеют пространственные и временные границы, которые гарантируют безопасность операций доступа к памяти. Короче говоря, границы гарантируют, что не используется невыделенная память и нет использования памяти после освобождения.
Пространственные границы сегмента определяют диапазон адресов памяти, связанных с сегментом. Например, код ниже выделяет нативный сегмент размером 100 байт, поэтому связанный с ним диапазон адресов — от некоторого базового адреса b до b + 99 включительно.
MemorySegment data = Arena.global().allocate(100);
Временные границы сегмента определяют время его жизни, то есть период до освобождения области памяти, на которой основан сегмент. FFM API гарантирует, что к сегменту памяти нельзя обратиться после того, как область памяти, на которой он основан, освобождена.
Временные границы сегмента определяются ареной, в которой выделен сегмент. Несколько сегментов, выделенных в одной арене, имеют одинаковые временные границы и могут безопасно содержать взаимные ссылки: сегмент A может хранить указатель на адрес в сегменте B, а сегмент B — указатель на адрес в сегменте A, и оба сегмента будут освобождены одновременно, так что ни в одном из них не будет висячего указателя.
Простейшая арена — глобальная арена, которая обеспечивает неограниченное время жизни: она всегда активна. Сегмент, выделенный в глобальной арене, как в коде выше, всегда доступен, а область памяти, на которой он основан, никогда не освобождается.
Однако большинству программ требуется освобождать память вне кучи во время работы программы, поэтому им нужны сегменты памяти с ограниченным временем жизни.
Автоматическая арена обеспечивает ограниченное время жизни: к сегменту, выделенному автоматической ареной, можно обращаться, пока сборщик мусора JVM не обнаружит, что сегмент памяти недостижим. В этот момент область памяти, на которой основан сегмент, освобождается. Например, этот метод выделяет сегмент в автоматической арене:
void processData() {
MemorySegment data = Arena.ofAuto().allocate(100);
... use the 'data' variable ...
... use the 'data' variable some more ...
} // the region of memory backing the 'data' segment
// is deallocated here (or later)
Пока переменная data не выходит за пределы метода, сегмент в конце концов будет обнаружен как недостижимый, и область памяти, на которой он основан, будет освобождена.
Ограниченного, но недетерминированного времени жизни автоматической арены не всегда достаточно. Например, API, отображающий сегмент памяти из файла, должен позволять клиенту детерминированно освобождать область памяти, на которой основан сегмент, поскольку ожидание, пока это сделает сборщик мусора, может отрицательно сказаться на производительности.
Ограниченная арена (confined) обеспечивает ограниченное и детерминированное время жизни: она активна с момента, когда клиент открывает арену, до момента, когда клиент её закрывает. К сегменту памяти, выделенному в ограниченной арене, можно обращаться только до закрытия арены. В этот момент область памяти, на которой основан сегмент, освобождается. Попытки обратиться к сегменту памяти после закрытия его арены завершаются исключением. Например, этот код открывает арену и выделяет в ней два сегмента:
MemorySegment input = null, output = null;
try (Arena processing = Arena.ofConfined()) {
input = processing.allocate(100);
... set up data in 'input' ...
output = processing.allocate(100);
... process data from 'input' to 'output' ...
... calculate the ultimate result from 'output' and store it elsewhere ...
} // the regions of memory backing the segments are deallocated here
...
input.get(ValueLayout.JAVA_BYTE, 0); // throws IllegalStateException
// (also for 'output')
Выход из блока try-with-resources закрывает арену. В этот момент все сегменты, выделенные ареной, атомарно становятся недействительными, а области памяти, на которых основаны сегменты, освобождаются.
За детерминированное время жизни ограниченной арены приходится платить: только один поток может обращаться к сегментам памяти, выделенным в ограниченной арене. Если доступ к сегменту нужен нескольким потокам, можно использовать общую арену (shared). К сегментам памяти, выделенным в общей арене, могут обращаться несколько потоков, и любой поток — независимо от того, обращается ли он к области — может закрыть арену, чтобы освободить сегменты. Закрытие арены атомарно делает сегменты недействительными, однако освобождение областей памяти, на которых основаны сегменты, может произойти не сразу, поскольку требуется дорогостоящая операция синхронизации, чтобы обнаружить и отменить ожидающие конкурентные операции доступа к сегментам.
Таким образом, арена управляет тем, какие потоки и когда могут обращаться к сегменту памяти, чтобы обеспечить и строгую временную безопасность, и предсказуемую модель производительности. FFM API предлагает выбор арен, чтобы разработчики могли найти компромисс между широтой доступа и своевременностью освобождения памяти.
Разыменование сегментов
Чтобы разыменовать данные в сегменте памяти, нужно учесть несколько факторов:
- количество разыменовываемых байтов,
- ограничения выравнивания адреса, по которому происходит разыменование,
- порядок байтов (endianness), в котором байты хранятся в сегменте памяти, и
- тип Java, используемый в операции разыменования (например,
intилиfloat).
Все эти характеристики описывает абстракция ValueLayout. Например, предопределённая раскладка значения JAVA_INT имеет ширину четыре байта, выравнивается по границам в четыре байта, использует порядок байтов нативной платформы (например, little-endian в Linux/x64) и связана с типом Java int.
У сегментов памяти есть простые методы разыменования для чтения значений из сегментов памяти и записи значений в них. Эти методы принимают раскладку значения, которая задаёт свойства операции разыменования. Например, можно записать 25 значений int по последовательным смещениям в сегменте памяти:
MemorySegment segment
= Arena.ofAuto().allocate(100, // size
ValueLayout.JAVA_INT.byteAlignment()); // alignment
for (int i = 0; i < 25; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ i,
/* value to write */ i);
}
Раскладки памяти и структурированный доступ
Рассмотрим следующее объявление на C, которое определяет массив из десяти структур Point, где у каждой структуры Point два члена:
struct Point {
int x;
int y;
} pts[10];
С помощью методов, показанных в предыдущем разделе, можно выделить нативную память для массива и инициализировать каждую из десяти структур Point следующим кодом (предполагаем, что sizeof(int) == 4):
MemorySegment segment
= Arena.ofAuto().allocate(2 * ValueLayout.JAVA_INT.byteSize() * 10, // size
ValueLayout.JAVA_INT.byteAlignment()); // alignment
for (int i = 0; i < 10; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ (i * 2),
/* value to write */ i); // x
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ (i * 2) + 1,
/* value to write */ i); // y
}
Чтобы реже прибегать к утомительным вычислениям, связанным с раскладкой памяти (например, (i * 2) + 1 в примере выше), можно использовать MemoryLayout и описывать содержимое сегмента памяти более декларативно. Нативный сегмент памяти, содержащий десять структур, каждая из которых — пара значений int, описывается раскладкой последовательности (sequence layout), содержащей десять вхождений раскладки структуры (struct layout), каждая из которых — пара раскладок JAVA_INT:
SequenceLayout ptsLayout
= MemoryLayout.sequenceLayout(10,
MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));
Из раскладки последовательности можно получить дескриптор переменной (variable handle), который читает и записывает элемент данных в любом сегменте памяти с той же раскладкой. Один из видов элементов, которые мы хотим записывать, — член с именем x в произвольной структуре последовательности структур. Соответственно, мы получаем дескриптор переменной для таких элементов, задавая путь в раскладке (layout path), который ведёт к структуре, а затем к её члену x:
VarHandle xHandle = ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("x"));
Аналогично для члена y:
VarHandle yHandle = ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("y"));
Теперь можно выделить и инициализировать массив из десяти структур Point: выделить нативный сегмент с раскладкой последовательности структур, а затем записать два члена в каждой очередной структуре через два дескриптора переменных. Каждый дескриптор принимает MemorySegment, с которым нужно работать, базовый адрес последовательности структур внутри сегмента и индекс, указывающий, в какой структуре последовательности нужно записать член.
MemorySegment segment = Arena.ofAuto().allocate(ptsLayout);
for (int i = 0; i < ptsLayout.elementCount(); i++) {
xHandle.set(segment,
/* base */ 0L,
/* index */ (long) i,
/* value to write */ i); // x
yHandle.set(segment,
/* base */ 0L,
/* index */ (long) i,
/* value to write */ i); // y
}
Распределители сегментов
Выделение памяти часто становится узким местом, когда клиенты используют память вне кучи. Поэтому FFM API включает абстракцию SegmentAllocator, которая определяет операции выделения и инициализации сегментов памяти. Для удобства класс Arena реализует интерфейс SegmentAllocator, так что арены можно использовать для выделения нативных сегментов из множества существующих источников. Иначе говоря, Arena — это «единое окно» для гибкого выделения и своевременного освобождения памяти вне кучи:
try (Arena offHeap = Arena.ofConfined()) {
MemorySegment nativeInt = offHeap.allocateFrom(ValueLayout.JAVA_INT, 42);
MemorySegment nativeIntArray = offHeap.allocateFrom(ValueLayout.JAVA_INT,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
MemorySegment nativeString = offHeap.allocateFrom("Hello!");
...
} // memory released here
Распределители сегментов также можно получить через фабрики интерфейса SegmentAllocator. Например, одна фабрика создаёт нарезающий распределитель (slicing allocator), который отвечает на запросы выделения, возвращая сегменты памяти, являющиеся частью ранее выделенного сегмента. Таким образом, многие запросы можно удовлетворить без физического выделения дополнительной памяти. Следующий код получает нарезающий распределитель поверх существующего сегмента, а затем использует его для выделения сегмента, инициализированного из массива Java:
MemorySegment segment = ...
SegmentAllocator allocator = SegmentAllocator.slicingAllocator(segment);
for (int i = 0 ; i < 10 ; i++) {
MemorySegment s = allocator.allocateFrom(ValueLayout.JAVA_INT, 1, 2, 3, 4, 5);
...
}
Распределители сегментов можно использовать как строительные блоки для создания арен, поддерживающих собственные стратегии выделения. Например, если у большого числа нативных сегментов будет одинаковое ограниченное время жизни, собственная арена может эффективно выделять сегменты с помощью нарезающего распределителя. Так разработчики получают и масштабируемое выделение (благодаря нарезке), и детерминированное освобождение (благодаря арене).
Например, следующий код определяет нарезающую арену (slicing arena), которая ведёт себя как ограниченная арена, но внутри использует нарезающий распределитель для ответа на запросы выделения. Когда нарезающая арена закрывается, закрывается и лежащая в её основе ограниченная арена, и все сегменты, выделенные в нарезающей арене, становятся недействительными. (Некоторые детали опущены.)
class SlicingArena implements Arena {
final Arena arena = Arena.ofConfined();
final SegmentAllocator slicingAllocator;
SlicingArena(long size) {
slicingAllocator = SegmentAllocator.slicingAllocator(arena.allocate(size));
}
public void allocate(long byteSize, long byteAlignment) {
return slicingAllocator.allocate(byteSize, byteAlignment);
}
public void close() {
return arena.close();
}
}
Приведённый ранее код, который использовал нарезающий распределитель напрямую, теперь можно записать короче:
try (Arena slicingArena = new SlicingArena(1000)) {
for (int i = 0 ; i < 10 ; i++) {
MemorySegment s = slicingArena.allocateFrom(ValueLayout.JAVA_INT, 1, 2, 3, 4, 5);
...
}
} // all memory allocated is released here
Поиск внешних функций
Первая составляющая любой поддержки внешних функций — механизм поиска адреса заданного символа в загруженной нативной библиотеке. Эта возможность, представленная объектом SymbolLookup, необходима для связывания кода Java с внешними функциями (см. ниже). FFM API поддерживает три вида объектов поиска символов:
-
SymbolLookup::libraryLookup(String, Arena)создаёт поиск по библиотеке (library lookup), который находит все символы в указанной пользователем нативной библиотеке. При создании объекта поиска библиотека загружается (например, с помощьюdlopen()) и связывается с объектомArena. Библиотека выгружается (например, с помощьюdlclose()), когда переданная арена закрывается. -
SymbolLookup::loaderLookup()создаёт поиск по загрузчику (loader lookup), который находит все символы во всех нативных библиотеках, загруженных классами текущего загрузчика классов с помощью методовSystem::loadLibraryиSystem::load. -
Linker::defaultLookup()создаёт поиск по умолчанию (default lookup), который находит все символы в библиотеках, обычно используемых на нативной платформе (то есть в операционной системе и на процессоре), связанной с экземпляромLinker.
Имея объект поиска символов, клиент может найти внешнюю функцию методом SymbolLookup::find(String). Если функция с таким именем есть среди символов, видимых объекту поиска, метод возвращает сегмент памяти нулевой длины (см. ниже), базовый адрес которого указывает на точку входа функции. Например, следующий код с помощью поиска по загрузчику загружает библиотеку OpenGL и находит адрес её функции glGetString:
try (Arena arena = Arena.ofConfined()) {
SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", arena);
MemorySegment glVersion = opengl.find("glGetString").get();
...
} // libGL.so unloaded here
SymbolLookup::libraryLookup(String, Arena) существенно отличается от механизма загрузки библиотек JNI, то есть от System::loadLibrary. Нативные библиотеки, предназначенные для работы с JNI, могут с помощью функций JNI выполнять операции Java, например выделение объектов или доступ к методам, которые связаны с загрузкой классов. Поэтому такие библиотеки при загрузке в JVM должны связываться с загрузчиком классов. Далее, чтобы сохранить целостность загрузчиков классов, одну и ту же библиотеку, использующую JNI, нельзя загрузить из классов, определённых в разных загрузчиках классов.
FFM API, напротив, не предоставляет нативному коду функций для доступа к окружению Java и не предполагает, что нативные библиотеки предназначены для работы с FFM API. Нативные библиотеки, загруженные через SymbolLookup::libraryLookup(String, Arena), не обязательно написаны для обращения из кода Java и не пытаются выполнять операции Java. Поэтому они не привязаны к конкретному загрузчику классов и могут (повторно) загружаться столько раз, сколько нужно клиентам FFM API в разных загрузчиках.
Связывание кода Java с внешними функциями
Интерфейс Linker — основа взаимодействия кода Java с нативным кодом. Хотя в этом документе мы часто говорим о взаимодействии кода Java с библиотеками C, понятия этого интерфейса достаточно общие, чтобы в будущем поддерживать и другие языки, отличные от Java. Интерфейс Linker позволяет выполнять как нисходящие вызовы (downcalls, вызовы из кода Java в нативный код), так и восходящие вызовы (upcalls, вызовы из нативного кода обратно в код Java).
interface Linker {
MethodHandle downcallHandle(MemorySegment address,
FunctionDescriptor function);
MemorySegment upcallStub(MethodHandle target,
FunctionDescriptor function,
Arena arena);
}
Для нисходящих вызовов метод downcallHandle принимает адрес внешней функции — обычно MemorySegment, полученный из поиска по библиотеке, — и предоставляет внешнюю функцию как дескриптор метода нисходящего вызова (downcall method handle). Затем код Java вызывает дескриптор метода нисходящего вызова через его метод invoke (или invokeExact), и внешняя функция выполняется. Все аргументы, переданные методу invoke дескриптора метода, передаются внешней функции.
Для восходящих вызовов метод upcallStub принимает дескриптор метода — обычно ссылающийся на метод Java, а не дескриптор метода нисходящего вызова — и преобразует его в экземпляр MemorySegment. Затем этот сегмент памяти передаётся как аргумент, когда код Java вызывает дескриптор метода нисходящего вызова. По сути, сегмент памяти служит указателем на функцию. (Подробнее о восходящих вызовах см. ниже.)
Клиенты компонуются с функциями C с помощью нативного компоновщика (native linker), который они получают через Linker::nativeLinker(). Нативный компоновщик — это реализация интерфейса Linker, соответствующая двоичному интерфейсу приложений (Application Binary Interface, ABI) нативной платформы, на которой работает JVM. ABI задаёт соглашение о вызовах (calling convention), благодаря которому код, написанный на одном языке, может передавать аргументы коду, написанному на другом языке, и получать результат. ABI также задаёт размер, выравнивание и порядок байтов скалярных типов C, способ обработки вызовов с переменным числом аргументов и другие детали. Хотя интерфейс Linker нейтрален по отношению к соглашениям о вызовах, нативный компоновщик оптимизирован для соглашений о вызовах многих платформ:
- Linux/x64
- Linux/AArch64
- Linux/RISC-V
- Linux/PPC64
- Linux/s390
- macOS/x64
- macOS/AArch64
- Windows/x64
- Windows/AArch64
- AIX/ppc64
Нативный компоновщик поддерживает соглашения о вызовах других платформ, делегируя их libffi.
В качестве примера предположим, что мы хотим выполнить нисходящий вызов из кода Java к функции strlen, определённой в стандартной библиотеке C:
size_t strlen(const char *s);
Дескриптор метода нисходящего вызова, предоставляющий strlen, получается так (подробности FunctionDescriptor будут описаны чуть ниже):
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
linker.defaultLookup().find("strlen").get(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);
Вызов дескриптора метода нисходящего вызова запускает strlen и делает её результат доступным коду Java:
try (Arena arena = Arena.ofConfined()) {
MemorySegment str = arena.allocateFrom("Hello");
long len = (long)strlen.invoke(str); // 5
}
В качестве аргумента для strlen мы используем один из вспомогательных методов allocateFrom из Arena, чтобы преобразовать строку Java в сегмент памяти вне кучи. Передача этого сегмента памяти в strlen.invoke приводит к тому, что базовый адрес сегмента памяти передаётся функции strlen в качестве аргумента char *.
Дескрипторы методов хорошо подходят для предоставления внешних функций, потому что JVM уже оптимизирует вызов дескрипторов методов вплоть до нативного кода. Когда дескриптор метода ссылается на метод в файле class, вызов дескриптора метода обычно приводит к JIT-компиляции целевого метода; после этого JVM интерпретирует байт-код Java, вызывающий MethodHandle::invokeExact, передавая управление ассемблерному коду, сгенерированному для целевого метода. Таким образом, традиционный дескриптор метода в Java за кулисами нацелен на код, написанный не на Java; дескриптор метода нисходящего вызова — естественное расширение, которое позволяет разработчикам явно нацеливаться на код не на Java. Дескрипторы методов также обладают свойством, называемым сигнатурным полиморфизмом (signature polymorphism), которое позволяет вызывать их с примитивными аргументами без упаковки. В итоге дескрипторы методов позволяют Linker предоставлять внешние функции естественным, эффективным и расширяемым образом.
Описание типов C в коде Java
Чтобы создать дескриптор метода нисходящего вызова, нативный компоновщик требует от клиента предоставить FunctionDescriptor, описывающий типы параметров C и тип возвращаемого значения C целевой функции C. Типы C описываются объектами MemoryLayout, главным образом ValueLayout для скалярных типов C, таких как int и float, и StructLayout для типов структур C. Разметка памяти (memory layout), связанная с типом структуры C, должна быть составной разметкой, которая определяет вложенные разметки для всех полей структуры C, включая любое платформенно-зависимое выравнивающее заполнение, которое может вставить нативный компилятор.
Нативный компоновщик использует FunctionDescriptor, чтобы вывести тип дескриптора метода нисходящего вызова. Каждый дескриптор метода строго типизирован, то есть строго ограничивает число и типы аргументов, которые можно передать его методу invokeExact во время выполнения. Например, дескриптор метода, созданный для приёма одного аргумента MemorySegment, нельзя вызвать через invokeExact(<MemorySegment>, <MemorySegment>), хотя invokeExact — метод с переменным числом аргументов. Тип дескриптора метода нисходящего вызова описывает сигнатуру Java, которую разработчики должны использовать при вызове дескриптора метода нисходящего вызова. По сути, это представление функции C на уровне Java.
Разработчики должны учитывать текущую нативную платформу, если они обращаются к функциям C, использующим скалярные типы, такие как long, int и size_t. Причина в том, что соответствие скалярных типов C предопределённым разметкам значений зависит от платформы. Соответствие между скалярными типами C и разметками значений JAVA_* на текущей платформе предоставляет Linker::canonicalLayouts().
Например, предположим, что дескриптор метода нисходящего вызова должен предоставлять функцию C, которая принимает int языка C и возвращает long языка C:
-
В Linux/x64 и macOS/x64 типы C
longиintсоответствуют предопределённым разметкамJAVA_LONGиJAVA_INT, так что нужныйFunctionDescriptorможно получить черезFunctionDescriptor.of(JAVA_LONG, JAVA_INT). Затем нативный компоновщик делает так, что типом дескриптора метода нисходящего вызова становится сигнатура Java изintвlong. -
В Windows/x64 тип C
longсоответствует предопределённой разметкеJAVA_INT, так что нужныйFunctionDescriptorнеобходимо получить с помощьюFunctionDescriptor.of(JAVA_INT, JAVA_INT). Затем нативный компоновщик делает так, что типом дескриптора метода нисходящего вызова становится сигнатура Java изintвint.
Разработчики могут обращаться к функциям C, использующим указатели, не учитывая текущую нативную платформу или размер указателей на ней. На всех платформах тип указателя C соответствует предопределённой разметке ADDRESS, размер которой определяется во время выполнения. Разработчикам не нужно различать такие типы указателей C, как int* и char**.
Например, предположим, что дескриптор метода нисходящего вызова должен предоставлять функцию C void, принимающую указатель. Поскольку каждый тип указателя C соответствует разметке ADDRESS, нужный FunctionDescriptor можно получить с помощью FunctionDescriptor.ofVoid(ADDRESS). Затем нативный компоновщик делает так, что типом дескриптора метода нисходящего вызова становится сигнатура Java из MemorySegment в void. Когда дескриптору метода нисходящего вызова передаётся MemorySegment, целевой функции C будет передан базовый адрес сегмента.
Наконец, в отличие от JNI, нативный компоновщик поддерживает передачу структурированных данных внешним функциям. Предположим, что дескриптор метода нисходящего вызова должен предоставлять функцию C void, которая принимает структуру, описанную такой разметкой:
MemoryLayout SYSTEMTIME = MemoryLayout.ofStruct(
JAVA_SHORT.withName("wYear"), JAVA_SHORT.withName("wMonth"),
JAVA_SHORT.withName("wDayOfWeek"), JAVA_SHORT.withName("wDay"),
JAVA_SHORT.withName("wHour"), JAVA_SHORT.withName("wMinute"),
JAVA_SHORT.withName("wSecond"), JAVA_SHORT.withName("wMilliseconds")
);
Нужный FunctionDescriptor можно получить с помощью FunctionDescriptor.ofVoid(SYSTEMTIME). Нативный компоновщик сделает так, что типом дескриптора метода нисходящего вызова будет сигнатура Java из MemorySegment в void.
С учётом соглашения о вызовах нативной платформы нативный компоновщик использует FunctionDescriptor, чтобы определить, как поля структуры должны передаваться функции C, когда дескриптор метода нисходящего вызова вызывается с аргументом MemorySegment. При одном соглашении о вызовах нативный компоновщик может разложить входящий сегмент памяти, передать первые четыре поля через регистры общего назначения процессора, а оставшиеся поля — через стек C. При другом соглашении о вызовах нативный компоновщик может передать структуру косвенно: выделить область памяти, целиком скопировать в неё содержимое входящего сегмента памяти и передать функции C указатель на эту область. Такая низкоуровневая упаковка аргументов происходит за кулисами, без какого-либо контроля со стороны клиентского кода.
Если функция C возвращает структуру по значению (здесь не показано), то новый сегмент памяти нужно выделить вне кучи и вернуть клиенту Java. Для этого дескриптор метода, возвращаемый downcallHandle, требует дополнительного аргумента SegmentAllocator, который нативный компоновщик использует для выделения сегмента памяти, хранящего структуру, возвращённую функцией C.
Как упоминалось ранее, хотя нативный компоновщик ориентирован на взаимодействие кода Java с библиотеками C, интерфейс Linker нейтрален по отношению к языку: он не определяет, как задаются какие-либо нативные типы данных, поэтому разработчики сами отвечают за получение подходящих определений разметок для типов C. Этот выбор сделан сознательно, поскольку определения разметок для типов C — будь то простые скаляры или сложные структуры — в конечном счёте зависят от платформы. Мы ожидаем, что на практике такие разметки будут генерироваться автоматически инструментами, специфичными для целевых нативных платформ.
Сегменты памяти нулевой длины
Внешние функции часто выделяют область памяти и возвращают указатель на неё. Моделировать такую область сегментом памяти сложно, потому что её размер неизвестен среде выполнения Java. Например, функция C с типом возвращаемого значения char* может вернуть указатель на область, содержащую одно значение char, или на область, содержащую последовательность значений char, завершённую '\0'. Размер области не очевиден для кода, вызывающего внешнюю функцию.
FFM API представляет указатель, возвращённый внешней функцией, как сегмент памяти нулевой длины. Адрес сегмента — это значение указателя, а размер сегмента равен нулю. Аналогично, когда клиент читает указатель из сегмента памяти, возвращается сегмент памяти нулевой длины.
Сегмент нулевой длины имеет тривиальные пространственные границы, поэтому любая попытка обратиться к такому сегменту завершается исключением IndexOutOfBoundsException. Это ключевое свойство безопасности: поскольку такие сегменты связаны с областью памяти неизвестного размера, операции доступа с участием этих сегментов невозможно проверить. По сути, сегмент памяти нулевой длины оборачивает адрес, и его нельзя использовать без явного намерения.
Клиенты могут превратить сегмент памяти нулевой длины в нативный сегмент заданного размера с помощью метода MemorySegment::reinterpret. Этот метод присваивает сегменту памяти нулевой длины новые пространственные и временные границы, чтобы разрешить операции разыменования. Сегмент памяти, возвращаемый этим методом, небезопасен: сегмент памяти нулевой длины может опираться на область памяти длиной 10 байт, но клиент может переоценить размер области и с помощью MemorySegment::reinterpret получить сегмент длиной 100 байт. Позже это может привести к попыткам разыменовать память за границами области, что может вызвать аварийное завершение JVM или — что ещё хуже — незаметное повреждение памяти.
Поскольку переопределение пространственных и временных границ сегмента памяти нулевой длины небезопасно, метод MemorySegment::reinterpret является ограниченным (restricted). Его использование в программе по умолчанию приводит к тому, что среда выполнения Java выдаёт предупреждения (подробнее см. ниже).
Восходящие вызовы
Иногда полезно передать код Java как указатель на функцию какой-либо внешней функции. Это можно сделать с помощью поддержки восходящих вызовов в Linker. В этом разделе мы шаг за шагом построим более сложный пример, демонстрирующий все возможности Linker, с полным двунаправленным взаимодействием кода и данных через границу между Java и нативным кодом.
Рассмотрим эту функцию, определённую в стандартной библиотеке C:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
Чтобы вызвать qsort из кода Java, сначала нужно создать дескриптор метода нисходящего вызова:
Linker linker = Linker.nativeLinker();
MethodHandle qsort = linker.downcallHandle(
linker.defaultLookup().find("qsort").get(),
FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);
Как и раньше, мы используем разметку JAVA_LONG для отображения типа C size_t, а разметку ADDRESS — как для первого параметра-указателя (указателя на массив), так и для последнего параметра (указателя на функцию).
qsort сортирует содержимое массива с помощью пользовательской функции-компаратора compar, переданной как указатель на функцию. Поэтому, чтобы вызвать дескриптор метода нисходящего вызова, нам нужен указатель на функцию, который передаётся последним параметром в метод invokeExact дескриптора метода. Linker::upcallStub помогает создавать указатели на функции на основе существующих дескрипторов методов, как показано ниже.
Сначала напишем метод static, который сравнивает два значения int, представленные косвенно как объекты MemorySegment:
class Qsort {
static int qsortCompare(MemorySegment elem1, MemorySegment elem2) {
return Integer.compare(elem1.get(JAVA_INT, 0), elem2.get(JAVA_INT, 0));
}
}
Затем создадим дескриптор метода, указывающий на метод-компаратор Java:
MethodHandle comparHandle
= MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class,
MemorySegment.class,
MemorySegment.class));
В-третьих, теперь, когда у нас есть дескриптор метода для нашего компаратора на Java, мы можем создать указатель на функцию с помощью Linker::upcallStub. Как и для нисходящих вызовов, сигнатуру указателя на функцию мы описываем с помощью FunctionDescriptor:
MemorySegment comparFunc
= linker.upcallStub(comparHandle,
/* A Java description of a C function
implemented by a Java method! */
FunctionDescriptor.of(JAVA_INT,
ADDRESS.withTargetLayout(JAVA_INT),
ADDRESS.withTargetLayout(JAVA_INT)),
Arena.ofAuto());
Наконец, у нас есть сегмент памяти comparFunc, который указывает на заглушку, через которую можно вызвать нашу функцию-компаратор на Java. Теперь у нас есть всё необходимое, чтобы вызвать дескриптор нисходящего вызова qsort:
try (Arena arena = Arena.ofConfined()) {
MemorySegment array
= arena.allocateFrom(ValueLayout.JAVA_INT,
0, 9, 3, 4, 6, 5, 1, 8, 2, 7);
qsort.invoke(array, 10L, ValueLayout.JAVA_INT.byteSize(), comparFunc);
int[] sorted = array.toArray(JAVA_INT); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
}
Этот код создаёт массив вне кучи, копирует в него содержимое массива Java, а затем передаёт этот массив дескриптору qsort вместе с функцией-компаратором, которую мы получили от нативного компоновщика. После вызова содержимое массива вне кучи будет отсортировано согласно нашей функции-компаратору, написанной на Java. Затем мы извлекаем из сегмента новый массив Java, который содержит отсортированные элементы.
Сегменты памяти и байтовые буферы
API java.nio.channels предоставляет обширную функциональность для ввода-вывода с файлами и сокетами. В этом API операции ввода-вывода выражаются через объекты ByteBuffer, а не через простые массивы байтов. Клиент, который записывает данные в канал, должен сначала поместить их в байтовый буфер; после чтения данных из канала клиент должен извлечь их из байтового буфера. Например, следующий код использует FileChannel, чтобы читать содержимое файла в байтовый буфер вне кучи порциями по 1024 байта:
try (FileChannel channel = FileChannel.open(... a file path ...)) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
int bytesRead;
while ((bytesRead = channel.read(buffer)) != -1) {
... extract and process buffer contents ...
buffer.clear();
}
}
Поскольку байтовый буфер, скорее всего, меньше файла, код должен многократно читать из канала, а затем очищать байтовый буфер, чтобы подготовить его к следующей операции чтения.
На фоне такого низкоуровневого выделения буферов и работы с ними разработчики часто с удивлением узнают, что не могут управлять освобождением байтовых буферов вне кучи; вместо этого они должны ждать, пока их освободит сборщик мусора. Если немедленное освобождение совершенно необходимо, им остаётся прибегать только к нестандартным, недетерминированным приёмам, таким как вызов sun.misc.Unsafe::invokeCleaner.
FFM API позволяет разработчикам сочетать функциональность каналов со стандартным, детерминированным освобождением памяти, которое обеспечивают сегменты памяти и арены.
FFM API включает метод MemorySegment::asByteBuffer, который позволяет использовать любой сегмент памяти как байтовый буфер. Время жизни полученного байтового буфера определяется временными границами сегмента памяти, которые, в свою очередь, задаются ареной, использованной для выделения этого сегмента памяти. Клиенты по-прежнему читают из каналов и пишут в них с помощью байтовых буферов, но теперь сами управляют тем, когда освобождается память байтового буфера. Вот предыдущий пример, переписанный так, чтобы использовать байтовый буфер, память которого освобождается, когда блок try-with-resources закрывает арену:
// try-with-resources manages two resources: a channel and an arena
try (FileChannel channel = FileChannel.open(... a file path ...);
Arena offHeap = Arena.ofConfined()) {
ByteBuffer buffer = offHeap.allocate(1024).asByteBuffer();
int readBytes;
while ((readBytes = channel.read(buffer)) != -1) {
... unpack and process the contents of buffer ...
buffer.clear();
}
} // buffer’s memory is deallocated here
FFM API также включает метод MemorySegment::ofBuffer, который позволяет использовать любой байтовый буфер как сегмент памяти: он создаёт сегмент памяти, опирающийся на ту же область памяти. Например, следующий метод вызывает нативную функцию strlen, которая принимает char *, передавая ей сегмент памяти, полученный из байтового буфера вне кучи:
void readString(ByteBuffer offheapString) {
MethodHandle strlen = ...
long len = strlen.invokeExact(MemorySegment.ofBuffer(offheapString));
...
}
Байтовые буферы встречаются во многих программах на Java, поскольку долгое время они были единственным поддерживаемым способом передать данные вне кучи в нативный код. Однако нативному коду неудобно обращаться к данным в байтовом буфере, потому что сначала он должен вызвать функцию JNI, чтобы получить указатель на область памяти, на которую опирается байтовый буфер. Напротив, к данным в сегменте памяти нативный код обращается легко, потому что, когда код на Java передаёт нативному коду объект MemorySegment, FFM API передаёт базовый адрес сегмента памяти — указатель на его данные, — а не адрес самого объекта MemorySegment.
Безопасность
Бо́льшая часть FFM API безопасна по своему устройству. Многие сценарии, которые раньше требовали JNI и нативного кода, теперь можно реализовать вызовом методов FFM API, которые никогда не нарушают целостность платформы Java. Например, важный сценарий использования JNI — гибкое выделение и освобождение памяти — теперь поддерживается в коде на Java с помощью сегментов памяти и арен и не требует нативного кода.
Однако часть FFM API небезопасна по своей природе. Например, код на Java может запросить у Linker дескриптор метода нисходящего вызова, но указать типы параметров, несовместимые с типами параметров нижележащей внешней функции. Вызов полученного дескриптора метода приведёт к сбою того же рода — аварийному завершению VM или неопределённому поведению нативного кода, — какой может возникнуть при вызове метода native в JNI. Среда выполнения Java не может предотвратить такие сбои, а код на Java не может их перехватить. С помощью FFM API также можно создавать небезопасные сегменты, то есть сегменты памяти, пространственные и временные границы которых задаёт пользователь и которые среда выполнения Java не может проверить (см. MemorySegment::reinterpret).
Иными словами, любое взаимодействие между кодом на Java и нативным кодом может нарушить целостность платформы Java. Поэтому небезопасные методы FFM API являются ограниченными. Это означает, что их использование разрешено, но по умолчанию приводит к выдаче предупреждения во время выполнения. Например:
WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: Linker::downcallHandle has been called by com.foo.Server in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
Такие предупреждения записываются в стандартный поток ошибок и выдаются не более одного раза для каждого модуля, код которого вызывает ограниченный метод.
Чтобы разрешить коду в модуле M использовать небезопасные методы без предупреждений, укажите параметр --enable-native-access=M в командной строке средства запуска java. Несколько модулей указываются списком через запятую; укажите ALL-UNNAMED, чтобы разрешить использование без предупреждений всему коду в пути классов. Кроме того, в исполняемом JAR-файле можно использовать атрибут манифеста Enable-Native-Access: ALL-UNNAMED, чтобы разрешить использование без предупреждений всему коду в пути классов; никакое другое имя модуля не может быть указано в качестве значения этого атрибута.
Если указан параметр --enable-native-access, любое использование небезопасных методов вне списка указанных модулей приводит к выбросу IllegalCallerException, а не к выдаче предупреждения. В одном из будущих выпусков этот параметр, вероятно, станет обязательным для использования небезопасных методов; то есть, если параметр не указан, использование небезопасных методов приведёт не к предупреждению, а к IllegalCallerException.
Чтобы обеспечить единообразный подход к взаимодействию кода на Java с нативным кодом, связанный JEP предлагает аналогичным образом ограничить использование JNI. По-прежнему можно будет вызывать методы native из кода на Java, а нативный код сможет вызывать небезопасные функции JNI, но для того, чтобы избежать предупреждений, а позднее исключений, потребуется параметр --enable-native-access. Это согласуется с более общим планом сделать платформу Java безопасной по умолчанию, при котором конечные пользователи или разработчики приложений должны явно разрешать небезопасные действия, такие как нарушение строгой инкапсуляции или связывание с неизвестным кодом.
Риски и допущения
Создать API для доступа к внешней памяти, который был бы одновременно безопасным и эффективным, — сложная задача. Поскольку пространственные и временные границы нужно проверять при каждом обращении, крайне важно, чтобы JIT-компиляторы могли оптимизировать эти проверки, например вынося их за пределы горячих циклов. Реализации JIT, вероятно, потребуют доработки, чтобы использование этого API было столь же эффективным и поддающимся оптимизации, как использование существующих API, таких как ByteBuffer и Unsafe. Реализации JIT также потребуют доработки, чтобы использование нативных дескрипторов методов, создаваемых этим API, было не менее эффективным и поддающимся оптимизации, чем использование существующих нативных методов JNI.
Зависимости
Инструмент jextract зависит от FFM API. Он принимает заголовочные файлы нативной библиотеки и автоматически генерирует дескрипторы методов нисходящих вызовов, необходимые для взаимодействия с этой библиотекой. Это снижает накладные расходы на использование нативных библиотек из кода на Java.