Вступ до Project Panama. Частина 1: Hello World

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Мене звуть Денис Макогон, я — Principal Java Developer та Java Advocate у команді Java Developer Relationships в компанії Oracle. Сьогодні я хочу поділитися першою статтею з циклу про OpenJDK Project Panama.

Вступ

Оскільки JDK 19 буде випущено в найближчий час, настав влучний момент, щоб поговорити про Project Panama, а точніше про Foreign Function and Memory API, який полегшує взаємодію між Java та нативним кодом.

Це вступ у Foreign Function and Memory API на прикладі «Hello World» мовою Java, що викликає нативний С код.

Перед початком

Для того щоб успішно запускати семпли коду із цієї статті, вам буде потрібна інсталяція JDK 19 (наразі Early Access build версією не нижче 19+24).

Введення у Project Panama

Project Panama створений, щоб бути містком між двома світами: JVM та нативним кодом, написаним іншими мовами програмування, такими як C/C++. Він є більш гнучкою за потужною альтернативою до Java Native Interface.

Сам проєкт складається із трьох великих частин:

  • Foreign Function and Memory API (JEP 424);
  • інструмент кодогенерації jextract;
  • Vector API (JEP 338).

Варто визначити найважливіші компоненти, якими ця стаття буде оперувати:

  • memory segment та memory address — набір API-класів для роботи з нативний пам’яттю та вказівником на неї;
  • memory layout та descriptors — API для моделювання нативних типів даних (структур, примітивів) і дескрипторів нативних функцій;
  • memory session, symbol lookup — абстракції для управління життєвим циклом одного або кількох сегментів нативної пам’яті;
  • linker та symbol lookup — набір API-класів для виконання нативних викликів (downcalls, upcalls);
  • segment allocator — теж API для виділення сегментів пам’яті в схожий із memory session.

Будь ласка, зверніть увагу, що ця та наступні статті з циклу не покривають Vector API, а лише фокусуться на роботі із нативним кодом на C/C++.

Hello World!

Чим глибше ви вивчаєте, тим більше ви розумієте, що для початку роботи з Project Panama належним чином, дуже важливо мати гарний вступ, просто щоб переконатися, що ви не пропустили важливі концепції, прийоми та методології роботи із нативним кодом з середини JVM.

У цій початковій статті розглянемо лише Linker, а також коротко пройдемося по SymbolLookup та, власне, керуванню нативною пам’яттю (MemorySession).
Ці три основні компоненти, описані вище, є, як кажуть, «building blocks» для більш глибокого розуміння та маштабної розробки програм, що складаються з Java та нативного коду.

Linker

З технічної точки зору, Linker є містком між двома бінарними інтерфейсами: власним кодом JVM і C/C++ або імплементацією C ABI.

JDK 19 пропонує набір реалізації C ABI для всіх популярних платформ:

public static Linker getSystemLinker() {
    return switch (CABI.current()) {
       case Win64 -> Windowsx64Linker.getInstance();
       case SysV -> SysVx64Linker.getInstance();
       case LinuxAArch64 -> LinuxAArch64Linker.getInstance();
       case MacOsAArch64 -> MacOsAArch64Linker.getInstance();
   }
}

У термінології JDK, Linker є прикладом реалізації C ABI для конкретної платформи.
Linker надає набір методів для виконання обох функцій бінарного інтерфейсу:

  • downcall — подія, ініційована підсистемою високого рівня у нашому випадку JVM на підсистему нижнього рівня, наприклад, ядро ОС, або деякий код Java, який викликає певний нативний код.
  • _upcall — зворотня операція до downcall, наприклад, нативний код, який викликає Java-код.

Є дуже влучне порівняння: Linker схожий на ваш телефон — телефонуйте кому завгодно, просто наберіть відповідний номер телефону, а методи пошуку символів SymbolLookup подібні до вашої книги контактів — просто вкажіть правильний «правильний номер» (дескриптор функції), кому ви хочете зателефонувати!

Щоб виконати downcall, нам потрібен дескриптор нативної функції для виклику, нативна адреса, відшукана за допомогою пошуку символів SymbolLookup,
і Linker для створення застосунку методу нативної функції.

У цій статті ми розглянемо реалізацію класичного додатку Hello World мовою С на Java:

int printf(const char * __restrict, ...)

C «Hello World» мовою Java

Щоб написати додаток «Hello World» на Java, який використовує нативну функцію C printf, нам потрібно:

1. Знайти нативну адресу функції C printf

Отже, пошук нативної адреси виглядає наступним чином:

Linker linker = Linker.nativeLinker();
SymbolLookup linkerLookup = linker.defaultLookup();
SymbolLookup systemLookup = SymbolLookup.loaderLookup();
SymbolLookup symbolLookup = name ->
        systemLookup.lookup(name).or(() -> linkerLookup.lookup(name));
Optional<MemorySegment> printfMemorySegment = symbolLookup.lookup("printf");

Навряд пошук функції printf, яка є частиною стандартної бібліотеки С, буде невдалим, бо JVM «знає де» шукати ці функції без зайвої допомоги. Але технічно це можливо, більш детально про це поговоримо в інших частинах циклу статей.

2. Створити дескриптор нативної функції

Коли ми вже знаємо, де знаходиться C printf, нам потрібно написати дескриптор функції, який складається з типу значення, що повертається, та іменованих параметрів. Варто враховувати, що функції типу printf називаються variadic — окрім іменованих параметрів вони також приймають недетерміновану кількість неіменованих агрументів. У Java такі функції називаються методами із varargs.

Щоб спростити реалізацію (так, доведеться це зробити), будемо реалізовувати спрощену версію FunctionDescriptor для printf:

FunctionDescriptor printfDescriptor = 
                   FunctionDescriptor.of(JAVA_INT, ADDRESS);

З точки зору Java та JVM, не має значення, який тип значення ховається за C pointer, тому що JVM достатньо знати, що це покажчик певної розрядності (32/64-біт, залежить від платформи).

Відповідно до реалізації, дескриптор printfDescriptor визначає нативну функцію, що повертає значення типу int, а її параметром є покажчик.
Цей дескриптор майже відповідає визначенню функції C printf із stdio.h,
оскільки він визначає стандартну функцію, тоді як вже з’ясували, printf — variadic-функція, тобто потрібні vararg-и, але їх нема. У наступній частині я поясню, як реалізувати С variadic-функції засобами Java, використовуючи Foreign Function and Memory API.

Моделювання типів даних С засобами MemoryLayouts

У Java «memory layouts» використовується для моделювання нативних типів даних. У нашому випадку JAVA_INT, і ADDRESS є макетами відповідних типів C int32 та покажчик.

int32 у термінах Java:

OfInt JAVA_INT = new OfInt(ByteOrder.nativeOrder()).withBitAlignment(32);

JAVA_INT є екземпляром макета значення, носієм якого є Java int.class. За допомогою цього макета ми інструктуємо Linker створити зв’язок між C int32і відповідним типом Java int.

C pointer:

OfAddress ADDRESS = new OfAddress(ByteOrder.nativeOrder())
                  .withBitAlignment(ValueLayout.ADDRESS_SIZE_BITS);

ADDRESS є макетом відповідного типу C pointer, але без прив’язки до певного типу даних (примітивів або структур).

3. Створити застосунок нативної функції

Використовуючи наші попередні здобутки (дескриптор та нативна адреса) ми можемо створити так званий застосунок методу (method handle):

MethodHandle printfMethodHandle = symbolLookup.lookup("printf").map(
            addr -> linker.downcallHandle(addr, printfDescriptor)
    ).orElse(null);

Цей код сворює застосунок, який буквально можна викликати через відповідний метод MethodHandle::invoke. Якщо ви не знайомі з відповідними API, які працюють із MethodHandle, то я дуже рекомендую це зробити першочергово, але якщо коротко, то «method handle» або застосунок методу — це типізований застосунок безпосередньо виконання методів, конструкторів, атрибутів класу або подібна низькорівнева операція з необов’язковими перетвореннями аргументів або значень результату виконання (return value), або більш потужна альтернатива до Core Reflection API, хоча із релізом JDK 18, навіть Reflection API тепер базується на MethodHandle.

Маючи пояснення та коментарі до трьох базових компонентів виконання нативного коду зсередини JVM, буде краще більш детально звернути увагу на визначенння down- та upcalls, враховуючи знання:

  • downcall — це виклик нативної функції через MethodHandle, сформований з адреси нативної функції та її Java-версії дескриптора функції;
  • upcall — це виклик деякого коду, написаного на Java за допомогою MethodHandle, перетвореного у сегмент нативної пам’яті, який може бути переданий нативним функціям у вигляді покажчика.

Цікаво, що ці два терміни змінюються місцями разом із зміною суб’єкту, щодо якого робиться визначення.

4. Виділити сегмент нативної пам’яті

Ще коли я вивчав С/С++, виділення і звільнення сегментів пам’яті були болючими для мене, тому що незалежно від того, наскільки ви хороший розробник програмного забезпечення, зрештою ви забудете щось прибрати або звільнити, і це призведе до витоку пам’яті або дуже болючого Segmentation fault.

З іншого боку, Java покладається на Garbage Collector (GC) для виділення та звільнення пам’яті, але Foreign Function & Memory API виділяє пам’ять off-heap. Виділення пам’яті «поза купою» є важливою частиною будь-якої історії взаємодії між багатомірними системами, у нашому випадку для обміну даними між нативним кодом на JVM і навпаки.

Отже, нам потрібно якимось чином прив’язати об’єкти Java до нативних сегментів пам’яті, для реалізації безпосереднього доступу функції C printf до цих даних.

Foreign Function & Memory API дозволяє виділяти та отримувати доступ до сегментів пам’яті, їх адрес та форм суміжної пам’яті регіонів, розташованих в на або по за heap-ом.

Foreign Function & Memory API реалізовані таким чином, що усі виділені сегменти пам’яті прив’язані до певного сеансу (MemorySession). Екземпляр цього класу надає набір API для створення нативних сегментів пам’яті прив’язаних до відповідних данних Java. Дивіться на це як на уніфікований інструмент роботи із нативною пам’яттю як, наприклад, C malloc. У свою чергу MemorySession реалізує інтерфейс AutoClosable, це значно спрощує чистку пам’яті та поверненню її оперційній системі у неявний спосіб завдяки try-with-resources.

Варто вказати, що нове API пропонує більше одного способу виділення сегменту пам’яті, одним із можливих методів виділення власної пам’яті є SegmentAllocator, схожий на MemorySession:

try (var memorySession = MemorySession.openConfined()) {
    SegmentAllocator allocator = SegmentAllocator.newNativeArena(memorySession);
    var cStringFromAllocator = allocator.allocateUtf8String("Hello World" + "\n");
    var cStringFromSession = memorySession.allocateUtf8String("Hello World" + "\n");
}

Вони між собою тісно пов’язані, якщо дуже коротко, то MemorySession реалізує інтерфейс SegmentAllocator. Для простоти розуміння процесу роботи із пам’яттю, додаток «Hello World» використовуватиме MemorySession як інструмент виділення пам’яті.

Отже, щоб виконати функцію C printf, нам потрібно виділити сегмент пам’яті під const char * за допомогою MemorySession і передати його функції C printf:

MemorySegment cString = memorySession.allocateUtf8String(str + "\n");

5. Безпосередньо виконати

Маючи відповідні дані, ми можемо зібрати все до купи:

private static int printf(String str, MemorySession memorySession) throws Throwable {
    Objects.requireNonNull(printfMethodHandle);
    var cString = memorySession.allocateUtf8String(str + "\n");
    return (int) printfMethodHandle.invoke(cString);
}
public static void main(String[] args) throws Throwable {
    var str = "Hello World";
    try (var memorySession = MemorySession.openConfined()) {
        System.out.println(printf(str, memorySession));
    }
}

До цього моменту ми дізналися, що сеанс пам’яті (MemorySession) або розподільник сегментів (SegmentAllocator) є ключовими API для розподілу пам’яті. MemorySession має бути оголошений у межах try-with-resources, щоб досягти неявного звільнення пам’яті при виході за межі цього блоку. Що MethodHandle є відповідальним за створення застосунку до нативної функції, у свою чергу він залежить від адресу цієї функції та реалізації дескриптору цієї функції інструментами Java.

Об’єкти типу Linker, SymbolLookup, MemoryLayouts, та FunctionDescriptor загалом є статичними об’єктами які не змінюються (майже) упродовж всього циклу життя програми, бо вони залежать від вже скомпільованого коду, що запакований у так звану shared library, вигляд якої залежить від платформи (*.so — Linux, *.dylib — macOS, *.dll — Windows), але всі вони мають хедер-файли (*.h) і містять описи тих функцій, які знаходяться в бібліотеках.

Висновки

Перша частина з циклу статтей дає уявлення про Foreign Function and Memory API та застосування цих API на прикладі виклику нативних функціх із використанням Java, а також кількість роботи, яка потрібна для того, щоб виконати простий виклик функції типу C printf. Може здатися дещо складною, але це просто необхідно для того, щоб зрозуміти, як працють ці нові API і як потрібно підходити до реалізації подібних додатків.

Хороша новина полягає в тому, що є більш простий шлях до роботи із нативним кодом за домогою інструменту jextract, але його розглянемо в наступних статтях!

Передивившись код, наведений у цій статті, вам буде зрозуміліше, що під час виклику нативного коду з Java за допомогою Foreign Function & Memory API необхідно вирішити кілька моментів:

  • Знайти необхідну вам бібліотеку та відповідний їй хедер-файли.
  • Базуючись на хедер-файлі, створити FunctionDescriptor на Java.
  • Знайти нативну адресу функції та використовуючи дескриптор функції, створити застосунок методу (MethodHandle).
  • Переконатися, що застосунок методу був створений належним чином (наприклад, якщо бібліотека не відображена в системних шляхах, пошук потенційно завершиться помилкою, а застосунок методу матиме значення null).
  • Вирішіти, як програма працюватиме із пам’яттю: через SegmentAllocator або MemorySession, переконайтеся, що вибрана вами стратегію прослідковується через увесь код вашого додатку.

Увесь код цієї статті доступний за цим посиланням.

------

Оригінал мого посту англійською доступний за цим посиланням.

dev.java | inside.java | Java on YouTube | Java on Twitter

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую за пост, але я не до кінця зрозумів, це ви переклали корпоративний пост з блогу Oracle, чи це був ваш власний пост?

Це мій власний пост, але він спочатку був написаний англійською, а вже потім я його переклав denismakogon.github.io/...​roject-panama-part-1.html

Тепер зрозуміло. Я на жаль, ніколи не використовував JNI у своїй роботі, тому цікаво було б дізнатися, у чому його недоліки та чому «Foreign Function and Memory API» краще, ніж JNI?
І ще одне питання — яке практичне застосування нового API у прикладних додатках? Для чого може знадобитися виклик C функції безпосередньо?

Влучне питання, я як раз пишу зараз от таку статтю, і вона обов’язково вийде тут, але трохи згодом.

І ще одне питання — яке практичне застосування нового API у прикладних додатках? Для чого може знадобитися виклик C функції безпосередньо?

А тут якраз все дуже просто та прозоро, є от такий проект github.com/bytedeco/javacpp, його сторили як альтернативу JNI із великим портфоліо підтримуваних рішень на С та С++.

Panama як раз закриває необхідність в існуванні таких от «посередників» між С-native кодом та JVM. Наразі є дуже багато бібліотек типу OpenCV, FFMPEG які не існують в екосистемі Java, але є безпосередні задачі які можна вирішувати засобами оціх бібліотек але вже на рівні JVM.

Наразі, якщо ви використовуєте щось із стеку AI/ML/DS у Java, типу TensorFlow, то ви маєте справу із JNI; коли ви збираєте бібліотеку OpenCV (delabassee.com/OpenCVJava), то ви теж будете мати справу із JNI, бо доступ до нативного коду який виходить за межі того, що є в Java standard library, то до впровадження нових API єдиний спобіб це зробити був через JNI.

Я не здивований тим, що ви не стикалися із JNI, бо ну дуже велика кількість розробників не працюють із кодом поза меж C stdlib, така от реальність і це не погано, бо як-то кажуть молотком можна або цвяхи забивати, або на фортепіано грати, але із розумом =)

Підписатись на коментарі