JEP 419: Foreign Function & Memory API (Second Incubator)
Foreign Function & Memory API, вторая версия Incubator (инкубационный модуль)
| Ответственный | Maurizio Cimadamore |
| Тип | Feature |
| Область | JDK |
| Статус | Closed / Delivered |
| Выпуск | 18 |
| Компонент | core-libs |
| Обсуждение | panama dash dev at openjdk dot java dot net |
| Связан с | JEP 412: Foreign Function & Memory API (Incubator) |
| JEP 424: Foreign Function & Memory API (Preview) | |
| Рецензенты | Jim Laskey, Paul Sandoz |
| Создан | 2021/09/21 11:56 |
| Обновлён | 2023/05/12 15:34 |
| Задача | 8274073 |
Аннотация
Ввести API, с помощью которого программы на Java могут взаимодействовать с кодом и данными за пределами среды выполнения Java. API эффективно вызывает внешние функции (то есть код за пределами JVM) и безопасно обращается к внешней памяти (то есть памяти, которой не управляет JVM). Так программы на Java могут вызывать нативные библиотеки и обрабатывать нативные данные без хрупкости и опасностей JNI.
История
Foreign Function & Memory API был предложен в JEP 412 и в середине 2021 года включён в Java 17 как API в статусе Incubator. Он объединил два более ранних Incubator-API: Foreign-Memory Access API и Foreign Linker API. Этот JEP предлагает внести доработки на основе отзывов и повторно выпустить API в статусе Incubator в Java 18. В это обновление входят следующие изменения:
- поддержка большего числа типов-носителей, например
booleanиMemoryAddress, в var handle для доступа к памяти; - более общий API разыменования, доступный в интерфейсах
MemorySegmentиMemoryAddress; - более простой API для получения method handle нисходящих вызовов (downcall), в котором больше не требуется передавать параметр
MethodType; - более простой API для управления временными зависимостями между resource scope; и
- новый API для копирования Java-массивов в сегменты памяти и из них.
Цели
-
Простота использования — заменить Java Native Interface (JNI) более совершенной моделью разработки на чистой Java.
-
Производительность — обеспечить производительность, сравнимую с существующими API, такими как JNI и
sun.misc.Unsafe, или превосходящую её. -
Универсальность — предоставить способы работы с разными видами внешней памяти (например, нативной памятью, энергонезависимой памятью и управляемой памятью кучи), а со временем — поддержку других платформ (например, 32-битной x86) и внешних функций, написанных на языках, отличных от C (например, C++, Fortran).
-
Безопасность — отключить небезопасные операции по умолчанию и разрешать их только после явного согласия разработчиков приложений или конечных пользователей.
Что не является целью
Целью не является
- заново реализовать JNI поверх этого API или как-либо ещё изменять JNI;
- заново реализовать устаревшие API Java, такие как
sun.misc.Unsafe, поверх этого API; - предоставить инструменты, которые автоматически генерируют код на Java из заголовочных файлов нативного кода; или
- изменить способ упаковки и развёртывания Java-приложений, взаимодействующих с нативными библиотеками (например, с помощью многоплатформенных JAR-файлов).
Мотивация
Платформа Java всегда давала богатую основу разработчикам библиотек и приложений, которым нужно выйти за пределы JVM и взаимодействовать с другими платформами. API Java удобно и надёжно предоставляют доступ к ресурсам вне Java: к удалённым данным (JDBC), к веб-сервисам (HTTP-клиент), к обслуживанию удалённых клиентов (каналы NIO) или к взаимодействию с локальными процессами (сокеты Unix-домена). К сожалению, разработчики на Java по-прежнему сталкиваются с серьёзными препятствиями при доступе к важному виду ресурсов вне Java: коду и данным на той же машине, что и JVM, но за пределами среды выполнения Java.
Внешняя память
Данные, хранящиеся в памяти за пределами среды выполнения Java, называют данными вне кучи (off-heap). (Куча — это место, где живут объекты Java, то есть данные в куче (on-heap), и где работают сборщики мусора.) Доступ к данным вне кучи критически важен для производительности популярных библиотек Java, таких как Tensorflow, Ignite, Lucene и Netty, прежде всего потому, что так они избегают затрат и непредсказуемости, связанных со сборкой мусора. Он также позволяет сериализовать и десериализовать структуры данных, отображая файлы в память, например, с помощью mmap. Однако платформа Java не предоставляет удовлетворительного решения для доступа к данным вне кучи.
-
ByteBufferAPI позволяет создавать прямые (direct) байтовые буферы, размещаемые вне кучи, но их максимальный размер — два гигабайта, и они освобождаются не сразу. Эти и другие ограничения связаны с тем, что APIByteBufferпроектировался не только для доступа к памяти вне кучи, но и для обмена крупными объёмами данных между производителем и потребителем в таких областях, как кодирование и декодирование кодировок и частичные операции ввода-вывода. В этих условиях не удалось удовлетворить многочисленные запросы на улучшения работы с памятью вне кучи, поданные за эти годы (например, 4496703, 6558368, 4837564 и 5029431). -
sun.misc.UnsafeAPI предоставляет операции доступа к памяти для данных в куче, которые работают и для данных вне кучи. ИспользоватьUnsafeэффективно, потому что его операции доступа к памяти определены как интринсики HotSpot JVM и оптимизируются JIT-компилятором. Однако использоватьUnsafeопасно, потому что он позволяет обращаться к любому адресу памяти. Это значит, что программа на Java может обрушить JVM, обратившись к уже освобождённому адресу; по этой и другим причинам использованиеUnsafeвсегда настоятельно не рекомендовалось. -
Можно с помощью JNI вызвать нативную библиотеку, которая затем обращается к данным вне кучи, но из-за накладных расходов на производительность такой подход редко применим: переход из Java в нативный код на несколько порядков медленнее доступа к памяти, потому что вызовы методов JNI не получают выгоды от многих распространённых JIT-оптимизаций, таких как встраивание (inlining).
Итак, когда речь идёт о доступе к данным вне кучи, разработчики на Java оказываются перед дилеммой: выбрать безопасный, но неэффективный путь (ByteBuffer) или отказаться от безопасности ради производительности (Unsafe)? На самом деле им нужен поддерживаемый API для доступа к данным вне кучи (то есть к внешней памяти), с самого начала спроектированный безопасным и с учётом JIT-оптимизаций.
Внешние функции
JNI поддерживает вызов нативного кода (то есть внешних функций) начиная с Java 1.1, но по многим причинам этого недостаточно.
-
JNI требует нескольких трудоёмких артефактов: API на Java (методы
native), заголовочного файла на C, полученного из API на Java, и реализации на C, которая вызывает нужную нативную библиотеку. Разработчикам на Java приходится работать с несколькими наборами инструментов, чтобы поддерживать платформенно-зависимые артефакты согласованными, что особенно обременительно, когда нативная библиотека быстро развивается. -
JNI может взаимодействовать только с библиотеками, написанными на языках (обычно C и C++), которые используют соглашение о вызовах операционной системы и процессора, для которых собрана JVM. Метод
nativeнельзя использовать для вызова функции, написанной на языке с другим соглашением. -
JNI не согласует систему типов Java с системой типов C. Составные данные в Java представлены объектами, а в C — структурами, поэтому любой объект Java, переданный в метод
native, нативному коду приходится кропотливо распаковывать. Например, возьмём record-классPersonв Java: при передаче объектаPersonв методnativeнативный код должен с помощью C API JNI извлекать поля (например,firstNameиlastName) из объекта. Поэтому разработчики на Java иногда упаковывают свои данные в один объект (например, в байтовый массив или прямой байтовый буфер), но чаще, поскольку передача объектов Java через JNI медленная, они с помощью APIUnsafeвыделяют память вне кучи и передают её адрес в методnativeкакlong, — что делает код на Java катастрофически небезопасным!
За эти годы появилось множество фреймворков, заполняющих пробелы JNI, в том числе JNA, JNR и JavaCPP. Хотя эти фреймворки часто заметно лучше JNI, ситуация всё ещё далека от идеальной, особенно по сравнению с языками, в которых взаимодействие с нативным кодом поддерживается на первоклассном уровне. Например, пакет ctypes в Python может динамически оборачивать функции нативных библиотек без всякого связующего кода. Другие языки, такие как Rust, предоставляют инструменты, которые автоматически создают нативные обёртки из заголовочных файлов C/C++.
В конечном счёте у разработчиков на Java должен быть поддерживаемый API, с помощью которого можно напрямую использовать любую нативную библиотеку, полезную для конкретной задачи, без утомительного связующего кода и громоздкости JNI. Отличная абстракция, на которой можно строить такой API, — method handle (дескрипторы методов), появившиеся в Java 7 для поддержки быстрых динамических языков на JVM. Предоставление доступа к нативному коду через method handle радикально упростило бы написание, сборку и распространение библиотек Java, зависящих от нативных библиотек. Кроме того, API, способный моделировать внешние функции (то есть нативный код) и внешнюю память (то есть данные вне кучи), стал бы прочной основой для сторонних фреймворков взаимодействия с нативным кодом.
Описание
Foreign Function & Memory API (FFM API) определяет классы и интерфейсы, с помощью которых клиентский код в библиотеках и приложениях может
- выделять внешнюю память
(MemorySegment,MemoryAddressиSegmentAllocator), - работать со структурированной внешней памятью и обращаться к ней
(MemoryLayout,VarHandle), - управлять жизненным циклом внешних ресурсов (
ResourceScope) и - вызывать внешние функции (
SymbolLookup,CLinkerиNativeSymbol).
FFM API находится в пакете jdk.incubator.foreign модуля jdk.incubator.foreign.
Пример
В качестве краткого примера использования FFM API ниже приведён код на Java, который получает method handle для функции radixsort библиотеки C, а затем с его помощью сортирует четыре строки, изначально находящиеся в Java-массиве (некоторые детали опущены):
// 1. Find foreign function on the C library path
CLinker linker = CLinker.getInstance();
MethodHandle radixSort = linker.downcallHandle(
linker.lookup("radixsort"), ...);
// 2. Allocate on-heap memory to store four strings
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. Allocate off-heap memory to store four pointers
MemorySegment offHeap = MemorySegment.allocateNative(
MemoryLayout.ofSequence(javaStrings.length,
ValueLayout.ADDRESS), ...);
// 4. Copy the strings from on-heap to off-heap
for (int i = 0; i < javaStrings.length; i++) {
// Allocate a string off-heap, then store a pointer to it
MemorySegment cString = implicitAllocator().allocateUtf8String(javaStrings[i]);
offHeap.setAtIndex(ValueLayout.ADDRESS, i, cString);
}
// 5. Sort the off-heap data by calling the foreign function
radixSort.invoke(offHeap, javaStrings.length, MemoryAddress.NULL, '\0');
// 6. Copy the (reordered) strings from off-heap to on-heap
for (int i = 0; i < javaStrings.length; i++) {
MemoryAddress cStringPtr = offHeap.getAtIndex(ValueLayout.ADDRESS, i);
javaStrings[i] = cStringPtr.getUtf8String(0);
}
assert Arrays.equals(javaStrings, new String[] {"car", "cat", "dog", "mouse"}); // true
Этот код гораздо понятнее любого решения на основе JNI, поскольку неявные преобразования и разыменования памяти, которые были бы скрыты за вызовами методов native, теперь выражены непосредственно на Java. Можно использовать и современные идиомы Java; например, с помощью потоков (streams) несколько потоков могут параллельно копировать данные между памятью в куче и вне кучи.
Сегменты памяти
Сегмент памяти — это абстракция, моделирующая непрерывную область памяти, расположенную вне кучи или в куче. Сегменты памяти могут быть
- нативными сегментами, заново выделенными в нативной памяти (например, с помощью
malloc), - отображёнными сегментами, обёрнутыми вокруг области отображённой нативной памяти (например, с помощью
mmap), или - сегментами массивов или буферов, обёрнутыми вокруг памяти, связанной соответственно с существующими Java-массивами или байтовыми буферами.
Все сегменты памяти дают строго соблюдаемые пространственные и временные гарантии, а также гарантии привязки к потоку, которые делают операции разыменования памяти безопасными. Например, следующий код выделяет 100 байт вне кучи:
MemorySegment segment = MemorySegment.allocateNative(100,
newImplicitScope());
Пространственные границы сегмента определяют диапазон адресов памяти, связанных с сегментом. Границы сегмента в коде выше задаются базовым адресом b, представленным экземпляром MemoryAddress, и размером в байтах (100), что даёт диапазон адресов от b до b + 99 включительно.
Временные границы сегмента определяют время его жизни, то есть момент, когда сегмент будет освобождён. Время жизни сегмента и состояние его привязки к потоку моделируются абстракцией ResourceScope, которая рассматривается ниже. Resource scope в коде выше — это новый общий (shared) scope, который гарантирует, что память, связанная с этим сегментом, освобождается, когда сборщик мусора сочтёт объект MemorySegment недостижимым. Общий scope также гарантирует, что сегмент памяти доступен из нескольких потоков.
Иначе говоря, код выше создаёт сегмент, поведение которого близко соответствует поведению ByteBuffer, выделенного фабричным методом allocateDirect. FFM API также поддерживает детерминированное освобождение памяти и другие варианты привязки к потоку, которые рассматриваются ниже.
Разыменование сегментов
Чтобы разыменовать данные в сегменте памяти, нужно учесть несколько факторов:
- количество разыменовываемых байтов,
- ограничения выравнивания адреса, по которому происходит разыменование,
- порядок байтов (endianness), в котором байты хранятся в этой области памяти, и
- тип Java, используемый в операции разыменования (например,
intилиfloat).
Все эти характеристики отражены в абстракции ValueLayout. Например, предопределённая раскладка значения JAVA_INT имеет ширину четыре байта и не имеет ограничений выравнивания. Она использует порядок байтов нативной платформы (например, little-endian на Linux/x64) и связана с типом Java int.
У сегментов памяти есть простые методы разыменования для чтения значений из сегментов памяти и записи значений в них. Эти методы принимают раскладку значения, которая однозначно задаёт свойства операции разыменования. Например, мы можем записать 25 значений int по последовательным смещениям в сегменте памяти с помощью следующего кода:
MemorySegment segment = MemorySegment.allocateNative(100,
newImplicitScope());
for (int i = 0; i < 25; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ i,
/* value to write */ i);
}
Раскладки памяти и структурированный доступ
Рассмотрим следующее объявление на C. Оно определяет массив структур Point, где каждая структура Point имеет два члена, а именно Point.x и Point.y:
struct Point {
int x;
int y;
} pts[10];
Чтобы инициализировать такой нативный массив с помощью методов разыменования из предыдущего раздела, нам пришлось бы написать следующий код:
MemorySegment segment = MemorySegment.allocateNative(2 * 4 * 10,
newImplicitScope());
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. Например, нужную раскладку нативного сегмента памяти из примеров выше можно описать так:
SequenceLayout ptsLayout
= MemoryLayout.sequenceLayout(10,
MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));
Этот код создаёт последовательную раскладку памяти (sequence memory layout), содержащую десять повторений раскладки структуры, элементы которой — две раскладки JAVA_INT с именами x и y соответственно. С такой раскладкой мы можем не вычислять смещения в нашем коде, а создать var handle для доступа к памяти — особый вид var handle, который принимает параметр MemorySegment (разыменовываемый сегмент), за которым следуют одна или несколько координат long (индексы, по которым должна выполняться операция разыменования):
VarHandle xHandle // (MemorySegment, long) -> int
= ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("x"));
VarHandle yHandle // (MemorySegment, long) -> int
= ptsLayout.varHandle(PathElement.sequenceElement(),
PathElement.groupElement("y"));
MemorySegment segment = MemorySegment.allocateNative(ptsLayout,
newImplicitScope());
for (int i = 0; i < ptsLayout.elementCount().getAsLong(); i++) {
xHandle.set(segment,
/* index */ (long) i,
/* value to write */ i); // x
yHandle.set(segment,
/* index */ (long) i,
/* value to write */ i); // y
}
Объект ptsLayout управляет созданием var handle для доступа к памяти через создание пути раскладки (layout path), который служит для выбора вложенной раскладки из сложного выражения раскладки. Поскольку выбранная раскладка значения связана с типом Java int, результирующие var handle xHandle и yHandle также будут иметь тип int. Кроме того, поскольку выбранная раскладка значения определена внутри последовательной раскладки, результирующие var handle получают дополнительную координату типа long, а именно индекс структуры Point, координату которой нужно прочитать или записать. Объект ptsLayout также управляет выделением нативного сегмента памяти, которое основано на сведениях о размере и выравнивании, полученных из раскладки. Вычислять смещения внутри цикла больше не нужно, поскольку для инициализации элементов Point.x и Point.y используются разные var handle.
Области ресурсов
Во всех примерах выше используется недетерминированное освобождение: память, связанная с выделенными сегментами, освобождается сборщиком мусора после того, как экземпляр сегмента памяти становится недостижимым. Мы говорим, что такие сегменты освобождаются неявно.
Бывают случаи, когда клиент может захотеть сам управлять моментом освобождения памяти. Предположим, например, что большой сегмент памяти отображается из файла с помощью MemorySegment::map. Клиент может предпочесть освободить (то есть отменить отображение) память, связанную с сегментом, как только сегмент перестанет быть нужен, а не ждать, пока это сделает сборщик мусора, поскольку ожидание может негативно сказаться на производительности приложения.
Сегменты памяти поддерживают детерминированное освобождение с помощью областей ресурсов (resource scopes). Область ресурсов моделирует жизненный цикл одного или нескольких ресурсов, таких как сегменты памяти. Только что созданная область ресурсов находится в активном состоянии (alive), то есть ко всем управляемым ею ресурсам можно безопасно обращаться. По запросу клиента область ресурсов можно закрыть, после чего доступ к управляемым ею ресурсам больше не разрешён. Класс ResourceScope реализует интерфейс AutoCloseable, поэтому области ресурсов работают с оператором try-with-resources:
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
MemorySegment s1 = MemorySegment.map(Path.of("someFile"),
0, 100000,
MapMode.READ_WRITE, scope);
MemorySegment s2 = MemorySegment.allocateNative(100, scope);
...
} // both segments released here
Этот код создаёт область ресурсов и использует её для создания двух сегментов: отображённого сегмента (s1) и нативного сегмента (s2). Жизненный цикл двух сегментов привязан к времени жизни области ресурсов, поэтому обращение к сегментам (например, их разыменование с помощью var handle для доступа к памяти) за пределами оператора try-with-resources приведёт к выбросу исключения во время выполнения.
Помимо управления временем жизни сегмента памяти, область ресурсов также определяет, какие потоки могут обращаться к сегменту. Ограниченная (confined) область ресурсов разрешает доступ только потоку, который создал эту область, тогда как общая (shared) область ресурсов разрешает доступ из любого потока.
Области ресурсов, как ограниченные, так и общие, можно связать с объектом java.lang.ref.Cleaner, который выполняет неявное освобождение, если область ресурсов становится недостижимой, пока она ещё активна, и тем самым предотвращает случайные утечки памяти.
Аллокаторы сегментов
Выделение памяти часто может становиться узким местом, когда клиенты используют память вне кучи. Поэтому FFM API включает абстракцию SegmentAllocator, которая определяет полезные операции для выделения и инициализации сегментов памяти. Аллокаторы сегментов получают через фабрики в интерфейсе SegmentAllocator. Одна из таких фабрик возвращает неявный аллокатор, то есть аллокатор, который выделяет нативные сегменты, опирающиеся на новую неявную область. Предоставляются и другие, более оптимизированные аллокаторы. Например, следующий код создаёт аллокатор на основе арены и использует его для выделения сегмента, содержимое которого инициализируется из массива Java int:
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
SegmentAllocator allocator = SegmentAllocator.newNativeArena(scope);
for (int i = 0 ; i < 100 ; i++) {
MemorySegment s = allocator.allocateArray(JAVA_INT,
new int[] { 1, 2, 3, 4, 5 });
...
}
...
} // all memory allocated is released here
Этот код создаёт ограниченную область ресурсов, а затем создаёт связанный с этой областью неограниченный аллокатор арены (unbounded arena allocator). Этот аллокатор выделяет сегмент памяти и отвечает на запросы выделения, возвращая срезы этого заранее выделенного сегмента. Если в текущем сегменте недостаточно места для запроса выделения, выделяется новый сегмент. Вся память, связанная с сегментами, созданными аллокатором (то есть в теле цикла for), атомарно освобождается при закрытии области ресурсов, связанной с аллокатором арены. Этот приём сочетает преимущества детерминированного освобождения, которое обеспечивает абстракция ResourceScope, с более гибкой и масштабируемой схемой выделения. Он может быть очень полезен при написании кода, который управляет большим количеством сегментов вне кучи.
Небезопасные сегменты памяти
Итак, мы рассмотрели сегменты памяти, адреса памяти и раскладки памяти. Операции разыменования возможны только над сегментами памяти. Поскольку у сегмента памяти есть пространственные и временные границы, среда выполнения Java гарантирует, что память, связанная с данным сегментом, разыменовывается безопасно. Однако бывают ситуации, когда у клиентов может быть только экземпляр MemoryAddress, как это часто бывает при взаимодействии с нативным кодом. Чтобы разыменовать адрес памяти, у клиента есть два варианта:
-
Во-первых, клиент может использовать один из методов разыменования, определённых в классе
MemoryAddress. Эти методы небезопасны, поскольку у адреса памяти нет пространственных или временных границ, и поэтому FFM API никак не может гарантировать, что разыменовываемая область памяти действительна. -
Либо клиент может небезопасно превратить адрес в сегмент с помощью фабрики
MemorySegment::ofAddressNative. Эта фабрика добавляет новые пространственные и временные границы к в остальном «сырому» адресу памяти, чтобы сделать возможными операции разыменования. Сегмент памяти, возвращаемый этой фабрикой, небезопасен: «сырой» адрес памяти может быть связан с областью памяти длиной 10 байт, но клиент может случайно переоценить размер области и создать небезопасный сегмент памяти длиной 100 байт. Позже это может привести к попыткам разыменовать память за пределами области памяти, связанной с небезопасным сегментом, что может вызвать сбой JVM или, что хуже, привести к незаметному повреждению памяти.
Оба этих варианта небезопасны, поэтому считаются ограниченными операциями (restricted operations), которые по умолчанию отключены (подробнее см. ниже).
Поиск внешних функций
Первая составляющая любой поддержки внешних функций — механизм загрузки нативных библиотек. В JNI это делается методами System::loadLibrary и System::load, которые внутри сводятся к вызовам dlopen или его эквивалента. Библиотеки, загруженные этими методами, всегда связаны с загрузчиком классов, а именно с загрузчиком класса, который вызвал метод. Связь между библиотеками и загрузчиками классов крайне важна, поскольку она определяет жизненный цикл загруженных библиотек: только когда загрузчик классов перестаёт быть достижимым, все его библиотеки можно безопасно выгрузить.
FFM API не предоставляет новых методов для загрузки нативных библиотек. Разработчики используют методы System::loadLibrary и System::load для загрузки нативных библиотек, которые будут вызываться через FFM API. Связь между библиотеками и загрузчиками классов сохраняется, поэтому библиотеки будут выгружаться так же предсказуемо, как и в JNI.
В отличие от JNI, FFM API позволяет найти адрес заданного символа в загруженной библиотеке. Эта возможность, представленная объектом SymbolLookup, крайне важна для связывания кода Java с внешними функциями (см. ниже). Получить объект SymbolLookup можно двумя способами:
-
Вызвав
SymbolLookup::loaderLookup, который возвращает поиск символов, находящий все символы во всех библиотеках, загруженных текущим загрузчиком классов, или -
Получив экземпляр
CLinker, который реализует интерфейсSymbolLookupи может использоваться для поиска платформенно-зависимых символов в стандартной библиотеке C.
Имея поиск символов, клиент может найти внешнюю функцию с помощью метода SymbolLookup::lookup(String). Если функция с указанным именем присутствует среди символов, видимых этому поиску символов, метод возвращает NativeSymbol, указывающий на точку входа функции. Например, следующий код загружает библиотеку OpenGL, в результате чего она связывается с текущим загрузчиком классов, и находит адрес её функции glGetString:
System.loadLibrary("GL");
SymbolLookup loaderLookup = SymbolLookup.loaderLookup();
NativeSymbol clangVersion = loaderLookup.lookup("glGetString").get();
Связывание кода Java с внешними функциями
Интерфейс CLinker — основа взаимодействия кода Java с нативным кодом. Хотя CLinker ориентирован на взаимодействие между Java и библиотеками C, понятия этого интерфейса достаточно общие, чтобы в будущем поддерживать и другие языки, отличные от Java. Интерфейс поддерживает как нисходящие вызовы (downcalls, вызовы из кода Java в нативный код), так и восходящие вызовы (upcalls, вызовы из нативного кода обратно в код Java).
interface CLinker {
MethodHandle downcallHandle(NativeSymbol func,
FunctionDescriptor function);
NativeSymbol upcallStub(MethodHandle target,
FunctionDescriptor function,
ResourceScope scope);
}
Для нисходящих вызовов метод downcallHandle принимает адрес внешней функции — как правило, NativeSymbol, полученный при поиске в библиотеке, — и предоставляет внешнюю функцию как method handle нисходящего вызова (downcall method handle). Затем код Java вызывает этот method handle нисходящего вызова через его метод invoke (или invokeExact), и внешняя функция выполняется. Все аргументы, переданные методу invoke этого method handle, передаются внешней функции.
Для восходящих вызовов метод upcallStub принимает method handle — как правило, ссылающийся на метод Java, а не method handle нисходящего вызова, — и преобразует его в экземпляр NativeSymbol. Затем этот нативный символ передаётся как аргумент, когда код Java вызывает method handle нисходящего вызова. По сути, нативный символ служит указателем на функцию. (Подробнее о восходящих вызовах см. ниже.)
Предположим, мы хотим выполнить нисходящий вызов из Java функции strlen, определённой в стандартной библиотеке C:
size_t strlen(const char *s);
Method handle нисходящего вызова, предоставляющий strlen, можно получить следующим образом (подробности о FunctionDescriptor будут описаны чуть ниже):
CLinker linker = CLinker.systemCLinker();
MethodHandle strlen = linker.downcallHandle(
linker.lookup("strlen").get(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);
Вызов method handle нисходящего вызова запустит strlen и сделает её результат доступным в Java. Для аргумента strlen мы используем вспомогательный метод, который преобразует строку Java в сегмент памяти вне кучи (с помощью неявного распределителя), а затем этот сегмент передаётся по ссылке:
MemorySegment str = implicitAllocator().allocateUtf8String("Hello");
long len = strlen.invoke(cString); // 5
Method handles хорошо подходят для предоставления внешних функций, потому что JVM уже оптимизирует вызов method handles вплоть до нативного кода. Когда method handle ссылается на метод в файле class, вызов этого method handle обычно приводит к JIT-компиляции целевого метода; после этого JVM интерпретирует байт-код Java, вызывающий MethodHandle::invokeExact, передавая управление ассемблерному коду, сгенерированному для целевого метода. Таким образом, традиционный method handle в Java «за кулисами» нацелен на код, написанный не на Java; method handle нисходящего вызова — естественное расширение, с помощью которого разработчики могут явно нацеливаться на код, написанный не на Java. Кроме того, method handles обладают свойством, которое называется сигнатурным полиморфизмом (signature polymorphism) и позволяет выполнять вызов с примитивными аргументами без упаковки. В итоге method handles позволяют CLinker предоставлять внешние функции естественным, эффективным и расширяемым образом.
Описание типов C в Java
Чтобы создать method handle нисходящего вызова, FFM API требует, чтобы клиент предоставил FunctionDescriptor, описывающий типы параметров C и возвращаемый тип C целевой функции C. В FFM API типы C описываются объектами MemoryLayout, например ValueLayout для скалярных типов C и GroupLayout для типов структур C. Обычно у клиентов уже есть под рукой объекты MemoryLayout для разыменования данных во внешней памяти, и их можно повторно использовать, чтобы получить FunctionDescriptor.
FFM API также использует FunctionDescriptor, чтобы вывести тип method handle нисходящего вызова. Каждый method handle строго типизирован, то есть строго ограничивает количество и типы аргументов, которые можно передать его методу invokeExact во время выполнения. Например, method handle, созданный для приёма одного аргумента MemoryAddress, нельзя вызвать через invokeExact(<MemoryAddress>, <MemoryAddress>), хотя invokeExact — метод с переменным числом аргументов. Тип method handle нисходящего вызова описывает сигнатуру Java, которую клиенты должны использовать при вызове этого method handle. По сути, это представление функции C со стороны Java.
Например, предположим, что method handle нисходящего вызова должен предоставлять функцию C, которая принимает C-тип int и возвращает C-тип long. На Linux/x64 и macOS/x64 типам C long и int соответствуют предопределённые раскладки JAVA_LONG и JAVA_INT соответственно, поэтому нужный FunctionDescriptor можно получить с помощью FunctionDescriptor.of(JAVA_LONG, JAVA_INT). Затем CLinker сделает так, что типом method handle нисходящего вызова будет сигнатура Java int в long.
Клиенты должны учитывать текущую платформу, если они вызывают функции C, использующие скалярные типы, такие как long, int и size_t. Причина в том, что соответствие скалярных типов C константам раскладок различается на разных платформах. На Windows/x64 типу C long соответствует раскладка JAVA_INT, поэтому нужным FunctionDescriptor был бы FunctionDescriptor.of(JAVA_INT, JAVA_INT), а типом method handle нисходящего вызова была бы сигнатура Java int в int.
Другой пример: предположим, что method handle нисходящего вызова должен предоставлять функцию C типа void, которая принимает указатель. На всех платформах типу указателя C соответствует предопределённая раскладка ADDRESS, поэтому нужный FunctionDescriptor можно получить с помощью FunctionDescriptor.ofVoid(ADDRESS). Затем CLinker сделает так, что типом method handle нисходящего вызова будет сигнатура Java Addressable в void. Addressable — общий супертип сущностей FFM API, которые можно передавать по ссылке, таких как MemorySegment, MemoryAddress и NativeSymbol.
Клиенты могут использовать указатели C, не учитывая текущую платформу. Клиентам не нужно знать размер указателей на текущей платформе, поскольку размер раскладки ADDRESS выводится из текущей платформы, и не нужно различать типы указателей C, такие как int* и char**.
Наконец, в отличие от JNI, CLinker поддерживает передачу структурированных данных во внешние функции. Предположим, что method handle нисходящего вызова должен предоставлять функцию 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). CLinker сделает так, что типом method handle нисходящего вызова будет сигнатура Java MemorySegment в void.
Раскладка памяти, соответствующая типу структуры C, должна быть составной раскладкой, которая определяет вложенные раскладки для всех полей структуры C, включая любое платформенно-зависимое выравнивающее заполнение, которое может вставить нативный компилятор.
Если функция C возвращает структуру по значению (здесь не показано), то новый сегмент памяти должен быть выделен вне кучи и возвращён клиенту Java. Для этого method handle, возвращаемый downcallHandle, требует дополнительного аргумента SegmentAllocator, который FFM API использует для выделения сегмента памяти, где будет храниться структура, возвращённая функцией C.
Как упоминалось ранее, CLinker нацелен на обеспечение взаимодействия между Java и библиотеками C, но при этом не зависит от языка: у него нет специальных сведений о том, как определяются типы C, поэтому клиенты сами отвечают за получение подходящих определений раскладок для типов C. Этот выбор сделан намеренно, поскольку определения раскладок для типов C — будь то простые скаляры или сложные структуры — в конечном счёте зависят от платформы, а значит, могут механически генерироваться инструментом, который досконально понимает данную целевую платформу.
Упаковка аргументов Java для функций C
Соглашение о вызовах (calling convention) обеспечивает взаимодействие между разными языками, определяя, как код на одном языке вызывает функцию на другом языке, передаёт аргументы и получает результаты. API CLinker нейтрален по отношению к соглашениям о вызовах, но реализация CLinker сразу поддерживает несколько соглашений о вызовах: Linux/x64, Linux/AArch64, macOS/x64 и Windows/x64. Поскольку она написана на Java, её гораздо проще сопровождать и расширять, чем JNI, соглашения о вызовах которого жёстко зашиты в код HotSpot на C++.
Рассмотрим FunctionDescriptor, полученный выше для структуры/раскладки SYSTEMTIME. С учётом соглашения о вызовах ОС и процессора, на которых работает JVM, CLinker использует FunctionDescriptor, чтобы вывести, как поля структуры должны передаваться в функцию C, когда method handle нисходящего вызова вызывается с аргументом MemorySegment. При одном соглашении о вызовах CLinker может разложить входящий сегмент памяти, передать первые четыре поля через регистры общего назначения процессора, а остальные поля — через стек C. При другом соглашении о вызовах CLinker может передать структуру косвенно: выделить область памяти, скопировать в неё целиком содержимое входящего сегмента памяти и передать в функцию C указатель на эту область памяти. Эта низкоуровневая упаковка аргументов происходит «за кулисами», без какого-либо контроля со стороны клиентского кода.
Восходящие вызовы
Иногда полезно передать код Java как указатель на функцию в какую-либо внешнюю функцию. Это можно сделать с помощью поддержки восходящих вызовов в CLinker. В этом разделе мы шаг за шагом построим более сложный пример, демонстрирующий все возможности CLinker, с полным двунаправленным взаимодействием кода и данных через границу между Java и нативным кодом.
Рассмотрим следующую функцию, определённую в стандартной библиотеке C:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
Чтобы вызвать qsort из Java, сначала нужно создать method handle нисходящего вызова:
CLinker linker = CLinker.systemCLinker();
MethodHandle qsort = linker.downcallHandle(
linker.lookup("qsort").get(),
FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);
Как и раньше, мы используем раскладку JAVA_LONG для отображения типа C size_t, а раскладку ADDRESS — как для первого параметра-указателя (указателя на массив), так и для последнего параметра (указателя на функцию).
qsort сортирует содержимое массива с помощью пользовательской функции сравнения compar, передаваемой как указатель на функцию. Поэтому, чтобы вызвать method handle нисходящего вызова, нам нужен указатель на функцию, который мы передадим последним параметром в метод invokeExact этого method handle. CLinker::upcallStub помогает создавать указатели на функции из существующих method handles следующим образом.
Сначала мы пишем на Java метод static, который сравнивает два значения long, косвенно представленные объектами MemoryAddress:
class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return addr1.get(JAVA_INT, 0) - addr2.get(JAVA_INT, 0);
}
}
Затем мы создаём method handle, указывающий на метод сравнения на Java:
MethodHandle comparHandle
= MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class,
MemoryAddress.class,
MemoryAddress.class));
В-третьих, теперь, когда у нас есть method handle для нашего метода сравнения на Java, мы можем создать указатель на функцию с помощью CLinker::upcallStub. Как и для нисходящих вызовов, сигнатуру указателя на функцию мы описываем с помощью FunctionDescriptor:
NativeSymbol comparFunc =
linker.upcallStub(comparHandle,
/* A Java description of a C function
implemented by a Java method! */
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS),
newImplicitScope());
);
Наконец у нас есть адрес памяти comparFunc, указывающий на заглушку, через которую можно вызвать нашу функцию сравнения на Java, и теперь у нас есть всё необходимое для вызова handle нисходящего вызова qsort:
MemorySegment array = implicitAllocator().allocateArray(
ValueLayout.JAVA_INT,
new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 });
qsort.invoke(array, 10L, 4L, comparFunc);
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
Этот код создаёт массив вне кучи, копирует в него содержимое массива Java, а затем передаёт массив в handle qsort вместе с функцией сравнения, полученной из CLinker. После вызова содержимое массива вне кучи будет отсортировано в соответствии с нашей функцией сравнения, написанной на Java. Затем мы извлекаем из сегмента новый массив Java, содержащий отсортированные элементы.
Безопасность
По сути, любое взаимодействие между кодом Java и нативным кодом может нарушить целостность платформы Java. Связывание с функцией C в предварительно скомпилированной библиотеке ненадёжно по своей природе, поскольку среда выполнения Java не может гарантировать, что сигнатура функции соответствует ожиданиям кода Java и даже что символ в библиотеке C действительно является функцией. Более того, даже если связана подходящая функция, сам её вызов может привести к низкоуровневым сбоям, например ошибкам сегментации, которые в итоге приводят к аварийному завершению VM. Такие сбои не может предотвратить среда выполнения Java и не может перехватить код Java.
Особенно опасен нативный код, использующий функции JNI. Такой код может обращаться к внутренним компонентам JDK без флагов командной строки (например, --add-opens) с помощью таких функций, как getStaticField и callVirtualMethod. Он также может изменять значения полей final спустя долгое время после их инициализации. Если позволить нативному коду обходить проверки, применяемые к коду Java, это подрывает все границы и допущения в JDK. Иными словами, JNI небезопасен по своей природе.
JNI нельзя отключить, поэтому невозможно гарантировать, что код Java не вызовет нативный код, использующий опасные функции JNI. Это риск для целостности платформы, почти незаметный для разработчиков приложений и конечных пользователей, потому что 99% использования этих функций обычно приходится на библиотеки третьих, четвёртых и пятых сторон, находящиеся между приложением и JDK.
Большая часть FFM API безопасна по своей конструкции. Многие сценарии, которые раньше требовали использования JNI и нативного кода, можно реализовать вызовом методов FFM API, которые не могут нарушить целостность платформы Java. Например, основной сценарий использования JNI — гибкое выделение памяти — поддерживается простым методом MemorySegment::allocateNative, который не задействует нативный код и всегда возвращает память, управляемую средой выполнения Java. В целом код Java, использующий FFM API, не может привести к аварийному завершению JVM.
Однако часть FFM API небезопасна по своей природе. При работе с CLinker код Java может запросить дескриптор метода для downcall-вызова, указав типы параметров, несовместимые с типами параметров базовой функции C. Вызов такого дескриптора метода в Java приведёт к тем же последствиям — аварийному завершению VM или неопределённому поведению, — что и вызов метода native в JNI. FFM API также может создавать небезопасные сегменты, то есть сегменты памяти, пространственные и временные границы которых задаёт пользователь и которые среда выполнения Java не может проверить (см. MemorySegment::ofAddressNative).
Небезопасные методы FFM API не несут тех же рисков, что функции JNI: например, они не могут изменять значения полей final в объектах Java. С другой стороны, небезопасные методы FFM API легко вызвать из кода Java. Поэтому использование небезопасных методов FFM API ограничено: по умолчанию доступ к ним отключён, и вызов таких методов выбрасывает IllegalAccessException. Чтобы разрешить доступ к небезопасным методам коду в некотором модуле M, укажите java --enable-native-access=M в командной строке. (Несколько модулей указываются списком через запятую; чтобы разрешить доступ всему коду в class path, укажите ALL-UNNAMED.) Большинство методов FFM API безопасны, и код Java может использовать их независимо от того, задан ли --enable-native-access.
Здесь мы не предлагаем ограничивать какие-либо аспекты JNI. По-прежнему можно будет вызывать методы native в Java, а нативный код по-прежнему сможет вызывать небезопасные функции JNI. Однако, вероятно, в одном из будущих выпусков мы так или иначе ограничим JNI. Например, небезопасные функции JNI, такие как newDirectByteBuffer, могут быть отключены по умолчанию, так же как небезопасные методы FFM API. В более широком смысле механизм JNI настолько непоправимо опасен, что мы надеемся, что библиотеки будут предпочитать FFM API, написанный целиком на Java, как для безопасных, так и для небезопасных операций, чтобы со временем мы смогли отключить весь JNI по умолчанию. Это согласуется с более общим планом развития Java, по которому платформа должна быть безопасной по умолчанию, а конечные пользователи должны явно разрешать небезопасные действия, такие как нарушение строгой инкапсуляции или компоновка с неизвестным кодом.
Здесь мы не предлагаем как-либо изменять sun.misc.Unsafe. Поддержка памяти вне кучи в FFM API — отличная альтернатива обёрткам вокруг malloc и free в sun.misc.Unsafe, а именно allocateMemory, setMemory, copyMemory и freeMemory. Мы надеемся, что библиотеки и приложения, которым нужно хранилище вне кучи, перейдут на FFM API, чтобы со временем мы смогли объявить эти методы sun.misc.Unsafe устаревшими, а затем и удалить их.
Альтернативы
Продолжать использовать java.nio.ByteBuffer, sun.misc.Unsafe, JNI и другие сторонние фреймворки.
Риски и допущения
Создать API для доступа к внешней памяти, который был бы одновременно безопасным и эффективным, — сложная задача. Поскольку пространственные и временные проверки, описанные в предыдущих разделах, должны выполняться при каждом обращении, крайне важно, чтобы JIT-компиляторы могли устранять эти проверки при оптимизации, например вынося их за пределы горячих циклов. Реализации JIT, скорее всего, потребуют доработки, чтобы использование этого API было столь же эффективным и поддающимся оптимизации, как использование существующих API, таких как ByteBuffer и Unsafe. Реализации JIT также потребуют доработки, чтобы использование нативных дескрипторов методов, полученных через этот API, было по крайней мере столь же эффективным и поддающимся оптимизации, как использование существующих нативных методов JNI.
Зависимости
-
Foreign Function & Memory API можно использовать для более универсального и эффективного доступа к энергонезависимой памяти, который уже возможен благодаря JEP 352 (Non-Volatile Mapped Byte Buffers).
-
Описанная здесь работа, вероятно, позволит в дальнейшем создать инструмент
jextract, который по заголовочным файлам заданной нативной библиотеки автоматически генерирует нативные дескрипторы методов, необходимые для взаимодействия с этой библиотекой. Это ещё больше снизит накладные расходы на использование нативных библиотек из Java.