Zum Inhalt springen

Java FFM API (JEP 454): Foreign Function & Memory API Tutorial

Ein klarer Glasstab von links und ein rauer Gusseisenstab von rechts, in der Mitte durch eine schlanke Goldkupplung verbunden, vor Mauve; eine dünne weiße Lichtlinie läuft durch den Glasstab, die Kupplung und weiter in den Eisenstab

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?
  • Wie schreibt und liest man mit der FFM API fremden Speicher?
  • Wie ruft man eigene Libraries auf, und wofür braucht man jextract?
  • 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-Entwickler:innen, 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:innen 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 langsam, sobald Daten die Grenze überqueren: Java-Objekte über JNI zu übergeben ist teuer. Viele Libraries legen deshalb Off-Heap-Speicher über sun.misc.Unsafe an und übergeben nur dessen Adresse.

Die FFM API hingegen ist:

  • Einfach zu benutzen, wie du im folgenden Abschnitt sehen wirst. Erklärtes Ziel von JEP 454 ist, JNI durch eine knappe, lesbare, reine Java-API zu ersetzen – ohne C-Glue-Code und Header-Dateien.
  • Sicher: Zugriffe auf nativen Speicher werden durch sogenannte Arenen verwaltet, die sicherstellen, dass Speicheradressen gültig sind und die andernfalls eine Exception werfen (anstatt die JVM abstürzen zu lassen). Die Methoden, die den Weg in den nativen Code öffnen – etwa Linker.downcallHandle() –, gelten allerdings als „restricted“: Eine falsch beschriebene Signatur der nativen Funktion kann die JVM weiterhin zum Absturz bringen. Deshalb warnt die JVM bei ihrem Aufruf, wie du im ersten Beispiel sehen wirst.
  • Schnell: Ziel von JEP 454 ist ein Overhead, der dem von JNI vergleichbar oder geringer ist. Beim Umwandeln von C-Strings in Java-Strings war die FFM API in Java 22 je nach Stringlänge 2,7- bis 4-mal so schnell wie JNI – bei 100 Bytes 43 statt 144 Nanosekunden.

Mit der Veröffentlichung der FFM API durch JEP 454 gibt es in den meisten Fällen keinen Grund mehr, JNI zu verwenden.

Ein paar Nischen bleiben JNI dennoch: Muss nativer Code tief in die JVM eingreifen – etwa Felder von Java-Objekten lesen und schreiben oder neue Objekte erzeugen –, bietet JNI weiterhin Möglichkeiten, die über die Upcalls der FFM API hinausgehen.

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()-Funktion der Standard-C-Library, um die Länge des Strings „Happy Coding!“ zu berechnen.

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

size_t strlen( const char* str );

Die Funktion hat einen Parameter:

  • str – Zeiger auf den zu untersuchenden, nullterminierten String

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

Ich zeige dir zunächst das Programm zum Aufruf dieser Funktion. 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.findOrThrow("strlen");

    // 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
  }
}

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 Funktionen häufig verwendeter Bibliotheken abrufen können. Welche Bibliotheken das sind, hängt von Betriebssystem und CPU ab.
  3. Mit SymbolLookup.findOrThrow(…) fragen wir nach der Speicheradresse der strlen()-Funktion. Gibt es die Funktion nicht, wirft die Methode eine NoSuchElementException. (findOrThrow() gibt es seit Java 23; in Java 22 schreibst du find(…).orElseThrow() – find() liefert ein Optional<MemorySegment>.)
  4. Mit einem sogenannten Function Descriptor geben wir an, welche Ein- und Ausgabeparameter die strlen()-Funktion hat. Das erste Argument, ValueLayout.JAVA_LONG, definiert den Rückgabetyp der Funktion. JAVA_LONG passt zu size_t auf 64-Bit-Systemen; plattformunabhängig liefert dir linker.canonicalLayouts().get("size_t") das passende Layout. Das zweite Argument, ValueLayout.ADDRESS, definiert den Typ des ersten (und einzigen) Funktionsparameters 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 Funktion 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-Funktion strlen() auf; das Ergebnis casten wir zu einem long (der in Schritt 4 definierte Function Descriptor stellt sicher, dass wir das tun können).
  9. Am Ende des try-with-resources-Blocks 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 mit den Imports aus dem GitHub-Repository, aber ohne die package-Zeile, 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 FFMTestStrlen in an unnamed module (file:/home/user/ffm/FFMTestStrlen.java)
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 = 13

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

$ java --enable-native-access=ALL-UNNAMED FFMTestStrlen.java
len = 13

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. Dazu werfen wir zunächst einen Blick auf die Definition dieser Funktion:

void qsort( void *ptr, size_t count, size_t size,
            int (*comp)(const void *, const void *) );

Die Funktion 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);

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.findOrThrow("qsort");

    // 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-heap 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;
  }
}

Die Besonderheiten dieses Programms im Vergleich zum vorherigen:

  • Schritt 4: Für den Funktionsdeskriptor 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 der beiden zu vergleichenden 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(…)-Funktion an.

  • Schritt 12: Mit MemorySegment.toArray(…) wandeln wir das an der Off-Heap-Speicheradresse arrayAddress gespeicherte Array zurück in ein Java-Array.

Das folgende Bild zeigt, wie die Aufrufe zwischen Java und C hin- und hergehen. Oben steht der Java-Code, unten der native Code. Durchgezogene Pfeile sind Aufrufe, gestrichelte die Rückgabewerte; der Upcall Stub ist der Code, den Linker.upcallStub(…) in Schritt 10 erzeugt hat:

main() ruft qsort() über das Downcall-Handle auf; qsort() ruft für jeden Vergleich den Upcall Stub auf, der die Java-Methode compare() aufruft und deren Rückgabewert an qsort() zurückgibt
Downcall und Upcall: qsort() ruft für jeden Vergleich über den Upcall Stub die Java-Methode compare() auf

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]

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 des Arena-Interfaces 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ältst du wie folgt:

Arena arena = Arena.global();

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 Referenzen 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();

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 close() auf einer automatischen Arena 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 die Segmente 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 Arena das Interface AutoCloseable erweitert, sollten wir die Arena in einem try-with-resources-Block erzeugen:

try (Arena arena = Arena.ofConfined()) {
  // . . .
}

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 Thread 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();

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.

Die vier Arenen im Überblick

Die folgende Tabelle fasst zusammen, wann eine Arena ihren Speicher freigibt, aus welchen Threads du sie verwenden darfst und ob du sie selbst schließen kannst. „✗“ in der letzten Spalte heißt: close() wirft eine UnsupportedOperationException.

ArenaErzeugungSpeicher wird freigegebenZugriff ausclose()
globalArena.global()beim Beenden der JVMallen Threads✗
automatischArena.ofAuto()durch den Garbage Collectorallen Threads✗
beschränktArena.ofConfined()bei close()dem erzeugenden Thread✓
geteiltArena.ofShared()bei close()allen Threads✓

MemorySegment

Ein MemorySegment ist ein Objekt, das einen zusammenhängenden Speicherbereich beschreibt. Ein Memory Segment kann auf verschiedene Arten allokiert werden. Das Arena-Interface bietet dazu – über sein Superinterface SegmentAllocator – 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. Das MemoryLayout-Interface beschreibe ich im nächsten Abschnitt.

Eine vollständige Übersicht aller Methoden zum Allokieren von Speichersegmenten findest du im Javadoc 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 ein Subinterface von MemoryLayout, das definiert, wie grundlegende Datentypen, 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 ein Subinterface 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);

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

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

StructLayout

Ein StructLayout, ein Subinterface von GroupLayout und damit von MemoryLayout, beschreibt das Speicherlayout eines Structs, also eines Speicherbereichs, in dem verschiedene Datentypen hintereinander abgelegt sind. Die Elemente des Structs haben einen Namen und wiederum ein MemoryLayout. Der Name wird nicht mit gespeichert; über ihn greifst du auf die Elemente des Structs zu.

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"));

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-Typ in den gewünschten Java-Rückgabewert.

Das Interface 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);
    }
  }
}

Kommen wir nun zu einem etwas komplizierteren 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));
            });
  }
}

Kommen wir zuletzt zu einem besonders komplizierten 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.

Das folgende Bild zeigt das Speicherlayout dieses Arrays: vier Structs zu je sechs Bytes, darin year, month und day zu je zwei Bytes. Unter jedem Struct steht sein Offset in Bytes. Hervorgehoben ist das Feld, in das monthInArrayHandle.set(segment, 0, 2, …) schreibt:

Vier Datum-Structs zu je sechs Bytes mit den Feldern year, month und day; das Feld month des Structs mit Index 2 liegt bei Offset 0 + 2 × 6 + 2 = 14
Das VarHandle berechnet den Offset aus Basis-Offset, Array-Index und Feldname: 0 + 2 × 6 + 2 = 14

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

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

  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 dateArrayLayout = 
        MemoryLayout.sequenceLayout(ARRAY_LENGTH, dateLayout);

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

  private static void writeToSegment(
      MemorySegment segment, SequenceLayout dateArrayLayout) {
    VarHandle yearInArrayHandle =
        dateArrayLayout.varHandle(
            PathElement.sequenceElement(), PathElement.groupElement("year"));
    VarHandle monthInArrayHandle =
        dateArrayLayout.varHandle(
            PathElement.sequenceElement(), PathElement.groupElement("month"));
    VarHandle dayInArrayHandle =
        dateArrayLayout.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(
            dateSegment -> {
              int year = (int) yearHandle.get(dateSegment, 0);
              int month = (int) monthHandle.get(dateSegment, 0);
              int day = (int) dayHandle.get(dateSegment, 0);
              System.out.printf("%04d-%02d-%02d%n", year, month, day);
            });
  }
}

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.

Eigene Libraries aufrufen

Bisher haben wir nur Funktionen der C-Standardbibliothek aufgerufen, die Linker.defaultLookup() ohne weiteres Zutun findet. Für eine eigene oder eine fremde Library brauchst du ein anderes SymbolLookup. Dafür gibt es zwei Wege.

Der erste Weg ist SymbolLookup.libraryLookup(…). Die Methode lädt die Library – unter Linux z. B. die Datei libmylib.so – und bindet sie an eine Arena. Schließt du die Arena, wird die Library wieder entladen:

try (Arena arena = Arena.ofConfined()) {
  SymbolLookup mylib = SymbolLookup.libraryLookup("libmylib.so", arena);
  MemorySegment myFunction = mylib.findOrThrow("my_function");
  // . . .
}

Der zweite Weg führt über das klassische System.loadLibrary(…) aus JNI. Die so geladene Library findest du anschließend über SymbolLookup.loaderLookup(), das alle Libraries des aufrufenden Classloaders durchsucht:

System.loadLibrary("mylib");
SymbolLookup mylib = SymbolLookup.loaderLookup();
MemorySegment myFunction = mylib.findOrThrow("my_function");

Beide Wege gelten als „restricted“: libraryLookup() als Methode der FFM API, System.loadLibrary() seit Java 24 durch JEP 472. Ohne --enable-native-access lösen sie also die Warnung aus, die du aus dem ersten Beispiel kennst.

jextract: Bindings aus Header-Dateien erzeugen

Für eine Library mit Dutzenden Funktionen und Structs wird es mühsam, jeden Funktionsdeskriptor und jedes Memory Layout von Hand zu schreiben. Diese Arbeit nimmt dir das Werkzeug jextract ab: Es liest die Header-Dateien einer C-Library und erzeugt daraus Java-Code mit den Downcall-Handles und Memory Layouts, die du sonst selbst schreiben würdest.

jextract ist nicht Teil des JDK. Es wird im Projekt openjdk/jextract entwickelt; fertige Builds gibt es als Early-Access-Builds.

jextract versteht nur C-Header-Dateien. Das passt zur FFM API: Ihr nativer Linker ruft Funktionen nach dem C-ABI (Application Binary Interface) der Plattform auf, also nach deren Aufrufkonvention und Datentypen. Eine C++- oder Rust-Library erreichst du über eine C-Schnittstelle – in C++ über extern "C", in Rust z. B. mit dem Werkzeug cbindgen.

Ein typischer Aufruf sieht so aus:

jextract \
  --include-dir /path/to/mylib/include \
  --output src \
  --target-package org.example.mylib \
  --library mylib \
  /path/to/mylib/include/mylib.h

jextract legt die erzeugten Klassen im Package org.example.mylib unter src ab. Die meisten Funktionen findest du in einer Klasse, deren Name sich aus der Header-Datei ableitet – für mylib.h heißt sie mylib_h.

Die erzeugten Bindings hängen von der Plattform ab, auf der jextract läuft: Der C-Präprozessor wertet die Header plattformabhängig aus, und C-Typen wie long sind nicht überall gleich groß. Ich empfehle dir deshalb, die Bindings auf der Plattform zu erzeugen, auf der du sie einsetzt.

Eine kleine Geschichte der Foreign Function & Memory API

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

Die FFM API entstand aus zwei zunächst getrennten APIs: der „Foreign-Memory Access API“, die im März 2020 mit Java 14 als Incubator erschien, und der „Foreign Linker API“, die ein Jahr später mit Java 16 folgte. In Java 17 wurden beide zur „Foreign Function & Memory API“ zusammengeführt. In Java 22 wurde sie im März 2024 – nach zwei Incubator- und drei Preview-Runden – finalisiert.

Die folgende Tabelle zeigt alle zehn JEPs auf diesem Weg:

Java-VersionJEPAPIStadium
Java 14JEP 370Foreign-Memory Access APIIncubator
Java 15JEP 383Foreign-Memory Access APIzweiter Incubator
Java 16JEP 389Foreign Linker APIIncubator
Java 16JEP 393Foreign-Memory Access APIdritter Incubator
Java 17JEP 412Foreign Function & Memory APIIncubator
Java 18JEP 419Foreign Function & Memory APIzweiter Incubator
Java 19JEP 424Foreign Function & Memory APIPreview
Java 20JEP 434Foreign Function & Memory APIzweite Preview
Java 21JEP 442Foreign Function & Memory APIdritte Preview
Java 22JEP 454Foreign Function & Memory APIfinal

Fazit

Die meisten Java-Entwickler:innen 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. Für eine echte Library empfehle ich dir, die Bindings mit jextract zu erzeugen, statt jeden Funktionsdeskriptor von Hand zu schreiben. Und gib nativen Zugriff schon heute mit --enable-native-access frei – dann läuft dein Code auch noch, wenn deny zum Default wird.

Tiefer in die Materie führen JDK Enhancement Proposal 454 und die Webseite von Project Panama.

Konntest du etwas aus diesem Artikel mitnehmen? Mit einer Bewertung auf meinem ProvenExpert-Profil hilfst du anderen Entwickler:innen einzuschätzen, ob sich das Lesen lohnt – und mir zu verstehen, welche Inhalte dir weiterhelfen.

👉 Bewertung abgeben

Dieses Thema im eigenen Code?

Du hast den Artikel gelesen – im Training arbeitet ihr damit. 2 Tage lang gehen wir die Themen an euren eigenen Projekten durch, statt an konstruierten Beispielen.

Praxisnah, verständlich und direkt auf euren Projektalltag übertragbar. Statt Theorie vermittle ich Prinzipien, die euch helfen, Code langfristig besser, wartbarer und performanter zu schreiben.

Java 25 SchulungAlle Trainings ansehen

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche