String message = "Hello Sciam";
try (Arena arena = Arena.ofConfined()) {
MemorySegment memorySegment = arena.allocateFrom(message);
}
L’API Foreign Function & Memory dans Java 22
Clément de Tastes
Tech Lead
L’API Foreign Function & Memory dans Java 22
Un peu d’histoire
Depuis le JDK 1.1, il est possible de manipuler des données "off-heap" et d’interagir avec du code natif via l’API JNI : "Java Native Interface".
L’utilisation du modificateur native
permet l’invocation de fonctions natives, moyennant une succession d’étapes parfois périlleuses.
Cela implique notamment le développement d’une "glue" en langage natif permettant de faire le lien entre code C et Java par exemple, dans laquelle il faudra effectuer les conversions des types de données.
Il faut aussi être particulièrement vigilant quant à la gestion de la mémoire, le Garbage Collector ne réalisant pas de collecte en dehors de la mémoire "heap".
Historiquement, le JDK propose 2 API permettant de manipuler la mémoire "off-heap" :
-
ByteBuffer::allocateDirect
permet d’interagir avec une zone mémoire "off-heap" sous forme de tableau d’octets, -
Unsafe
fournit diverses API de bas niveau telles queallocateMemory()
.
Ces APIs présentent un certain nombre d’inconvénients : ByteBuffer
limite l’accès à une zone dont la taille maximale est de 2Go, les méthodes permettant d’y accéder restent rudimentaires et ses performances ne sont pas optimales.
Unsafe
est quant à lui performant mais n’est pas une API standard (et doit être retirée dans une version ultérieure une fois toutes ses fonctionnalités proposées avec un équivalent standard) et ouvre la porte à divers écueils en termes de sécurité ou de contrôle.
FFM a pour ambition de fournir un moyen sûr et performant de répondre à ces problématiques.
Une longue gestation
Dire que la route a été longue serait un euphémisme. FFM est la fusion des JEP "Foreign Memory Access API" et "Foreign Linker API" apparues toutes deux en incubator respectivement en JDK 14 et JDK 16. Il aura donc fallu pas moins de 4 années depuis la première version en incubator pour voir arriver ces fonctionnalités en standard dans le JDK 22 au travers de la JEP 454. Néanmoins, on pourra reconnaître un certain mérite à la stratégie de releases incrémentales sous forme d’incubator ou de preview : celle-ci a permis une montée en maturité progressive en tenant compte des retours de la communauté et des différents acteurs.
L’API Foreign Function & Memory trouve sa place dans le JDK dans le package java.lang.foreign
du module java.base
.
La gestion de la mémoire "off-heap"
Pour manipuler les données stockées dans la mémoire "off-heap", l’API FFM nous propose une représentation de celles-ci sous la forme de l’objet MemorySegment
. Un tel objet peut être obtenu par l’intermédiaire d’une Arena
qui va en contrôler la portée et nous permettra de déterminer à quel moment la mémoire allouée à ce segment sera libérée.
Memory Segment permet également d’accéder à de la mémoire "on-heap" mais nous n’allons pas aborder ce point.
|
Une instance de l’interface Arena
peut être obtenue via l’une de ses 4 fabriques :
-
Arena::global
: les segments sont toujours accessibles, et ce, par n’importe quel thread. La mémoire native "off-heap" correspondante n’est libérée qu’à l’arrêt de la JVM. -
Arena::ofAuto
: les segments sont ici encore accessibles par n’importe quel thread, mais le Garbage Collector se chargera de libérer les zones mémoires après que lesMemorySegment
correspondants deviennent inatteignables. -
Arena::ofConfined
: les segments ne sont accessibles que depuis le thread qui a créé l'Arena
et ne seront libérés que lorsque celle-ci sera fermée, par l’invocation de sa méthodeclose()
explicitement ou par l’utilisation d’un "try-with-resources", l’interfaceArena
héritant d'AutoCloseable
. -
Arena::ofShared
: commeArena::ofConfined
, mais les segments sont accessibles par tous les threads.
Ces différences sont synthétisées dans ce tableau :
Type | Durée de vie limitée | Arrêt explicite possible | Accessible depuis plusieurs threads |
---|---|---|---|
Global |
✗ |
✗ |
✓ |
Automatic |
✓ |
✗ |
✓ |
Confined |
✓ |
✓ |
✗ |
Shared |
✓ |
✓ |
✓ |
Il est également possible de fournir sa propre implémentation de l’interface Arena .
|
Arena
possède un certain nombre de surcharges de la méthode allocateFrom()
permettant, par exemple, la très utile allocation d’une chaîne de caractères dans sa représentation native.
MemorySegment
allocateFrom() se charge de l’encodage de la chaîne de caractères, ainsi que de l’ajout du caractère de terminaison \0
|
L’interface MemorySegment
possède une variété de méthodes permettant de lire ou d’écrire dans la mémoire "off-heap". Ces méthodes prennent en paramètre un objet de type ValueLayout
qui modélise la structure de données sous-jacente, telles que des valeurs primitives. Ce ValueLayout
précise :
-
la taille de la donnée,
-
son "endianness" (big-endian / little-endian),
-
l’alignement,
-
et le type de donnée Java correspondant.
Par exemple, si l’on souhaite lire octet par octet le contenu de la chaîne de caractères en mémoire, on pourra utiliser le layout ValueLayout.JAVA_BYTE
, qui correspond à la structure suivante :
-
taille = 1 octet
-
endianness =
ByteOrder.nativeOrder()
-
alignement = 1, autrement dit l’adresse mémoire correspondante est un multiple de 8 bits
-
type Java associé :
byte
byte[] values = { -2, -1, 0, 1, 2 };
MemorySegment memorySegment = arena.allocate(values.length); (1)
for (int i = 0; i < values.length; i++) {
memorySegment.set(ValueLayout.JAVA_BYTE, i, values[i]); (2)
}
for (int i = 0; i < values.length; i++) {
byte b = memorySegment.get(ValueLayout.JAVA_BYTE, i); (3)
System.out.println(b);
}
1 | Allocation d’un MemorySegment |
2 | Écriture dans le segment |
3 | Lecture depuis le segment |
-2 -1 0 1 2
La recherche et la description de fonctions étrangères
L’utilisation du Linker
L’interface Linker
va nous être utile à plus d’un titre, notamment pour le chargement d’une bibliothèque native.
Son principal atout est de nous abstraire d’un certain nombre de considérations techniques.
En effet, chaque bibliothèque adhère à une "Application Binary Interface" (ABI) qui est un ensemble de conventions et types de données qui dépendent du système d’exploitation, du compilateur et du processeur.
Linker
a connaissance de ces conventions et jouera le rôle de médiateur entre le code Java et le code natif.
Une instance de Linker
s’obtient via la fabrique nativeLinker()
.
Linker
Linker linker = Linker.nativeLinker(); (1)
SymbolLookup defaultLookup = linker.defaultLookup(); (2)
1 | Obtention d’une instance de Linker |
2 | defaultLookup() permet d’obtenir une instance de SymbolLookup nous permettant de rechercher parmi un ensemble de bibliothèques standard (telles que la bibliothèque standard C) |
L’interface SymbolLookup
permet de fournir un accès aux bibliothèques et fonctions natives qui adhèrent aux spécifications de la plateforme. Pour en obtenir une instance, on dispose de 3 fabriques :
-
SymbolLookup.libraryLookup(String, arena)
etSymbolLookup.libraryLookup(Path, arena)
permettent de charger dynamiquement une bibliothèque par son nom ou son chemin et en liant son cycle de vie à celui de l'Arena
, -
SymbolLookup.loaderLookup()
crée unSymbolLookup
qui recherchera dans les bibliothèques chargées par leClassLoader
, par exemple viaSystem.load()
ouSystem.loadLibrary()
comme on le ferait avec JNI.
Le chargement d’une bibliothèque native
Nous allons utiliser à titre d’exemple SQLite dont le code est écrit en langage C, par l’intermédiaire de sa bibliothèque sqlite3.dll
sur Windows afin de manipuler localement une base de données dans notre application.
try (Arena arena = Arena.ofConfined()) { (1)
SymbolLookup lookup = SymbolLookup.libraryLookup("sqlite3", arena); (2)
}
1 | Création d’une Arena de type "confined" |
2 | Obtention d’une instance de SymbolLookup sur la bibliothèque sqlite3. Omettre l’extension fonctionne et est recommandé pour des raisons de portabilité, permettant ainsi au même code de charger la .dll sur Windows ou le .so sur Linux par exemple. |
La localisation d’une fonction native
Après avoir initialisé le fichier vide ffm.db
qui contiendra notre base de données, la prochaine étape va consister à l’appel de la fonction native sqlite3_open
qui permet d’établir un lien avec la base.
L’interface SymbolLookup
précédemment obtenue nous permet de localiser l’adresse mémoire correspondant à la fonction, via sa méthode find()
.
Son type de retour est Optional<MemorySegment>
, ce qui permet de gérer le cas où la recherche aurait échoué.
MemorySegment
correspondant à la fonction sqlite3_open
String openFunctionName = "sqlite3_open";
MemorySegment openSegment = lookup.find(openFunctionName)
.orElseThrow(() -> new IllegalStateException("Impossible de localiser la fonction " + openFunctionName));
L’appel d’une fonction native
L’obtention d’un MethodHandle
vers la fonction native
Le Linker
va nous permettre d’obtenir une instance de MethodHandle
sur la fonction native.
Pour invoquer la fonction native, il va falloir fournir une description de la signature de la méthode.
L’interface FunctionDescriptor
et sa fabrique of()
permet de définir le type de retour et les paramètres acceptés par la méthode.
Le fichier header sqlite3.h
nous indique la signature :
int sqlite3_open(
const char *filename, /* Database filename (UTF-8) */
sqlite3 **ppDb /* OUT: SQLite db handle */
);
On obtient la description correspondante en Java :
FunctionDescriptor openDesc = FunctionDescriptor.of(
ValueLayout.JAVA_INT, (1)
ValueLayout.ADDRESS, (2)
ValueLayout.ADDRESS (3)
);
1 | Type de retour de la méthode |
2 | Type du premier paramètre : pointeur vers le nom du fichier .db |
3 | Type du second paramètre : pointeur vers un handle de la base de données |
L’interface Linker
, par l’intermédiaire de sa méthode downcallHandle()
permet l’obtention de l’instance de MethodHandle
. Comme l’indique son nom, cela permet de réaliser des appels descendants, de Java vers le code natif.
MethodHandle openHandle = linker.downcallHandle(openSegment, openDesc);
L’invocation de la méthode native
Il ne nous reste plus qu’à préparer les paramètres et invoquer la méthode native.
Tous les paramètres de la méthode native à invoquer doivent aussi se trouver dans la mémoire "off-heap" et donc faire l’objet d’allocations au travers des API de FFM, pour en obtenir les MemorySegment correspondants.
|
String databaseFilename = "ffm.db";
MemorySegment filenameSegment = arena.allocateFrom(databaseFilename); (1)
MemorySegment dbPtrPtr = arena.allocate(ValueLayout.ADDRESS); (2)
try {
int code = (int) openHandle.invokeExact(filenameSegment, dbPtrPtr); (3)
if (code == 0) {
System.out.println("Lien avec la base " + databaseFilename + " établi avec succès");
} else {
System.err.println("Erreur au chargement de la base : code = " + code);
}
} catch (Throwable e) {
throw new IllegalStateException("Erreur lors de l'invocation de la fonction native " + openFunctionName, e);
}
1 | Allocation de la chaîne de caractères native contenant le nom du fichier de base de données |
2 | Allocation d’un segment vers le pointeur du handle de la base de données, second paramètre |
3 | Invocation de la méthode native |
L’appel montant : natif vers Java
L’interface Linker
permet également de réaliser des upcalls, à savoir des appels montants depuis le code natif jusqu’au code Java.
Cela se réalise par le biais de la méthode upcallStub()
qui prendre en paramètres :
-
un
MethodHandle
de la fonction Java à appeler depuis le code natif -
une description de cette fonction sous la forme de
FunctionDescriptor
-
une instance de type
Arena
La fonction native sqlite3_trace_v2
permet de configurer des traces avec l’appel d’une fonction callback. Sa signature est la suivante :
SQLITE_API int sqlite3_trace_v2(
sqlite3*,
unsigned uMask,
int(*xCallback)(unsigned,void*,void*,void*),
void *pCtx
);
On va pouvoir déclarer une méthode Java qui sera appelée comme callback de trace. La signature de la méthode Java doit correspondre à son homologue natif.
static int traceCallback(
final MemorySegment m1,
final MemorySegment m2,
final MemorySegment m3,
final MemorySegment m4) {
System.out.println("Appel de traceCallback()");
return 0;
}
Obtenons le MethodHandle
correspondant :
MethodHandle traceCallbackHandle = MethodHandles.lookup().findStatic(
SQLite.class,
"traceCallback",
MethodType.methodType(
int.class,
MemorySegment.class,
MemorySegment.class,
MemorySegment.class,
MemorySegment.class
)
);
Puis le FunctionDescriptor
associé :
FunctionDescriptor traceCallbackDesc = FunctionDescriptor.of(
ValueLayout.JAVA_INT,
ValueLayout.ADDRESS,
ValueLayout.ADDRESS,
ValueLayout.ADDRESS,
ValueLayout.ADDRESS
);
On peut désormais créer l’upcall grâce au Linker
. Ce dernier va se charger de créer un pointeur sur notre fonction.
MemorySegment upcallSegment = linker.upcallStub(traceCallbackHandle, traceCallbackDesc, arena);
Enfin, l’appel à la fonction sqlite3_trace_v2
peut être effectué, en lui fournissant notre upcall. On reproduit les étapes précédentes comme pour la connexion à la base de données, en réalisant un downcall.
String traceFunctionName = "sqlite3_trace_v2";
MemorySegment traceSegment = lookup.find("sqlite3_trace_v2").orElseThrow(); (1)
FunctionDescriptor traceDesc = FunctionDescriptor.of( (2)
ValueLayout.JAVA_INT,
ValueLayout.ADDRESS,
ValueLayout.JAVA_INT,
ValueLayout.ADDRESS,
ValueLayout.ADDRESS
);
MethodHandle traceHandle = linker.downcallHandle(traceSegment, traceDesc); (3)
MemorySegment dbPtr = dbPtrPtr.get(ValueLayout.ADDRESS, 0); (4)
try {
int traceCode = (int) traceHandle.invokeExact( (5)
dbPtr,
0x01, // SQLITE_TRACE_STMT (6)
upcallStub,
MemorySegment.NULL
);
System.out.println("traceCode " + traceCode);
} catch (Throwable e) {
throw new IllegalStateException("Erreur lors de l'invocation de la fonction " + traceFunctionName, e);
}
1 | Obtention de l’adresse de la fonction en mémoire |
2 | Description de la signature de la fonction |
3 | Création du handle vers la fonction native |
4 | sqlite3_open renvoie un pointeur de pointeur (sqlite3 **ppDb ) et on a besoin ici du pointeur (sqlite3 *pDb ) comme paramètre |
5 | Invocation de la fonction native |
6 | Événement déclenchant une trace, ici la valeur SQLITE_TRACE_STMT |
Pour vérifier que notre callback est fonctionnel et la méthode Java effectivement appelée en bout de chaîne, on peut exécuter une requête SQL via la fonction sqlite3_exec()
. Par exemple, en créant une table dans notre base de données. On reproduit les étapes précédentes à chaque fois :
-
recherche de la fonction,
-
description de la signature,
-
obtention d’un
MethodHandle
-
invocation
L’outil JExtract
Présentation
JExtract est un outil en Early-Access du projet OpenJDK dont le but est la génération automatique d’un binding Java depuis les fichiers headers natifs.
L’outil est capable d’interpréter les fichiers .h
et de générer le code Java permettant l’invocation des méthodes natives sous-jacentes par le biais de l’API FFM.
Les exemples réalisés précédemment faits à la main peuvent être répétitifs, chronophages et source d’erreurs. Ils nécessitent de lire et analyser les fichiers headers individuellement et d’écrire toutes les recherches de fonctions et définitions de structures manuellement.
La possibilité d’automatiser cette partie prend alors tout son sens.
Si l’on venait à migrer d’une version de bibliothèque à une autre sur une volumétrie importante de code, il serait fastidieux de mettre à niveau le code Java.
Régénérer les bindings avec l’outil JExtract
permet de nous soulager d’une partie du travail.
Bien que l’outil fasse partie du projet CodeTools d’OpenJDK, il ne fait pas partie du JDK en tant que tel et n’est donc pas disponible de base dans les distributions du JDK 22. |
La génération du code Java
Mettons en pratique cet outil pour voir comment il peut nous aider dans l’utilisation de la bibliothèque sqlite3. La commande prend en paramètre un certain nombre d’options suivi du fichier header dont on souhaite extraire les données pour en générer le code Java.
jextract -l sqlite3 \ (1)
-t fr.sciam.sqlite \ (2)
--header-class-name SQLite3 \ (3)
sqlite3.h (4)
1 | nom de la bibliothèque à charger au runtime (avec ou sans l’extension .dll , .so , …) |
2 | package dans lequel les sources Java seront générées |
3 | nom de la classe header Java (par défaut cela aurait été ici sqlite3_h.java ) |
4 | le fichier header |
Ce n’est pas la liste exhaustive des paramètres, cf. jextract --help pour plus de détails
|
Une fois la génération terminée, on dispose de la classe SQLite3
qui propose les bindings pour chacune des méthodes définies dans le header, notamment sqlite3_open()
que nous avons utilisé précédemment, ainsi que les données permettant les différentes manipulations.
private static class sqlite3_open { (1)
public static final FunctionDescriptor DESC = FunctionDescriptor.of(
SQLite3.C_INT,
SQLite3.C_POINTER,
SQLite3.C_POINTER
);
public static final MethodHandle HANDLE = Linker.nativeLinker().downcallHandle(
SQLite3.findOrThrow("sqlite3_open"),
DESC
);
}
/**
* {@snippet lang=c :
* int sqlite3_open(const char *filename, sqlite3 **ppDb) (2)
* }
*/
public static int sqlite3_open(MemorySegment filename, MemorySegment ppDb) { (3)
var mh = sqlite3_open.HANDLE;
try {
if (TRACE_DOWNCALLS) {
traceDowncall("sqlite3_open", filename, ppDb); (4)
}
return (int) mh.invokeExact(filename, ppDb); (5)
} catch (Throwable ex) {
throw new AssertionError("should not reach here", ex);
}
}
1 | Classe interne contenant la description de la fonction au format FunctionDescriptor ainsi que son MethodHandle associé |
2 | Code snippet qui reprend la signature de la méthode native sous-jacente |
3 | La méthode de connexion à la base de données avec ses paramètres |
4 | Affichage d’un log de l’appel, si la propriété jextract.trace.downcalls est activée |
5 | Invocation de la méthode native via le MethodHandle |
Le gain est donc de ne pas avoir eu à écrire tout ce code technique.
L’utilisation du code généré pour un appel descendant (downcall)
Pour l’utiliser, le code de notre application pourrait ressembler à :
sqlite3_open
import static fr.sciam.sqlite.SQLite3.sqlite3_open;
try (Arena arena = Arena.ofConfined()) {
MemorySegment dbName = arena.allocateFrom("ffm.db");
MemorySegment dbPtrPtr = arena.allocate(ValueLayout.ADDRESS);
sqlite3_open(dbName, dbPtrPtr);
}
L’utilisation du code généré pour un appel montant (upcall)
Il en est de même pour la configuration de notre upcall pour la configuration des traces, dont l’obtention était particulièrement verbeuse.
JExtract a généré une méthode utilitaire d’allocation du MemorySegment
correspondant au callback Java à appeler depuis le code natif :
public static MemorySegment allocate(sqlite3_trace_v2$xCallback.Function fi, Arena arena) {
return Linker.nativeLinker().upcallStub(UP$MH.bindTo(fi), $DESC, arena);
}
Pour l’utiliser dans notre appel descendant de configuration des traces, nous pouvons faire :
sqlite3_trace_v2$xCallback.Function function = (_, _, _, _) -> { (1)
System.out.println("Dans le callback de trace");
return 0;
};
MemorySegment callbackSegment = sqlite3_trace_v2$xCallback.allocate(function, arena); (2)
sqlite3_trace_v2( (3)
dbPtr,
0x1, // SQLITE_TRACE_STMT
callbackSegment,
MemorySegment.NULL
);
1 | Définition du callback Java, avec un petit clin d’œil à cette nouvelle syntaxe disponible en standard depuis Java 22 (JEP 456 : Unnamed Variables & Patterns) |
2 | Allocation du MemorySegment correspondant au callback |
3 | Invocation de la fonction native |
Les fonctions natives renvoyant un pointeur
Certaines fonctions natives sont susceptibles de renvoyer un pointeur vers une région mémoire.
La JVM n’a pas la possibilité de connaître la taille ni la structure de cette région, ni même sa durée de vie.
Pour cela, l’API utilise un MemorySegment
de taille nulle pour représenter ce type de pointeur.
Ceci est utilisé pour :
-
les pointeurs renvoyés par une fonction native
-
les pointeurs passés depuis le code natif vers un upcall
-
les pointeurs lus depuis un
MemorySegment
Il est impossible de manipuler directement un tel MemorySegment
, sous peine de voir la JVM lever l’exception IndexOutOfBoundsException
. En effet, elle ne peut pas accéder ou valider en toute sécurité une opération d’accès à une région mémoire dont la taille est inconnue.
Néanmoins, la méthode MemorySegment::reinterpret
permet de travailler sur de tels segments en y accédant de manière sûre et en rattachant la zone mémoire associée à une Arena
. Il existe plusieurs surcharges de cette méthode dont les paramètres font intervenir :
-
la taille en octet à laquelle le segment va être redimensionné
-
l'
Arena
à associer avec leMemorySegment
-
une action à exécuter lorsque l'
Arena
sera fermée, sous la forme d’unConsumer<MemorySegment>
Cela est par exemple le cas pour la gestion des colonnes de type blob
(Binary Large Object) dans SQLite : la fonction sqlite3_column_blob
renvoie un pointeur vers la région mémoire contenant l’objet, et sa taille est donnée par sqlite3_column_bytes
.
blob
en Cconst void *blob = sqlite3_column_blob(stmt, 0);
int blob_size = sqlite3_column_bytes(stmt, 0);
Les Memory layouts et les accès structurés
Accéder à des données structurées en mémoire en ne se limitant qu’à des opérations basiques nuirait à la lisibilité et à la maintenabilité du code, et l’on tomberait dans l’un des écueils du direct ByteBuffer
.
FFM tente d’y remédier avec l’interface MemoryLayout
qui permet de définir une structuration de la donnée et d’y accéder de manière simplifiée. StructLayout
et SequenceLayout
sont des interfaces filles de l’interface scellée MemoryLayout
MemoryLayout::structLayout
permet de définir une structure de données.
MemoryLayout::sequenceLayout
permet de définir une répétition de la structure.
Si l’on souhaite lire et écrire une succession de données se répétant, des positions GPS par exemple, modélisées par :
record Coordinates(float latitude, float longitude) {}
Coordinates[] array = { /* ... */ };
On modélise le layout :
StructLayout structure = MemoryLayout.structLayout(
ValueLayout.JAVA_FLOAT.withName("latitude"),
ValueLayout.JAVA_FLOAT.withName("longitude")
);
La structure ainsi que les champs qui la constituent peuvent être nommés, afin d’en faciliter l’accès ultérieur. |
SequenceLayout sequence = MemoryLayout.sequenceLayout(array.length, structure);
On peut obtenir des VarHandle
permettant d’accéder directement aux champs, avec une gestion automatique de l’adresse mémoire au sein de la séquence et de la structure.
PathElement element = PathElement.sequenceElement();
VarHandle latitude = sequence.varHandle(element, PathElement.groupElement("latitude"));
VarHandle longitude = sequence.varHandle(element, PathElement.groupElement("longitude"));
Enfin, on peut lire ou écrire nos données.
MemorySegment segment = arena.allocate(sequence);
// Écriture
for (int i = 0; i < array.length; i++) {
Coordinates c = array[i];
latitude.set(segment, 0, i, c.latitude());
longitude.set(segment, 0, i, c.longitude());
}
// Lecture
for (int i = 0; i < array.length; i++) {
float lat = (float) latitude.get(segment, 0, i);
float lon = (float) longitude.get(segment, 0, i);
System.out.println("lat " + lat + ", lon " + lon);
}
MemoryLayout propose également les layouts de type union et padding
|
Le mot de la fin
FFM propose le confort de ne pas avoir à écrire la moindre ligne de code natif et apporte le niveau de sécurité qui manquait à JNI. Cela dit, une bonne compréhension des mécanismes de bas niveau reste indispensable : gestion de la mémoire, des pointeurs et la capacité à interpréter les signatures des méthodes natives.
sqlite3_close(dbPtr);
Sommaire :