Вступ до Project Panama. Частина 4. Як керувати off-heap пам’яттю

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Частина 1 | Частина 2 | Частина 3

Важливою частино роботи із нативним кодом є керування пам’яттю. Мова С дає одразу і найпотужніший, і найнебезпечніший інструмент роботи з пам’яттю — C malloc (та С free). Специфіка така, що необачне керування пам’яттю може призвести до позаштатного завершення роботи застосунку. Проблема стає більш масштабною та ще ризикованішою, коли мова йде про роботу з нативною (off-heap) пам’яттю засобами JDK для успішного виконання нативних функцій.

Керування пам’яттю

Факт № 1: JVM за замовчанням керує лише heap-ом.

Факт № 2: Project Panama дозволяє керувати усією наявною пам’яттю для застосунків.

Foreign Function & Memory API надає два класи для керування пам’яттю: MemorySession, SegmentAllocator.

MemorySession

С malloc виконує функцію аллокації сегментів пам’яті, як антипод до неї існує функція free, як видаляє сегмент пам’яті за фактичним посиланням на неї:

#include <stdlib.h>
#include <stdio.h>
int main() {
    int len = 10;
    char * buffer = (char*) malloc(len + 1);
    for (int ix = 0; ix < len; ix++)
        buffer[ix] = rand() % 26 + 'a';
    buffer[len]='\0';
    puts(buffer);
    free(buffer);
    return 0;
}

У такому додатку майже немає жодних проблем з тим, щоб уникнути виклик free, але якщо мова йде про додатки, які працюють у режимі демонів, то постає проблема з тим, що «десь» тече пам’ять. Тому, скажімо так, існує важливе правило: free ЗАВЖДИ має слідувати за malloc!

Якщо уявити, що ми можемо створити Java варіат до malloc, то буде дуже розумно загорнути його у AutoCloseable. Як Java-розробники, ви, певно, знайомі з цим інтерфейсом. Він допомагає «автоматично» викликати метод close при виході за межі блоку try-with-resources. Розробники Project Panama попіклувалися про нас, розробників, й впровадили API для керування пам’яттю — MemorySession.

MemorySession — це механізм доступу до off-heap пам’яті, який дозволяє виділяти та повертати сегменти пам’яті до операційної системи. Ну, і для комфорту використання цей механізм розширяє інтерфейс AutoCloseable:

try(var ms = MemorySession.openConfined()) {
     …
}

Коли мова йде про C malloc, то виділення пам’яті стосується суто велечин нативних типів або їх комбінацій (масиви, структури). У Java ж ситуація інша — необхідно вміти виділяти нативну пам’ять під Java об’єкти, які мають відповідний нативний тип, якщо коротко, то мова йде про усі примітивні типи, значення котрих вміщюється в long або є композитним типом (масив, структура, юніон).

Основним функціоналом MemorySession є виділення пам’яті, тому вкрай важливим функціоналом є наступні методи:

try(var ms = MemorySession.openConfined()) {
    var forBoolean = ms.allocate(1);
    var forIntAligned = ms.allocate(4, 8);
    var forPTR = ms.allocate(ValueLayout.ADDRESS);
}

Тобто, з трьома основними функціями:

  • Виділення N байтів
  • Виділення N байтів з вирівнюванням
  • Виділення N байтів згідно з макетом типу

MemoryLayout

JVM нічого не знає про нативні С типи. Єдине, що вона може — оперувати сегментами байтів (CRUD). Питання полягає у тому, як створити такий сегмент пам’яті, який би відповідав примітивним типам Java? Відповіддю на це питання як раз і є MemoryLayout, а також розширення цього класу ValueLayout, GroupLayout, SequenceLayout, PaddingLayout.

ValueLayout

У JDK ValueLayout є вдосталь імплементацій макетів типів, щоб покрити усі примітиви Java:

Як зазначено вище, за допомогою макетів можно виділяти відповідний сегмент (масив байтів), у який можна записати відповідне значення типу Java, наприклад:

try(var ms = MemorySession.openConfined()) {
    var jChar = ms.allocate(JAVA_CHAR); // 2 bytes
    var jShort = ms.allocate(JAVA_SHORT); // 2 bytes
}

Як бачите, за допомогою цих макетів ми виділяємо пам’ять під змінні, типи яких належать Java, але мають відображення у С типах. Це значить, що неможливо зробити це, наприклад, для класу або рекорду, навіть якщо можна порахувати, скільки байт займає клас із усіма полями.

GroupLayout

Інша ж справа, якщо мати відображення Java класу на С структуру, але для того, щоб мати змогу створювати сегменти пам’яті під структури, необхідно пояснити і відповідним чином описати структуру, як послідовність змінних з відповідними макетами типів. Іншими словами — визначити тип структури за допомогою StructLayout.

Розглянемо наступний приклад:

struct A {
    int a;
    short b;
    char c;
    short d;
};

Еквівалент типу С struct A буде відповідний Java макет:

var structLayout = MemoryLayout.structLayout(
        JAVA_INT.withName("a").withBitAlignment(32),
        JAVA_SHORT.withName("b").withBitAlignment(16),
        JAVA_BYTE.withName("c").withBitAlignment(8),
        MemoryLayout.paddingLayout(8),
        JAVA_SHORT.withName("d").withBitAlignment(16),
        MemoryLayout.paddingLayout(24)
).withName("A");

Застосування паддінгу тут не є доцільним, але є дуже позаковим, бо struct A в С займає як раз 12 байт, бо пам’ять розмічена по DWORD (4 байти).

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

try(var ms = MemorySession.openConfined()) {
    var jStruct = ms.allocate(structLayout); // 12 bytes
}

SequenceLayout

Для того, щоб виділити пам’ять під примітив, треба лише вказати макет типу:

try(var ms = MemorySession.openConfined()) {
    var jArray = ms.allocateArray(JAVA_INT, 5); // int[5], 20 bytes
}

Така ж процедура стосується й масивів композитних типів, скажімо, struct A[5]. Тут можна застосувати два підходи:

try(var ms = MemorySession.openConfined()) {
    var jArray = ms.allocateArray(structLayout, 5); // A[5], 60 bytes
}

або

var sequenceLayout = MemoryLayout.sequenceLayout(5, structLayout).withName("B");
try(var ms = MemorySession.openConfined()) {
    var jArray = ms.allocate(sequenceLayout, 5); // A[5], 60 bytes
}

UnionLayout та PaddingLayout я залишу вас на подивится.

Робота з сегментами пам’яті

Взагалі, макети існують для того, щоб якісно описувати структуру сегменту пам’яті, виділеної під змінну, типу якої відповідає макет. Розглянемо наступний приклад:

int a = 100;
try(var ms = MemorySession.openConfined()) {
    var jInt = ms.allocate(JAVA_INT);
    jInt.set(JAVA_INT, 0, a);
    var b = jInt.get(JAVA_INT, 0);
    assert a == b;
}

Цей код означає, що у сегмент пам’яті розміром 4 байти з нульової позиції ми записуємо значення int a = 100.

Розглянемо складніший і детальніший приклад — перетворення String на char*. Враховуючи те, що C не має гадки про Java String, необхідно перетворити на той тип даних який буде зрозумілий, а саме на char[] :

var str = “Hello World!”;
var bytes = str.getBytes(StandardCharsets.UTF_8);
try(var ms = MemorySession.openConfined()) {
    var ms = ms.allocate(bytes.length + 1);
    var bytes$ms = MemorySegment.ofArray(bytes);
    ms.copyFrom(bytes$ms);
    ms.set(JAVA_BYTE, bytes.length, (byte)0);
}

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

Як ви можете побачити, функціонал MemorySession має можливості роботи з пам’яттю, розмір якої обмежений лише тим, що є у наявності в опертивній системі. Недоліком є те, що об’єм наявної пам’яті може змінюватися, що потенційно може призвести до банальної нестачі пам’яті (OutOfMemory).

SegmentAllocator

У той час, як MemorySession працює з тим залишком пам’яті, який їй доступний, SegmentAllocator працює дещо інакше, бо передбачає створення великих сегментів пам’яті одночасно, такі сегменти ще називаються «арени». Загалом SegmentAllocator використовується у тих випадках, коли треба зарезерувати сегмент необхідного розміру під певну задачу, і вже у межах неї займатися виділенням пам’яті сегментами необхідної довжини:

var one = "My name is %s.\n";
var two = "Denis";
var three = "I'm %dyo old.\n";
var four = 31;
var five = one + three;
try(var ms = MemorySession.openConfined()) {
    var sa = SegmentAllocator.newNativeArena(
            one.length() + 1 + two.length() + 1 +
            three.length() + 1 + five.length() + 1,
           ms
    );
    var one$sa = sa.allocateUtf8String(one);
    var two$sa = sa.allocateUtf8String(two);
    var three$sa = sa.allocateUtf8String(three);
    var five$sa = sa.allocateUtf8String(five);
    stdio_h.printf(one$sa.address(), two$sa.address());
    stdio_h.printf(three$sa.address(), four);
    stdio_h.printf(five$sa.address(), two$sa.address(), four);
}

Термін «арена» походить з C++ і фактично означає ізольований, великий сегмент пам’яті. Розподіл off-heap пам’яті на арени був розроблений, щоб зменшити витрати на процедуру виділення пам’яті посегментно. Усі ці сегменти пам’яті у межах арени можна звільнити одразу одночасно, виконавши рекламацію усієї арени цілком, в ідеалі без запуску деструкторів об’єктів відображених на арені. Це робить розподіл об’єктів швидшим, зводячи його до простого інкріменту покажчика на точку розподілу зайнятої та вільної пам’яті, що робить звільнення усіє арени одночасно майже безкоштовним.

Дивлячись на цей приклад не можна однозначно сказати, що станеться з ареною після того, як SegmentAllocator стане непотрібним. А магія JVM криється як раз «під капотом», при видаленні аллокатора у циклі GC уся пам’ять, що виділена ним, повернеться операційній системі. Треба зазначити, що те ж саме станеться з MemorySession, але різниця полягає у тому, що арену можна звільнити за один раунд, а от звільнення пам’яті, виділеної MemorySession буде повільнішим, бо «рекламація» буде проходити посегментно.

На що треба звернути увагу в роботі з аренами:

  1. Арена обмежена у розмірі від самого початку.
  2. Виділення пам’яті у межах арени робиться за допомогою SegmentAllocator, а не MemorySession.
  3. SegmentAllocator не має методу close, отже арену не треба ані закривати, ані видаляти явним чином.
  4. Виділення сегменту, розмір якого не вписується в арену (нестача пам’яті, завеликий сегмент) призведе до помилки OutOfMemory.

Якщо подивитися у реалізацю, то буде зрозуміло, що MemorySession та SegmentAllocator пов’язані між собою — MemorySession розширює функціонал SegmentAllocator, але працює як SegmentAllocator, чия арена налічує усю наявну пам’ять. Враховуючи цей факт, можна зробити висновок, що загальним рішенням для роботи з пам’яттю буде MemorySession, а SegmentAllocator — для специфічних задач.

P.S.

Пам’ятайте про те, що Foreign Function & Memory API доступно у режимі Preview. Щоб працювати з цим API, треба:

  • Скомпілюйте програму за допомогою javac --release 19 --enable-preview Main.java та запустіть її за допомогою java --enable-preview Main.
  • Використовуючи програму запуску вихідного коду, запустіть програму з java --source 19 --enable-preview Main.java.
  • Використовуючи jshell, почніть його з jshell --enable-preview.

----

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

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному2
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

Перше враження з 19-ї OpenJDK — новий JNI частково зламав зворотню сумісність з існуючим кодом. Наприклад будь який дебаг призводить до крешу.

Доброго дня. Нема нового JNI й не буде. JDK пропонує альтернативний шлях реалізації нативного коду у Java за допомогою C ABI.

новий JNI частково зламав зворотню сумісність з існуючим кодом

Що саме і де зламалося, можете бути більш конкретним, бо починаючи із 9-ї версії не було введено жодних змін, які б були зворотньо-несумістні.

Наприклад будь який дебаг призводить до крешу

Будь який дебаг чого, у якому середовищі, за допомогою яких інструментів? Давайте більше контексту.

Те що в Panama змінили API на Foreign Function дещо більш схоже на JNA це ок. Та й механізми JNI для зворотної сумісності певно зробили через них.

Зміни абсолютно точно є. Можна взяти код з моєї статті dou.ua/...​-video-card-capabilities , що використовує OpenCL.

Наприклад оцей класс github.com/...​ou/opencl/MultArrays.java

На 18-й OpenJDK усе працює як слід. А вже на 19 тільки без дебагеру, і з ворнінгами.

На дебазі гарантований крешдамп:

---------------  S U M M A R Y ------------

Command Line: -XX:+ShowCodeDetailsInExceptionMessages -agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:50902 -javaagent:D:\Tools\eclipse\configuration\org.eclipse.osgi\215\0\.cp\lib\javaagent-shaded.jar -Dfile.encoding=UTF-8 org.dou.opencl.MultArrays

Host: AMD Ryzen 7 5800X 8-Core Processor             , 16 cores, 31G,  Windows 10 , 64 bit Build 19041 (10.0.19041.1889)
Time: Wed Sep 21 15:26:14 2022 FLE Daylight Time elapsed time: 0.978032 seconds (0d 0h 0m 0s)

---------------  T H R E A D  ---------------

Current thread (0x000001a87d50c750):  JavaThread "main" [_thread_in_native, id=3824, stack(0x0000004542d00000,0x0000004542e00000)]

Stack: [0x0000004542d00000,0x0000004542e00000],  sp=0x0000004542dfd8a8,  free space=1014k
Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code)
C  [jdwp.dll+0x26c84]

Java frames: (J=compiled Java code, j=interpreted, Vv=VM code)
j  org.lwjgl.system.MemoryUtil.nmemAlloc(J)J+0
j  org.lwjgl.system.MemoryUtil.nmemAllocChecked(J)J+11
j  org.lwjgl.system.MemoryUtil.memAlloc(I)Ljava/nio/ByteBuffer;+5
j  org.lwjgl.system.windows.WindowsLibrary.getPath()Ljava/lang/String;+5
j  org.lwjgl.system.Library.loadNativeFromSystem(Ljava/lang/String;)Lorg/lwjgl/system/SharedLibrary;+6
j  org.lwjgl.system.Library.loadNative(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/String;ZZ)Lorg/lwjgl/system/SharedLibrary;+371
j  org.lwjgl.system.Library.loadNative(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/String;Z)Lorg/lwjgl/system/SharedLibrary;+5
j  org.lwjgl.system.Library.loadNative(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/String;)Lorg/lwjgl/system/SharedLibrary;+4
j  org.lwjgl.system.Library.loadNative(Ljava/lang/Class;Ljava/lang/String;Lorg/lwjgl/system/Configuration;Ljava/util/function/Supplier;[Ljava/lang/String;)Lorg/lwjgl/system/SharedLibrary;+59
j  org.lwjgl.system.Library.loadNative(Ljava/lang/Class;Ljava/lang/String;Lorg/lwjgl/system/Configuration;[Ljava/lang/String;)Lorg/lwjgl/system/SharedLibrary;+5
j  org.lwjgl.opencl.CL.create()V+52
j  org.lwjgl.opencl.CL.<clinit>()V+19
v  ~StubRoutines::call_stub 0x000001a80f2e100e
j  org.dou.opencl.ClRuntime.<init>()V+13
j  org.dou.opencl.MultArrays.main([Ljava/lang/String;)V+8
v  ~StubRoutines::call_stub 0x000001a80f2e100e

siginfo: EXCEPTION_ACCESS_VIOLATION (0xc0000005), reading address 0xffffffffffffffff

Тобто виклик до старого доброго malloc з CRT під дебагом, виявляться не сумісним з новими розподілювачами пам’яті від Panama та усе крешать.

Щодо нічого не змінювалось, зайдемо в jni.h і подивимось наступне:

#define JNI_VERSION_1_1 0x00010001
#define JNI_VERSION_1_2 0x00010002
#define JNI_VERSION_1_4 0x00010004
#define JNI_VERSION_1_6 0x00010006
#define JNI_VERSION_1_8 0x00010008
#define JNI_VERSION_9   0x00090000
#define JNI_VERSION_10  0x000a0000
#define JNI_VERSION_19  0x00130000

Таким чином було додано новий JNI_VERSION_19,

Як бачимо з 9-ї версії внутрішні механізми змінюються і це вже друга зміна.

Це також каже про про внутрішню побудову foreign functions яка по суті вводить JNA github.com/java-native-access/jna в середину JVM що не дивно, JNA була зроблена самими Sun є легенда яким саме чином, але залишу її на моїй колишній роботі.

Покопався щоб з’ясувати чому власне зламалось і чому виключно на дебазі. Баг як завжди через трюки які використовують JNI-щіки через проблеми які власне Panama і фіксить. LWJGL вже внесли до себе фікс, бо в них там відвертий хак по самописному трейсінгу позахіпової пам’яті який робиться через ThreadLocal.
Але от в чому справа, оскільки в Java знову ввели fibers (вони же corutines, во ниже Virtual Threads), bugs.openjdk.org/browse/JDK-8286176 постало питання як JNI буде відрязняти файбера від повноцінного потоку, це усе що було додано до JNI 19.
Оця єдина функція, що була додана і заламала самописний трейсінг який зробили LWJGL. Нажаль такого барахла в багатьох хто займається JNI повно, на сам перед сама ідея міксувати зборку сміття та malloc/free не дуже добра. Було би добре щоб був якийсь механізм в середені який би перехоплював виклики до malloc через який не будь DLL hook чи LD_PRELOAD , та видавав віртуальну пам’яті єдиного з JVM heap розподілювача чи принаймні просто повертав 0, щоб довелось використовувати тільки MemorySession та інші штатні розподілювачі. Це буде лише один breaking change, але раз і назавжди доведеться прибрати велосипеди з усіх проектів.

зробити такий хук у автоматичному режимі носить досить високу складність, та й не зрозуміло, чи буде від використовуватися великою кількістю розробників.

А от що точно можна зробити, так це upcall до Java не просто для виклику Java-методів, а за для видачі памʼяті. Тобто, ви викликаєту метод, який володіє копією, або керую глобальним станом MemorySession або SegmentAllocator, і роздавати памʼять таким чином, щоб вона уся була врахованною, наприклад:

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

public class NewMain {

    protected static SegmentAllocator allocator;
    static Linker linker;
    static MethodHandle mallocHandle;
    static {
        linker = Linker.nativeLinker();
        try {
             mallocHandle = MethodHandles.lookup().findStatic(NewMain.class, "malloc",
                    MethodType.methodType(MemoryAddress.class, long.class));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    static MemoryAddress malloc(long bytes) {
        var newSegment = allocator.allocateArray(ValueLayout.JAVA_BYTE, bytes);
        return newSegment.address();
    }


    public static void main(String[] args) {
        try(var memorySession = MemorySession.openConfined()) {
            allocator = SegmentAllocator.newNativeArena(memorySession);

            var mallocUpcall = linker.upcallStub(
                    mallocHandle, 
                    FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.JAVA_LONG),
                    memorySession
            );
            
        }
    }

Java mallocUpcall є еквівалентом до void* (*mallocPtr)(size_t); у С.

Технічно, це дозволить створити наступний С код:

#include <stdio.h>
#include <stdlib.h>

void callback_function(void* (*mallocPtr)(size_t)) {
   float* floatValue = (*mallocPtr)(sizeof(float));
}

int main()
{
    void* (*mallocPtr)(size_t);
    mallocPtr = &malloc;
    void* mem = mallocPtr(10);
    free(mem);
    callback_function(mallocPtr);
    return 0;
}

Отже, викликавши callback_function із параметром, що вказує на Java-метод malloc ми можемо зробити так, що за виділення памʼяті буде відповідати лише JVM.

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