
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.Unsafean 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.)
- Über die statische Methode
Linker.nativeLinker()bekommen wir einen Linker – die zentrale Komponente, die den Zugriff auf fremde Funktionen orchestriert. - Über
Linker.defaultLookup()lassen wir uns einSymbolLookup-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. - Mit
SymbolLookup.findOrThrow(…)fragen wir nach der Speicheradresse derstrlen()-Funktion. Gibt es die Funktion nicht, wirft die Methode eineNoSuchElementException. (findOrThrow()gibt es seit Java 23; in Java 22 schreibst dufind(…).orElseThrow()–find()liefert einOptional<MemorySegment>.) - 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_LONGpasst zusize_tauf 64-Bit-Systemen; plattformunabhängig liefert dirlinker.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. - Die Methode
Linker.downcallHandle(…)liefert uns einMethodHandlefür die Funktion an der angegebenen Speicheradresse und den zuvor definierten Funktionsdeskriptor. Method Handles sind nichts Neues – es gibt sie bereits seit Java 7. Arena.ofConfined()liefert uns eine sogenannte Arena – ein Objekt, das den Zugriff auf nativen Speicher verwaltet – mehr dazu später.Arena.allocateFrom(…)reserviert einen nativen Speicherblock und legt dort die Zeichenkette „Happy Coding!“ im UTF-8-Format ab.- Mit
MethodHandle.invoke(…)rufen wir die C-Funktionstrlen()auf; das Ergebnis casten wir zu einemlong(der in Schritt 4 definierte Function Descriptor stellt sicher, dass wir das tun können). - 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 Arraycount– Anzahl der Elemente im Arraysize– Größe der einzelnen Elemente des Arrays in Bytescomp– 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(…), daqsort(…)keinen Rückgabewert hat. Wir geben die folgenden Argumente an:ValueLayout.ADDRESS– für den Zeiger auf das zu sortierende ArrayValueLayout.JAVA_LONG– für die Anzahl der Elemente im ArrayValueLayout.JAVA_LONG– für die Größe der einzelnen Array-ElementeValueLayout.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, jeweilsValueLayout.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-Methodecompare(…)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-SpeicheradressearrayAddressgespeicherte 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:
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.
| Arena | Erzeugung | Speicher wird freigegeben | Zugriff aus | close() |
|---|---|---|---|---|
| global | Arena.global() | beim Beenden der JVM | allen Threads | ✗ |
| automatisch | Arena.ofAuto() | durch den Garbage Collector | allen Threads | ✗ |
| beschränkt | Arena.ofConfined() | bei close() | dem erzeugenden Thread | ✓ |
| geteilt | Arena.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,countin der zweiten Variante) von Objekten eines bestimmten Typs (definiert durchelementLayout) abgestimmt ist. DasMemoryLayout-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 durchresLayoutdefinierten Rückgabetyp und den durchargLayoutsdefinierten Eingabetypen.ofVoid(MemoryLayout... argLayouts)
erzeugt einen Function Descriptor ohne Rückgabetyp und mit den durchargLayoutsdefinierten 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:
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-Version | JEP | API | Stadium |
|---|---|---|---|
| Java 14 | JEP 370 | Foreign-Memory Access API | Incubator |
| Java 15 | JEP 383 | Foreign-Memory Access API | zweiter Incubator |
| Java 16 | JEP 389 | Foreign Linker API | Incubator |
| Java 16 | JEP 393 | Foreign-Memory Access API | dritter Incubator |
| Java 17 | JEP 412 | Foreign Function & Memory API | Incubator |
| Java 18 | JEP 419 | Foreign Function & Memory API | zweiter Incubator |
| Java 19 | JEP 424 | Foreign Function & Memory API | Preview |
| Java 20 | JEP 434 | Foreign Function & Memory API | zweite Preview |
| Java 21 | JEP 442 | Foreign Function & Memory API | dritte Preview |
| Java 22 | JEP 454 | Foreign Function & Memory API | final |
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.




