Java Foreign Function & Memory API
Вызов нативного кода и доступ к внеcкучной памяти в современном Java с помощью Foreign Function and Memory API.
Foreign Function and Memory (FFM) API — это современный и безопасный способ решить в Java две задачи, которые прежде требовали хрупкого Java Native Interface (JNI): вызывать функции, написанные на C и других нативных языках, а также читать и записывать память, находящуюся за пределами кучи Java. Этот API стал финальной возможностью в JDK 22 и находится в пакете java.lang.foreign.
В этой главе рассматривается, как работает внеcкучная память в FFM, как Arena управляет её временем жизни, как макеты описывают нативные данные и как вызвать функцию C из Java. По её окончании вы поймёте, когда FFM является подходящим инструментом и как его части сочетаются друг с другом.
Почему FFM заменяет JNI
До появления FFM взаимодействие с нативным кодом означало написание вручную склейки JNI, работу с ручными байтовыми буферами и постоянный риск крашнуть JVM неправильным указателем. Единственное несоответствие типов или ошибка на один байт в смещении могли повредить кучу или вызвать segfault всего процесса — а поскольку крах происходил в нативном коде, стек вызовов Java не выводился.
FFM заменяет всё это небольшим, типобезопасным API, построенным вокруг трёх идей:
Arenaуправляет временем жизни памяти: когда она закрывается, всё выделенное ею освобождается.MemorySegment— это представление с проверкой границ в этой памяти, поэтому выход за пределы диапазона выбрасывает исключение вместо повреждения памяти.Linkerсоздаёт вызываемый дескриптор нативной функции, заблаговременно сопоставляя типы C с типами Java.
В результате ошибки проявляются как исключения Java во время компоновки, а не как случайные крашы позже. В остальной части главы каждая из этих частей рассматривается по очереди.
Внеcкучная память с Arena и MemorySegment
MemorySegment — это непрерывная область памяти известного размера. В отличие от массива Java, она может находиться за пределами кучи, поэтому сборщик мусора никогда не перемещает её, и её можно напрямую передавать нативному коду. Сегмент никогда не создаётся напрямую — вы запрашиваете его у Arena, и arena владеет временем жизни сегмента.
Когда arena закрывается, все выделенные ею сегменты сразу освобождаются. Это делает утечки памяти и ошибки использования после освобождения трудновоспроизводимыми: обратитесь к сегменту после закрытия его arena — и вы получите исключение, а не крах.
import java.lang.foreign.*;
try (Arena arena = Arena.ofConfined()) {
// Allocate room for four ints, off the Java heap.
MemorySegment seg = arena.allocate(ValueLayout.JAVA_INT, 4);
seg.setAtIndex(ValueLayout.JAVA_INT, 0, 100);
int first = seg.getAtIndex(ValueLayout.JAVA_INT, 0);
System.out.println(first); // 100
} // arena.close() frees the segment hereКаждое чтение и запись происходит через ValueLayout, который точно указывает, сколько байт занимает значение и как оно расположено. Именно это обеспечивает проверку границ и типобезопасность каждого обращения.
Выбор Arena
Arena — это менеджер времени жизни, и выбранный фабричный метод определяет, кто может обращаться к памяти и когда она освобождается. Выбор правильного варианта — главное решение по безопасности в коде FFM.
| Arena | Время жизни | Доступ из потоков |
|---|---|---|
Arena.ofConfined() | До вызова close() | Только создавший поток |
Arena.ofShared() | До вызова close() | Любой поток |
Arena.ofAuto() | До сборки GC | Любой поток |
Arena.global() | Всё время работы программы | Любой поток |
Используйте ofConfined() в общем случае: кратковременная память, используемая одним потоком и детерминированно освобождаемая через try-with-resources. Обращайтесь к ofShared() только когда нескольким потокам нужно читать один сегмент, а к ofAuto() — когда конец времени жизни трудно обозначить. Если ваш код использует виртуальные потоки, предпочтите ofShared() или ofAuto(), поскольку confined arena привязана к одному потоку-носителю.
Описание макетов
ValueLayout описывает одно примитивное значение; MemoryLayout может описывать целые структуры и массивы. Макеты позволяют вычислять смещения и размеры, не захардкоживая магические числа, что делает доступ к нативным структурам читаемым.
import java.lang.foreign.*;
import static java.lang.foreign.ValueLayout.*;
// A C struct: struct Point { int x; int y; };
MemoryLayout point = MemoryLayout.structLayout(
JAVA_INT.withName("x"),
JAVA_INT.withName("y")
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment p = arena.allocate(point);
var xHandle = point.varHandle(MemoryLayout.PathElement.groupElement("x"));
var yHandle = point.varHandle(MemoryLayout.PathElement.groupElement("y"));
xHandle.set(p, 0L, 3);
yHandle.set(p, 0L, 4);
System.out.println(xHandle.get(p, 0L) + ", " + yHandle.get(p, 0L)); // 3, 4
}Именованные поля и аксессоры PathElement означают, что вы описываете структуру один раз и позволяете API вычислять байтовые смещения за вас.
Вызов нативных функций с помощью Linker
Главная возможность FFM — это downcall: вызов функции C из Java. Вы получаете платформенный Linker, ищете адрес функции с помощью SymbolLookup, описываете её сигнатуру с помощью FunctionDescriptor и получаете MethodHandle, который можно вызывать как любой метод Java.
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
Linker linker = Linker.nativeLinker();
// strlen lives in the standard C library, found via the default lookup.
MethodHandle strlen = linker.downcallHandle(
linker.defaultLookup().find("strlen").orElseThrow(),
// size_t strlen(const char *s);
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment cString = arena.allocateUtf8String("hello");
long len = (long) strlen.invoke(cString); // 5
}FunctionDescriptor сопоставляет типы C с носителями Java: указатель C становится ValueLayout.ADDRESS, C size_t отображается на JAVA_LONG, C int — на JAVA_INT. Сопоставьте правильно — и вызов типобезопасен; ошибитесь — и вы узнаете об этом во время компоновки, а не как случайный крах. Поскольку нативные вызовы выходят за пределы сети безопасности JVM, FFM является ограниченной операцией — модуль, использующий её, должен получить доступ с флагом --enable-native-access.
Полный выполняемый пример
API java.lang.foreign являлся предварительной версией до JDK 22, поэтому приведённая ниже программа демонстрирует те же две идеи — внеcкучная память и работа со строками в нативном стиле — используя только всегда доступные классы JDK, которые FFM был призван заменить. Прямой ByteBuffer — это память, выделенная за пределами кучи Java, точно так же как MemorySegment; чтение типизированных значений по байтовым смещениям отражает доступ через ValueLayout; а сканирование байт до нулевого терминатора — это именно то, что делает strlen в C.
Что следует вынести из запуска:
isDirect = trueподтверждает, что буфер выделен за пределами кучи Java — то же свойство, которое позволяетMemorySegmentбезопасно передаваться нативному коду без перемещения GC.- Запись
(i + 1) * 10по каждому 4-байтовому смещению и обратное чтение даёт10, 20, 30, 40сsum = 100, показывая, что внеcкучная память — это настоящее, индексируемое, типизированное хранилище, как иMemorySegment. byteSize = 16— это четыре 4-байтовых int'а; адресация по явному байтовому смещению — это именно то, какValueLayoutвычисляет позиции в реальном FFM API.- Вручную созданная
cStringзаканчивается нулевым байтом, поэтому сканирование в стиле strlen останавливается там:strlen of the C string = 16совпадает сJava String.length() = 16, доказывая, что нулевой терминатор отмечает конец так, как ожидает C. - Ни один буфер не освобождается вручную — прямые буферы освобождаются, когда становятся недостижимыми, что соответствует
Arena.ofAuto(), тогда как реальнаяofConfined()arena FFM освобождала бы детерминированно приclose().
Когда использовать FFM
FFM — специализированный инструмент, а не повседневный. Обращайтесь к нему, когда действительно нужна нативная совместимость или внеcкучная память:
- Вызов существующей нативной библиотеки — кодека изображений на C, драйвера базы данных, SDK оборудования — без написания склейки JNI.
- Совместное использование больших буферов с нативным кодом, когда копирование в кучу Java расточительно, например, для конвейеров графики или аудио.
- Работа с очень большими внеcкучными наборами данных, которые не должны нагружать сборщик мусора.
Для обычной работы с файлами и буферами оставайтесь с высокоуровневыми API вроде Java NIO — они проще и безопасны по умолчанию. И помните, что FFM — это ограниченная операция: поскольку нативные вызовы выходят за пределы гарантий безопасности JVM, необходимо запускать программу с --enable-native-access, иначе вы получите предупреждение или ошибку во время выполнения.