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

JEP 412: Foreign Function & Memory API (Incubator)

Foreign Function & Memory API в статусе Incubator (инкубационный модуль)

ОтветственныйMaurizio Cimadamore
ТипFeature
ОбластьJDK
СтатусClosed / Delivered
Выпуск17
Компонентcore-libs
Обсуждениеpanama dash dev at openjdk dot java dot net
ТрудоёмкостьM
ДлительностьM
Связан сJEP 424: Foreign Function & Memory API (Preview)
JEP 389: Foreign Linker API (Incubator)
JEP 393: Foreign-Memory Access API (Third Incubator)
JEP 419: Foreign Function & Memory API (Second Incubator)
РецензентыAdam Pocock, Alex Buckley, Paul Sandoz
Создан2021/04/10 21:05
Обновлён2022/03/02 17:11
Задача8265033

Аннотация

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

История

API, предлагаемый в этом JEP, — развитие двух API в статусе Incubator: Foreign-Memory Access API и Foreign Linker API. Foreign-Memory Access API был впервые предложен в JEP 370 и включён в Java 14 в конце 2019 года как API в статусе Incubator; повторно он выходил в статусе Incubator по JEP 383 в Java 15 и по JEP 393 в Java 16. Foreign Linker API был впервые предложен в JEP 389 и включён в Java 16 в конце 2020 года, также как API в статусе Incubator.

Цели

  • Простота использования — заменить Java Native Interface (JNI) более совершенной моделью разработки на чистой Java.

  • Производительность — обеспечить производительность, сравнимую с существующими API, такими как JNI и sun.misc.Unsafe, а то и превосходящую их.

  • Универсальность — предоставить способы работы с разными видами внешней памяти (например, с нативной памятью, энергонезависимой памятью и управляемой памятью в куче) и со временем поддержать другие платформы (например, 32-битную x86) и внешние функции, написанные на языках, отличных от C (например, C++, Fortran).

  • Безопасность — по умолчанию отключить небезопасные операции и разрешать их только после явного согласия разработчиков приложений или конечных пользователей.

Что не является целью

Целью не является

  • переписать JNI поверх этого API или как-либо иначе изменить JNI;
  • переписать поверх этого API устаревшие Java API, такие как sun.misc.Unsafe;
  • предоставить инструменты, которые автоматически генерируют код на Java из заголовочных файлов нативного кода; или
  • изменить способ упаковки и развёртывания Java-приложений, взаимодействующих с нативными библиотеками (например, с помощью многоплатформенных JAR-файлов).

Мотивация

Платформа Java всегда давала богатую основу разработчикам библиотек и приложений, которым нужно выйти за пределы JVM и взаимодействовать с другими платформами. Java API удобно и надёжно открывают доступ к ресурсам вне Java, будь то обращение к удалённым данным (JDBC), вызов веб-сервисов (HTTP-клиент), обслуживание удалённых клиентов (каналы NIO) или взаимодействие с локальными процессами (сокеты домена Unix). К сожалению, Java-разработчики по-прежнему сталкиваются с серьёзными препятствиями при доступе к важному виду ресурсов вне Java: к коду и данным на той же машине, что и JVM, но за пределами среды выполнения Java.

Внешняя память

Данные, хранящиеся в памяти за пределами среды выполнения Java, называют данными вне кучи (off-heap). (Куча — место, где живут объекты Java, то есть данные в куче (on-heap), и где работают сборщики мусора.) Доступ к данным вне кучи критически важен для производительности популярных библиотек Java, таких как Tensorflow, Ignite, Lucene и Netty, прежде всего потому, что так они избегают затрат и непредсказуемости, связанных со сборкой мусора. Он также позволяет сериализовать и десериализовать структуры данных, отображая файлы в память, например, через mmap. Однако сегодня платформа Java не предоставляет удовлетворительного решения для доступа к данным вне кучи.

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

  • API sun.misc.Unsafe предоставляет операции доступа к памяти для данных в куче, которые работают и для данных вне кучи. Использовать Unsafe эффективно, поскольку его операции доступа к памяти определены как интринсики HotSpot JVM и оптимизируются JIT-компилятором. Однако использовать Unsafe опасно, потому что он позволяет обращаться к любому адресу памяти. Это значит, что программа на Java может обрушить JVM, обратившись к уже освобождённому адресу; по этой и другим причинам использование Unsafe всегда настоятельно не рекомендовалось.

  • Можно использовать JNI для вызова нативной библиотеки, которая затем обращается к данным вне кучи, но из-за накладных расходов это редко применимо: переход из Java в нативный код на несколько порядков медленнее доступа к памяти, потому что вызовы методов JNI не выигрывают от многих распространённых JIT-оптимизаций, таких как встраивание (inlining).

Итак, когда речь идёт о доступе к данным вне кучи, Java-разработчики стоят перед дилеммой: выбрать безопасный, но неэффективный путь (ByteBuffer) или отказаться от безопасности ради производительности (Unsafe)? Им нужен поддерживаемый API для доступа к данным вне кучи (т. е. к внешней памяти), с самого начала спроектированный безопасным и с учётом JIT-оптимизаций.

Внешние функции

JNI поддерживает вызов нативного кода (т. е. внешних функций) начиная с Java 1.1, но по многим причинам этого недостаточно.

  • JNI требует нескольких трудоёмких артефактов: Java API (методы native), заголовочного файла C, полученного из Java API, и реализации на C, которая вызывает нужную нативную библиотеку. Java-разработчикам приходится работать с несколькими наборами инструментов, чтобы поддерживать синхронность платформенно-зависимых артефактов, что особенно обременительно, когда нативная библиотека быстро развивается.

  • JNI может взаимодействовать только с библиотеками, написанными на языках (обычно C и C++), которые используют соглашение о вызовах той операционной системы и того процессора, для которых собрана JVM. Метод native нельзя использовать для вызова функции, написанной на языке с другим соглашением.

  • JNI не согласует систему типов Java с системой типов C. Составные данные в Java представлены объектами, а в C — структурами, поэтому любой объект Java, переданный в метод native, нативному коду приходится кропотливо распаковывать. Например, рассмотрим record-класс 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. Отличная абстракция, на которой можно строить такой API, — дескрипторы методов (method handles), появившиеся в Java 7 для поддержки быстрых динамических языков на JVM. Доступ к нативному коду через дескрипторы методов радикально упростил бы написание, сборку и распространение библиотек Java, зависящих от нативных библиотек. Кроме того, API, способный моделировать внешние функции (т. е. нативный код) и внешнюю память (т. е. данные вне кучи), стал бы надёжной основой для сторонних фреймворков взаимодействия с нативным кодом.

Описание

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

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

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

Пример

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

// 1. Find foreign function on the C library path
MethodHandle radixSort = CLinker.getInstance().downcallHandle(
                             CLinker.systemLookup().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,
                                                     CLinker.C_POINTER), ...);
// 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 = CLinker.toCString(javaStrings[i], newImplicitScope());
    MemoryAccess.setAddressAtIndex(offHeap, i, cString.address());
}
// 5. Sort the off-heap data by calling the foreign function
radixSort.invoke(offHeap.address(), 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 = MemoryAccess.getAddressAtIndex(offHeap, i);
    javaStrings[i] = CLinker.toJavaStringRestricted(cStringPtr);
}
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) в приведённом выше коде — новая неявная область, которая гарантирует, что память, связанная с этим сегментом, будет освобождена, когда сборщик мусора сочтёт объект MemorySegment недостижимым. Неявная область также гарантирует, что сегмент памяти доступен из нескольких потоков.

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

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

Разыменование памяти, связанной с сегментом, выполняется с помощью дескриптора переменной (var handle) — абстракции для доступа к данным, появившейся в Java 9. В частности, сегмент разыменовывается с помощью дескриптора переменной для доступа к памяти (memory-access var handle). Такой дескриптор переменной использует пару координат доступа:

  • координату типа MemorySegment — сегмент, память которого нужно разыменовать, и
  • координату типа long — смещение от базового адреса сегмента, по которому происходит разыменование.

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

MemorySegment segment = MemorySegment.allocateNative(100, newImplicitScope());
VarHandle intHandle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
for (int i = 0; i < 25; i++) {
    intHandle.set(segment, /* offset */ i * 4, /* value to write */ i);
}

Более сложные идиомы доступа можно выразить, комбинируя дескрипторы переменных для доступа к памяти с помощью одного или нескольких методов-комбинаторов класса MemoryHandles. С их помощью клиент может, например, переупорядочить координаты данного дескриптора переменной для доступа к памяти, отбросить одну или несколько координат и вставить новые координаты. Так можно создавать дескрипторы переменных для доступа к памяти, которые принимают один или несколько логических индексов в многомерном массиве, размещённом в плоской области памяти вне кучи.

Чтобы с FFM API было проще начать работать, класс MemoryAccess предоставляет статические методы доступа для разыменования сегментов памяти без необходимости создавать дескрипторы переменных для доступа к памяти. Например, есть метод доступа, который записывает значение int в сегмент по заданному смещению, и с ним приведённый выше код можно упростить до:

MemorySegment segment = MemorySegment.allocateNative(100, newImplicitScope());
for (int i = 0; i < 25; i++) {
    MemoryAccess.setIntAtOffset(segment, i * 4, i);
}

Схемы размещения в памяти

Чтобы реже приходилось выполнять утомительные вычисления, связанные с размещением в памяти (например, i * 4 в примере выше), можно использовать MemoryLayout для более декларативного описания содержимого сегмента памяти. Например, нужную схему размещения нативного сегмента памяти из примеров выше можно описать так:

SequenceLayout intArrayLayout
    = MemoryLayout.sequenceLayout(25,
        MemoryLayout.valueLayout(32, ByteOrder.nativeOrder()));

Так создаётся схема размещения последовательности (sequence memory layout), в которой 32-битная схема размещения значения (value layout — схема, описывающая одно 32-битное значение) повторяется 25 раз. Имея схему размещения, мы можем не вычислять смещения в коде и упростить как выделение памяти, так и создание дескрипторов переменных для доступа к памяти:

MemorySegment segment = MemorySegment.allocateNative(intArrayLayout, newImplicitScope());
VarHandle indexedElementHandle =
    intArrayLayout.varHandle(int.class, PathElement.sequenceElement());
for (int i = 0; i < intArrayLayout.elementCount().getAsLong(); i++) {
    indexedElementHandle.set(segment, (long) i, i);
}

Объект intArrayLayout управляет созданием дескриптора переменной для доступа к памяти через создание пути в схеме размещения (layout path), который служит для выбора вложенной схемы из сложного выражения схемы размещения. Объект intArrayLayout также управляет выделением нативного сегмента памяти, которое основано на информации о размере и выравнивании, полученной из схемы размещения. Константа цикла из предыдущих примеров, 25, заменена количеством элементов схемы размещения последовательности.

Области ресурсов

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

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

Сегменты памяти поддерживают детерминированное освобождение с помощью областей ресурсов (resource scopes). Область ресурсов моделирует жизненный цикл, связанный с одним или несколькими ресурсами, например сегментами памяти. Только что созданная область ресурсов находится в активном состоянии, то есть ко всем ресурсам, которыми она управляет, можно безопасно обращаться. По запросу клиента область ресурсов можно закрыть, после чего обращаться к ресурсам, которыми управляет эта область, больше нельзя. Класс 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

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

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

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

Некоторые области ресурсов, называемые неявными областями ресурсов, не поддерживают явное освобождение — вызов close завершится ошибкой. Неявные области ресурсов всегда управляют своими ресурсами с помощью Cleaner. Неявные области можно создавать фабричным методом ResourceScope::newImplicitScope, как показано в предыдущих примерах.

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

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

try (ResourceScope scope = ResourceScope.newConfinedScope()) {
    SegmentAllocator allocator = SegmentAllocator.arenaAllocator(scope);
    for (int i = 0 ; i < 100 ; i++) {
        MemorySegment s = allocator.allocateArray(C_INT, new int[] { 1, 2, 3, 4, 5 });
        ...
    }
    ...
} // all memory allocated is released here

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

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

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

Для разыменования адреса памяти у клиента есть два варианта.

  • Если известно, что адрес попадает в сегмент памяти, клиент может выполнить операцию перебазирования (rebase) с помощью MemoryAddress::segmentOffset. Операция перебазирования заново интерпретирует смещение адреса относительно базового адреса сегмента и получает новое смещение, которое можно применить к существующему сегменту, — после чего этот сегмент можно безопасно разыменовать.

  • Если же такого сегмента нет, клиент может создать его небезопасно с помощью фабричного метода MemoryAddress::asSegment. Фактически этот метод присоединяет новые пространственные и временные границы к «сырому» адресу памяти, чтобы сделать возможными операции разыменования. Сегмент памяти, который возвращает этот метод, небезопасен: «сырой» адрес памяти может быть связан с областью памяти длиной 10 байт, но клиент может случайно переоценить размер области и создать небезопасный сегмент памяти длиной 100 байт. Позже это может привести к попыткам разыменовать память за пределами области памяти, связанной с небезопасным сегментом, что может вызвать аварийное завершение JVM или, хуже того, незаметное повреждение памяти. Поэтому создание небезопасных сегментов считается ограниченной операцией (restricted operation) и по умолчанию отключено (подробнее см. ниже).

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

Первая составляющая любой поддержки внешних функций — механизм загрузки нативных библиотек. В JNI для этого служат методы System::loadLibrary и System::load, которые внутри сводятся к вызовам dlopen или его аналога. Библиотеки, загруженные этими методами, всегда связаны с загрузчиком классов (а именно с загрузчиком класса, который вызвал метод System). Связь между библиотеками и загрузчиками классов имеет ключевое значение, поскольку она определяет жизненный цикл загруженных библиотек: только когда загрузчик классов становится недостижимым, все его библиотеки можно безопасно выгрузить.

FFM API не предоставляет новых методов для загрузки нативных библиотек. Разработчики используют методы System::loadLibrary и System::load для загрузки нативных библиотек, которые будут вызываться через FFM API. Связь между библиотеками и загрузчиками классов сохраняется, поэтому библиотеки будут выгружаться так же предсказуемо, как и в JNI.

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

  • SymbolLookup::loaderLookup возвращает объект поиска символов, который видит все символы во всех библиотеках, загруженных текущим загрузчиком классов.
  • CLinker::systemLookup возвращает зависящий от платформы объект поиска символов, который видит символы стандартной библиотеки C.

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

System.loadLibrary("GL");
SymbolLookup loaderLookup  = SymbolLookup.loaderLookup();
MemoryAddress clangVersion = loaderLookup.lookup("glGetString").get();

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

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

interface CLinker {
    MethodHandle downcallHandle(MemoryAddress func,
                                MethodType type,
                                FunctionDescriptor function);
    MemoryAddress upcallStub(MethodHandle target,
                             FunctionDescriptor function,
                             ResourceScope scope);
}

Для downcall-вызовов метод downcallHandle принимает адрес внешней функции — обычно MemoryAddress, полученный при поиске в библиотеке, — и предоставляет внешнюю функцию в виде дескриптора метода для downcall-вызова (downcall method handle). Затем Java-код вызывает этот дескриптор метода через его метод invokeExact, и выполняется внешняя функция. Все аргументы, переданные методу invokeExact дескриптора метода, передаются внешней функции.

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

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

size_t strlen(const char *s);

Дескриптор метода для downcall-вызова, предоставляющий strlen, можно получить так (подробности о MethodType и FunctionDescriptor будут описаны чуть ниже):

MethodHandle strlen = CLinker.getInstance().downcallHandle(
    CLinker.systemLookup().lookup("strlen").get(),
    MethodType.methodType(long.class, MemoryAddress.class),
    FunctionDescriptor.of(C_LONG, C_POINTER)
);

Вызов дескриптора метода для downcall-вызова выполнит strlen и сделает её результат доступным в Java. В качестве аргумента strlen мы используем вспомогательный метод, который преобразует Java-строку в сегмент памяти вне кучи, и передаём адрес этого сегмента:

MemorySegment str = CLinker.toCString("Hello", newImplicitScope());
long len          = strlen.invokeExact(str.address());  // 5

Дескрипторы методов хорошо подходят для предоставления доступа к внешним функциям, потому что JVM уже оптимизирует вызов дескрипторов методов вплоть до нативного кода. Когда дескриптор метода ссылается на метод в файле class, вызов дескриптора метода обычно приводит к JIT-компиляции целевого метода; после этого JVM интерпретирует байт-код Java, вызывающий MethodHandle::invokeExact, передавая управление ассемблерному коду, сгенерированному для целевого метода. Таким образом, вызов обычного дескриптора метода уже является квазивнешним вызовом; дескриптор метода для downcall-вызова, нацеленный на функцию в библиотеке C, — это просто более „внешняя“ форма дескриптора метода. Дескрипторы методов также обладают свойством, которое называется сигнатурный полиморфизм и позволяет выполнять вызовы с примитивными аргументами без упаковки. В итоге дескрипторы методов позволяют CLinker предоставлять доступ к внешним функциям естественным, эффективным и расширяемым способом.

Описание типов C в Java

Чтобы создать дескриптор метода для downcall-вызова, FFM API требует от клиента предоставить двустороннее представление целевой функции C: высокоуровневую сигнатуру на основе непрозрачных Java-объектов (MemoryAddress, MemorySegment) и низкоуровневую сигнатуру на основе прозрачных Java-объектов (MemoryLayout). Рассмотрим каждую сигнатуру по очереди:

  • Высокоуровневая сигнатура, MethodType, служит типом дескриптора метода для downcall-вызова. Каждый дескриптор метода строго типизирован, то есть строго ограничивает количество и типы аргументов, которые можно передать его методу invokeExact. Например, дескриптор метода, созданный для приёма одного аргумента MemoryAddress, нельзя вызвать через invokeExact(<MemoryAddress>, <MemoryAddress>) или через invokeExact("Hello"). Таким образом, MethodType описывает Java-сигнатуру, которую клиенты должны использовать при вызове дескриптора метода для downcall-вызова. По сути, это представление функции C со стороны Java.

  • Низкоуровневая сигнатура, FunctionDescriptor, состоит из объектов MemoryLayout. Благодаря этому CLinker точно знает аргументы функции C и может правильно их разместить, как описано ниже. У клиентов обычно уже есть под рукой объекты MemoryLayout для разыменования данных во внешней памяти, и такие объекты можно повторно использовать здесь как сигнатуры внешних функций.

Например, чтобы получить дескриптор метода для downcall-вызова функции C, которая принимает int и возвращает long, потребуются следующие аргументы MethodType и FunctionDescriptor для downcallHandle:

MethodType mtype         = MethodType.methodType(long.class, int.class);
FunctionDescriptor fdesc = FunctionDescriptor.of(C_LONG, C_INT);

(Этот пример рассчитан на Linux/x64 и macOS/x64, где Java-типам long и int соответствуют предопределённые раскладки CLinkerC_LONG и C_INT соответственно. Соответствие Java-типов раскладкам памяти зависит от платформы; например, на Windows/x64 Java-тип long соответствует раскладке C_LONG_LONG.)

Другой пример: чтобы получить дескриптор метода для downcall-вызова функции C void, которая принимает указатель, потребуются следующие MethodType и FunctionDescriptor:

MethodType mtype         = MethodType.methodType(void.class, MemoryAddress.class);
FunctionDescriptor fdesc = FunctionDescriptor.ofVoid(C_POINTER);

(Все типы указателей C выражаются в Java как объекты MemoryAddress; соответствующая раскладка, размер которой зависит от текущей платформы, — C_POINTER. Клиенты не различают, например, int* и char**, потому что Java-типы и раскладки памяти, передаваемые в CLinker, вместе содержат достаточно информации, чтобы правильно передать Java-аргументы функции C.)

Наконец, в отличие от JNI, CLinker поддерживает передачу структурированных данных внешним функциям. Чтобы получить дескриптор метода для downcall-вызова функции C void, которая принимает структуру, потребуются следующие MethodType и FunctionDescriptor:

MethodType mtype         = MethodType.methodType(void.class, MemorySegment.class);
MemoryLayout SYSTEMTIME  = MemoryLayout.ofStruct(
  C_SHORT.withName("wYear"),      C_SHORT.withName("wMonth"),
  C_SHORT.withName("wDayOfWeek"), C_SHORT.withName("wDay"),
  C_SHORT.withName("wHour"),      C_SHORT.withName("wMinute"),
  C_SHORT.withName("wSecond"),    C_SHORT.withName("wMilliseconds")
);
FunctionDescriptor fdesc = FunctionDescriptor.ofVoid(SYSTEMTIME);

(В высокоуровневой сигнатуре MethodType Java-клиент всегда использует непрозрачный тип MemorySegment там, где функция C ожидает структуру, переданную по значению. В низкоуровневой сигнатуре FunctionDescriptor раскладка памяти, связанная с типом структуры C, должна быть составной раскладкой, которая определяет вложенные раскладки для всех полей структуры C, включая выравнивающие байты (padding), которые может вставить нативный компилятор.)

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

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

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

Рассмотрим показанный выше дескриптор функции для структуры и раскладки SYSTEMTIME. С учётом соглашения о вызовах ОС и процессора, на которых работает JVM, CLinker использует дескриптор функции, чтобы определить, как поля структуры должны передаваться функции C, когда дескриптор метода для downcall-вызова вызывается с аргументом MemorySegment. При одном соглашении о вызовах CLinker может разложить входящий сегмент памяти, передать первые четыре поля через регистры общего назначения процессора, а остальные поля — через стек C. При другом соглашении о вызовах CLinker может сделать так, чтобы FFM API передал структуру косвенно: выделил область памяти, целиком скопировал в неё содержимое входящего сегмента памяти и передал функции C указатель на эту область памяти. Такая упаковка аргументов на самом низком уровне происходит незаметно, без какого-либо контроля со стороны клиентского кода.

Upcall-вызовы

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

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

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

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

MethodHandle qsort = CLinker.getInstance().downcallHandle(
    CLinker.systemLookup().lookup("qsort").get(),
    MethodType.methodType(void.class, MemoryAddress.class, long.class,
                          long.class, MemoryAddress.class),
    FunctionDescriptor.ofVoid(C_POINTER, C_LONG, C_LONG, C_POINTER)
);

Как и раньше, мы используем C_LONG и long.class, чтобы отобразить тип C size_t, и используем MemoryAddess.class как для первого параметра-указателя (указатель на массив), так и для последнего параметра (указатель на функцию).

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

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

class Qsort {
    static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
        return MemoryAccess.getIntAtOffset(MemorySegment.globalNativeSegment(),
                                           addr1.toRawLongValue()) -
               MemoryAccess.getIntAtOffset(MemorySegment.globalNativeSegment(),
                                           addr2.toRawLongValue());
    }
}

Во-вторых, мы создаём дескриптор метода, указывающий на Java-метод компаратора:

MethodHandle comparHandle
    = MethodHandles.lookup()
                   .findStatic(Qsort.class, "qsortCompare",
                               MethodType.methodType(int.class,
                                                     MemoryAddress.class,
                                                     MemoryAddress.class));

В-третьих, теперь, когда у нас есть дескриптор метода для нашего Java-компаратора, можно создать указатель на функцию с помощью CLinker::upcallStub. Как и для downcall-вызовов, мы описываем сигнатуру указателя на функцию с помощью раскладок из класса CLinker:

MemoryAddress comparFunc =
  CLinker.getInstance().upcallStub(comparHandle,
                                   FunctionDescriptor.of(C_INT,
                                                         C_POINTER,
                                                         C_POINTER),
                                   newImplicitScope());
);

Наконец у нас есть адрес памяти comparFunc, который указывает на заглушку, позволяющую вызвать нашу функцию-компаратор на Java, и теперь у нас есть всё необходимое, чтобы вызвать дескриптор downcall-вызова qsort:

MemorySegment array = MemorySegment.allocateNative(4 * 10, newImplicitScope());
array.copyFrom(MemorySegment.ofArray(new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 }));
qsort.invokeExact(array.address(), 10L, 4L, comparFunc);
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]

Этот код создаёт массив вне кучи, копирует в него содержимое Java-массива, а затем передаёт массив дескриптору 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 не может проверить (см. MemoryAddress::asSegment).

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

Здесь мы не предлагаем ограничивать какой-либо аспект JNI. По-прежнему можно будет вызывать методы native в Java, а нативный код по-прежнему сможет вызывать небезопасные функции JNI. Однако, вероятно, в одном из будущих выпусков мы так или иначе ограничим JNI. Например, небезопасные функции JNI, такие как newDirectByteBuffer, могут быть по умолчанию отключены, так же как небезопасные методы FFM API. В более широком смысле механизм JNI настолько неисправимо опасен, что мы надеемся, что библиотеки будут предпочитать написанный целиком на Java FFM API как для безопасных, так и для небезопасных операций, чтобы со временем мы могли по умолчанию отключить 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.