JEP 442: Foreign Function & Memory API (Third Preview)
Foreign Function & Memory API (третья версия Preview (предварительная версия))
| Ответственный | Maurizio Cimadamore |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 21 |
| Компонент | core-libs |
| Обсуждение | panama dash dev at openjdk dot org |
| Связан с | JEP 434: Foreign Function & Memory API (Second Preview) |
| JEP 454: Foreign Function & Memory API | |
| Рецензенты | Alex Buckley, Jorn Vernee |
| Одобрен | Mark Reinhold |
| Создан | 2023/02/01 14:58 |
| Обновлён | 2023/09/27 16:27 |
| Задача | 8301625 |
Аннотация
Ввести API, с помощью которого программы на Java могут взаимодействовать с кодом и данными за пределами среды выполнения Java. API эффективно вызывает внешние функции (т. е. код вне JVM) и безопасно обращается к внешней памяти (т. е. к памяти, которой не управляет JVM). Так программы на Java могут вызывать нативные библиотеки и обрабатывать нативные данные без хрупкости и опасностей JNI. Это API в статусе Preview.
История
Foreign Function & Memory (FFM) API впервые появился в статусе Preview в JDK 19 в рамках JEP 424, затем снова вышел в статусе Preview в JDK 20 в рамках JEP 434. Этот JEP предлагает третью версию Preview с доработками по результатам отзывов. В этой версии мы:
- централизовали управление временем жизни нативных сегментов в интерфейсе
Arena; - расширили пути раскладок (layout paths) новым элементом для разыменования адресных раскладок;
- добавили параметр компоновщика (linker) для оптимизации вызовов функций, которые выполняются недолго и не будут делать обратный вызов (upcall) в Java (например,
clock_gettime); - предоставили резервную реализацию нативного компоновщика на основе
libffi, чтобы упростить перенос на другие платформы; и - удалили класс
VaList.
Цели
-
Простота использования — заменить Java Native Interface (JNI) более совершенной моделью разработки на чистом Java.
-
Производительность — обеспечить производительность, сопоставимую с существующими API, такими как JNI и
sun.misc.Unsafe, если не более высокую. -
Универсальность — предоставить способы работы с разными видами внешней памяти (например, с нативной памятью, постоянной памятью и управляемой памятью кучи), а со временем поддержать и другие платформы (например, 32-битную x86) и внешние функции, написанные на языках, отличных от C (например, на C++, Fortran).
-
Безопасность — позволить программам выполнять небезопасные операции с внешней памятью, но по умолчанию предупреждать пользователей о таких операциях.
Что не является целью
Целью не является
- заново реализовать JNI поверх этого API или как-либо иначе изменить JNI;
- заново реализовать устаревшие API Java, такие как
sun.misc.Unsafe, поверх этого API; - предоставить инструменты, которые механически генерируют код Java из заголовочных файлов нативного кода; или
- изменить способ упаковки и развёртывания приложений Java, взаимодействующих с нативными библиотеками (например, с помощью многоплатформенных JAR-файлов).
Мотивация
Платформа Java всегда давала богатую основу разработчикам библиотек и приложений, которым нужно выйти за пределы JVM и взаимодействовать с другими платформами. API Java удобно и надёжно открывают доступ к ресурсам за пределами Java: к удалённым данным (JDBC), веб-сервисам (HTTP-клиент), удалённым клиентам, которых нужно обслуживать (каналы NIO), или к локальным процессам (сокеты Unix-домена). К сожалению, разработчики на Java по-прежнему сталкиваются с серьёзными препятствиями при доступе к важному виду ресурсов вне Java: к коду и данным на той же машине, что и JVM, но за пределами среды выполнения Java.
Внешняя память
Объекты, созданные с помощью ключевого слова new, хранятся в куче JVM, где они подлежат сборке мусора, когда становятся не нужны. Однако стоимость и непредсказуемость сборки мусора неприемлемы для библиотек, критичных к производительности, таких как Tensorflow, Ignite, Lucene и Netty. Им нужно хранить данные вне кучи, в памяти вне кучи (off-heap), которую они сами выделяют и освобождают. Доступ к памяти вне кучи также позволяет сериализовать и десериализовать данные, отображая файлы непосредственно в память, например с помощью mmap.
Исторически платформа Java предоставляла два API для доступа к памяти вне кучи:
-
API
ByteBufferпредоставляет прямые байтовые буферы — объекты Java, за которыми стоят области памяти вне кучи фиксированного размера. Однако максимальный размер области ограничен двумя гигабайтами, а методы чтения и записи памяти примитивны и провоцируют ошибки: по сути, они дают лишь индексированный доступ к примитивным значениям. Хуже того, память, стоящая за прямым байтовым буфером, освобождается только тогда, когда объект буфера удаляется сборщиком мусора, а этим разработчик управлять не может. Из-за отсутствия своевременного освобождения APIByteBufferплохо подходит для системного программирования на Java. -
API
sun.misc.Unsafeпредоставляет низкоуровневый доступ к памяти в куче, который работает и для памяти вне кучи.Unsafeработает быстро (потому что JVM реализует его операции доступа к памяти как интринсики), позволяет использовать огромные области вне кучи (теоретически до 16 эксабайт) и даёт детальный контроль над освобождением (потому чтоUnsafe::freeMemoryможно вызвать в любой момент). Однако эта модель программирования слаба, потому что даёт разработчику слишком много контроля. Библиотека в долго работающем серверном приложении со временем выделяет несколько областей памяти вне кучи и работает с ними; данные в одной области будут указывать на данные в другой, и области нужно освобождать в правильном порядке, иначе висячие указатели приведут к ошибкам использования после освобождения (use-after-free). Из-за отсутствия безопасного освобождения APIUnsafeплохо подходит для системного программирования на Java.(Та же критика относится к API вне JDK, которые дают детальное управление выделением и освобождением памяти, оборачивая нативный код, вызывающий
mallocиfree.)
Итак, сложным клиентам нужен API, который может выделять память вне кучи, работать с ней и совместно её использовать так же гибко и безопасно, как память в куче. Такой API должен находить баланс между потребностью в предсказуемом освобождении и необходимостью предотвращать несвоевременное освобождение, которое может привести к аварийному завершению JVM или, что хуже, к незаметному повреждению памяти.
Внешние функции
JNI поддерживает вызов нативного кода (т. е. внешних функций) начиная с Java 1.1, но по многим причинам он неудовлетворителен.
-
JNI требует нескольких трудоёмких артефактов: API на Java (методы
native), заголовочного файла C, полученного из API на Java, и реализации на C, которая вызывает нужную нативную библиотеку. Разработчикам на Java приходится работать с несколькими наборами инструментов, чтобы поддерживать платформенно-зависимые артефакты в согласованном состоянии, что особенно обременительно, когда нативная библиотека быстро развивается. -
JNI может взаимодействовать только с библиотеками, написанными на языках (обычно C и C++), которые используют соглашение о вызовах операционной системы и процессора, для которых собрана JVM. Метод
nativeнельзя использовать для вызова функции, написанной на языке с другим соглашением. -
JNI не согласует систему типов Java с системой типов C. Составные данные в Java представлены объектами, а в C — структурами, поэтому любой объект Java, переданный в метод
native, приходится кропотливо распаковывать в нативном коде. Например, рассмотрим класс Records (записи)Personв Java: при передаче объектаPersonв методnativeнативному коду придётся использовать C API из JNI, чтобы извлечь из объекта поля (например,firstNameиlastName). Поэтому разработчики на Java иногда упаковывают свои данные в один объект (например, в массив байтов или прямой байтовый буфер), но чаще, поскольку передача объектов Java через JNI медленная, они используют APIUnsafe, чтобы выделить память вне кучи и передать её адрес в методnativeкакlong, — и это делает код на Java катастрофически небезопасным!
За прошедшие годы появилось множество фреймворков, восполняющих пробелы JNI, в том числе JNA, JNR и JavaCPP. Эти фреймворки часто заметно лучше JNI, но ситуация всё ещё далека от идеальной, особенно по сравнению с языками, где взаимодействие с нативным кодом поддерживается полноценно. Например, пакет ctypes в Python может динамически оборачивать функции нативных библиотек без всякого связующего кода. Другие языки, такие как Rust, предоставляют инструменты, которые механически создают нативные обёртки из заголовочных файлов C/C++.
В конечном счёте у разработчиков на Java должен быть поддерживаемый API, который позволяет напрямую использовать любую нативную библиотеку, полезную для конкретной задачи, без утомительного связующего кода и неуклюжести JNI. Отличная абстракция, на которую можно опереться, — method handles (дескрипторы методов), появившиеся в Java 7 для поддержки быстрых динамических языков на JVM. Доступ к нативному коду через method handles радикально упростил бы написание, сборку и распространение библиотек Java, зависящих от нативных библиотек. Кроме того, API, способный моделировать внешние функции (т. е. нативный код) и внешнюю память (т. е. данные вне кучи), стал бы прочной основой для сторонних фреймворков взаимодействия с нативным кодом.
Описание
Foreign Function & Memory API (FFM API) определяет классы и интерфейсы, с помощью которых клиентский код в библиотеках и приложениях может
- управлять выделением и освобождением внешней памяти
(MemorySegment,ArenaиSegmentAllocator), - работать со структурированной внешней памятью и обращаться к ней
(MemoryLayoutиVarHandle), а также - вызывать внешние функции (
Linker,FunctionDescriptorиSymbolLookup).
FFM API находится в пакете java.lang.foreign модуля java.base.
Пример
В качестве краткого примера использования FFM API приведём код на Java, который получает method handle для библиотечной функции C radixsort, а затем с его помощью сортирует четыре строки, изначально находящиеся в массиве Java (некоторые детали опущены).
Поскольку FFM API — это API в статусе Preview, компилировать и запускать код нужно с включёнными Preview-возможностями, т. е. с javac --release 21 --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.ofConfined()) {
// 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 или байтовым буфером соответственно.
Все сегменты памяти имеют пространственные и временны́е границы, которые гарантируют безопасность операций доступа к памяти. Коротко говоря, границы гарантируют, что не будет использоваться невыделенная память и не будет использования после освобождения.
Пространственные границы сегмента определяют диапазон адресов памяти, связанных с сегментом. Например, код ниже выделяет нативный сегмент размером 100 байт, поэтому связанный с ним диапазон адресов — от некоторого базового адреса b до b + 99 включительно.
MemorySegment data = Arena.global().allocate(100);
Временны́е границы сегмента определяют время его жизни, то есть период до освобождения области памяти, стоящей за сегментом. FFM API гарантирует, что к сегменту памяти нельзя обратиться после того, как стоящая за ним область памяти освобождена.
Временны́е границы сегмента определяются ареной, в которой сегмент выделен. Несколько сегментов, выделенных в одной арене, имеют одинаковые временны́е границы и могут безопасно ссылаться друг на друга: сегмент A может содержать указатель на адрес в сегменте B, а сегмент B — указатель на адрес в сегменте A, и оба сегмента будут освобождены одновременно, так что ни в одном из них не окажется висячего указателя.
Простейшая арена — глобальная арена, которая даёт неограниченное время жизни: она всегда активна. Сегмент, выделенный в глобальной арене, как в коде выше, всегда доступен, а область памяти, стоящая за сегментом, никогда не освобождается.
Однако большинству программ нужно освобождать память вне кучи во время работы, и поэтому им нужны сегменты памяти с ограниченным временем жизни.
Автоматическая арена даёт ограниченное время жизни: к сегменту, выделенному автоматической ареной, можно обращаться, пока сборщик мусора JVM не обнаружит, что сегмент памяти недостижим. В этот момент область памяти, стоящая за сегментом, освобождается. Например, этот метод выделяет сегмент в автоматической арене:
void processData() {
MemorySegment data = Arena.ofAuto().allocateNative(100);
... use the 'data' variable ...
... use the 'data' variable some more ...
} // the region of memory backing the 'data' segment
// is deallocated here (or later)
Пока переменная data не выходит за пределы метода, сегмент рано или поздно будет признан недостижимым, и стоящая за ним область памяти будет освобождена.
Ограниченного, но недетерминированного времени жизни автоматической арены не всегда достаточно. Например, API, который отображает сегмент памяти из файла, должен позволять клиенту детерминированно освобождать область памяти, стоящую за сегментом. Если ждать, пока это сделает сборщик мусора, может пострадать производительность.
Замкнутая (confined) арена даёт ограниченное и детерминированное время жизни: она активна с момента, когда клиент открывает арену, до момента, когда клиент её закрывает. К сегменту памяти, выделенному в замкнутой арене, можно обращаться только до закрытия арены. В этот момент область памяти, стоящая за сегментом, освобождается. Попытки обратиться к сегменту памяти после закрытия его арены завершатся исключением. Например, этот код открывает арену и выделяет в ней два сегмента:
MemorySegment input = null, output = null;
try (Arena processing = Arena.ofConfined()) {
input = processing.allocate(100);
... set up data in 'input' ...
output = processing.allocate(100);
... process data from 'input' to 'output' ...
... calculate the ultimate result from 'output' and store it elsewhere ...
} // the regions of memory backing the segments are deallocated here
...
input.get(ValueLayout.JAVA_BYTE, 0); // throws IllegalStateException
// (also for 'output')
При выходе из блока try-with-resources арена закрывается. В этот момент все сегменты, выделенные ареной, атомарно становятся недействительными, а области памяти, стоящие за сегментами, освобождаются.
За детерминированное время жизни замкнутой арены приходится платить: к сегментам памяти, выделенным в замкнутой арене, может обращаться только один поток. Если доступ к сегменту нужен нескольким потокам, можно использовать разделяемую арену. К сегментам памяти, выделенным в разделяемой арене, могут обращаться несколько потоков, и любой поток, обращается он к области или нет, может закрыть арену, чтобы освободить сегменты. Закрытие арены атомарно делает сегменты недействительными, однако области памяти, стоящие за сегментами, могут освобождаться не сразу. Причина в том, что для обнаружения и отмены незавершённых конкурентных операций доступа к сегментам нужна дорогостоящая операция синхронизации.
Итак, арена определяет, какие потоки и когда могут обращаться к сегменту памяти. Так обеспечиваются и строгая временна́я безопасность, и предсказуемая модель производительности. FFM API предлагает на выбор несколько видов арен, чтобы клиент мог найти баланс между широтой доступа и своевременностью освобождения памяти.
Разыменование сегментов
Чтобы разыменовать данные в сегменте памяти, нужно учесть несколько факторов:
- количество разыменовываемых байтов;
- ограничения на выравнивание адреса, по которому выполняется разыменование;
- порядок байтов, в котором они хранятся в сегменте памяти;
- тип Java, используемый в операции разыменования (например,
intилиfloat).
Все эти характеристики описывает абстракция ValueLayout. Например, предопределённая раскладка значения JAVA_INT имеет ширину четыре байта, выравнивается по границам в четыре байта, использует порядок байтов платформы (например, little-endian на Linux/x64) и связана с типом Java int.
У сегментов памяти есть простые методы разыменования для чтения значений из сегментов памяти и записи в них. Эти методы принимают раскладку значения, которая однозначно задаёт свойства операции разыменования. Например, можно записать 25 значений int по последовательным смещениям в сегменте памяти:
MemorySegment segment
= Arena.ofAuto().allocate(100, // size
ValueLayout.JAVA_INT.byteAlignment); // alignment
for (int i = 0; i < 25; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ i,
/* value to write */ i);
}
Раскладки памяти и структурированный доступ
Рассмотрим следующее объявление на C. Оно определяет массив структур Point, в котором у каждой структуры Point два члена: Point.x и Point.y:
struct Point {
int x;
int y;
} pts[10];
С методами разыменования из предыдущего раздела для инициализации такого нативного массива пришлось бы написать следующий код (предполагаем, что sizeof(int) == 4):
MemorySegment segment
= Arena.ofAuto().allocate(2 * ValueLayout.JAVA_INT.byteSize() * 10, // size
ValueLayout.JAVA_INT.byteAlignment); // alignment
for (int i = 0; i < 10; i++) {
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ (i * 2),
/* value to write */ i); // x
segment.setAtIndex(ValueLayout.JAVA_INT,
/* index */ (i * 2) + 1,
/* value to write */ i); // y
}
Чтобы реже приходилось выполнять утомительные вычисления, связанные с раскладкой памяти (например, (i * 2) + 1 в примере выше), можно описать содержимое сегмента памяти более декларативно с помощью MemoryLayout. Например, нужную раскладку нативного сегмента памяти из примеров выше можно описать так:
SequenceLayout ptsLayout
= MemoryLayout.sequenceLayout(10,
MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));
Здесь создаётся раскладка-последовательность, содержащая десять повторений раскладки структуры, элементы которой — две раскладки JAVA_INT с именами x и y соответственно. Имея такую раскладку, можно не вычислять смещения в коде, а создать два дескриптора переменных для доступа к памяти — особых дескриптора переменных (var handles), которые принимают параметр 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 = Arena.ofAuto().allocate(ptsLayout);
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 управляет созданием дескриптора переменной для доступа к памяти через создание пути в раскладке, с помощью которого из сложного выражения раскладки выбирается вложенная раскладка. Поскольку выбранная раскладка значения связана с типом Java int, итоговые дескрипторы переменных xHandle и yHandle тоже будут иметь тип int. Кроме того, поскольку выбранная раскладка значения определена внутри раскладки-последовательности, дескрипторы переменных получают дополнительную координату типа long — индекс структуры Point, координата которой читается или записывается. Объект ptsLayout также управляет выделением нативного сегмента памяти, исходя из сведений о размере и выравнивании, полученных из раскладки. Вычислять смещения внутри цикла больше не нужно, поскольку для инициализации элементов Point.x и Point.y используются разные дескрипторы переменных.
Распределители сегментов
Выделение памяти часто становится узким местом, когда клиенты используют память вне кучи. Поэтому FFM API включает абстракцию SegmentAllocator, которая определяет операции выделения и инициализации сегментов памяти. Для удобства класс Arena реализует интерфейс SegmentAllocator, так что арены можно использовать для выделения нативных сегментов. Иначе говоря, Arena — это «единое окно» для гибкого выделения и своевременного освобождения памяти вне кучи:
try (Arena offHeap = Arena.ofConfined()) {
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);
...
} // memory released here
Распределители сегментов можно также получить через фабрики в интерфейсе SegmentAllocator. Например, одна из фабрик создаёт нарезающий распределитель, который отвечает на запросы выделения, возвращая сегменты памяти, являющиеся частями ранее выделенного сегмента. Таким образом, многие запросы можно выполнить, не выделяя физически дополнительную память. Следующий код получает нарезающий распределитель поверх существующего сегмента, а затем выделяет с его помощью сегмент, инициализированный из массива Java:
MemorySegment segment = ...
SegmentAllocator allocator = SegmentAllocator.slicingAllocator(segment);
for (int i = 0 ; i < 10 ; i++) {
MemorySegment s = allocator.allocateArray(JAVA_INT,
1, 2, 3, 4, 5);
...
}
Распределители сегментов можно использовать как строительные блоки для создания арен с собственными стратегиями выделения. Например, если у большого числа нативных сегментов будет одно и то же ограниченное время жизни, собственная арена может эффективно выделять эти сегменты с помощью нарезающего распределителя. Так клиенты получают и масштабируемое выделение (благодаря нарезке), и детерминированное освобождение (благодаря арене).
Например, следующий код определяет нарезающую арену, которая ведёт себя как замкнутая арена, но для ответа на запросы выделения внутри использует нарезающий распределитель. При закрытии нарезающей арены закрывается нижележащая замкнутая арена, и все сегменты, выделенные в нарезающей арене, становятся недействительными. (Некоторые детали опущены.)
class SlicingArena implements Arena {
final Arena arena = Arena.ofConfined();
final SegmentAllocator slicingAllocator;
SlicingArena(long size) {
slicingAllocator = SegmentAllocator.slicingAllocator(arena.allocate(size));
}
public void allocate(long byteSize, long byteAlignment) {
return slicingAllocator.allocate(byteSize, byteAlignment);
}
public void close() {
return arena.close();
}
}
Код, который раньше использовал нарезающий распределитель напрямую, теперь можно записать короче:
try (Arena slicingArena = new SlicingArena(1000)) {
for (int i = 0 ; i < 10 ; i++) {
MemorySegment s = slicingArena.allocateArray(JAVA_INT,
1, 2, 3, 4, 5);
...
}
} // all memory allocated is released here
Поиск внешних функций
Первая составляющая любой поддержки внешних функций — механизм, который находит адрес заданного символа в загруженной нативной библиотеке. Эта возможность, представленная объектом SymbolLookup, необходима для связывания кода Java с внешними функциями (см. ниже). FFM API поддерживает три вида объектов поиска символов:
-
SymbolLookup::libraryLookup(String, Arena)создаёт поиск по библиотеке, который находит все символы в заданной пользователем нативной библиотеке. При создании объекта поиска библиотека загружается (например, с помощьюdlopen()) и связывается с объектомArena. Библиотека выгружается (например, с помощьюdlclose()), когда переданная арена закрывается. -
SymbolLookup::loaderLookup()создаёт поиск по загрузчику, который находит все символы во всех нативных библиотеках, загруженных классами текущего загрузчика классов с помощью методовSystem::loadLibraryиSystem::load. -
Linker::defaultLookup()создаёт поиск по умолчанию, который находит все символы в библиотеках, обычно используемых в сочетании ОС и процессора, связанном с экземпляромLinker.
Имея объект поиска символов, клиент может найти внешнюю функцию методом SymbolLookup::find(String). Если функция с таким именем есть среди символов, видимых объекту поиска, метод возвращает сегмент памяти нулевой длины (см. ниже), базовый адрес которого указывает на точку входа функции. Например, следующий код с помощью поиска по загрузчику загружает библиотеку OpenGL и находит адрес её функции glGetString:
try (Arena arena = Arena.ofConfined()) {
SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", arena);
MemorySegment glVersion = opengl.find("glGetString").get();
...
} // libGL.so unloaded here
SymbolLookup::libraryLookup(String, Arena) в одном важном отношении отличается от System::loadLibrary — механизма загрузки библиотек JNI. Нативные библиотеки, рассчитанные на работу с JNI, могут с помощью функций JNI выполнять операции Java, например выделять объекты или обращаться к методам, что может вызвать загрузку классов. Поэтому такие связанные с JNI библиотеки при загрузке в JVM должны связываться с загрузчиком классов. А чтобы сохранить целостность загрузчиков классов, одну и ту же связанную с JNI библиотеку нельзя загружать из классов, определённых в разных загрузчиках классов.
FFM API, напротив, не предоставляет нативному коду функций для доступа к среде Java и не предполагает, что нативные библиотеки рассчитаны на работу с FFM API. Нативные библиотеки, загруженные через SymbolLookup::libraryLookup(String, Arena), не знают, что к ним обращается код, выполняющийся в JVM, и не пытаются выполнять операции Java. Поэтому они не привязаны к конкретному загрузчику классов, и клиенты FFM API в разных загрузчиках могут загружать (и перезагружать) их столько раз, сколько нужно.
Связывание кода Java с внешними функциями
Интерфейс Linker — основа взаимодействия кода Java с внешним кодом. Хотя в этом документе чаще говорится о взаимодействии Java с библиотеками на C, концепции этого интерфейса достаточно общие, чтобы в будущем поддерживать и другие языки, кроме Java. Интерфейс Linker позволяет выполнять как нисходящие вызовы (вызовы из кода Java в нативный код), так и восходящие вызовы (вызовы из нативного кода обратно в код Java).
interface Linker {
MethodHandle downcallHandle(MemorySegment address,
FunctionDescriptor function);
MemorySegment upcallStub(MethodHandle target,
FunctionDescriptor function,
Arena arena);
}
Для нисходящих вызовов метод downcallHandle принимает адрес внешней функции (обычно MemorySegment, полученный через поиск по библиотеке) и предоставляет внешнюю функцию в виде дескриптора метода нисходящего вызова. Затем код Java вызывает дескриптор метода нисходящего вызова через его метод invoke (или invokeExact), и выполняется внешняя функция. Все аргументы, переданные методу invoke дескриптора метода, передаются внешней функции.
Для восходящих вызовов метод upcallStub принимает дескриптор метода (обычно ссылающийся на метод Java, а не дескриптор метода нисходящего вызова) и преобразует его в экземпляр MemorySegment. Затем этот сегмент памяти передаётся как аргумент, когда код Java вызывает дескриптор метода нисходящего вызова. По сути, сегмент памяти служит указателем на функцию. (Подробнее о восходящих вызовах см. ниже.)
Допустим, мы хотим выполнить нисходящий вызов из Java функции strlen, определённой в стандартной библиотеке C:
size_t strlen(const char *s);
Клиенты могут связывать функции C с помощью нативного компоновщика (см. Linker::nativeLinker) — реализации Linker, соответствующей ABI, который определяется ОС и процессором, на которых работает JVM. Дескриптор метода нисходящего вызова, предоставляющий strlen, можно получить следующим образом (подробности о FunctionDescriptor будут описаны чуть ниже):
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
linker.defaultLookup().find("strlen").get(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);
Вызов дескриптора метода нисходящего вызова запустит strlen и сделает её результат доступным в Java. Для аргумента strlen мы используем вспомогательный метод, который преобразует строку Java в сегмент памяти вне кучи с помощью ограниченной арены; затем этот сегмент передаётся по ссылке:
try (Arena arena = Arena.ofConfined()) {
MemorySegment str = arena.allocateUtf8String("Hello");
long len = (long) strlen.invoke(str); // 5
}
Дескрипторы методов хорошо подходят для предоставления внешних функций, потому что JVM уже оптимизирует вызов дескрипторов методов вплоть до нативного кода. Когда дескриптор метода ссылается на метод в файле class, вызов дескриптора метода обычно приводит к JIT-компиляции целевого метода; после этого JVM интерпретирует байт-код Java, вызывающий MethodHandle::invokeExact, передавая управление ассемблерному коду, сгенерированному для целевого метода. Таким образом, традиционный дескриптор метода в Java незаметно обращается к коду не на Java; дескриптор метода нисходящего вызова — естественное расширение, позволяющее разработчикам обращаться к коду не на Java явно. Кроме того, дескрипторы методов обладают свойством, называемым сигнатурным полиморфизмом, которое позволяет вызывать их с примитивными аргументами без упаковки. В итоге дескрипторы методов позволяют Linker предоставлять внешние функции естественным, эффективным и расширяемым образом.
Описание типов C в Java
Чтобы создать дескриптор метода нисходящего вызова, FFM API требует от клиента предоставить FunctionDescriptor, описывающий типы параметров C и возвращаемый тип C целевой функции C. В FFM API типы C описываются объектами MemoryLayout, например ValueLayout для скалярных типов C и GroupLayout для типов структур C. Обычно у клиентов уже есть объекты MemoryLayout для разыменования данных во внешней памяти, и их можно повторно использовать, чтобы получить FunctionDescriptor.
FFM API также использует FunctionDescriptor, чтобы вывести тип дескриптора метода нисходящего вызова. Каждый дескриптор метода строго типизирован, то есть строго ограничивает количество и типы аргументов, которые можно передать его методу invokeExact во время выполнения. Например, дескриптор метода, созданный для приёма одного аргумента MemorySegment, нельзя вызвать через invokeExact(<MemorySegment>, <MemorySegment>), даже если invokeExact — метод с переменным числом аргументов. Тип дескриптора метода нисходящего вызова описывает сигнатуру Java, которую клиенты должны использовать при вызове этого дескриптора метода нисходящего вызова. По сути, это представление функции C со стороны Java.
Например, пусть дескриптор метода нисходящего вызова должен предоставлять функцию C, которая принимает int языка C и возвращает long языка C. В Linux/x64 и macOS/x64 типам C long и int соответствуют предопределённые раскладки JAVA_LONG и JAVA_INT соответственно, поэтому нужный FunctionDescriptor можно получить через FunctionDescriptor.of(JAVA_LONG, JAVA_INT). Затем нативный компоновщик сделает так, что типом дескриптора метода нисходящего вызова будет сигнатура Java из int в long.
Клиенты должны учитывать текущую платформу, если они обращаются к функциям C, использующим скалярные типы, такие как long, int и size_t. Дело в том, что соответствие скалярных типов C константам раскладок различается в зависимости от платформы. В Windows/x64 типу C long соответствует раскладка JAVA_INT, поэтому нужным FunctionDescriptor был бы FunctionDescriptor.of(JAVA_INT, JAVA_INT), а типом дескриптора метода нисходящего вызова — сигнатура Java из int в int.
В качестве другого примера пусть дескриптор метода нисходящего вызова должен предоставлять функцию C с типом возврата void, которая принимает указатель. На всех платформах типу указателя C соответствует предопределённая раскладка ADDRESS, поэтому нужный FunctionDescriptor можно получить через FunctionDescriptor.ofVoid(ADDRESS). Затем нативный компоновщик сделает так, что типом дескриптора метода нисходящего вызова будет сигнатура Java из MemorySegment в void. То есть параметр MemorySegment может передаваться по ссылке или по значению в зависимости от раскладки, указанной в соответствующем дескрипторе функции.
Клиенты могут использовать указатели C, не учитывая текущую платформу. Клиентам не нужно знать размер указателей на текущей платформе, поскольку размер раскладки ADDRESS выводится из текущей платформы, и не нужно различать типы указателей C, такие как int* и char**.
Наконец, в отличие от JNI, нативный компоновщик поддерживает передачу структурированных данных внешним функциям. Пусть дескриптор метода нисходящего вызова должен предоставлять функцию C void, которая принимает структуру, описанную такой раскладкой:
MemoryLayout SYSTEMTIME = MemoryLayout.ofStruct(
JAVA_SHORT.withName("wYear"), JAVA_SHORT.withName("wMonth"),
JAVA_SHORT.withName("wDayOfWeek"), JAVA_SHORT.withName("wDay"),
JAVA_SHORT.withName("wHour"), JAVA_SHORT.withName("wMinute"),
JAVA_SHORT.withName("wSecond"), JAVA_SHORT.withName("wMilliseconds")
);
Нужный FunctionDescriptor можно получить через FunctionDescriptor.ofVoid(SYSTEMTIME). Linker сделает так, что типом дескриптора метода нисходящего вызова будет сигнатура Java из MemorySegment в void.
Раскладка памяти, соответствующая типу структуры C, должна быть составной раскладкой, которая определяет вложенные раскладки для всех полей структуры C, включая любое платформенно-зависимое выравнивающее заполнение, которое может вставить нативный компилятор.
Если функция C возвращает структуру по значению (здесь не показано), то необходимо выделить новый сегмент памяти вне кучи и вернуть его клиенту на Java. Для этого дескриптору метода, возвращаемому downcallHandle, требуется дополнительный аргумент SegmentAllocator, который FFM API использует, чтобы выделить сегмент памяти для хранения структуры, возвращённой функцией C.
Как упоминалось ранее, хотя реализация нативного компоновщика ориентирована на взаимодействие между Java и библиотеками C, интерфейс Linker не зависит от языка: у него нет конкретных сведений о том, как определяются типы C, поэтому получать подходящие определения раскладок для типов C должны сами клиенты. Это сделано намеренно, поскольку определения раскладок для типов C — будь то простые скаляры или сложные структуры — в конечном счёте зависят от платформы и поэтому могут механически генерироваться инструментом, который досконально понимает данную целевую платформу.
Упаковка аргументов Java для функций C
Соглашение о вызовах обеспечивает взаимодействие между разными языками, определяя, как код на одном языке вызывает функцию на другом языке, передаёт аргументы и получает результаты. API Linker нейтрален по отношению к соглашениям о вызовах, но реализация нативного компоновщика «из коробки» поддерживает несколько соглашений о вызовах: Linux/x64, Linux/AArch64, Linux/RISC-V, macOS/x64, macOS/AArch64, Windows/x64 и Windows/AArch64. Другие платформы поддерживаются через резервный компоновщик — реализацию нативного компоновщика на основе libffi. Поскольку API Linker написан на Java, его гораздо проще сопровождать и расширять, чем JNI, соглашения о вызовах которого жёстко заложены в код HotSpot на C++.
Рассмотрим FunctionDescriptor, полученный выше для структуры/раскладки SYSTEMTIME. С учётом соглашения о вызовах ОС и процессора, на которых работает JVM, нативный компоновщик использует FunctionDescriptor, чтобы определить, как поля структуры должны передаваться функции C при вызове дескриптора метода нисходящего вызова с аргументом MemorySegment. При одном соглашении о вызовах реализация нативного компоновщика может разложить входящий сегмент памяти, передать первые четыре поля через регистры общего назначения процессора, а остальные поля — через стек C. При другом соглашении о вызовах реализация нативного компоновщика может передать структуру косвенно: выделить область памяти, целиком скопировать в неё содержимое входящего сегмента памяти и передать функции C указатель на эту область. Эта низкоуровневая упаковка аргументов происходит незаметно, без какого-либо контроля со стороны клиентского кода.
Сегменты памяти нулевой длины
Внешние функции часто выделяют область памяти и возвращают указатель на неё. Моделировать такую область сегментом памяти сложно, потому что её размер неизвестен среде выполнения Java. Например, функция C с возвращаемым типом char* может вернуть указатель на область, содержащую одно значение char, или на область, содержащую последовательность значений char, завершающуюся '\0'. Размер области неочевиден для кода, вызывающего внешнюю функцию.
FFM API представляет указатель, возвращённый внешней функцией, как сегмент памяти нулевой длины. Адрес сегмента равен значению указателя, а размер сегмента равен нулю. Аналогично, когда клиент читает указатель из сегмента памяти, возвращается сегмент памяти нулевой длины.
У сегмента нулевой длины тривиальные пространственные границы, поэтому любая попытка доступа к такому сегменту завершается ошибкой IndexOutOfBoundsException. Это важнейшая мера безопасности: поскольку эти сегменты связаны с областью памяти неизвестного размера, операции доступа к ним невозможно проверить. По сути, сегмент памяти нулевой длины оборачивает адрес, и его нельзя использовать без явного намерения.
Клиенты могут превратить сегмент памяти нулевой длины в нативный сегмент выбранного ими размера с помощью метода MemorySegment::reinterpret. Этот метод задаёт сегменту памяти нулевой длины новые пространственные и временные границы, чтобы разрешить операции разыменования. Сегмент памяти, возвращаемый этим методом, небезопасен: за сегментом памяти нулевой длины может стоять область памяти длиной 10 байт, но клиент может переоценить размер области и с помощью MemorySegment::reinterpret получить сегмент длиной 100 байт. Позже это может привести к попыткам разыменовать память за пределами области, что может вызвать сбой JVM или — что ещё хуже — незаметное повреждение памяти.
Переопределение пространственных и временных границ сегмента памяти нулевой длины небезопасно. Поэтому метод MemorySegment::reinterpret является ограниченным, и его использование в программе приводит к тому, что среда выполнения Java выдаёт предупреждения (подробнее см. ниже).
Восходящие вызовы
Иногда полезно передать код Java как указатель на функцию какой-либо внешней функции. Это можно сделать с помощью поддержки восходящих вызовов в Linker. В этом разделе мы шаг за шагом построим более сложный пример, который демонстрирует все возможности Linker с полноценным двусторонним взаимодействием как кода, так и данных через границу между Java и нативным кодом.
Рассмотрим эту функцию, определённую в стандартной библиотеке C:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
Чтобы вызвать qsort из Java, сначала нужно создать дескриптор метода нисходящего вызова:
Linker linker = Linker.nativeLinker();
MethodHandle qsort = linker.downcallHandle(
linker.defaultLookup().find("qsort").get(),
FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);
Как и раньше, мы используем раскладку JAVA_LONG для отображения типа C size_t и раскладку ADDRESS как для первого параметра-указателя (указатель на массив), так и для последнего параметра (указатель на функцию).
qsort сортирует содержимое массива с помощью пользовательской функции сравнения compar, переданной как указатель на функцию. Поэтому, чтобы вызвать дескриптор метода нисходящего вызова, нам нужен указатель на функцию, который будет передан последним параметром в метод invokeExact дескриптора метода. Linker::upcallStub помогает создавать указатели на функции на основе существующих дескрипторов методов следующим образом.
Во-первых, напишем на 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));
}
}
Во-вторых, создадим дескриптор метода, указывающий на Java-метод сравнения:
MethodHandle comparHandle
= MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class,
MemorySegment.class,
MemorySegment.class));
В-третьих, теперь, когда у нас есть дескриптор метода для нашего Java-компаратора, можно создать указатель на функцию с помощью Linker::upcallStub. Как и для нисходящих вызовов, мы описываем сигнатуру указателя на функцию с помощью FunctionDescriptor:
MemorySegment comparFunc
= linker.upcallStub(comparHandle,
/* A Java description of a C function
implemented by a Java method! */
FunctionDescriptor.of(JAVA_INT,
ADDRESS.withTargetLayout(JAVA_INT),
ADDRESS.withTargetLayout(JAVA_INT)),
Arena.ofAuto());
);
Наконец, у нас есть сегмент памяти comparFunc, который указывает на заглушку, с помощью которой можно вызвать нашу Java-функцию сравнения, и теперь у нас есть всё необходимое, чтобы вызвать дескриптор нисходящего вызова qsort:
try (Arena arena = Arena.ofConfined()) {
MemorySegment array
= arena.allocateArray(ValueLayout.JAVA_INT,
0, 9, 3, 4, 6, 5, 1, 8, 2, 7);
qsort.invoke(array, 10L, ValueLayout.JAVA_INT.byteSize(), comparFunc);
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
}
Этот код создаёт массив вне кучи, копирует в него содержимое Java-массива, а затем передаёт этот массив дескриптору 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-код может запросить дескриптор метода нисходящего вызова, указав типы параметров, несовместимые с типами параметров нижележащей внешней функции. Вызов такого дескриптора метода нисходящего вызова в Java приведёт к тем же последствиям — аварийному завершению VM или неопределённому поведению, — которые могут возникнуть при вызове метода native в JNI. FFM API также может создавать небезопасные сегменты, то есть сегменты памяти, пространственные и временные границы которых задаются пользователем и не могут быть проверены средой выполнения Java (см. MemorySegment::reinterpret).
Небезопасные методы FFM API не несут тех же рисков, что функции JNI: они, например, не могут изменять значения полей final в Java-объектах. С другой стороны, небезопасные методы FFM API легко вызывать из Java-кода. Поэтому использование небезопасных методов FFM API ограничено: оно разрешено, но по умолчанию каждое такое использование приводит к выдаче предупреждения во время выполнения. Чтобы разрешить коду в модуле M использовать небезопасные методы без предупреждений, укажите параметр --enable-native-access=M в командной строке java. (Несколько модулей указываются списком через запятую; укажите ALL-UNNAMED, чтобы разрешить использование без предупреждений для всего кода в class path.) Если этот параметр указан, любое использование небезопасных методов за пределами списка указанных модулей приведёт к выбросу IllegalCallerException, а не к выдаче предупреждения. Вероятно, в одном из будущих выпусков этот параметр станет обязательным для использования небезопасных методов.
Здесь мы не предлагаем ограничивать какой-либо аспект JNI. По-прежнему можно будет вызывать методы native в Java, а нативный код по-прежнему сможет вызывать небезопасные функции JNI. Однако, вероятно, в одном из будущих выпусков мы так или иначе ограничим JNI. Например, небезопасные функции JNI, такие как newDirectByteBuffer, могут быть отключены по умолчанию, так же как небезопасные методы FFM API. В более широком смысле механизм JNI настолько непоправимо опасен, что мы надеемся, что библиотеки будут предпочитать FFM API на чистой Java как для безопасных, так и для небезопасных операций, чтобы со временем мы могли отключить весь JNI по умолчанию. Это соответствует более широкой дорожной карте Java, согласно которой платформа должна быть безопасной «из коробки», а конечные пользователи должны явно соглашаться на небезопасные действия, такие как нарушение строгой инкапсуляции или компоновка с неизвестным кодом.
Здесь мы никак не предлагаем изменять sun.misc.Unsafe. Поддержка памяти вне кучи в FFM API — отличная альтернатива обёрткам вокруг malloc и free в sun.misc.Unsafe, а именно allocateMemory, setMemory, copyMemory и freeMemory. Мы надеемся, что библиотеки и приложения, которым требуется хранение вне кучи, перейдут на FFM API, чтобы со временем мы могли объявить эти методы sun.misc.Unsafe устаревшими, а затем и удалить их.
Альтернативы
Продолжать использовать java.nio.ByteBuffer, sun.misc.Unsafe, JNI и другие сторонние фреймворки.
Риски и допущения
Создание API для доступа к внешней памяти, который был бы одновременно безопасным и эффективным, — сложнейшая задача. Поскольку пространственные и временные проверки, описанные в предыдущих разделах, необходимо выполнять при каждом обращении, крайне важно, чтобы JIT-компиляторы могли оптимизировать эти проверки, например вынося их за пределы горячих циклов. Реализациям JIT, вероятно, потребуется доработка, чтобы использование API было таким же эффективным и поддающимся оптимизации, как использование существующих API, таких как ByteBuffer и Unsafe. Реализациям JIT также потребуется доработка, чтобы использование нативных дескрипторов методов, полученных из API, было как минимум таким же эффективным и поддающимся оптимизации, как использование существующих нативных методов JNI.
Зависимости
-
Foreign Function & Memory API можно использовать для более универсального и эффективного доступа к энергонезависимой памяти, который уже возможен через JEP 352 (Non-Volatile Mapped Byte Buffers).
-
Описанная здесь работа, вероятно, позволит в дальнейшем создать инструмент jextract, который на основе заголовочных файлов заданной нативной библиотеки автоматически генерирует нативные дескрипторы методов, необходимые для взаимодействия с этой библиотекой. Это ещё больше снизит издержки использования нативных библиотек из Java.