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

JEP 434: Foreign Function & Memory API (Second Preview)

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

ОтветственныйMaurizio Cimadamore
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск20
Компонентcore-libs
Обсуждениеpanama dash dev at openjdk dot org
Связан сJEP 424: Foreign Function & Memory API (Preview)
JEP 442: Foreign Function & Memory API (Third Preview)
РецензентыAlex Buckley, Jorn Vernee
ОдобренBrian Goetz
Создан2022/09/12 13:35
Обновлён2023/05/12 15:34
Задача8293649

Аннотация

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

История

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) и впервые вышел в статусе Preview в JDK 19 (JEP 424). Этот JEP предлагает внести улучшения на основе отзывов и повторно выпустить API в статусе Preview в JDK 20. В этой версии:

  • Абстракции MemorySegment и MemoryAddress объединены (адреса памяти теперь моделируются сегментами памяти нулевой длины);
  • Sealed-иерархия MemoryLayout улучшена, чтобы её было удобнее использовать с Pattern Matching (сопоставление с образцом) в выражениях и операторах switch (JEP 433), и
  • MemorySession разделён на Arena и SegmentScope, чтобы упростить совместное использование сегментов через границы сопровождения.

Цели

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

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

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

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

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

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

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

Мотивация

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

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

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

Исторически платформа Java предоставляла два API для доступа к памяти вне кучи:

  • API ByteBuffer предоставляет прямые байтовые буферы — Java-объекты, за которыми стоят области памяти вне кучи фиксированного размера. Однако максимальный размер области ограничен двумя гигабайтами, а методы чтения и записи памяти примитивны и подвержены ошибкам: они дают лишь немногим больше, чем индексированный доступ к примитивным значениям. Серьёзнее то, что память, на которой основан прямой байтовый буфер, освобождается только тогда, когда объект буфера удаляется сборщиком мусора, а этим разработчик управлять не может. Из-за отсутствия поддержки своевременного освобождения API ByteBuffer плохо подходит для системного программирования на Java.

  • API sun.misc.Unsafe предоставляет низкоуровневый доступ к памяти в куче, который работает и для памяти вне кучи. Использовать Unsafe быстро (поскольку его операции доступа к памяти JVM превращает в интринсики), он позволяет работать с огромными областями вне кучи (теоретически до 16 эксабайт) и даёт тонкий контроль над освобождением (поскольку Unsafe::freeMemory можно вызвать в любой момент). Однако эта модель программирования слаба, потому что даёт разработчику слишком много контроля. Библиотека в долгоработающем серверном приложении со временем выделит несколько областей памяти вне кучи и будет с ними взаимодействовать; данные в одной области будут указывать на данные в другой, и области должны освобождаться в правильном порядке, иначе висячие указатели приведут к ошибкам use-after-free. Из-за отсутствия поддержки безопасного освобождения API Unsafe плохо подходит для системного программирования на Java.

    (Та же критика относится и к API за пределами JDK, которые предоставляют тонкий контроль над выделением и освобождением памяти, оборачивая нативный код, вызывающий malloc и free.)

Итак, продвинутые клиенты заслуживают API, который может выделять память вне кучи, работать с ней и делиться ею так же гибко и безопасно, как памятью в куче. Такой API должен находить баланс между потребностью в предсказуемом освобождении и необходимостью предотвращать несвоевременное освобождение, которое может приводить к сбоям JVM или, хуже того, к незаметному повреждению памяти.

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

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

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

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

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

Описание

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

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

Пример

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

Поскольку FFM API — API в статусе Preview, код нужно компилировать и запускать с включёнными Preview-возможностями, т. е. с javac --release 20 --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.find("radixsort"), ...);
// 2. Allocate on-heap memory to store four strings
String[] javaStrings = { "mouse", "cat", "dog", "car" };
// 3. Use try-with-resources to manage the lifetime of off-heap memory
try (Arena offHeap = Arena.openConfined()) {
    // 4. Allocate a region of off-heap memory to store four pointers
    MemorySegment pointers = offHeap.allocateArray(ValueLayout.ADDRESS, javaStrings.length);
    // 5. Copy the strings from on-heap to off-heap
    for (int i = 0; i < javaStrings.length; i++) {
        MemorySegment cString = offHeap.allocateUtf8String(javaStrings[i]);
        pointers.setAtIndex(ValueLayout.ADDRESS, i, cString);
    }
    // 6. Sort the off-heap data by calling the foreign function
    radixsort.invoke(pointers, javaStrings.length, MemorySegment.NULL, '\0');
    // 7. Copy the (reordered) strings from off-heap to on-heap
    for (int i = 0; i < javaStrings.length; i++) {
        MemorySegment cString = pointers.getAtIndex(ValueLayout.ADDRESS, i);
        javaStrings[i] = cString.getUtf8String(0);
    }
} // 8. All off-heap memory is deallocated here
assert Arrays.equals(javaStrings, new String[] {"car", "cat", "dog", "mouse"});  // true

Этот код гораздо понятнее любого решения на JNI, поскольку неявные преобразования и обращения к памяти, которые были бы скрыты за вызовами методов native, теперь выражены непосредственно на Java. Можно использовать и современные идиомы Java: например, с помощью потоков данных (streams) несколько потоков могут параллельно копировать данные между памятью в куче и вне кучи.

Сегменты памяти и области действия

Сегмент памяти — это абстракция, за которой стоит непрерывная область памяти, расположенная вне кучи или в куче. Сегмент памяти может быть

  • нативным сегментом, выделенным с нуля в памяти вне кучи (как бы через malloc),
  • отображённым сегментом, обёрнутым вокруг области отображённой памяти вне кучи (как бы через mmap), или
  • сегментом массива или буфера, обёрнутым вокруг области памяти в куче, связанной с существующим Java-массивом или байтовым буфером соответственно.

Все сегменты памяти дают пространственные и временные гарантии, а также гарантии ограничения потоком, которые делают операции доступа к памяти безопасными.

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

MemorySegment data = MemorySegment.allocateNative(100, SegmentScope.global());

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

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

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

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

void processData() {
    MemorySegment data = MemorySegment.allocateNative(100, SegmentScope.auto());
    ... use the 'data' variable ...
    ... use the 'data' variable some more ...
}  // The region of memory backing the 'data' segment will be deallocated here (or later)

Если переменная data не выходит за пределы метода, сегмент в итоге будет признан недостижимым, и область памяти, на которой он основан, будет освобождена.

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

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

MemorySegment input = null, output = null;
try (Arena processing = Arena.openConfined()) {
    input = MemorySegment.allocateNative(100, processing.scope());
    ... set up data in 'input' ...
    output = MemorySegment.allocateNative(100, processing.scope());
    ... process data from 'input' to 'output' ...
    ... calculate the ultimate result from 'output' and store it elsewhere ...
}  // the regions of memory backing the segments are deallocated here
...
input.get(ValueLayout.JAVA_BYTE, 0);  // throws IllegalStateException (also for 'output')

Когда арена закрывается с помощью конструкции try-with-resources, область действия арены перестаёт быть активной, сегменты становятся недействительными, а области памяти, на которых они основаны, освобождаются атомарно.

Арены дают строгую гарантию временной безопасности: к сегменту памяти, выделенному с областью действия арены, нельзя обратиться после закрытия арены, поскольку область действия арены больше не активна. Стоимость этой гарантии зависит от числа потоков, имеющих доступ к сегменту памяти. Если арена открывается и закрывается одним и тем же потоком и все сегменты памяти, выделенные с областью действия этой арены, используются только этим потоком, обеспечить корректность просто. Если же к сегментам памяти, выделенным с областью действия арены, обращаются несколько потоков, обеспечить корректность сложно: например, один поток может обращаться к сегменту, выделенному с областью действия арены, в то время как другой поток пытается закрыть арену. Чтобы гарантировать временную безопасность и не заставлять однопоточных клиентов платить лишнюю цену, существует два вида арен: изолированные и разделяемые.

  • Изолированная арена (Arena::openConfined) даёт строгие гарантии привязки к потоку. У изолированной арены есть поток-владелец, как правило, поток, который её открыл. К сегментам памяти, выделенным в изолированной арене (то есть с областью действия изолированной арены), может обращаться только поток-владелец. Любая попытка закрыть изолированную арену из потока, отличного от потока-владельца, завершится исключением.

  • У разделяемой арены (Arena::openShared) нет потока-владельца. К сегментам памяти, выделенным в разделяемой арене, могут обращаться несколько потоков. Кроме того, разделяемую арену может закрыть любой поток, и закрытие гарантированно безопасно и атомарно даже при гонках.

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

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

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

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

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

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

MemorySegment segment = MemorySegment.allocateNative(100, // size
                                                     ValueLayout.JAVA_INT.byteAlignment, // alignment
                                                     SegmentScope.auto());
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];

Чтобы инициализировать такой нативный массив с помощью методов разыменования из предыдущего раздела, пришлось бы написать следующий код (далее мы предполагаем, что sizeof(int)==4):

MemorySegment segment = MemorySegment.allocateNative(2 * ValueLayout.JAVA_INT.byteSize() * 10, // size
                                                     ValueLayout.JAVA_INT.byteAlignment, // alignment
                                                     SegmentScope.auto());
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, SegmentScope.auto());
for (int i = 0; i < ptsLayout.elementCount(); 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.

Аллокаторы сегментов

Выделение памяти часто становится узким местом, когда клиенты используют память вне кучи. Поэтому FFM API включает абстракцию SegmentAllocator, которая определяет операции выделения и инициализации сегментов памяти. Для удобства класс Arena реализует интерфейс SegmentAllocator, чтобы арены можно было использовать для выделения нативных сегментов. Иными словами, Arena — это «универсальное средство» для гибкого выделения и своевременного освобождения памяти вне кучи:

try (Arena offHeap = Arena.openConfined()) {
    MemorySegment nativeArray  = offHeap.allocateArray(ValueLayout.JAVA_INT, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
    MemorySegment nativeString = offHeap.allocateUtf8String("Hello!");
    MemorySegment upcallStub   = linker.upcallStub(handle, desc, offHeap.scope());
   ...
} // memory released here

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

try (Arena arena = Arena.openConfined()) {
    SegmentAllocator allocator = SegmentAllocator.slicingAllocator(arena.allocate(1024));
    for (int i = 0 ; i < 10 ; i++) {
        MemorySegment s = allocator.allocateArray(JAVA_INT,  new int[] { 1, 2, 3, 4, 5 });
        ...
    }
    ...
 } // all memory allocated is released here

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

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

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

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

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

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

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

try (Arena arena = Arena.openConfined()) {   
    SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", arena);
    MemorySegment glVersion = opengl.find("glGetString").get();
    ...
} // libGL.so unloaded here

SymbolLookup::libraryLookup(String, SegmentScope) существенно отличается от механизма загрузки библиотек JNI, то есть от System::loadLibrary. Нативные библиотеки, рассчитанные на работу с JNI, могут использовать функции JNI для выполнения Java-операций, таких как выделение объектов или доступ к методам, которые могут вызвать загрузку классов. Поэтому такие связанные с JNI библиотеки должны быть связаны с загрузчиком классов, когда JVM их загружает. А чтобы сохранить целостность загрузчиков классов, одну и ту же связанную с JNI библиотеку нельзя загрузить из классов, определённых в разных загрузчиках классов. FFM API, напротив, не предоставляет нативному коду функций для доступа к среде Java и не предполагает, что нативные библиотеки рассчитаны на работу с FFM API. Нативные библиотеки, загруженные через SymbolLookup::libraryLookup(String, SegmentScope), не знают, что к ним обращается код, работающий в 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,
                          SegmentScope scope);
}

Для нисходящих вызовов метод 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().find("strlen").get(),
    FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);

Вызов method handle нисходящего вызова запустит strlen и сделает её результат доступным в Java. Для аргумента strlen мы используем вспомогательный метод, который преобразует Java-строку в сегмент памяти вне кучи (с помощью ограниченной арены), а затем этот сегмент передаётся по ссылке:

try (Arena arena = Arena.openConfined()) {
    MemorySegment str = arena.allocateUtf8String("Hello");
    long len          = strlen.invoke(str);  // 5
}

Method handles хорошо подходят для предоставления внешних функций, потому что JVM уже оптимизирует вызов method handles вплоть до нативного кода. Когда method handle ссылается на метод в файле class, вызов этого method handle, как правило, приводит к JIT-компиляции целевого метода; после этого JVM интерпретирует байт-код Java, вызывающий MethodHandle::invokeExact, передавая управление ассемблерному коду, сгенерированному для целевого метода. Таким образом, обычный method handle в Java «за кулисами» нацелен на не-Java-код; method handle нисходящего вызова — естественное расширение, которое позволяет разработчикам явно обращаться к не-Java-коду. Кроме того, method handles обладают свойством, называемым сигнатурным полиморфизмом, которое позволяет вызывать их с примитивными аргументами без упаковки. В итоге 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, созданный для приёма одного аргумента MemorySegment, нельзя вызвать через invokeExact(<MemorySegment>, <MemorySegment>), хотя invokeExact — метод с переменным числом аргументов. Тип method handle нисходящего вызова описывает Java-сигнатуру, которую клиенты должны использовать при вызове method handle нисходящего вызова. По сути, это представление функции C со стороны Java.

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

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

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

Клиенты могут использовать указатели 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, macOS/AArch64 и Windows/x64. Поскольку она написана на Java, её гораздо проще сопровождать и расширять, чем JNI, соглашения о вызовах которого жёстко зашиты в код HotSpot на C++.

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

Сегменты памяти нулевой длины

Внешние функции часто выделяют область памяти и возвращают указатель на неё. Моделировать такую область сегментом памяти сложно, потому что размер области неизвестен среде выполнения Java. Например, функция C с возвращаемым типом char* может вернуть указатель на область, содержащую одно значение char, или на область, содержащую последовательность значений char, завершающуюся '\0'. Размер области не очевиден для кода, вызывающего внешнюю функцию.

FFM API представляет указатель, возвращённый внешней функцией, как сегмент памяти нулевой длины. Адрес сегмента равен значению указателя, а размер сегмента равен нулю. Аналогично, когда клиент читает адрес из сегмента памяти, возвращается сегмент памяти нулевой длины.

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

У клиентов есть два способа получить доступ к нативным сегментам памяти нулевой длины, и оба они небезопасны:

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

  • Кроме того, клиенты могут получить неограниченную раскладку значения-адреса с помощью метода ValueLayout.OfAddress::asUnbounded. Когда операция доступа использует неограниченную раскладку значения-адреса, FFM API рассматривает соответствующий «сырой» адрес памяти как нативный сегмент максимального размера (то есть java.lang.Long.MAX_VALUE). Поэтому к такому нативному сегменту можно обращаться напрямую.

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

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

Иногда полезно передать 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().find("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 handle, как показано ниже.

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

class Qsort {
    static int qsortCompare(MemorySegment elem1, MemorySegment elem2) {
        return Integer.compare(elem1.get(JAVA_INT, 0), elem2.get(JAVA_INT, 0));
    }
}

Затем создаём method handle, указывающий на Java-метод сравнения:

MethodHandle comparHandle
    = MethodHandles.lookup()
                   .findStatic(Qsort.class, "qsortCompare",
                               MethodType.methodType(int.class,
                                                     MemorySegment.class,
                                                     MemorySegment.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.asUnbounded(), ADDRESS.asUnbounded()),
                    SegmentScope.auto());
);

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

try (Arena arena = Arena.openConfined()) {
    MemorySegment array = arena.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-код может запросить method handle нисходящего вызова, указав типы параметров, несовместимые с типами параметров соответствующей внешней функции. Вызов такого method handle в Java приведёт к тем же последствиям — аварийному завершению VM или неопределённому поведению, — что и вызов метода native в JNI. 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 настолько непоправимо опасен, что мы надеемся, что библиотеки будут предпочитать чистый 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 также потребуют доработки, чтобы использование нативных method handle, полученных из API, было как минимум таким же эффективным и поддающимся оптимизации, как использование существующих нативных методов JNI.

Зависимости

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

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