openjdk.ruOpenJDK на русском

JEP 424: Foreign Function & Memory API (Preview)

Foreign Function & Memory API в статусе Preview (предварительная версия)

ОтветственныйMaurizio Cimadamore
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск19
Компонентcore-libs
Обсуждениеpanama dash dev at openjdk dot java dot net
Связан сJEP 419: Foreign Function & Memory API (Second Incubator)
JEP 412: Foreign Function & Memory API (Incubator)
JEP 434: Foreign Function & Memory API (Second Preview)
РецензентыAlex Buckley, John Rose, Paul Sandoz
ОдобренBrian Goetz, Paul Sandoz
Создан2022/02/17 10:19
Обновлён2025/09/04 12:57
Задача8282048

Аннотация

Ввести API, с помощью которого программы на Java могут взаимодействовать с кодом и данными вне среды выполнения Java. API эффективно вызывает внешние функции (то есть код вне JVM) и безопасно обращается к внешней памяти (то есть памяти, которой не управляет JVM). Так программы на Java могут вызывать нативные библиотеки и обрабатывать нативные данные без хрупкости и опасностей JNI. Это Preview-API.

История

Foreign Function & Memory (FFM) API объединяет два более ранних API в статусе Incubator (инкубационный модуль): Foreign-Memory Access API (JEP 370, 383 и 393) и Foreign Linker API (JEP 389). FFM API был в статусе Incubator в JDK 17 (JEP 412) и повторно в статусе Incubator в JDK 18 (JEP 419). Этот JEP включает доработки FFM API, сделанные по отзывам за то время, пока он был в статусе Incubator. В JDK 19 Foreign Function & Memory API больше не находится в статусе Incubator, теперь это Preview-API.

Цели

  • Простота использования — заменить 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 не предоставляет удовлетворительного решения для доступа к данным вне кучи.

  • API ByteBuffer позволяет создавать прямые байтовые буферы, размещаемые вне кучи, но их максимальный размер — два гигабайта, и память они освобождают не сразу. Эти и другие ограничения связаны с тем, что API ByteBuffer проектировался не только для доступа к памяти вне кучи, но и для обмена большими объёмами данных по схеме «производитель/потребитель» в таких областях, как кодирование/декодирование кодировок и частичные операции ввода-вывода. В этих условиях не удалось удовлетворить многочисленные запросы на улучшения работы вне кучи, поданные за эти годы (например, 4496703, 6558368, 4837564 и 5029431).

  • API sun.misc.Unsafe предоставляет операции доступа к памяти для данных в куче, которые работают и для данных вне кучи. Использовать Unsafe эффективно, потому что его операции доступа к памяти определены как встроенные функции (intrinsics) 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 медленная, они используют API Unsafe, чтобы выделить память вне кучи и передать её адрес в метод native как long, — и это делает код на Java катастрофически небезопасным!

За эти годы появилось множество фреймворков, заполняющих пробелы JNI, в том числе JNA, JNR и JavaCPP. Хотя эти фреймворки часто заметно лучше JNI, ситуация всё ещё далека от идеала, особенно по сравнению с языками, которые предлагают полноценное взаимодействие с нативным кодом. Например, пакет ctypes в Python может динамически оборачивать функции нативных библиотек без всякого связующего кода. Другие языки, например Rust, предоставляют инструменты, которые автоматически получают нативные обёртки из заголовочных файлов C/C++.

В конечном счёте у Java-разработчиков должен быть поддерживаемый API, который позволяет им напрямую использовать любую нативную библиотеку, полезную для конкретной задачи, без утомительного связующего кода и неуклюжести JNI. Отличная абстракция, на которой можно строить, — это method handles, появившиеся в Java 7 для поддержки быстрых динамических языков на JVM. Если открыть доступ к нативному коду через method handles, это радикально упростит написание, сборку и распространение библиотек Java, зависящих от нативных библиотек. Кроме того, API, способный моделировать внешние функции (то есть нативный код) и внешнюю память (то есть данные вне кучи), станет прочной основой для сторонних фреймворков взаимодействия с нативным кодом.

Описание

Foreign Function & Memory API (FFM API) определяет классы и интерфейсы, с помощью которых клиентский код в библиотеках и приложениях может

  • выделять внешнюю память
    (MemorySegment, MemoryAddress и SegmentAllocator),
  • работать со структурированной внешней памятью и обращаться к ней
    (MemoryLayout, VarHandle),
  • управлять выделением и освобождением внешней памяти
    (MemorySession) и
  • вызывать внешние функции (Linker, FunctionDescriptor и SymbolLookup).

FFM API находится в пакете java.lang.foreign модуля java.base.

Пример

Короткий пример использования FFM API: ниже приведён код на Java, который получает method handle для функции библиотеки C radixsort, а затем с его помощью сортирует четыре строки, изначально находящиеся в массиве Java (некоторые детали опущены).

Поскольку FFM API — это Preview-API, код нужно компилировать и запускать с включёнными Preview-возможностями, то есть с javac --release 19 --enable-preview ... и java --enable-preview ....

// 1. Find foreign function on the C library path
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle radixSort = linker.downcallHandle(
                             stdlib.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
SegmentAllocator allocator = SegmentAllocator.implicitAllocator();
MemorySegment offHeap  = allocator.allocateArray(ValueLayout.ADDRESS, javaStrings.length);
// 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 = allocator.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,
                                                     MemorySession.openImplicit());

Пространственные границы сегмента определяют диапазон адресов памяти, связанных с сегментом. Границы сегмента в коде выше задаются базовым адресом b, представленным экземпляром MemoryAddress, и размером в байтах (100), что даёт диапазон адресов от b до b + 99 включительно.

Временные границы сегмента определяют время жизни сегмента, то есть момент, когда сегмент будет освобождён. Время жизни сегмента и состояние его привязки к потоку моделируются абстракцией MemorySession, описанной ниже. Сессия памяти в коде выше — это новая неявная сессия, которая гарантирует, что память, связанная с этим сегментом, освобождается, когда сборщик мусора сочтёт объект MemorySegment недостижимым. Неявная сессия также гарантирует, что сегмент памяти доступен из нескольких потоков.

Иными словами, код выше создаёт сегмент, поведение которого близко к поведению ByteBuffer, выделенного фабричным методом allocateDirect. FFM API также поддерживает детерминированное освобождение памяти и другие варианты привязки к потоку, описанные ниже.

Разыменование сегментов

Чтобы разыменовать данные в сегменте памяти, нужно учесть несколько факторов:

  • количество разыменовываемых байтов,
  • ограничения выравнивания для адреса, по которому происходит разыменование,
  • порядок байтов, в котором байты хранятся в этой области памяти, и
  • тип Java, используемый в операции разыменования (например, int или float).

Все эти характеристики описывает абстракция ValueLayout. Например, предопределённая раскладка значения JAVA_INT имеет ширину четыре байта, выровнена по границе четырёх байтов, использует порядок байтов нативной платформы (например, little-endian в Linux/x64) и связана с типом Java int.

У сегментов памяти есть простые методы разыменования для чтения значений из сегментов памяти и записи значений в них. Эти методы принимают раскладку значения, которая однозначно задаёт свойства операции разыменования. Например, записать 25 значений int по последовательным смещениям в сегменте памяти можно следующим кодом:

MemorySegment segment = MemorySegment.allocateNative(100,
                                                     MemorySession.openImplicit());
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,
                                                     MemorySession.openImplicit());
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")));

Этот код создаёт раскладку памяти в виде последовательности, содержащую десять повторений раскладки структуры, элементы которой — две раскладки 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,
                                                     MemorySession.openImplicit());
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 доступа к памяти через создание пути в раскладке, который служит для выбора вложенной раскладки из сложного выражения раскладки. Поскольку выбранная раскладка значения связана с типом Java int, тип полученных var handle xHandle и yHandle тоже будет int. Кроме того, поскольку выбранная раскладка значения определена внутри раскладки-последовательности, var handle получают дополнительную координату типа long, а именно индекс структуры Point, координату которой нужно прочитать или записать. Объект ptsLayout также управляет выделением нативного сегмента памяти, которое основано на сведениях о размере и выравнивании, полученных из раскладки. Вычислять смещения внутри цикла больше не нужно, поскольку для инициализации элементов Point.x и Point.y используются разные var handle.

Сеансы памяти

Во всех примерах выше используется недетерминированное освобождение: память, связанная с выделенными сегментами, освобождается сборщиком мусора после того, как экземпляр сегмента памяти становится недостижимым. Мы говорим, что такие сегменты освобождаются неявно.

Бывают случаи, когда клиент может захотеть управлять моментом освобождения памяти. Предположим, например, что большой сегмент памяти отображён из файла с помощью MemorySegment::map. Клиент может предпочесть освободить (то есть отменить отображение) память, связанную с сегментом, как только сегмент перестанет быть нужен, а не ждать, пока это сделает сборщик мусора, поскольку ожидание может отрицательно сказаться на производительности приложения.

Сегменты памяти поддерживают детерминированное освобождение с помощью сеансов памяти. Сеанс памяти моделирует жизненный цикл одного или нескольких сегментов памяти. Только что созданный сеанс памяти находится в состоянии активен, то есть ко всем сегментам, которыми он управляет, можно безопасно обращаться. По запросу клиента сеанс памяти можно закрыть, и тогда доступ к сегментам, которыми управляет сеанс, больше не разрешается. Класс MemorySession реализует интерфейс AutoCloseable, поэтому сеансы памяти работают с оператором try-with-resources:

try (MemorySession session = MemorySession.openConfined()) {
    MemorySegment s1 = MemorySegment.map(Path.of("someFile"),
                                         0, 100000,
                                         MapMode.READ_WRITE, session);
    MemorySegment s2 = MemorySegment.allocateNative(100, session);
    ...
} // both segments released here

Этот код создаёт сеанс памяти и с его помощью создаёт два сегмента: отображённый сегмент (s1) и нативный сегмент (s2). Жизненный цикл обоих сегментов привязан к времени жизни сеанса памяти, поэтому обращение к сегментам (например, их разыменование с помощью var handle доступа к памяти) за пределами оператора try-with-resources приведёт к выбросу исключения во время выполнения.

Помимо управления временем жизни сегмента памяти, сеанс памяти также определяет, какие потоки могут обращаться к сегменту. Ограниченный сеанс памяти разрешает доступ только потоку, который создал сеанс, а общий сеанс памяти разрешает доступ из любого потока.

Сеансы памяти, как ограниченные, так и общие, можно связать с объектом java.lang.ref.Cleaner, который выполняет неявное освобождение, если сеанс памяти становится недостижимым, пока он ещё активен, и тем самым предотвращает случайные утечки памяти.

Распределители сегментов

Когда клиенты используют память вне кучи, выделение памяти часто может становиться узким местом. Поэтому FFM API включает абстракцию SegmentAllocator, которая определяет полезные операции для выделения и инициализации сегментов памяти. Распределители сегментов получают через фабрики в интерфейсе SegmentAllocator. Одна из таких фабрик возвращает неявный распределитель, то есть распределитель, который выделяет нативные сегменты на основе нового неявного сеанса. Предусмотрены и другие, более оптимизированные распределители. Например, следующий код создаёт распределитель на основе арены и с его помощью выделяет сегмент, содержимое которого инициализируется из массива Java int:

try (MemorySession session = MemorySession.openConfined()) {
    SegmentAllocator allocator = SegmentAllocator.newNativeArena(session);
    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

Этот код создаёт ограниченный сеанс памяти, а затем создаёт связанный с этим сеансом неограниченный распределитель-арену. Этот распределитель выделяет сегмент памяти и отвечает на запросы выделения, возвращая фрагменты этого заранее выделенного сегмента. Если в текущем сегменте недостаточно места для запроса выделения, выделяется новый сегмент. Вся память, связанная с сегментами, созданными распределителем (то есть в теле цикла for), освобождается атомарно, когда закрывается сеанс памяти, связанный с распределителем-ареной. Этот приём сочетает преимущества детерминированного освобождения, которое обеспечивает абстракция MemorySession, с более гибкой и масштабируемой схемой выделения. Он может быть очень полезен при написании кода, который управляет большим числом сегментов вне кучи.

Небезопасные сегменты памяти

Итак, мы рассмотрели сегменты памяти, адреса памяти и раскладки памяти. Операции разыменования возможны только над сегментами памяти. Поскольку у сегмента памяти есть пространственные и временные границы, среда выполнения Java гарантирует, что память, связанная с данным сегментом, разыменовывается безопасно. Однако бывают ситуации, когда у клиентов есть только экземпляр MemoryAddress, как часто бывает при взаимодействии с нативным кодом. Чтобы разыменовать адрес памяти, у клиента есть два варианта:

  • Во-первых, клиент может использовать один из методов разыменования, определённых в классе MemoryAddress. Эти методы небезопасны, поскольку у адреса памяти нет пространственных или временных границ, и поэтому FFM API никак не может гарантировать, что разыменовываемая область памяти допустима.

  • Во-вторых, клиент может небезопасно превратить адрес в сегмент с помощью фабрики MemorySegment::ofAddress. Эта фабрика присоединяет новые пространственные и временные границы к «сырому» адресу памяти, чтобы сделать возможными операции разыменования. Сегмент памяти, возвращаемый этой фабрикой, небезопасен: «сырой» адрес памяти может быть связан с областью памяти длиной 10 байт, но клиент может случайно переоценить размер области и создать небезопасный сегмент памяти длиной 100 байт. Позже это может привести к попыткам разыменовать память за пределами области памяти, связанной с небезопасным сегментом, что может вызвать аварийное завершение JVM или, что хуже, незаметное повреждение памяти.

Оба варианта небезопасны и поэтому относятся к ограниченным, то есть их использование приводит к выводу предупреждений во время выполнения (подробнее см. ниже).

Поиск внешних функций

Первая составляющая любой поддержки внешних функций — механизм поиска адреса заданного символа в загруженной нативной библиотеке. Эта возможность, представленная объектом SymbolLookup, крайне важна для связывания кода Java с внешними функциями (см. ниже). FFM API поддерживает три вида объектов поиска символов:

  • SymbolLookup::libraryLookup(String, MemorySession) создаёт поиск в библиотеке, который находит все символы в указанной пользователем нативной библиотеке. При создании объекта поиска библиотека загружается (например, с помощью dlopen()) и связывается с объектом MemorySession. При закрытии этого сеанса библиотека выгружается (например, с помощью dlclose()).

  • SymbolLookup::loaderLookup() создаёт поиск через загрузчик, который находит все символы во всех нативных библиотеках, загруженных классами текущего загрузчика классов с помощью методов System::loadLibrary и System::load.

  • Linker::defaultLookup() создаёт поиск по умолчанию, который находит все символы в библиотеках, часто используемых в сочетании ОС и процессора, связанном с экземпляром Linker.

Имея объект поиска символов, клиент может найти внешнюю функцию с помощью метода SymbolLookup::lookup(String). Если функция с таким именем есть среди символов, видимых объекту поиска, метод возвращает сегмент памяти нулевой длины, базовый адрес которого указывает на точку входа функции. Например, следующий код с помощью поиска через загрузчик загружает библиотеку OpenGL и находит адрес её функции glGetString:

try (MemorySession session = MemorySession.openConfined()) {   
    SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", session);
    MemorySegment glVersion = opengl.lookup("glGetString").get();
    ...
} // libGL.so unloaded here

SymbolLookup::libraryLookup(String, MemorySession) существенно отличается от механизма загрузки библиотек в JNI, то есть от System::loadLibrary. Нативные библиотеки, рассчитанные на работу с JNI, могут с помощью функций JNI выполнять операции Java, например выделение объектов или обращение к методам, что может вызвать загрузку классов. Поэтому такие связанные с JNI библиотеки при загрузке в JVM должны быть связаны с загрузчиком классов. Затем, чтобы сохранить целостность загрузчиков классов, одну и ту же связанную с JNI библиотеку нельзя загрузить из классов, определённых в разных загрузчиках классов. FFM API, напротив, не предоставляет нативному коду функций для доступа к среде Java и не предполагает, что нативные библиотеки рассчитаны на работу с FFM API. Нативные библиотеки, загруженные через SymbolLookup::libraryLookup(String, MemorySession), не знают, что к ним обращается код, работающий в JVM, и не пытаются выполнять операции Java. Поэтому они не привязаны к конкретному загрузчику классов и могут (пере)загружаться столько раз, сколько нужно клиентам FFM API в разных загрузчиках.

Связывание кода Java с внешними функциями

Интерфейс Linker — основа взаимодействия кода Java с внешним кодом. Хотя в этом документе мы часто говорим о взаимодействии Java и библиотек C, понятия этого интерфейса достаточно общие, чтобы в будущем поддерживать и другие языки, кроме Java. Интерфейс Linker позволяет выполнять как нисходящие вызовы (вызовы из кода Java в нативный код), так и восходящие вызовы (вызовы из нативного кода обратно в код Java).

interface Linker {
    MethodHandle downcallHandle(Addressable func,
                                FunctionDescriptor function);
    MemorySegment upcallStub(MethodHandle target,
                          FunctionDescriptor function,
                          MemorySession session);
}

Для нисходящих вызовов метод downcallHandle принимает адрес внешней функции (обычно MemorySegment, полученный из поиска в библиотеке) и предоставляет внешнюю функцию как method handle нисходящего вызова. Позже код Java вызывает method handle нисходящего вызова, вызывая его метод invoke (или invokeExact), и внешняя функция выполняется. Все аргументы, переданные методу invoke этого method handle, передаются внешней функции.

Для восходящих вызовов метод upcallStub принимает method handle (обычно ссылающийся на метод Java, а не method handle нисходящего вызова) и преобразует его в экземпляр MemorySegment. Позже этот сегмент памяти передаётся как аргумент, когда код Java вызывает method handle нисходящего вызова. Фактически сегмент памяти служит указателем на функцию. (Подробнее о восходящих вызовах см. ниже.)

Предположим, что мы хотим выполнить нисходящий вызов из Java функции strlen, определённой в стандартной библиотеке C:

size_t strlen(const char *s);

Клиенты могут связывать функции C с помощью нативного компоновщика (см. Linker::nativeLinker). Это реализация Linker, соответствующая ABI, который определяется ОС и процессором, на которых работает JVM. Method handle нисходящего вызова, предоставляющий strlen, можно получить так (подробности о FunctionDescriptor описаны чуть ниже):

Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
    linker.defaultLookup().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 нисходящего вызова — естественное расширение, с которым разработчики могут вызывать такой код явно. Кроме того, у method handles есть свойство, которое называется сигнатурным полиморфизмом: оно позволяет вызывать их с примитивными аргументами без упаковки. В итоге method handles позволяют Linker предоставлять внешние функции естественным, эффективным и расширяемым образом.

Описание типов 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, которая принимает int языка C и возвращает long языка C. В Linux/x64 и macOS/x64 типы C long и int связаны с предопределёнными раскладками JAVA_LONG и JAVA_INT соответственно, поэтому необходимый FunctionDescriptor можно получить с помощью FunctionDescriptor.of(JAVA_LONG, JAVA_INT). Затем нативный компоновщик сделает так, что тип method handle нисходящего вызова будет сигнатурой Java из int в long.

Клиенты должны учитывать текущую платформу, если они вызывают функции C, использующие скалярные типы, такие как long, int и size_t. Причина в том, что соответствие скалярных типов C константам раскладок зависит от платформы. В Windows/x64 long языка C связан с раскладкой JAVA_INT, поэтому необходимым FunctionDescriptor будет FunctionDescriptor.of(JAVA_INT, JAVA_INT), а типом method handle нисходящего вызова будет сигнатура Java из int в int.

В качестве другого примера предположим, что method handle нисходящего вызова должен предоставлять функцию C типа void, которая принимает указатель. На всех платформах тип указателя C связан с предопределённой раскладкой ADDRESS, поэтому необходимый FunctionDescriptor можно получить с помощью FunctionDescriptor.ofVoid(ADDRESS). Затем нативный компоновщик сделает так, что тип method handle нисходящего вызова будет сигнатурой Java из Addressable в void. Addressable — общий супертип сущностей FFM API, которые можно передавать по ссылке, например MemorySegment и MemoryAddress.

Клиенты могут использовать указатели C, не учитывая текущую платформу. Клиентам не нужно знать размер указателей на текущей платформе, поскольку размер раскладки ADDRESS определяется исходя из текущей платформы. Не нужно им и различать типы указателей C, такие как int* и char**.

Наконец, в отличие от JNI, нативный компоновщик поддерживает передачу структурированных данных внешним функциям. Предположим, что 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). Linker сделает так, что тип method handle нисходящего вызова будет сигнатурой Java из MemorySegment в void.

Раскладка памяти, связанная с типом структуры C, должна быть составной. Такая раскладка определяет вложенные раскладки для всех полей структуры C, включая любое платформенно-зависимое выравнивающее заполнение, которое может вставить нативный компилятор.

Если функция C возвращает структуру по значению (здесь это не показано), то новый сегмент памяти необходимо выделить вне кучи и вернуть клиенту Java. Для этого method handle, возвращаемый downcallHandle, требует дополнительного аргумента SegmentAllocator. С его помощью FFM API выделяет сегмент памяти, в котором хранится структура, возвращаемая функцией C.

Как упоминалось ранее, реализация нативного компоновщика нацелена на взаимодействие между Java и библиотеками C, но сам интерфейс Linker не зависит от языка: у него нет никаких специальных сведений о том, как определяются типы C, поэтому клиенты сами отвечают за получение подходящих определений раскладок для типов C. Такое решение принято намеренно, поскольку определения раскладок для типов C (будь то простые скаляры или сложные структуры) в конечном счёте зависят от платформы. Поэтому их может механически генерировать инструмент, который досконально понимает данную целевую платформу.

Упаковка аргументов Java для функций C

Соглашение о вызовах обеспечивает взаимодействие между разными языками: оно определяет, как код на одном языке вызывает функцию на другом языке, передаёт аргументы и получает результаты. API Linker нейтрален в отношении соглашений о вызовах, но реализация нативного компоновщика сразу поддерживает несколько соглашений о вызовах: Linux/x64, Linux/AArch64, macOS/x64 и Windows/x64. Реализация написана на Java, поэтому её гораздо проще сопровождать и расширять, чем JNI, соглашения о вызовах которого жёстко зашиты в код HotSpot на C++.

Рассмотрим FunctionDescriptor, полученный выше для структуры/раскладки SYSTEMTIME. Исходя из соглашения о вызовах ОС и процессора, на которых работает JVM, нативный компоновщик использует FunctionDescriptor, чтобы определить, как поля структуры должны передаваться функции C, когда method handle нисходящего вызова вызывается с аргументом MemorySegment. Для одного соглашения о вызовах реализация нативного компоновщика может разложить входящий сегмент памяти, передать первые четыре поля через регистры процессора общего назначения, а остальные поля — через стек C. Для другого соглашения о вызовах реализация нативного компоновщика может передать структуру косвенно: выделить область памяти, скопировать в неё целиком содержимое входящего сегмента памяти и передать функции C указатель на эту область памяти. Такая самая низкоуровневая упаковка аргументов происходит неявно и не требует никакого контроля со стороны клиентского кода.

Восходящие вызовы

Иногда бывает полезно передать код Java какой-либо внешней функции в виде указателя на функцию. Это можно сделать с помощью поддержки восходящих вызовов в Linker. В этом разделе мы шаг за шагом построим более сложный пример, который демонстрирует все возможности Linker: полноценное двунаправленное взаимодействие как кода, так и данных через границу между Java и нативным кодом.

Рассмотрим следующую функцию, определённую в стандартной библиотеке C:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

Чтобы вызвать qsort из Java, сначала нужно создать method handle нисходящего вызова:

Linker linker = Linker.nativeLinker();
MethodHandle qsort = linker.downcallHandle(
    linker.defaultLookup().lookup("qsort").get(),
    FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);

Как и раньше, мы используем раскладку JAVA_LONG для отображения типа C size_t, а раскладку ADDRESS — как для первого параметра-указателя (указателя на массив), так и для последнего параметра (указателя на функцию).

qsort сортирует содержимое массива с помощью пользовательской функции сравнения compar, переданной как указатель на функцию. Поэтому, чтобы вызвать method handle нисходящего вызова, нам нужен указатель на функцию, который мы передадим последним параметром методу invokeExact этого method handle. Linker::upcallStub помогает создавать указатели на функции на основе существующих method handles следующим образом.

Во-первых, мы пишем на Java метод static, который сравнивает два значения int, представленные косвенно как объекты MemoryAddress:

class Qsort {
    static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
        return Integer.compare(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, мы можем создать указатель на функцию с помощью 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, ADDRESS),
                    MemorySession.openImplicit());
);

Наконец у нас есть адрес памяти 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, ValueLayout.JAVA_INT.byteSize(), comparFunc);
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]

Этот код создаёт массив вне кучи, копирует в него содержимое массива Java, а затем передаёт этот массив handle qsort вместе с функцией-компаратором, полученной от нативного компоновщика. После вызова содержимое массива вне кучи будет отсортировано в соответствии с нашей функцией-компаратором, написанной на 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 небезопасна по своей природе. При работе с Linker код Java может запросить downcall method handle, указав типы параметров, несовместимые с типами параметров вызываемой внешней функции. Вызов такого downcall method handle в Java приведёт к тому же результату, что и вызов метода native в JNI: к аварийному завершению VM или к неопределённому поведению. FFM API может также создавать небезопасные сегменты, то есть сегменты памяти, пространственные и временные границы которых задаёт пользователь и которые среда выполнения Java не может проверить (см. MemorySegment::ofAddress).

Небезопасные методы FFM API не несут тех же рисков, что функции JNI: например, они не могут изменять значения полей final в объектах Java. С другой стороны, небезопасные методы FFM API легко вызвать из кода Java. Поэтому использование небезопасных методов FFM API ограничено: оно разрешено, но по умолчанию при каждом таком использовании во время выполнения выдаётся предупреждение. Чтобы код в модуле M мог использовать небезопасные методы без предупреждений, укажите параметр --enable-native-access=M в командной строке java. (Несколько модулей указываются списком через запятую; укажите ALL-UNNAMED, чтобы разрешить использование без предупреждений для всего кода в class path.) Если этот параметр задан, любое использование небезопасных методов вне указанного списка модулей приведёт к выбросу IllegalCallerException, а не к выдаче предупреждения. Вероятно, в одном из будущих выпусков этот параметр станет обязательным для использования небезопасных методов.

Здесь мы не предлагаем ограничивать 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 также потребуют доработки, чтобы использование нативных method handle, полученных из этого API, было как минимум таким же эффективным и так же поддавалось оптимизации, как использование существующих нативных методов JNI.

Зависимости

  • Foreign Function & Memory API можно использовать для доступа к энергонезависимой памяти, что уже возможно благодаря JEP 352 (Non-Volatile Mapped Byte Buffers), более общим и эффективным способом.

  • Описанная здесь работа, вероятно, позволит в дальнейшем создать инструмент jextract, который на основе заголовочных файлов заданной нативной библиотеки автоматически генерирует нативные method handle, необходимые для взаимодействия с этой библиотекой. Это ещё больше снизит накладные расходы на использование нативных библиотек из Java.