Как безопасно и эффективно вызывать C-функции из Java? Благодаря Project Panama — новому API из OpenJDK — это стало возможно без использования JNI.
В новом переводе от команды Spring АйО познакомимся с основами FFM API, посмотрим на запуск Hello World на C, роль jextract, и на то, как управлять памятью вне кучи JVM.
Введение
В этой серии статей мы изучим API проекта Panama из OpenJDK. Моя цель — показать вам, как эффективно использовать API для доступа к внешним функциям и памяти (Foreign Function and Memory Access), обеспечивая взаимодействие Java с нативным кодом. После того как интерфейс FFI (foreign function interface) и API FFM (foreign function memory) были стандартизированы в JDK 22, эти API находятся в следующих пакетах:
java.lang.invoke.;
java.lang.foreign.;
Хотя эта статья рассчитана на новичков, я предполагаю, что вы знакомы с основами языка Java, немного владеете bash-скриптами и имеете представление о концепциях программирования на языке C. Если вы новичок в языке C — не переживайте, позже я объясню основные понятия.
Для самых нетерпеливых — переходите к репозиторию Panama4Newbies на GitHub.
В первой части этой серии я расскажу об основных требованиях, а затем проведу через несколько упражнений в качестве введения — мы создадим память вне кучи Java и вызовем нативные функции на C.
Что такое Project Panama?
Project Panama — это новый способ для языка программирования Java получать доступ к нативным библиотекам, написанным на таких языках, как C, C++, Objective-C/C++, Swift, Rust и Python (в настоящее время поддерживается ABI языка C).
Комментарий от Михаила Поливаха
Вот тут важно уточнить, что у языка С как такого нет своего ABI. Вообще у языков программирования нет ABI. ABI в основной степени зависит от операционной системы, на которой выполняется тот или иной код. Для большинства серверных мощностей на Unix-like системах это System V ABI.
Но всё же, иногда люди говорят "C ABI" и имеют в виду стандартный ABI той или иной ОС.
Хотя это определение может показаться упрощённым, Project Panama находится в разработке уже более 11 лет и продолжает успешно развиваться! Огромная благодарность и признательность всему сообществу OpenJDK!
Project Panama — это надпроект, включающий в себя множество JEP (Java Enhancement Proposal), как показано ниже:
API для доступа к внешним функциям и памяти
Первая инкубация: JEP-412
Вторая инкубация: JEP-419
Предварительная версия: JEP-424
Финальная версия: JEP-454 (выпущена в Java 22)
API доступа к внешней памяти — позволяет выделять и отображать память за пределами кучи JVM. Поддерживает создание указателей C, структур и других примитивных или нативных типов данных: JEP-370, JEP-383
API внешней линковки (Foreign Linker API) — предоставляет возможность вызывать функции из нативных библиотек: JEP-389
Vector API — независимый от платформы способ использования SIMD векторных инструкций:
JEP-338 — 1-я версия (JDK 16)
JEP-414 — 2-я версия (JDK 17)
JEP-417 — 3-я версия (JDK 18)
JEP-426 — 4-я версия (JDK 19)
JEP-438 — 5-я версия (JDK 20)
JEP-448 — 6-я версия (JDK 21)
JEP-460 — 7-я версия (JDK 22)
JEP-469 — 8-я версия (JDK 23)
JEP-489 — 9-я версия (JDK 24)
JEP-508 — 10-я версия (JDK 25)
JEP-529 — 11-я инкубационная версия (JDK 26)
Примечание: в серии статей «Project Panama для новичков» рассмотрение Vector API будет отложено до тех пор, пока API не выйдет из стадии инкубации. В настоящее время это 11-я инкубация, запланированная на JDK 26. Проводится тесная интеграция с проектом Valhalla в рамках JEP-529. Основное внимание уделяется использованию value-типов и улучшению автовекторизации под конкретное аппаратное обеспечение, с конечной целью — снизить нагрузку на память и повысить производительность.
Ниже представлена временная шкала дорожной карты:

Примечание: на приведённой выше инфографике отсутствуют следующие предложения по улучшению Java (JEP):
JEP-424 (с февраля 2022 по сентябрь 2025)
JEP-454 (с июня 2023 по октябрь 2024)
Серия обучающих материалов основана на JEP 454, финальная версия которого была выпущена в составе JDK 22.
Зачем использовать Project Panama?
Прежде чем ответить на этот вопрос, позвольте задать другой: вы слышали о Minecraft? Если вы ответили «Да, конечно», — отлично! Если «Нет», то обязательно загляните на сайт https://www.minecraft.net/en-us. А вы знали, что Minecraft написан на Java с использованием популярной библиотеки для разработки игр с открытым исходным кодом под названием LWJGL (LightWeight Java Game Library)?
К чему я это всё веду? Если вы начнёте глубже изучать реализацию этой игровой библиотеки, то обнаружите, что она использует JNI (Java Native Interface) для доступа к нативным библиотекам, таким как OpenGL, OpenCL и др. Эти библиотеки написаны на языке C и позволяют взаимодействовать с аппаратными средствами устройства — такими как звук, устройства ввода или графические процессоры (GPU).
Теперь вернёмся к вопросу: «Зачем?».
Короткий ответ: Panama проще в использовании и может обеспечивать лучшую производительность.
Развёрнутый ответ: использование сгенерированного и обёрточного кода JNI может быть подвержено ошибкам и со временем становится трудно поддерживаемым. Кроме того, он требует преинсталированного нативно скомпилированного вспомогательного (glue) кода на системе. Иначе говоря, Project Panama — это чисто Java-решение, которое позволяет получить доступ к уже существующим нативным библиотекам с производительностью, сравнимой или превосходящей JNI.
Вот примеры сценариев использования Project Panama:
Доступ к коду на C/C++ из Java
Доступ к драйверам устройств в встраиваемых системах, например, на Raspberry Pi
Безопасный доступ к памяти за пределами кучи JVM. Замена использования небезопасного API Unsafe
Повышенная производительность при использовании низкоуровневых API для работы с аппаратным SIMD-ускорением
Вызовы (callback’и) из кода на C в Java
Хорошо, вы убедились. Тогда как начать?
Прежде чем приступить, важно познакомиться с jextract — инструментом командной строки, который автоматизирует генерацию низкоуровневого Java-кода на Project Panama. Благодаря привязке нативных библиотек к Java, jextract устраняет необходимость в утомительной ручной работе по вызову нативных функций и взаимодействию с нативными символами.
Где лежит jextract?
Получить jextract можно двумя способами: собрать из исходников или загрузить готовую сборку:
Сборка из исходников: клонируйте официальный репозиторий jextract на GitHub и следуйте инструкциям по сборке, приведённым в файле README.
Загрузка готовой версии: для более удобной установки загрузите последние ранние (early-access) бинарные сборки для вашей операционной системы (Windows, macOS или Linux) напрямую с сайта: jdk.java.net/jextract
По мере дальнейшего изучения мы узнаем, что инструмент jextract отвечает за генерацию Java-обвязки, создаваемой на основе C заголовочных/header файлов (.h) и соответствующих им нативных shared библиотек.
Файлы библиотек имеют следующие расширения:
.dll — для Windows
.so — для Linux
.dylib — для macOS
Начало работы
Перед тем как приступить, убедимся, что ваша среда настроена правильно. Ниже приведены инструкции по установке для вашей операционной системы.
Инструкции для macOS / Linux:
Шаг 1: Загрузите JDK 25+ и распакуйте архив в нужную директорию.
$ export JAVA_HOME=<путь к JDK>
Шаг 2: Загрузите последнюю версию jextract здесь. После загрузки распакуйте архив в директорию и установите переменную среды JEXTRACT_HOME:
$ export JEXTRACT_HOME=<путь к jextract>
Шаг 3: Установите переменную PATH:
# Для Mac/Linux
$ export JAVA_HOME=<путь к JDK>
$ export JEXTRACT_HOME=<путь к jextract>
$ export PATH=$JAVA_HOME/bin:$JEXTRACT_HOME/bin:$PATH
Примечание: чтобы сделать переменные среды постоянными, добавьте их в файл .bashrc или .bash_profile на Linux/MacOS. На новых версиях macOS используйте файлы .zshrc или .zprofile.
Комментарий от Михаила Поливаха
Это сильно зависит от того, какой у вас стандартый SHELL. Разные shell-ы используют разные rc файлы.
В примере указаны примеры файлов для bash и zsh shell-ов, что в целом покрывает, я предположу, процентов 90 всех пользователей. Но если у Вас, например, terminator, то для Вас файл будет другой
Шаг 4: Проверьте, что Java и jextract доступны:
$ java -version
$ jextract -h
Инструкции для Windows:
Шаг 1: Загрузите JDK и jextract по ссылке выше. Затем распакуйте архивы в директорию.
Шаг 2: Установите переменные среды JAVA_HOME, JEXTRACT_HOME и PATH:
c:\> set JAVA_HOME=<путь к JDK>
c:\> set JEXTRACT_HOME=<путь к jextract>
c:\> set PATH=%JAVA_HOME%\bin;%JEXTRACT_HOME%\bin;%PATH%
Примечание: чтобы сделать переменные среды постоянными на Windows:
Щёлкните правой кнопкой мыши по значку «Компьютер» и выберите «Свойства», или в Панели управления выберите «Система».
Выберите «Дополнительные параметры системы».
В открывшемся окне задайте переменные среды.
После этого перезапустите окно командной строки (cmd.exe).
Шаг 3: Проверьте доступность среды выполнения и инструмента jextract
c:\> java -version
c:\> jextract -h
После выполнения команды jextract -h, которая выводит список доступных параметров запуска, вы убедитесь, что всё настроено правильно. Вы должны увидеть нечто подобное:
Usage: jextract <options> <header file> [<header file>] [...]
Option Description
------ -----------
-?, -h, --help print help
-D --define-macro <macro>=<value> define <macro> to <value> (or 1 if <value> omitted)
-I, --include-dir <dir> add directory to the end of the list of include search paths
--dump-includes <file> dump included symbols into specified file
--header-class-name <name> name of the generated header class. If this option is not
specified, then header class name is derived from the header
file name. For example, class "foo_h" for header "foo.h".
--include-function <name> name of function to include
--include-constant <name> name of macro or enum constant to include
--include-struct <name> name of struct definition to include
--include-typedef <name> name of type definition to include
--include-union <name> name of union definition to include
--include-var <name> name of global variable to include
-l, --library <libspec> specify a shared library that should be loaded by the
generated header class. If <libspec> starts with :, then
what follows is interpreted as a library path. Otherwise,
<libspec> denotes a library name. Examples:
-l GL
-l :libGL.so.1
-l :/usr/lib/libGL.so.1
--use-system-load-library libraries specified using -l are loaded in the loader symbol
lookup (using either System::loadLibrary, or System::load).
Useful if the libraries must be loaded from one of the paths
in java.library.path.
--output <path> specify the directory to place generated files. If this
option is not specified, then current directory is used.
-t, --target-package <package> target package name for the generated classes. If this option
is not specified, then unnamed package is used.
--symbols-class-name <name> override the name of the root header class
--version print version information and exit
macOS platform options for running jextract (available only when running on macOS):
-F <dir> specify the framework directory
--framework <framework> specify framework library. --framework libGL is equivalent to
-l :/System/Library/Frameworks/libGL.framework/libGL
Если вы видите список параметров jextract, значит, вы готовы перейти к следующему разделу.
Нужен ли компилятор C?
Короткий ответ — нет. В демонстрационных целях я буду использовать стандартный компилятор C в своей среде macOS. Это необязательно, и я буду использовать его лишь для объяснения концепций внутри C-программы.
Если вы работаете в Windows, можете воспользоваться Visual C++ от Microsoft, в который входит компилятор C: https://docs.microsoft.com/en-us/cpp/build/walkthrough-compile-a-c-program-on-the-command-line?view=msvc-160
Также можно загрузить MinGW с сайта: https://www.mingw-w64.org
Приступим!
Прежде чем приступить к работе с API Project Panama, давайте рассмотрим пример Hello World на языке программирования C. Понимание структуры C-программы поможет нам разобраться, как вызывать нативный код.
Позже мы напишем чисто Java-программу Hello World, которая будет вызывать стандартные нативные функции, написанные на C.
Анатомия Hello World на C
Примечание: этот раздел учебника необязательный. Если у вас нет установленного компилятора C — просто пропустите шаг компиляции.
Шаг 1: Введите код из листинга 1 в текстовый редактор и сохраните его как helloworld.c.
Листинг 1: helloworld.c
#include <stdio.h>
int main() {
printf("Hello, World! \n");
return 0;
}
Шаг 2: Скомпилируйте код
$ gcc helloworld.c
Шаг 3: Просмотрите скомпилированный исполняемый файл
$ ls -l a.out
-rwxr-xr-x 1 jdoe staff 49424 Jul 29 21:06 a.out
Шаг 4: Запустите программу
$ ./a.out
Hello, World!
Ну что ж, всё было довольно просто! Давайте разберём, что на самом деле делает программа. Ниже — общий обзор:
Директива
#include <stdio.h>подключает стандартную библиотеку ввода-вывода ANSI C. Аналогично import в Java.
Комментарий от Михаила Поливаха
Сравнивать include в C/C++ и import в Java это прямо вообще некорректно. Если только с целью максимального упрощения - то ещё ладно, но опять же, имейте в виду что механизм работы include даже сравинвать нельзя с Java, как минмиум потому, что в C отсуствует райнтайм и include это деректива для препроцссора, который вообще как таковой отсусвует в С
Вообще все сравнение принимайте с "grain of salt", т.к. оно максимально притянутое за уши.
Функция
main()— это точка входа в программу, какpublic static void main()в Java.Функция
main()возвращает значение типаint.Тело функции вызывает
printf()из библиотеки stdio, принимающую аргумент типаconst char *.
Подробности по вышеуказанным пунктам:
Шаг 1:
stdio.h— стандартная библиотека ввода/вывода C. Файлы .h в C играют роль, аналогичную интерфейсам в Java: они описывают сигнатуры функций и константы библиотеки. В нашем случае stdio содержит функциюprintf(), аналогичнуюSystem.out.printf()в Java.
Шаг 2: В языке C у main() есть две возможные сигнатуры:
int main() {}
int main(int argc, char *argv[]) {}
Вторая используется, если нужно передавать аргументы командной строки.
Шаг 3: main() возвращает 0, если выполнение прошло успешно. Любое другое значение — код ошибки.
Шаг 4: int printf(const char *format, ...) — функция из stdio.h, принимающая строку формата и переменное количество аргументов. Аналогично System.out.printf("Hello, %s\n", "Panama!"); в Java.
Два ключевых момента при работе с C:
Внимательно относитесь к директивам #include. Заголовочные файлы (.h) необходимы для того, чтобы jextract мог сгенерировать привязки (bindings) — Java-классы, соответствующие функциям и структурам C.
Преобразование типов данных. Типы данных C должны быть сопоставлены с типами Java. К счастью, Project Panama автоматически создаёт вспомогательные методы, упрощающие этот процесс.
Теперь, когда у нас есть хорошее понимание программы Hello World на C, давайте создадим эквивалентный пример на Java с использованием Project Panama. То есть, наша Java-программа нативно вызовет функцию printf() из стандартной C-библиотеки.
Пример Hello World с использованием Project Panama
Как упоминалось ранее, программа Hello World на C использовала библиотеку stdio.h. Теперь мы сгенерируем Java-код для взаимодействия с этой библиотекой. Вместо ручного написания привязок (что выходит за рамки этого руководства), мы воспользуемся инструментом jextract. Этот «волшебный» инструмент сгенерирует чистый Java-код, который будет привязан к нативным библиотекам. Полученные .class-файлы будут содержать метаданные и большую часть низкоуровневого кода Panama, упрощающего работу с API для пользователя (то есть для нас с вами).
Примечание: при использовании параметра --output инструмента jextract можно вывести или сгенерировать исходный код Java, пригодный для использования в проектах. В этих сгенерированных файлах можно изучить, как устроен Panama-код «под капотом».
Благодаря чётко определённой спецификации языка C, jextract способен без труда генерировать соответствующий Java-код.
Комментарий от Михаила Поливаха
Авто рофлит, не слушайте его. Спецификация языка С это одни сполшные UB. UB - это ситуация, когда ребенка в детстве не сильно любили, а потом он вырос и начал писать компиляторы и спецификации языков программирования. Вот это UB.
В отличие от этого, язык C++ пока не поддерживается — работа с ним требует дополнительных шагов, которые здесь не рассматриваются (см. серию «Java Panama Polyglot (C++)», часть 1).
Теперь мы готовы приступить к созданию нашего собственного Java Hello World-примера с использованием Project Panama и вызовом printf() из стандартной C-библиотеки.
Давайте применим jextract к STDIO!
Инструмент jextract был обновлён для упрощённой работы со стандартными библиотеками C. Теперь можно указывать заголовочные файлы в двойных кавычках, как показано ниже:
jextract --output generatedsrc -t org.unix "<stdio.h>"
--output— директория, в которую будет сгенерирован Java-код-t— пространство имён (package), в котором будут размещены классы"
<stdio.h>" — стандартный заголовочный файл C (в кавычках)
Сгенерированный Java-код (Panama) будет помещён в директорию:
generatedsrc/org/unix
Пример результата команды:
$ ls -l generatedsrc/org/unix
__darwin_pthread_rwlock_t.java
__darwin_pthread_rwlockattr_t.java
__mbstate_t.java
__sbuf.java
__sFILE.java
_opaque_pthread_attr_t.java
_opaque_pthread_cond_t.java
_opaque_pthread_condattr_t.java
_opaque_pthread_mutex_t.java
... more
Если вам удалось успешно сгенерировать код, можно пропустить шаги 1 и 2 и сразу перейти к шагу 3 — созданию HelloWorld.java, используя сгенерированный Panama-код.
Шаг 1 (необязательный): найти заголовочный файл stdio.h
$ gcc -H -fsyntax-only helloworld.c
На macOS (Big Sur, Monterey) вывод будет примерно следующим:
. /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/stdio.h
.. /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/_stdio.h
...
Теперь, когда вы знаете точный путь к файлу, можно использовать его с jextract.
$ jextract [options] <путь_к_файлу/stdio.h>
Шаг 2 (необязательный): использовать jextract с указанием директории include-файлов через -I
На macOS:
$ jextract --output generatedsrc -t org.unix \
-I /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include \
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/stdio.h
На Linux:
$ jextract --output generatedsrc -t org.unix \
-I /usr/include /usr/include/stdio.h
Теперь вы можете использовать сгенерированный код в Java-программе Hello World с использованием Project Panama.
Шаг 3: создайте Java-файл HelloWorld.java
Скопируйте и вставьте следующий код в файл HelloWorld.java.
import static org.unix.stdio_h.*;
void main() {
// Use a confined arena for deterministic memory management
try (Arena arena = Arena.ofConfined()) {
// MemorySegment C's printf using a C string
MemorySegment cString = arena.allocateFrom("Hello World! Panama style\n");
int charCount = printf.makeInvoker().apply(cString);
}
}
Шаг 4: Компиляция сгенерированных файлов вместе с HelloWorld.java
Сначала скомпилируем Java-код, сгенерированный jextract, а затем — основной пример HelloWorld.java.
# Компиляция сгенерированного Java-кода из generatedsrc
javac generatedsrc/org/**/*.java -d classes
# Компиляция примера HelloWorld.java из директории src
javac -cp .:classes src/*.java -d classes
Шаг 5: Запуск Panama-программы HelloWorld.java
$ java -cp .:classes \
--enable-native-access=ALL-UNNAMED \
HelloWorld
Результат выполнения:
Hello World! Panama style
Как это работает?
Шаг 1: Поиск заголовочных файлов с помощью компилятора C
На платформах macOS/Linux компилятор C позволяет определить, где в системе расположены заголовочные файлы. Это особенно полезно, если вы устанавливаете сторонние библиотеки (например, OpenCL, TensorFlow и др.) с помощью менеджера пакетов, такого как Homebrew на macOS.
Шаг 2: Магия jextract
Инструмент jextract генерирует Java-исходный код:
--output— каталог, куда будет помещён сгенерированный код-t— пространство имён (package) для Java-классов-I(заглавная i) — путь к директории с #include-файламиПоследним аргументом указывается файл заголовка (например, stdio.h). Его можно указать в двойных кавычках, как в C-коде: "
<stdio.h>"
Ранее jextract мог сразу компилировать код, но теперь он только генерирует исходники. Если вам нужно указать несколько директорий с заголовками, просто добавьте дополнительные -I <путь>.
Шаг 3: HelloWorld.java и новые возможности Java 25
Обратите внимание:
В HelloWorld.java отсутствует ключевое слово
publicперед классомМетод
main()написан в сокращённой форме
Это стало возможным благодаря JEP 512 (Compact Classes), появившемуся в Java 25. Также добавлены удобства вроде IO.println() и других сокращений.
Вызов C-функции printf()
Если вы используете printf() (вариативную функцию), сгенерированный Panama-код теперь использует две ключевые конструкции:
int charCount = printf.makeInvoker().apply(cString);
.makeInvoker(MemoryLayout...)
— создаёт экземпляр объекта для вызова функции printf()
.apply(MemorySegment cCharArray, Object... values)
— вызывает функцию printf() с аргументами
При успешном вызове printf() возвращает количество выведенных символов. При ошибке — отрицательное число.
Шаг 4: Компиляция сгенерированного кода и HelloWorld.java:
javac generatedsrc/org/**/*.java -d classes
javac -cp .:classes src/*.java -d classes
Шаг 5: Запуск HelloWorld
java -cp .:classes \
--enable-native-access=ALL-UNNAMED \
HelloWorld
-сp— путь к скомпилированным классам--enable-native-access=ALL-UNNAMED— разрешает доступ к нативному коду без дополнительных флагов или предупреждений
А что насчёт сторонних C-библиотек?
При установке сторонних библиотек они обычно помещаются в:
/usr/lib
/usr/local/lib
Чтобы использовать такую библиотеку, применяется опция -l (строчная «L») в jextract.
Комментарий от Михаила Поливаха
Речь про то, что если Вы хотите использовать dynamically linked libraries (Windows) или они же shared libraries (Linux). Дело в том, что подавляющее количество библиотек да и вообще софта на С (это долгая история почему, если нужно - расскажем на подкасте) он dynamically linked.
Поэтому линкеру (тому самому, который вы вызываете с -l) надо будет понять, где конкретно на вашей машине лежат нужные транзитивные библиотеки, которые надо будет динамически прилинковать.
Линкер на самом деле довольно умный, он знает про /usr/lib и /usr/local/lib и т.д. Соот-но, чатсо достаточно лишь указать имя библиотеки для линоквни, линкер дальше сам разберется.
Вы можете указать:
Имя библиотеки (если она в стандартном месте), или
Абсолютный путь к файлу библиотеки
Пример: работа с TensorFlow
На macOS файл библиотеки будет: libtensorflow.dylib
На Linux — libtensorflow.so
На Windows — tensorflow.dll
Пример использования jextract для TensorFlow:
jextract \
-I /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/ \
-t org.tensorflow \
-I ${LIBTENSORFLOW_HOME}/include \
-l ${LIBTENSORFLOW_HOME}/lib/libtensorflow.dylib \
${LIBTENSORFLOW_HOME}/include/tensorflow/c/c_api.h
Обратите внимание:
На macOS указан полный путь к файлу библиотеки:
${LIBTENSORFLOW_HOME}/lib/libtensorflow.dylib
Это необходимо, если библиотека не установлена в стандартный системный путь. В противном случае достаточно указать только имя: -l tensorflow.
С этим знанием вы можете подключать к Java практически любые библиотеки на C — от стандартных до специализированных, используя Project Panama и jextract.
Шаг 3: Java-код вызывает C-функции
void main() {
// Используем ограниченную (confined) арену для детерминированного управления памятью
try (Arena arena = Arena.ofConfined()) { // (A)
// Создаём сегмент памяти для строки C
MemorySegment cString = arena.allocateFrom("Hello World! Panama style\n"); //(B)
int charCount = printf.makeInvoker().apply(cString); // (C)
}
}
(A) Создание Arena через try-with-resources
Создаётся объект Arena с типом ofConfined(), то есть локальная область управления нативной памятью. После выхода из блока try ресурсы будут автоматически освобождены, что гарантирует безопасную деаллокацию.
Комментарий от Михаила Поливаха
Arena - это по сути основная абстракция для Вас при работе с Off Heap аллокациями в рамках нового FFM API, некоторая замена Direct Byte Buffers.
Подробнее о разных типах арены — см. Javadoc Panama.
(B) Выделение памяти и преобразование Java-строки в C-строку
Метод allocateFrom(String) выделяет память внутри арены и конвертирует Java-строку в C-строку (тип char*).
Любая переменная, имитирующая C-тип в Java, будет представлена объектом
MemorySegment.В языке C строки — это массивы символов (
char*), оканчивающиеся \0.
(C) Вызов нативной функции printf()
Метод makeInvoker() создаёт объект для вызова функции printf. Затем вызывается метод apply(cString), который передаёт строку в функцию.
Обратите внимание: проблема с stdout
Если после вызова printf() в C вы вызываете System.out.println() в Java, вывод может быть перемешан. Например, на macOS вы можете получить:
Java System.out
Hello World (Native C Called)
Причина — буферизация вывода в C. Чтобы устранить это, необходимо принудительно сбросить буфер с помощью функции fflush().
Пример с fflush():
var cString = arena.allocateFrom("Hello World (Native C Called)\n");
printf.makeInvoker().apply(cString);
fflush(NULL()); // сгенерированная функция из org.unix.stdio_h.*
System.out.println("Java System.out\n");
Ожидаемый результат:
Hello World (Native C Called)
Java System.out
Теперь, когда вы умеете выводить C-строку через printf(), давайте перейдём к следующему этапу — подстановке значений в шаблонную строку, используя форматирование, аналогичное System.out.printf() в Java.
Функция printf() из C (stdio.h) и Java System.out.printf() имеют одинаковую сигнатуру вызова, что упрощает понимание и использование шаблонов строк.
Анатомия подстановки значений в C: спецификатор формата %s заменяется строкой "Fred":
printf("Hello %s!", "Fred"); // Вывод: Hello Fred!
При использовании API Foreign Function & Memory (FFM) необходимо определить типы данных функции с помощью FunctionDescriptor перед её вызовом. В данном случае функция printf ожидает char* (строку в стиле C), то есть указатель.
Ручная схема: если вы не используете jextract, используйте ValueLayout.ADDRESS для представления указателя на сегмент памяти. В данном случае мы хотим передать char*, поэтому используем макет ADDRESS, так как указатели в C — это просто адреса.
С использованием jextract: если вы генерируете привязки с помощью jextract, инструмент обычно предоставляет платформенно-зависимую константу, такую как stdio_h.C_POINTER, для обработки значений адресов любых типов указателей C.
Выделение памяти: нельзя напрямую передать строку Java в C. Сначала необходимо использовать Arena для выделения MemorySegment, содержащего байты строки в off heap памяти. Чтобы вызвать printf() из C, нужно выделить сегмент памяти const char* внутри арены и передать его функции printf().
Вариативные аргументы: если вы вызываете printf вручную (без использования jextract), необходимо использовать метод downcallHandle() объекта Linker. Вызов функции с переменным количеством аргументов — это более продвинутая тема, и мы можем ограничиться функциями, сгенерированными jextract. Но если вам интересно, обратите внимание на метод downcallHandle() объекта Linker.
MethodHandle downcallHandle(MemorySegment address,
FunctionDescriptor function,
Option... options);
Вернёмся к теме макетов памяти (MemoryLayouts), которые необходимо указать перед вызовом метода apply() для printf.
Ниже показан статический метод класса printf под названием makeInvoker(ValueLayout... (переменные аргументы)). Мы указываем первый вариативный аргумент как тип C_POINTER.
MemorySegment formatCStr = arena.allocateFrom("Hello %s!\n");
printf printfAlpha = printf.makeInvoker(stdio_h.C_POINTER);
MemorySegment nameStr = arena.allocateFrom("Fred");
printfAlpha.apply(formatCStr, nameStr); // Hello Fred!
Ниже приведены часто используемые спецификаторы формата (не полный список).
Спецификатор формата |
Тип данных |
Описание |
|---|---|---|
|
|
Выводит знаковое целое число в десятичном формате. |
|
|
Выводит число с плавающей запятой в экспоненциальной форме. |
|
|
Выводит последовательность символов до нулевого символа ( |
|
Указатель ( |
Выводит адрес в памяти, как правило, в шестнадцатеричном формате. |
Давайте рассмотрим более сложную форматную строку с использованием разных спецификаторов формата. Для начала посмотрим на следующий код на C, который будет преобразован в код Panama (FFI & FFM):
int charCount = printf("%s is %d years old and is %.1f feet tall.\n", "Fred", 60, 5.9d);
Выше вы видите спецификаторы %s, %d и %.1f, что означает наличие трёх спецификаторов формата: строка C, int и double (число с плавающей точкой). Теперь мы можем вызвать метод makeInvoker() с соответствующими типами данных, сгенерированными jextract. Эти типы будут следующими: C_POINTER, C_INT и C_DOUBLE.
void main() {
try (Arena arena = Arena.ofConfined()) {
// *******************************************************************************
// * How to call printf() with format string such as
// * printf("%s is %d years old and is %.1f feet tall.\", "Fred", 60, 5.9f);
// *******************************************************************************
MemorySegment formatCStr = arena.allocateFrom("%s is %d years old and is %.1f feet tall.\n");
MemorySegment nameCStr = arena.allocateFrom("Fred");
// Create an instance of a generated org.unix.printf class from jextract. (FYI, class is named all lowercase)
printf printfAlpha = printf.makeInvoker(
stdio_h.C_POINTER, /* 2nd param - C string %s. A pointer to character array address */
stdio_h.C_INT, /* 3rd param - C int %d. Integer value (32bit whole number) */
stdio_h.C_DOUBLE); /* 4th param - C double %f. Floating point decimal. floats promote to a double*/
int charCount = printfAlpha.apply(
formatCStr,
nameCStr,
60,
5.9f);
}
}
На этот раз я разделил вызовы makeInvoker() и apply(), в отличие от предыдущего примера с использованием цепочки методов. Разделив вызовы, вы можете увидеть, что makeInvoker() возвращает экземпляр объекта printf, способного принимать три вариативных аргумента: "Fred", 60, 5.9d. Первый параметр — это строка формата: "%s is %d years old and is %.1f feet tall.\n".
Результат выполнения:
Fred is 60 years old and is 5.9 feet tall.
Поздравляю, что дошли до этого этапа! Хотя приятно уметь создавать и выводить C-строки, давайте теперь посмотрим, как создавать примитивные типы данных вне кучи Java.
Рассмотрим, как создавать примитивные типы данных C, а затем — как создавать массивы этих примитивов.
Создание, получение и установка примитивных типов C из Java
Чтобы проще запомнить, используйте шаблон:
allocateFrom → MemorySegment → get/set (называемый также паттерн MemSeg).
При создании примитивных данных C (переменных), помните: они, как и объекты, требуют выделения памяти, имеют адреса, через которые можно получить или изменить значение (разыменование).
Создание C-строк и примитивов через Arena.allocateFrom()
String-allocateFrom(String str),allocateFrom(String str, CharSet cs)e.g.arena.allocateFrom("hello", UTF_8)byte-allocateFrom(ValueLayout.OfByte layout, byte value)boolean-allocateFrom(ValueLayout.OfBoolean layout, boolean value)char-allocateFrom(ValueLayout.OfChar layout, char value)double-allocateFrom(ValueLayout.OfDouble layout, double value)float-allocateFrom(ValueLayout.OfFloat layout, float value)int-allocateFrom(ValueLayout.OfInt layout, int value)long-allocateFrom(ValueLayout.OfLong layout, long value)short-allocateFrom(ValueLayout.OfShort layout, short value)
Примечание: это лишь основные методы. Существуют и другие перегруженные версии, позволяющие создавать массивы, структуры и настраиваемые макеты памяти.
Чтение (get) значений из MemorySegment
byte - get(ValueLayout.OfByte, long offset)boolean - get(ValueLayout.OfBoolean, long offset)char
- get(ValueLayout.OfChar, long offset)double - get(ValueLayout.OfDouble, long offset)float - get(ValueLayout.OfFloat, long offset)int - get(ValueLayout.OfInt, long offset)long - get(ValueLayout.OfLong, long offset)short - get(ValueLayout.OfShort, long offset)
Запись (set) значений в MemorySegment
byte - set(ValueLayout.OfByte, long offset, byte value)boolean - set(ValueLayout.OfBoolean, long offset, boolean value)char
- set(ValueLayout.OfChar, long offset, char value)double - set(ValueLayout.OfDouble, long offset, double value)float - set(ValueLayout.OfFloat, long offset, float value)int - set(ValueLayout.OfInt, long offset, int value)long - set(ValueLayout.OfLong, long offset, long value)short - set(ValueLayout.OfShort, long offset, short value)
Примечание: также доступны методы для создания пользовательских схем размещения памяти (layouts).
MemorySegment formatCStr = arena.allocateFrom("A slice of %f \n");
// Create an off heap double containing the value of Pi.
MemorySegment cDouble = arena.allocateFrom(C_DOUBLE, Math.PI);
// Create an instance of a printf object via makeInvoker() that accepts a double (%f floating point number)
printf printfFun = printf.makeInvoker(C_DOUBLE);
// Invoke printf function such as printf("A slice of %f \n", 3.141593d);
printfFun.apply(formatCStr, cDouble.get(C_DOUBLE, 0)); // A slice of 3.141593
Вывод:
A slice of 3.141593
Разумеется, вы также можете передать в функцию значение Math.PI (примитив типа double из Java):
printfFun.apply(cFormatStr, Math.PI); // A slice of 3.141593
Примечание: предпочтительнее использовать сгенерированный jextract макет stdio_h.C_DOUBLE, вместо ValueLayout.JAVA_DOUBLE, так как C_DOUBLE соответствует разрядности целевой ОС.
Если вы предполагаете, что double всегда занимает 64 бита (8 байт), это может не соответствовать реальности на некоторых платформах.
Вот как выглядит сгенерированная переменная C_DOUBLE типа ValueLayout.OfDouble в коде, созданном jextract:
// jextract generated C_DOUBLE
public static final ValueLayout.OfDouble C_DOUBLE = (ValueLayout.OfDouble) Linker.nativeLinker().canonicalLayouts().get("double");
Создание массивов примитивных типов C
Теперь, когда вы знаете, как создавать примитивные типы данных, давайте создадим массивы примитивов C. Ниже показано, как выделить память для одномерного массива вне кучи Java. Затем мы получаем доступ к элементам массива с помощью метода getAtIndex(ValueLayout, index) и выводим содержимое.
out.println("An array of data");
MemorySegment cDoubleArray = arena.allocateFrom(C_DOUBLE,
1.0, 2.0, 3.0, 4.0,
1.0, 1.0, 1.0, 1.0,
3.0, 4.0, 5.0, 6.0,
5.0, 6.0, 7.0, 8.0
);
for (long i = 0; i < (4*4); i++) {
if (i>0 && i % 4 == 0) {
System.out.println();
}
out.printf(" %f ", cDoubleArray.getAtIndex(C_DOUBLE, i));
}
Вывод:
An array of data
1.000000 2.000000 3.000000 4.000000
1.000000 1.000000 1.000000 1.000000
3.000000 4.000000 5.000000 6.000000
5.000000 6.000000 7.000000 8.000000
Как показано выше, метод MemorySegment.getAtIndex(ValueLayout, index) позволяет извлекать и отображать конкретное значение из массива.
Чтобы изменить значения массива, используйте метод MemorySegment.setAtIndex().
Пример: получаем каждое значение и умножаем его на 3.
for (long i = 0; i < 16; i++) {
double newVal = cDoubleArray.getAtIndex(C_DOUBLE, i) * 3;
cDoubleArray.setAtIndex(C_DOUBLE, i, newVal);
}
Результат:
3.000000 6.000000 9.000000 12.000000
3.000000 3.000000 3.000000 3.000000
9.000000 12.000000 15.000000 18.000000
15.000000 18.000000 21.000000 24.000000
Заключение
В Части 1 мы узнали:
что такое Project Panama, зачем он нужен и где его найти
как выглядит программа Hello World на C
как использовать jextract для генерации Java-кода из stdio.h
как написать Java Hello World, вызывающий функцию
printf()из Cкак создавать примитивные типы C, включая массивы
Во 2 части мы углубимся в концепции C — такие как структуры (structs) и указатели (pointers).

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.