java foreign function and memory api - ffm apijava foreign function and memory api - ffm api
HappyCoders Glasses

Java Foreign Function & Memory API
(FFM API)

Sven Woltmann
Sven Woltmann
Aktualisiert: 29. November 2024

Nach vielen Jahren der Entwicklung im Rahmen von Project Panama wurde die finale Version der „Foreign Function & Memory API” im März 2024 mit Java 22 veröffentlicht.

In diesem Artikel erfährst du:

  • Was ist die Foreign Function & Memory API?
  • Was ist der Unterschied zwischen FFM API und JNI?
  • Wie ruft man mit der FFM API fremden Code auf?
  • Wir schreibt und liest man mit der FFM API fremden Speicher?
  • Welche Bedeutung haben die Begriffe Arena, Memory Segment, Memory Layout und Function Descriptor?

Den Quellcode zum Artikel findest du in diesem GitHub-Repository.

Was ist die Foreign Function & Memory API?

Die Foreign Function & Memory API (kurz: FFM API) ermöglicht es Java-Entwicklerinnen und -Entwicklern, Funktionen aus Libraries, die in anderen Programmiersprachen geschrieben wurden (z. B. die Standard-C-Library), unkompliziert aus Java heraus aufzurufen.

Die FFM API ermöglicht es außerdem, aus Java heraus sicher auf Speicher zuzugreifen, der nicht von der JVM verwaltet wird, also Speicher außerhalb des Java-Heaps.

Wie das funktioniert, zeige ich dir im übernächsten Abschnitt. Zunächst solltest du wissen, warum die FFM API überhaupt entwickelt wurde.

Unterschied zwischen der FFM API und JNI

Um bisher auf nativen Code – also Code außerhalb der JVM – zuzugreifen, mussten Java-Entwickler das seit Java 1.1 existierende Java Native Interface (JNI) einsetzen. Wer das schon einmal gemacht hat, weiß, dass das keine angenehme Aufgabe ist:

  • JNI ist umständlich zu benutzen: Man muss viel Java- und C-Boilerplate-Code schreiben und diesen mit Änderungen im nativen Code synchronisieren. Dafür wurden zwar Tools bereitgestellt, doch die erleichtern die Aufgabe nur marginal.
  • JNI ist fehleranfällig: Fehler beim Zugriff auf nativen Speicher können die JVM leicht zum Absturz bringen.
  • JNI ist extrem langsam.

Die FFM API hingegen ist:

  • Einfach zu benutzen, wie du im folgenden Abschnitt sehen wirst. Der Implementierungsaufwand wurde mit der modernen FFM API gegenüber JNI laut Aussage der Panama-Entwickler um 90 % reduziert.
  • Sicher: Zugriffe auf nativen Speicher werden durch sogenannte Arenen verwaltet, die sicherstellen, dass Speicheradressen gültig sind und die andersfalls eine Exception werfen (anstatt die JVM abstürzen zu lassen).
  • Schnell: Die FFM API soll um Faktor vier bis fünf schneller sein als JNI.

Mit der Veröffentlichung der FFM API durch JDK Enhancement Proposal 454 gibt es nun keinen Grund mehr, JNI zu verwenden.

Kommen wir nun zur spannenden Frage: Wie funktioniert die FFM API?

Foreign Function & Memory API – Beispiele

Die neue API lässt sich am besten anhand von Beispielen erklären. Ich zeige dir zunächst ein einfaches Beispiel, das die strlen()-Funktion der Standard-C-Library aufruft. Danach folgt ein komplexeres Beispiel, das die C-qsort()-Funktion aufruft, welche wiederum eine Java-Callback-Funktion zum Vergleich zweier Elemente aufruft.

Im Anschluss werde ich dir die eingesetzten Komponenten der Foreign Function & Memory API detaillierter erklären.

Beispiel 1: strlen()-Funktion der Standard-C-Library

Beginnen wir mit einem sehr einfachen Beispiel (du findest es in der Klasse FFMTestStrlen im GitHub-Repository). Der folgende Code verwendet die strlen()-Methode der Standard-C-Library, um die Länge des Strings „Happy Coding!” zu berechnen.

Schauen wir uns einmal die Definition dieser C-Funktion an:

std::size_t strlen( const char* str );Code-Sprache: C++ (cpp)

Die Methode hat einen Parameter:

  • str – Zeiger auf den zu untersuchenden, Null-terminierten String

Der Rückgabetyp, size_t, steht für einen unsignierten Integer.

Ich zeige dir erstmal das Programm zum Aufruf dieser Methode. Eine kurze Erläuterung der einzelnen Schritte findest du in den Kommentaren, eine ausführlichere Erklärung unterhalb des Programmcodes.

public class FFMTestStrlen {
  public static void main(String[] args) throws Throwable {
    // 1. Get a linker – the central element for accessing foreign functions
    Linker linker = Linker.nativeLinker();

    // 2. Get a lookup object for commonly used libraries
    SymbolLookup stdlib = linker.defaultLookup();

    // 3. Get the address of the "strlen" function in the C standard library
    MemorySegment strlenAddress = stdlib.find("strlen").orElseThrow();

    // 4. Define the input and output parameters of the "strlen" function
    FunctionDescriptor descriptor =
        FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS);

    // 5. Get a handle to the "strlen" function
    MethodHandle strlen = linker.downcallHandle(strlenAddress, descriptor);

    // 6. Get a confined memory area (one that we can close explicitly)
    try (Arena offHeap = Arena.ofConfined()) {

      // 7. Convert the Java String to a C string and store it in off-heap memory
      MemorySegment str = offHeap.allocateFrom("Happy Coding!");

      // 8. Invoke the "strlen" function
      long len = (long) strlen.invoke(str);
      System.out.println("len = " + len);
    }
    // 9. Off-heap memory is deallocated at end of try-with-resources
  }
}
Code-Sprache: Java (java)

Was genau passiert in diesem Code? (Die folgende Nummerierung verweist auf die entsprechenden Kommentare im Quellcode.)

  1. Über die statische Methode Linker.nativeLinker() bekommen wir einen Linker – die zentrale Komponente, die den Zugriff auf fremde Funktionen orchestriert.
  2. Über Linker.defaultLookup() lassen wir uns ein SymbolLookup-Objekt liefern, über das wir die Speicheradressen von Methoden häufig verwendeter Bibliotheken abrufen können. Welche Bibliotheken das sind, hängt von Betriebssystem und CPU ab.
  3. Mit SymbolLookup.find(...) fragen wir nach der Speicheradresse der „strlib”-Funktion. Die Methode liefert ein Optional<MemorySegment> zurück, welches leer ist, sollte die Methode nicht existieren.
  4. Mit einem sogenannten Function Descriptor geben wir an, welche Ein- und Ausgabeparameter die strlib()-Methode hat. Das erste Argument, ValueLayout.JAVA_LONG, definiert den Rückgabetyp der Methode. Das zweite Argument, ValueLayout.ADDRESS, definiert den Typ des ersten (und einzigen) Methodenparameters als Speicheradresse (die des Strings, dessen Länge wir bestimmen wollen). Der Funktionsdeskriptor wird beim Aufruf der nativen Funktion sicherstellen, dass Java-Typen ordnungsgemäß in C-Typen umgewandelt werden und umgekehrt.
  5. Die Methode Linker.downcallHandle(...) liefert uns ein MethodHandle für die Methode an der angegebenen Speicheradresse und den zuvor definierten Funktionsdeskriptor. Method Handles sind nichts Neues – es gibt sie bereits seit Java 7.
  6. Arena.ofConfined() liefert uns eine sogenannte Arena – ein Objekt, das den Zugriff auf nativen Speicher verwaltet – mehr dazu später.
  7. Arena.allocateFrom(...) reserviert einen nativen Speicherblock und legt dort die Zeichenkette „Happy Coding!” im UTF-8-Format ab.
  8. Mit MethodHandle.invoke(...) rufen wir die C-strlen()-Methode auf; das Ergebnis casten wir zu einem long (der in Schritt 3 definierte Function Descriptor stellt sicher, dass wir das tun können).
  9. Am Ende des try-with-resources-Block wird Arena.close() aufgerufen und damit alle Speicherblöcke, die über diese Arena verwaltet werden, freigegeben.

Die hier gezeigten Elemente der Foreign Function & Memory API – Memory Segment, Arena, Value Layout und Function Descriptor – werden im Kapitel Komponenten der FFM API noch einmal näher beschrieben.

Start des Beispiel-Programms

Wenn du den Quellcode in der Datei FFMTestStrlen.java speicherst, kannst du ihn wie folgt ausführen:

$ java FFMTestStrlen.java 
WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: java.lang.foreign.Linker::downcallHandle has been called by eu.happycoders.java22.ffm.FFMTestStrlen in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

len = 13Code-Sprache: Klartext (plaintext)

Um die Warnung zu unterdrücken, musst du das Programm wie folgt starten:

$ java --enable-native-access=ALL-UNNAMED FFMTestStrlen.java
len = 13Code-Sprache: Klartext (plaintext)

Die String-Länge wurde korrekt berechnet!

Beispiel 2: qsort()-Funktion der Standard-C-Library

Als nächstes wollen wir uns an ein komplexeres Beispiel wagen. Wir wollen mit der qsort()-Funktion ein Array von Integers sortieren. Wir müssen dazu erstmal einen Blick auf die Definiton dieser Funktion werfen:

void qsort( void *ptr, size_t count, size_t size,
            int (*comp)(const void *, const void *) );Code-Sprache: C++ (cpp)

Die Methode verwendet die folgenden Parameter:

  • ptr – Zeiger auf das zu sortierende Array
  • count – Anzahl der Elemente im Array
  • size – Größe der einzelnen Elemente des Arrays in Bytes
  • comp – Vergleichsfunktion, die einen negativen ganzzahligen Wert zurückgibt, wenn das erste Argument kleiner als das zweite ist, einen positiven ganzzahligen Wert, wenn das erste Argument größer als das zweite ist, und Null, wenn die Argumente gleich sind.

Signatur der Vergleichsfunktion:

int cmp(const void *a, const void *b);Code-Sprache: C++ (cpp)

Ich zeige dir wieder zunächst den vollständigen Programmcode mit Kommentaren. Im Anschluss erkläre ich dir die neuen Komponenten dieses Beispiels ausführlicher.

public class FFMTestQsort {
  public static void main(String[] args) throws Throwable {
    // 1. Get a linker - the central element for accessing foreign functions
    Linker linker = Linker.nativeLinker();

    // 2. Get a lookup object for commonly used libraries
    SymbolLookup stdlib = linker.defaultLookup();

    // 3. Get the address of the "qsort" function in the C standard library
    MemorySegment qsortAddress = stdlib.find("qsort").orElseThrow();

    // 4. Define the input and output parameters of the "qsort" function:
    FunctionDescriptor qsortDescriptor =
        FunctionDescriptor.ofVoid(
            ValueLayout.ADDRESS, 
            ValueLayout.JAVA_LONG,
            ValueLayout.JAVA_LONG,
            ValueLayout.ADDRESS);

    // 5. Get a method handle to the "qsort" function
    MethodHandle qsortHandle = linker.downcallHandle(qsortAddress, qsortDescriptor);

    // 6. Define the input and output parameters of the "compare" function:
    FunctionDescriptor compareDescriptor =
        FunctionDescriptor.of(
            ValueLayout.JAVA_INT,
            ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT),
            ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT));

    // 7. Get a handle to the "compare" function
    MethodHandle compareHandle =
        MethodHandles.lookup()
            .findStatic(FFMTestQsort.class, "compare", compareDescriptor.toMethodType());

    // 8. Get a confined memory area (one that we can close explicitly)
    try (Arena offHeap = Arena.ofConfined()) {
      // 9. Allocate off-heap memory and store unsorted array in it
      int[] unsorted = createUnsortedArray();
      MemorySegment arrayAddress = offHeap.allocateFrom(ValueLayout.JAVA_INT, unsorted);

      // 10. Allocate off-head memory for an "upcall stub" to the comparison function
      MemorySegment compareAddress =
          linker.upcallStub(compareHandle, compareDescriptor, offHeap);

      // 11. Invoke the qsort function
      qsortHandle.invoke(
          arrayAddress, 
          unsorted.length, 
          ValueLayout.JAVA_INT.byteSize(), 
          compareAddress);

      // 12. Read array from off-heap memory
      int[] sorted = arrayAddress.toArray(ValueLayout.JAVA_INT);
      System.out.println("sorted   = " + Arrays.toString(sorted));
    }
    // 13. Off-heap memory is deallocated at end of try-with-resources
  }

  private static int compare(MemorySegment aAddr, MemorySegment bAddr) {
    int a = aAddr.get(ValueLayout.JAVA_INT, 0);
    int b = bAddr.get(ValueLayout.JAVA_INT, 0);
    return Integer.compare(a, b);
  }

  private static int[] createUnsortedArray() {
    ThreadLocalRandom random = ThreadLocalRandom.current();
    int[] unsorted = IntStream.generate(() -> random.nextInt(1000)).limit(10).toArray();
    System.out.println("unsorted = " + Arrays.toString(unsorted));
    return unsorted;
  }
}Code-Sprache: Java (java)

Die Besonderheiten dieses Programms im Vergleich zum vorherigen:

  • Schritt 4: Für den Funktionsdescriptor verwenden wir die Methode FunctionDescriptor.ofVoid(...), da qsort(...) keinen Rückgabewert hat. Wir geben die folgenden Argumente an:
    • ValueLayout.ADDRESS – für den Zeiger auf das zu sortierende Array
    • ValueLayout.JAVA_LONG – für die Anzahl der Elemente im Array
    • ValueLayout.JAVA_LONG – für die Größe der einzelnen Array-Elemente
    • ValueLayout.ADDRESS – für die Adresse der Vergleichsfunktion
  • Schritt 6: Hier definieren wir einen Funktionsdeskriptor für die Vergleichsfunktion: das erste Argument, ValueLayout.JAVA_INT, gibt den Rückgabetyp an; das zweite und dritte Argument, jeweils ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT) stehen für die Speicheradressen jeweils zweier zu vergleichender Array-Elemente.
  • Schritt 7: Hier lassen wir uns ein Method Handle für die Vergleichsfunktion generieren.
  • Schritt 9: Mit der Methode Arena.allocateFrom(...) allokieren wir Off-Heap-Speicher für ein Integer-Array und speichern darin das übergebene Array.
  • Schritt 10: Mit Linker.upcallStub(...) allokieren wir Off-Heap-Speicher für einen sogenannten „Upcall Stub” für die Vergleichsfunktion. Über diesen Stub kann später die C-Funktion die Java-Callback-Methode compare(...) aufrufen.
  • Schritt 11: Die Adresse dieses Stubs geben wir als viertes Argument beim Aufruf der qsort(...)-Methode an.
  • Schritt 12: Mit MemorySegment.toArray(...) wandeln wir das an der Off-Heap-Speicheradresse arrayAddress gespeicherte Array zurück in ein Java-Array.

Du findest den vollständigen Programmcode in der Klasse FFMTestQsort im GitHub-Repository.

Start des Beispiel-Programms

Wir starten das Programm wie folgt:

$ java --enable-native-access=ALL-UNNAMED FFMTestQsort.java
unsorted = [696, 788, 659, 413, 933, 143, 93, 200, 736, 300]
sorted   = [93, 143, 200, 300, 413, 659, 696, 736, 788, 933]Code-Sprache: Klartext (plaintext)

Unser Programm hat erfolgreich zehn Zahlen mit qsort() sortiert.

Komponenten der FFM API

Anhand der Beispiele hast du die wichtigsten Komponenten der Foreign Function & Memory API – Arena, Memory Segment, Function Descriptor und Value Layout – kennengelernt. In diesem Kapitel gehe ich detaillierter auf diese Komponenten ein.

Arena

Eine Arena verwaltet den Zugriff auf nativen Speicher und stellt sicher, dass allokierte Speicherblöcke wieder freigegeben werden und dass wir nicht auf bereits freigegebenen Speicher zugreifen.

Es gibt vier Typen von Arenen, die wir über statische Factory-Methoden der Arena-Klasse erzeugen können:

  • die globale („global”) Arena,
  • vom Garbage Collector automatisch verwaltete („auto”) Arenen,
  • beschränkte („confined”) Arenen und
  • geteilte („shared”) Arenen.

In den folgenden Abschnitten lernst du die Unterschiede der verschiedenen Typen kennen.

Globale Arena

Von der globalen Arena existiert nur eine einzige Instanz, die von allen Anwendungsthreads geteilt wird. In der globalen Arena allokierte Speichersegmente werden erst beim Beenden der JVM wieder freigegeben.

Die globale Arena erhälst du wie folgt:

Arena arena = Arena.global();Code-Sprache: Java (java)

Du kannst die globale Arena nicht schließen. Ein Aufruf von Arena.global().close() resultiert in einer UnsupportedOperationException.

Automatische Arena

In einer automatischen Arena allokierte Speichersegmente werden vom Garbage Collector freigegeben, sobald keine Refenzen auf die entsprechenden MemorySegment-Objekte mehr existieren.

Eine automatische Arena kann ebenfalls von allen Anwendungsthreads verwendet werden. Du erzeugst sie wie folgt:

Arena arena = Arena.ofAuto();Code-Sprache: Java (java)

Beachte, dass jeder Aufruf von Arena.ofAuto() eine neue automatische Arena erzeugt.

Eine automatische Arena wird dann geschlossen, wenn keine Referenzen mehr auf die Arena selbst und auf alle über sie allokierten Speichersegmente existieren. Ein manueller Aufruf von Arena.global().close() führt zu einer UnsupportedOperationException.

Beschränkte („confined”) Arena

Eine automatische Arena hat den Nachteil, dass das Deallokieren der Speichersegmente nicht deterministisch ist. Es passiert erst dann, wenn der Garbage Collector läuft und feststellt, dass es keine Referenzen mehr auf diese gibt.

Es gibt Anwendungsfälle, in denen wir selbst entscheiden wollen, wann der über eine Arena allokierte Speicher freigegeben wird. Dafür gibt es die sogenannten beschränkten („confined”) Arenen, wie wir sie auch in der Beispiel-Anwendung verwendet haben.

Die von einer beschränkten Arena allokierten Speichersegmente werden dann freigegeben, wenn die Arena durch den Aufruf von close() geschlossen wird. Da die Arena-Klasse auto-closeable ist, sollten wir die Arena in einem try-with-resources Block erzeugen:

try (Arena arena = Arena.ofConfined()) {
  . . .
}Code-Sprache: Java (java)

Alle innerhalb dieses Blocks allokierten Speichersegmente werden am Ende des Blocks durch den impliziten Aufruf von arena.close() freigegeben.

Der Versuch eine bereits geschlossene Arena zu verwenden, führt zu einer IllegalStateException.

Beschränkte Arenen dürfen nur in dem Threads verwendet werden, in dem sie erzeugt wurden.

Geteilte („shared”) Arena

Eine geteilte Arena kombiniert die Vorteile der beschränkten Arena (deterministische Lebenszeit der Speichersegmente) mit der Möglichkeit, aus mehreren Threads verwendet zu werden. Du erzeugst eine geteilte Arena wie folgt:

Arena arena = Arena.ofShared()Code-Sprache: Java (java)

Eine geteilte Arena wird geschlossen, sobald ein beliebiger Thread deren close()-Methode aufruft. Sollte danach ein anderer Thread versuchen, die Arena zu verwenden, kommt es zu einer IllegalStateException.

MemorySegment

Ein MemorySegment ist ein Objekt, dass einen zusammenhängenden Speicherbereich beschreibt. Ein Memory Segment kann auf verschiedene Arten allokiert werden. Die Arena-Klasse bietet dazu u. a. folgende Methoden an:

  • Arena.allocateFrom(String str)
    allokiert ein Memory Segment und speichert darin den übergebenen String als UTF-8-kodierte Bytefolge. Diese Methode haben wir im Beispiel oben verwendet.
  • allocate(long byteSize)
    allokiert ein Speichersegment der angegebenen Größe.
  • allocate(MemoryLayout elementLayout)
    allocate(MemoryLayout elementLayout, long count)
    allokieren ein Speichersegment, dessen Größe genau auf eine bestimmte Anzahl (1 in der ersten Variante, count in der zweiten Variante) von Objekten eines bestimmten Typs (definiert durch elementLayout) abgestimmt ist. Die MemoryLayout-Klasse beschreibe ich im nächsten Abschnitt.

Eine vollständige Übersicht aller Methoden zum Allokieren von Speichersegmenten findest du in der JavaDoc-Dokumentation von Arena und SegmentAllocator.

MemoryLayout

Ein MemoryLayout definiert den Speicheraufbau eines bestimmten Typs, wobei dieser Typ auch eine Kombination anderer Typen sein kann (z. B. ein Array oder Struct).

ValueLayout

ValueLayout ist eine Unterklasse von MemoryLayout, die definiert, wie grundlegende Datentypen wie z. B. int, long und double im Speicher repräsentiert werden.

Im Beispiel haben wir mit ValueLayout.JAVA_LONG den primitiven Java-Typ long beschrieben und mit ValueLayout.ADDRESS eine Speicheradresse der zugrunde liegenden Hardware.

SequenceLayout

Ein SequenceLayout, ebenfalls eine Unterklasse von MemoryLayout, beschreibt ein Array eines bestimmten Typs, wobei dieser Typ wiederum durch ein MemoryLayout beschrieben wird. Der folgende Code definiert z. B. ein Array mit zehn Java-Doubles:

MemoryLayout.sequenceLayout(10, ValueLayout.JAVA_DOUBLE);Code-Sprache: Java (java)

Und der folgende Code definiert das Speicherlayout für ein Array bestehend aus drei Arrays zu je zehn Integer-Arrays:

MemoryLayout.sequenceLayout(3, 
    MemoryLayout.sequenceLayout(10, ValueLayout.JAVA_INT));

StructLayout

Ein StructLayout, auch eine Unterklasse von MemoryLayout, beschreibt das Speicherlayout eines Structs, also eines Speicherbereichs, in dem verschiedene Datentypen hintereinander abgelegt abgelegt sind. Die Elemente des Structs haben einen Namen und wiederum ein MemoryLayout. Der Name wird nicht mit gespeichert, sondern wird dafür verwendet, um auf die Elemente des Structs zuzugreifen.

Der folgende Code beschreibt das Speicherlayout für ein Struct, das ein Jahr, einen Monat und einen Tag enthält:

MemoryLayout.structLayout(
    ValueLayout.JAVA_SHORT.withName("year"),
    ValueLayout.JAVA_SHORT.withName("month"), 
    ValueLayout.JAVA_SHORT.withName("day"));Code-Sprache: Java (java)

Ein Struct kann auch Arrays oder wiederum Structs enthalten.

FunctionDescriptor

Mit einem FunctionDescriptor beschreiben wir die Ein- und Ausgabeparameter einer nativen Funktion. Beim Aufruf einer nativen Funktion über ein Method Handle sorgt der Funktionsdeskriptor dafür, dass die übergebenen Java-Typen in die korrekten C-Typen umgewandelt werden und der Rückgabewert von einem C-Typen in den gewünschten Java-Rückgabewert.

Die Klasse FunctionDescriptor hat zwei statische Methoden:

  • of(MemoryLayout resLayout, MemoryLayout... argLayouts)
    erzeugt einen Function Descriptor mit dem durch resLayout definierten Rückgabetyp und den durch argLayouts definierten Eingabetypen.
  • ofVoid(MemoryLayout... argLayouts)
    erzeugt einen Function Descriptor ohne Rückgabetyp und mit den durch argLayouts definierten Eingabetypen.

Du hast nun die Grundelemente der Foreign Function & Memory API kennengelernt. Wie diese Elemente zusammenarbeiten, um Speicherbereiche zu schreiben und zu lesen, erfährst du im folgenden Kapitel.

Schreiben und Lesen von Memory-Segmenten

In diesem Kapitel lernst du, wie du auf den durch ein MemorySegment verwalteten Speicherbereich schreibend und lesend zugreifen kannst.

Wir beginnen mit einem einfachen Beispiel mit einem ValueLayout, gehen zu einem komplizierten Beispiel mit einem SequenceLayout und kommen schließlich zu einem sehr komplexen Beispiel mit einer Kombination aus SequenceLayout und StructLayout.

MemorySegment und ValueLayout

Das folgende Programm (Klasse FFMTestInts im GitHub-Repo) legt in der globalen Arena ein MemorySegment mit 100 Java-Integern an, füllt dieses unter Verwendung von MemorySegment.setAtIndex(...) mit Zufallszahlen und liest danach alle 100 Zahlen mit MemorySegment.getAtIndex(...) wieder aus:

public class FFMTestInts {
  private static final int COUNT = 100;

  public static void main(String[] args) {
    MemorySegment numbers = Arena.global().allocate(ValueLayout.JAVA_INT, COUNT);

    ThreadLocalRandom random = ThreadLocalRandom.current();
    for (int i = 0; i < COUNT; i++) {
      numbers.setAtIndex(ValueLayout.JAVA_INT, i, random.nextInt());
    }

    for (int i = 0; i < COUNT; i++) {
      int number = numbers.getAtIndex(ValueLayout.JAVA_INT, i);
      System.out.println(number);
    }
  }
}Code-Sprache: Java (java)

Kommen wir nun zu einem etwas komplizierterem Beispiel...

MemorySegment und SequenceLayout

Der folgende Code (Klasse FFMTestMultipleArrays) definiert ein MemoryLayout für ein Array von Integern und allokiert vier solche Arrays.

Um die Elemente des Arrays zu schreiben, wird für arrayLayout ein VarHandle definiert. Das Argument PathElement.sequenceElement() gibt dabei an, dass wir für den Zugriff auf das Array über das VarHandle den Index des jeweiligen Elements angeben wollen. Schließlich schreiben wir die Array-Elemente mit VarHandle.set(...) und geben dabei als Argumente das Segment, den Offset (die Größe des Array-Layouts multipliziert mit dem Index des Arrays, das wir gerade schreiben), den Index innerhalb des Arrays und den zu schreibenden Wert an.

Auslesen könnten wir die Werte mit einer analogen VarHandle.get(...)-Methode, doch ich möchte dir eine andere Variante zeigen: Über MemorySegment.elements(...) erzeugen wir einen Stream von Speichersegmenten, die jeweils ein Array enthalten. Über MemorySegment.toArray(...) laden wir das jeweilige Array aus dem Speichersegment.

public class FFMTestMultipleArrays {
  private static final int ARRAY_LENGTH = 8;
  private static final int NUMBER_OF_ARRAYS = 4;

  public static void main(String[] args) {
    SequenceLayout arrayLayout = MemoryLayout.sequenceLayout(ARRAY_LENGTH, JAVA_INT);
    VarHandle arrayHandle = arrayLayout.varHandle(PathElement.sequenceElement());

    MemorySegment segment = Arena.global().allocate(arrayLayout, NUMBER_OF_ARRAYS);

    ThreadLocalRandom random = ThreadLocalRandom.current();
    for (int i = 0; i < NUMBER_OF_ARRAYS; i++) {
      long offset = i * arrayLayout.byteSize();
      for (int j = 0; j < ARRAY_LENGTH; j++) {
        arrayHandle.set(segment, offset, j, random.nextInt(0, 1000));
      }
    }

    segment
        .elements(arrayLayout)
        .forEach(
            arraySegment -> {
              int[] array = arraySegment.toArray(JAVA_INT);
              System.out.println(Arrays.toString(array));
            });
  }
}
Code-Sprache: Java (java)

Kommen wir zuletzt zu einem besonders komplizierterem Beispiel...

MemorySegment und StructLayout

Das letzte Beispiel (Klasse FFMTestArrayOfStructs) definiert ein StructLayout, das aus den Komponenten year, month und day, jeweils vom Typ short besteht.

Es definiert zusätzlich ein SequenceLayout für ein Array von Datum-Structs.

Danach definieren wir VarHandles für die Struct-Elemente innerhalb des Arrays. Wir müssen dazu jeweils zwei Pfadelemente angeben: zuerst den Array-Index und danach den jeweiligen Elementnamen des Structs.

Wir schreiben die Structs über VarHandle.set(...) und geben als Argumente das Segment, den Offset 0 (da das Memory-Segment nur ein Element enthält, nämlich das Array von Structs), den Array-Index und den zu schreibenden Wert an.

Auslesen wollen wir die Structs über MemorySegment.elements(...) wie im vorherigen Beispiel. Diese Methode liefert einen Stream von Memory Segmenten, die jeweils einen Struct enthalten. Die Elemente der Structs laden wir schließlich über drei weitere VarHandles für den Struct (die zuvor erstellten VarHandles waren für Structs innerhalb eines Arrays).

public class FFMTestArrayOfStructs {
  private static final int ARRAY_LENGTH = 8;

  public static void main(String[] args) {
    StructLayout dateLayout =
        MemoryLayout.structLayout(
            ValueLayout.JAVA_SHORT.withName("year"),
            ValueLayout.JAVA_SHORT.withName("month"),
            ValueLayout.JAVA_SHORT.withName("day"));

    SequenceLayout positionArrayLayout = 
        MemoryLayout.sequenceLayout(ARRAY_LENGTH, dateLayout);

    MemorySegment segment = Arena.global().allocate(positionArrayLayout);
    writeToSegment(segment, positionArrayLayout);
    readFromSegment(segment, dateLayout);
  }

  private static void writeToSegment(
      MemorySegment segment, SequenceLayout positionArrayLayout) {
    VarHandle yearInArrayHandle =
        positionArrayLayout.varHandle(
            PathElement.sequenceElement(), PathElement.groupElement("year"));
    VarHandle monthInArrayHandle =
        positionArrayLayout.varHandle(
            PathElement.sequenceElement(), PathElement.groupElement("month"));
    VarHandle dayInArrayHandle =
        positionArrayLayout.varHandle(
            PathElement.sequenceElement(), PathElement.groupElement("day"));

    ThreadLocalRandom random = ThreadLocalRandom.current();
    for (int i = 0; i < ARRAY_LENGTH; i++) {
      yearInArrayHandle.set(segment, 0, i, (short) random.nextInt(1900, 2100));
      monthInArrayHandle.set(segment, 0, i, (short) random.nextInt(1, 13));
      dayInArrayHandle.set(segment, 0, i, (short) random.nextInt(1, 31));
    }
  }

  private static void readFromSegment(MemorySegment segment, StructLayout dateLayout) {
    VarHandle yearHandle = dateLayout.varHandle(PathElement.groupElement("year"));
    VarHandle monthHandle = dateLayout.varHandle(PathElement.groupElement("month"));
    VarHandle dayHandle = dateLayout.varHandle(PathElement.groupElement("day"));

    segment
        .elements(dateLayout)
        .forEach(
            positionSegment -> {
              int year = (int) yearHandle.get(positionSegment, 0);
              int month = (int) monthHandle.get(positionSegment, 0);
              int day = (int) dayHandle.get(positionSegment, 0);
              System.out.printf("%04d-%02d-%02d\n", year, month, day);
            });
  }
}
Code-Sprache: Java (java)

VarHandle.get(…) hat eigentlich einen Rückgabewert vom Typ Object, ist aber mit @MethodHandle.PolymorphicSignature annotiert. Das bedeutet, dass die get(…)-Methode im obigen Beispiel nicht zunächst ein Integer-Objekt zurückgibt, das dann in ein int-Primitiv unboxed wird, sondern direkt ein int-Pritimiv.

Im GitHub-Repository findest du noch ein weiteres Beispiel, FFMTestArrayOfArrays, das ich hier nicht mit abdrucke, da es keine neuen Konzepte einführt.

Du hast nun ein solides Grundlagenwissen über Arenen, Speichersegmente, Speicherlayouts und Funktionsdeskriptoren erworben. Damit solltest du bereit sein für erste Ausflüge in die Welt der nativen Funktionen und des nativen Speichers.

Eine kleine Geschichte der Foreign Function & Memory API

Zum Abschluss findest du in diesem Abschnitt noch kurzen Rückblick auf die Entwicklungsschritte der FFM API.

Bereits im März 2020, in Java 14, wurde die sogenannte „Foreign-Memory Access API” im Incubator-Stadium vorgestellt (JEP 370).

Ein Jahr später wurde in Java 16 die „Foreign Linker API” im Incubator-Stadium vorgestellt (JEP 389).

In Java 17 wurden die beiden APIs zur „Foreign Function & Memory API” zusammengeführt und diese vereinheitliche API noch einmal als Incubator-Version vorgelegt (JEP 412).

In Java 19 wurde die FFM API ins Preview-Stadium befördert (JEP 424).

In Java 22 wurde die API im März 2024 nach langer Entwicklungs- und Reifungszeit als produktionsreif deklariert und finalisiert (JEP 454).

Fazit

Die meisten Java-Entwicklerinnen und -Entwickler werden wahrscheinlich selten auf nativen Speicher zugreifen oder nativen Code ausführen müssen. Dennoch ist es hilfreich zu wissen, dass diese Möglichkeit existiert, z. B. um in anderen Sprachen geschriebene KI-Libraries aus Java heraus aufzurufen.

In diesem Artikel hast du die Grundlagen dafür gelernt. Falls du noch tiefer in die Materie einsteigen möchtest, empfehle ich dir, JDK Enhancement Proposal 454 und die Webseite von Project Panama zu studieren.

Planst du bereits eine native Library anzubinden? Wenn ja, welche? Lass es mich über die Kommentarfunktion wissen!

Willst du über alle neue Java-Features auf dem Laufenden sein? Dann klicke hier, um dich für den HappyCoders-Newsletter anzumelden.