L’API Foreign Function & Memory dans Java 22

Clément de Tastes

Tech Lead


Publié le 28/03/2024Temps de lecture : 8 minutes
Description

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 que allocateMemory().

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 les MemorySegment 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éthode close() explicitement ou par l’utilisation d’un "try-with-resources", l’interface Arena héritant d' AutoCloseable.

  • Arena::ofShared : comme Arena::ofConfined, mais les segments sont accessibles par tous les threads.

Ces différences sont synthétisées dans ce tableau :

Table 1. Récapitulatif des différents types d’Arenas
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.

Allocation d’une chaîne de caractères "off-heap" via un MemorySegment
String message = "Hello Sciam";
try (Arena arena = Arena.ofConfined()) {
  MemorySegment memorySegment = arena.allocateFrom(message);
}
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

Lecture et écriture dans la mémoire "off-heap"
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
Sortie du programme
-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().

Utilisation du 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) et SymbolLookup.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 un SymbolLookup qui recherchera dans les bibliothèques chargées par le ClassLoader, par exemple via System.load() ou System.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.

Chargement dynamique de la bibliothèque sqlite3
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é.

Obtention du 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.

Utilisation de la commande jextract
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 à :

Import de la méthode sqlite3_open
import static fr.sciam.sqlite.SQLite3.sqlite3_open;
Invocation
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 le MemorySegment

  • une action à exécuter lorsque l' Arena sera fermée, sous la forme d’un Consumer<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.

Obtention d’un pointeur vers un blob en C
const 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 :

Jeu de données
record Coordinates(float latitude, float longitude) {}
Coordinates[] array = { /* ... */ };

On modélise le layout :

Représentation d’une structure de coordonnées GPS
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.
Répétition de la structure
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.

À votre tour d’explorer les API de FFM en téléchargeant le JDK 22 et en consultant la javadoc.

On ferme !
sqlite3_close(dbPtr);