native method, a generated header, a C file you write and compile once per operating system, and a shared library you then have to ship next to your JAR. The Foreign Function & Memory API (FFM) removes the C file. You describe the native function in Java, the JDK links it, and a tool called jextract writes those descriptions for you from the library’s own header.
This jextract FFM example is a beginner on-ramp built on a real library rather than strlen. We generate bindings for SQLite, open a database through a pointer-to-pointer out-parameter, move strings both ways, turn error codes into exceptions, pass a Java lambda to C as a callback, and then measure the whole thing against a JNI version I wrote for the comparison. Every line of output below comes from a run on JDK 25 and is committed in the companion module. The things that went wrong while building it — there were three, including a JVM crash — get the most space.
If you already know the five FFM nouns (Linker, SymbolLookup, MemorySegment, MemoryLayout, Arena) and want hand-written downcalls, upcalls with qsort and errno capture, read Foreign Function & Memory API: Upcalls, errno, and JEP 472 Explained first or after this one; this post deliberately does not repeat it.
Versions this was tested against. JDK: Temurin 25.0.5+8. FFM API: final since JDK 22 (JEP 454, status “Closed / Delivered”). jextract: openjdk-25-jextract+2-4, an early-access build dated 2025/11/25, the newest on jdk.java.net/jextract when I checked on 30 October 2026, with libclang 13.0.0. SQLite: 3.45.1 (Ubuntu libsqlite3-dev 3.45.1-1ubuntu2.8), so not the newest SQLite. JMH 1.37, gcc 13.3.0 for the JNI shim. Linux x64 only; I did not run anything on macOS or Windows. jextract is not part of the JDK and has no GA release — only early-access builds.
FFM is the JDK part, jextract is the typing saver
Calling a C function from Java needs four things: find the function in a loaded library (SymbolLookup), describe its argument and return types (FunctionDescriptor), turn that into something callable (Linker.downcallHandle, which returns a MethodHandle), and manage whatever native memory the call touches (Arena and MemorySegment). All of that ships in java.lang.foreign and has been final since JDK 22.
Doing those four steps by hand for one function takes a handful of lines. SQLite’s header declares hundreds of them. jextract reads a .h file and writes those lines, once per function, plus a Java constant for every #define. Nothing it generates is magic: it is ordinary Java that calls the FFM API, which you can open and read. The left column below is everything between your code and libsqlite3.so; the right column is what JNI asks you to own instead.
jextract is a separate download, not a JDK tool. You will not find it in $JAVA_HOME/bin. The OpenJDK jextract repository says its binaries are published at jdk.java.net/jextract and are built from the master branch, which tracks the latest mainline JDK (github.com/openjdk/jextract). Its own guide also says that, for now, jextract and FFM only support C headers — a C++ library needs a C wrapper header (jextract GUIDE.md). The generated code is not covered by the jextract licence, per the download page.
For hand-written downcalls, qsort upcalls, errno capture and a first look at downcall cost, my earlier post Foreign Function & Memory API: Upcalls, errno, and JEP 472 Explained covers the ground this one skips. FFM’s place in the JDK 21-to-25 lane is in Java 21 to 25 LTS Features, and its sibling Panama API is in the Vector API post.
Generate the bindings: one command, four files
Install the header and the library (apt-get install libsqlite3-dev on Ubuntu), download jextract, and run it on sqlite3.h. scripts/fetch-jextract.sh downloads the early-access tarball and checks its SHA-256 against the one published next to it; scripts/gen.sh is the generation command:
"$JEXTRACT" --output "$OUT" --target-package demo.sqlite --header-class-name sqlite3_h \
-l sqlite3 "${args[@]}" "$SQLITE_H"
The flags: --target-package is the Java package, --header-class-name names the one class that holds every function (default would be sqlite3_h anyway for sqlite3.h; I spelled it out), and -l sqlite3 tells the generated class to load libsqlite3.so. The --include-function and --include-constant flags, repeated in the lines above that command in the script, keep only what this demo uses. Why filter? Run it with no filters and see:
$ jextract --output <tmp> -t demo.sqlite.full -l sqlite3 /usr/include/sqlite3.h # no filters: the whole header
warnings: 14
sqlite3.h:272:16: warning: Skipping sqlite3 (type Declared(sqlite3) is not supported)
sqlite3.h:4045:16: warning: Skipping sqlite3_stmt (type Declared(sqlite3_stmt) is not supported)
files: 83
lines: 44295
bytes: 1339454
lines in the one big header class: 22335
That output is from output/02-generate.txt. The whole 13,377-line header becomes 83 files and about 44,000 lines; the filtered run becomes four files and 1,867 lines, small enough to commit and read. Those four are in generated/demo/sqlite.
Opaque structs become plainMemorySegments. SQLite declaressqlite3andsqlite3_stmtwithout a body, so jextract cannot generate a struct class for them. The unfiltered run says so (see theSkipping sqlite3lines in the transcript above) and everysqlite3 *in the generated API shows up as a bareMemorySegment. That is fine — the pointer is only ever passed back to SQLite — but it means the compiler will not stop you passing a statement where a database belongs.
Going deeper: what is inside the generated class, and how the library gets loaded
Each function becomes a private holder class with a FunctionDescriptor, the looked-up address and the MethodHandle, plus four public accessors: name$descriptor(), name$handle(), name$address() and the plain call name(...). For sqlite3_open the transcript lists them:
$ grep -o 'public static [A-Za-z.]* sqlite3_open[a-z_0-9$]*(' generated/demo/sqlite/sqlite3_h.java | sort -u
public static FunctionDescriptor sqlite3_open$descriptor(
public static FunctionDescriptor sqlite3_open_v2$descriptor(
public static MemorySegment sqlite3_open$address(
public static MemorySegment sqlite3_open_v2$address(
public static MethodHandle sqlite3_open$handle(
public static MethodHandle sqlite3_open_v2$handle(
public static int sqlite3_open(
public static int sqlite3_open_v2(
Parameters from prototypes that name nothing arrive as x0, x1: SQLite’s header leaves most pointer parameters unnamed, and jextract does not invent names. Library loading is the other thing to understand. -l sqlite3 turns into SymbolLookup.libraryLookup(System.mapLibraryName("sqlite3"), ...), i.e. the file libsqlite3.so, which on Debian and Ubuntu is installed by the -dev package, not the runtime package. A runtime-only machine has libsqlite3.so.0, which you ask for with -l :libsqlite3.so.0 (a leading colon means a file name). Both cases, plus what a wrong name looks like at run time, are in output/11-library-loading.txt:
$ jextract -t demo.sqlite -l sqlitez --include-function sqlite3_libversion --header-class-name sqlite3_h /usr/include/sqlite3.h
static final SymbolLookup SYMBOL_LOOKUP = SymbolLookup.libraryLookup(System.mapLibraryName("sqlitez"), LIBRARY_ARENA)
$ java --enable-native-access=ALL-UNNAMED -cp <classes> V
Exception in thread "main" java.lang.ExceptionInInitializerError
at V.main(V.java:6)
Caused by: java.lang.IllegalArgumentException: Cannot open library: libsqlitez.so
at java.base/java.lang.foreign.SymbolLookup.libraryLookup(SymbolLookup.java:350)
at java.base/java.lang.foreign.SymbolLookup.libraryLookup(SymbolLookup.java:301)
$ jextract -t demo.sqlite -l :libsqlite3.so.0 --include-function sqlite3_libversion --header-class-name sqlite3_h /usr/include/sqlite3.h
static final SymbolLookup SYMBOL_LOOKUP = SymbolLookup.libraryLookup("libsqlite3.so.0", LIBRARY_ARENA)
$ java --enable-native-access=ALL-UNNAMED -cp <classes> V
sqlite3_libversion() = 3.45.1
The failure is an IllegalArgumentException: Cannot open library thrown from a static initialiser, so you see an ExceptionInInitializerError on the first use of the class, not at startup.
- Unfiltered generation, opaque struct warnings and everything else in the jextract guide
- The
--use-system-load-libraryflag (load throughSystem.loadLibrary, honouringjava.library.path) is described injextract --help; I did not test it
The first native call, and the warning you will see
With the bindings compiled alongside our code, the smallest useful program asks SQLite for its own version (src/demo/Hello.java): public static void main(String[] args) {
MemorySegment v = sqlite3_libversion(); // const char* -> MemorySegment
System.out.println("sqlite3_libversion() = " + v.getString(0));
System.out.println("returned segment byteSize = " + v.byteSize());
System.out.println("sqlite3_libversion_number() = " + sqlite3_libversion_number());
System.out.println("sqlite3_sourceid() = " + sqlite3_sourceid().getString(0));
}
}
sqlite3_libversion() returns a C const char *, which arrives in Java as a MemorySegment; getString(0) reads bytes from offset 0 up to the first NUL and decodes them as UTF-8. Run with the flag that grants native access (output/03-hello.txt):
$ java --enable-native-access=ALL-UNNAMED -cp build/classes demo.Hello
sqlite3_libversion() = 3.45.1
returned segment byteSize = 9223372036854775807
sqlite3_libversion_number() = 3045001
sqlite3_sourceid() = 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1
Look at the second line: the returned segment claims to be 9,223,372,036,854,775,807 bytes long, Long.MAX_VALUE. That is deliberate and it is both the reason getString(0) works without ceremony and, as we will see in the pointer section, the source of the nastiest bug in this post. Plant that number in your head.
Now run the same class without --enable-native-access. On JDK 25 the program still works, but the JVM prints a warning on stderr (output/04-native-access.txt):
$ java -cp build/classes demo.Hello # no flag at all, JDK 25 default
WARNING: A restricted method in java.lang.foreign.AddressLayout has been called
WARNING: java.lang.foreign.AddressLayout::withTargetLayout has been called by demo.sqlite.sqlite3_h$shared in an unnamed module (file:/tmp/work/jextract/repo/jextract/build/classes/)
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
sqlite3_libversion() = 3.45.1
returned segment byteSize = 9223372036854775807
sqlite3_libversion_number() = 3045001
sqlite3_sourceid() = 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1
The warning names a surprising culprit: AddressLayout::withTargetLayout, which the generated sqlite3_h$shared class calls in a static initialiser to give C_POINTER its unbounded target. FFM marks that kind of method restricted; the JVM allows it today with a warning, and the warning itself says restricted methods “will be blocked in a future release unless native access is enabled”. JEP 472 describes the plan: warn by default now, throw by default in a future release. You can see that future with --illegal-native-access=deny (same transcript, output/04-native-access.txt):
$ java --illegal-native-access=deny -cp build/classes demo.Hello
Exception in thread "main" java.lang.ExceptionInInitializerError
at demo.Hello.main(Hello.java:10)
Caused by: java.lang.IllegalCallerException: Illegal native access from an unnamed module (file:/tmp/work/jextract/repo/jextract/build/classes/)
at java.base/java.lang.Module.ensureNativeAccess(Module.java:307)
at java.base/java.lang.System$1.ensureNativeAccess(System.java:2107)
at java.base/jdk.internal.reflect.Reflection.ensureNativeAccess(Reflection.java:123)
A deny run fails with IllegalCallerException, wrapped in an ExceptionInInitializerError because it happens inside the generated class’s static initialiser. The fix for classpath code is the one the warning prints: --enable-native-access=ALL-UNNAMED. For an executable JAR, JEP 454 added an Enable-Native-Access: ALL-UNNAMED manifest attribute so users do not need the flag. I only tested the command-line flag; the manifest attribute is from the JEP text.
Do not read the warning as “this program is broken”. It is the JDK telling you a decision has not been made yet. In a Spring Boot fat JAR, a container entrypoint or a Gradle JavaExec task, add the flag once and forget it — and check that your tests pass it too, because surefire forks its own JVM.
Going deeper: watching every downcall, and how to read the warning in a larger app
The generated class has a switch you can flip without touching code: -Djextract.trace.downcalls=true prints the name and arguments of each downcall before it happens. Here it is on the same program, from output/03-hello.txt (the line order is the order of the calls, each traced line coming just before that call’s result):
$ java --enable-native-access=ALL-UNNAMED -Djextract.trace.downcalls=true -cp build/classes demo.Hello
sqlite3_libversion()
sqlite3_libversion() = 3.45.1
returned segment byteSize = 9223372036854775807
sqlite3_libversion_number()
sqlite3_libversion_number() = 3045001
sqlite3_sourceid()
sqlite3_sourceid() = 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1
Handy for the day a wrong argument produces a crash instead of an exception. The tracing flag exists in the generated code of this jextract build; I did not check older builds. The warning is printed once per module, so in a modular app expect one per module that calls restricted methods, and enable native access per module with --enable-native-access=my.module.
- JEP 472: Prepare to Restrict the Use of JNI — the warn-then-deny timeline
- Hello world, the JEP 472 section and
jnativescanoutput are in the earlier FFM post
Native memory has an owner: arenas
Java’s garbage collector does not manage the memory C works with. FFM’s answer is theArena: every block of native memory (a MemorySegment) is allocated from an arena, and the arena decides when the memory is freed and which threads may touch it. Touch a segment outside those rules and you get a Java exception, not a crash. There are four arena kinds:
try-with-resources block, because a call and its buffers have the same lifetime. src/demo/Arenas.java misuses each kind on purpose (output/06-arenas.txt):
$ java --enable-native-access=ALL-UNNAMED -cp build/classes demo.Arenas
== confined: one owner thread
owner reads -> ok
other thread reads -> WrongThreadException: Attempted access outside owning thread
other thread closes -> WrongThreadException: Attempted access outside owning thread
read after close -> IllegalStateException: Already closed
close twice -> IllegalStateException: Already closed
== shared: any thread, one close
other thread reads -> ok
other thread closes -> ok
read after close -> IllegalStateException: Already closed
== auto: the garbage collector frees it; close() is not allowed
allocate 8 bytes -> ok
close() -> UnsupportedOperationException: Attempted to close a non-closeable session
== global: never freed; close() is not allowed
allocate 8 bytes -> ok
close() -> UnsupportedOperationException: Attempted to close a non-closeable session
Three things to take from that output. A confined segment fails for any other thread — even for close() — with WrongThreadException; once closed, reads fail with IllegalStateException: Already closed, and closing twice fails the same way. A shared arena can be closed from another thread, which is the point of it; closing has to coordinate with every thread that might be using the memory, a cost I did not measure, so I would not use it as a default. And auto and global arenas cannot be closed at all — the message “Attempted to close a non-closeable session” is what you get if you try.
Segments are also bounds-checked, which is the safety net C does not have (end of output/06-arenas.txt):
== bounds: a segment knows its size
read int at offset 0 -> ok
read int at offset 4 -> IndexOutOfBoundsException: Out of bound access on segment MemorySegment{ kind: native, address: 0x7fba74666600, byteSize: 4 }; new offset = 4; new length = 4
read long at offset 0 -> IndexOutOfBoundsException: Out of bound access on segment MemorySegment{ kind: native, address: 0x7fba74666600, byteSize: 4 }; new offset = 0; new length = 8
The exact addresses in those messages change on every run; the shape does not.
The arena owns the buffers, not the library’s objects. When we open a database in a moment, SQLite allocates the connection itself. Closing your arena does not free it, and forgetting sqlite3_close leaks it even though every segment you allocated was freed. That asymmetry — Java frees what Java allocated, native code frees what native code allocated — is the single most useful rule in this whole API.
Going deeper: which arena for which job
Rules of thumb I used while writing the wrapper, none of them measured here. Per-call scratch buffers: confined, in a try block. A segment that outlives a method (a prepared-statement parameter buffer reused across calls, an upcall stub native code keeps a pointer to): shared or global, with the owner object closing it. Arena.ofAuto() only when ownership is genuinely unclear, because freeing then happens whenever the garbage collector gets to it. The library-lookup arena in the generated class is Arena.ofAuto(), so you never close the library yourself.
- Arenas.java — run it with an extra thread of your own to see the rules
- JEP 454, “Memory segments and arenas”
Strings in, pointers out: opening a database
C has no exceptions and no multiple return values, so functions hand results back through out-parameters: you pass the address of a variable and the function writes into it.sqlite3_open is the textbook case. Its signature is int sqlite3_open(const char *filename, sqlite3 **ppDb): the return value is a status code, and the new connection arrives by SQLite writing a pointer into the sqlite3 * variable whose address you passed — a pointer to a pointer.
arena.allocateFrom(path) copies a Java String into native memory as NUL-terminated UTF-8, and getString(0) reads one back. From src/demo/Db.java:
public static Db open(String path, int flags) {
// A confined arena: the two small native buffers below live exactly as long as this block.
try (Arena arena = Arena.ofConfined()) {
MemorySegment out = arena.allocate(C_POINTER); // room for ONE pointer: the sqlite3** out-parameter
MemorySegment cPath = arena.allocateFrom(path); // Java String -> NUL-terminated UTF-8 C string
int rc = sqlite3_open_v2(cPath, out, flags, MemorySegment.NULL);
MemorySegment db = out.get(C_POINTER, 0); // read the pointer SQLite wrote
if (rc != SQLITE_OK()) {
// SQLite hands back a handle even on failure; it carries the message and must still be closed.
String message = sqlite3_errmsg(db).getString(0);
sqlite3_close(db);
throw new SqliteException(rc, message);
}
return new Db(db);
}
}
Read the failure branch twice, because it is the part that surprises people: when sqlite3_open_v2 fails, SQLite still hands back a connection object so you can ask it for the error message, and you must close it. The header says so in plain words — “Whether or not an error occurs when it is opened, resources associated with the database connection handle should be released by passing it to sqlite3_close()”. The wrapper reads the message, closes the handle, then throws.
A pointer you read back is only as useful as its layout. Everything above usedC_POINTER, the layout jextract generates. The first version ofDbused the plainValueLayout.ADDRESSinstead and failed on the first error message withIndexOutOfBoundsException: No null terminator found. A pointer read through plainADDRESSis a segment of zero bytes: FFM has no idea how long the thing it points to is, so it refuses to read any of it. jextract’sC_POINTERisADDRESSwith a target layout of “unbounded chars”, which is why segments from the generated methods reportLong.MAX_VALUEbytes — the number from the first call. src/demo/Trap.java shows both side by side (output/07-pointer-traps.txt):
$ java --enable-native-access=ALL-UNNAMED -cp build/classes demo.Trap
ValueLayout.ADDRESS : byteSize=0
getString(0) : IndexOutOfBoundsException, "No null terminator found..."
reinterpret(100) : hello from C land
C_POINTER : byteSize=9223372036854775807
getString(0) : hello from C land
The unbounded layout is a convenience that makes getString(0) work at once. The hand-written alternative is reinterpret(100), which says “trust me, this is at least 100 bytes” — the approach in my earlier FFM post, where getenv returns a zero-length segment. Both are promises you make to the JVM that it cannot check.
And that is the flip side, found while writing the result-reading loop. sqlite3_column_text returns a NULL pointer for a SQL NULL. The segment for a NULL pointer has address 0 and the unbounded size, so the bounds check passes, and getString(0) reads address zero. src/demo/NullText.java does exactly that:
$ java --enable-native-access=ALL-UNNAMED -XX:ErrorFile=/tmp/jextract-hs_err.log -cp build/classes demo.NullText # first lines of the crash banner
address=0 byteSize=9223372036854775807 equals(NULL)=true
about to call getString(0) on it
#
# A fatal error has been detected by the Java Runtime Environment:
#
# SIGSEGV (0xb) at pc=0x00007f002fe4645d, pid=20843, tid=20844
#
# JRE version: OpenJDK Runtime Environment Temurin-25.0.5+8 (25.0.5+8) (build 25.0.5+8-LTS)
# Java VM: OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (25.0.5+8-LTS, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, linux-amd64)
exit code: 134
That is the JVM dying with SIGSEGV, exit code 134, no Java exception to catch. With plain ADDRESS you would have got a tidy exception; the unbounded layout traded that safety for convenience. The wrapper therefore asks sqlite3_column_type first and only then calls column_text (src/demo/Db.java, method text), and any pointer a C function is allowed to return as NULL gets a .address() == 0 check before it is read as a string. Note the exit-code line comes from my script (scripts/run-all.sh), not from the JVM.
Going deeper: why not just use ADDRESS everywhere, and what the JVM does with a NULL it hands back
AddressLayout.withTargetLayout is how a pointer layout is told what it points to; what I measured on JDK 25 is that a returned or loaded pointer with a target layout gets that layout’s size, even when its address is zero. That is why the NULL segment above says byteSize=9223372036854775807. The unbounded target is what the restricted-method warning from the first call was about, since withTargetLayout is a restricted method.
If you would rather keep bounds checks, you can post-process jextract’s output or write your own thin layer that returns MemorySegment.reinterpret(n) with a size you know. I did not try to configure jextract to generate plain ADDRESS layouts; its --help in this build lists no option for it.
- JEP 454, “Zero-length memory segments” — the official explanation of why a returned pointer starts at size zero
- The earlier FFM post for
reinterpretwithgetenv - Oracle Core Libraries Guide: Foreign Function and Memory API
Create, insert, query: using the wrapper
A database handle on its own does nothing. To run SQL you either callsqlite3_exec (fine for statements with no results) or prepare a statement, bind parameters, step through rows and finalise it. src/demo/Db.java wraps both. The part that needs care is binding text:
/** SQLITE_TRANSIENT tells SQLite to copy the bytes now, so the temporary C string may be freed straight away. */
public void bindText(Arena arena, int index, String value) {
check(sqlite3_bind_text(ptr, index, arena.allocateFrom(value), -1, SQLITE_TRANSIENT()));
}
The last argument is the lifetime of the string you pass. The SQLite header lists three options; SQLITE_STATIC says “the application remains responsible” and the pointer must stay valid until the statement is finalised or the parameter is rebound, while SQLITE_TRANSIENT says the object “is to be copied prior to the return from sqlite3_bind_*()”. Passing SQLITE_STATIC with an arena-allocated string and then closing the arena leaves SQLite holding a dangling pointer. I did not run that bug on purpose — use-after-free results are not deterministic — so treat it as header documentation, not as a measurement. Neat detail: jextract generated both constants (SQLITE_TRANSIENT is a macro containing a cast, which I half expected it to skip); they are MemorySegment.ofAddress(-1) and ofAddress(0) in the generated class.
Putting it together (src/demo/Tour.java, output in output/05-tour.txt):
try (Db db = Db.open(":memory:")) {
db.exec("CREATE TABLE person(id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER)");
try (Arena arena = Arena.ofConfined(); Db.Stmt ins = db.prepare(arena, "INSERT INTO person(name, age) VALUES (?1, ?2)")) {
String[] names = {"Ankur", "Asha", "Ravi"};
int[] ages = {41, 36, 29};
for (int i = 0; i < names.length; i++) {
ins.bindText(arena, 1, names[i]);
ins.bindInt(2, ages[i]);
ins.step();
ins.reset();
}
}
System.out.println("rows changed by last statement: " + db.changes() + ", last rowid: " + db.lastInsertRowid());
for (var row : db.query("SELECT id, name, age FROM person ORDER BY id")) {
System.out.println(row);
}
db.exec("INSERT INTO person(name) VALUES ('NoAge')");
System.out.println(db.query("SELECT name, age FROM person WHERE age IS NULL"));
Running it prints (output/05-tour.txt):
$ java --enable-native-access=ALL-UNNAMED -cp build/classes demo.Tour
rows changed by last statement: 1, last rowid: 3
[1, Ankur, 41]
[2, Asha, 36]
[3, Ravi, 29]
[[NoAge, null]]
Three rows inserted through one prepared statement and reset between uses, read back as strings. The last line is the SQL NULL case from the previous section arriving as a Java null instead of a crash.
Return codes become exceptions
Nearly every SQLite function returns anint: SQLITE_OK (0) for success or a code for failure. Java programmers expect exceptions, so the wrapper converts at the boundary. The text of the error comes from sqlite3_errmsg, or, for sqlite3_exec, from an out-parameter that SQLite fills with memory it allocated:
/** Runs SQL that returns no rows (DDL, INSERT...). Uses sqlite3_exec without a callback. */
public void exec(String sql) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment errOut = arena.allocate(C_POINTER); // char** errmsg, SQLite fills it only on failure
int rc = sqlite3_exec(handle, arena.allocateFrom(sql), MemorySegment.NULL, MemorySegment.NULL, errOut);
if (rc != SQLITE_OK()) {
MemorySegment msg = errOut.get(C_POINTER, 0);
String text = msg.address() == 0 ? errmsg() : msg.getString(0);
sqlite3_free(msg); // this string came from sqlite3_malloc: we must free it
throw new SqliteException(rc, text);
}
}
}
The sqlite3_free(msg) line is the other half of the ownership rule from the arena section. The header is explicit that error strings from the fifth parameter of sqlite3_exec are obtained from sqlite3_malloc() and must be released with sqlite3_free(); an arena cannot free them because it did not allocate them. Forget it and you leak a few bytes per failed statement, with no Java-side symptom at all. Here is what the exceptions look like for six real failures (src/demo/Errors.java, output/08-errors.txt):
$ java --enable-native-access=ALL-UNNAMED -cp build/classes demo.Errors
open in a directory that does not exist -> unable to open database file (rc=14)
open a missing file read-only -> unable to open database file (rc=14)
syntax error -> near "SELEC": syntax error (rc=1)
missing table -> no such table: ghost (rc=1)
NOT NULL violation -> NOT NULL constraint failed: t.name (rc=19)
PRIMARY KEY violation -> UNIQUE constraint failed: t.id (rc=19)
SQLITE_CONSTRAINT = 19, sqlite3_errstr(19) = constraint failed
Two things are worth noticing. Both ways of failing to open a database give code 14, SQLITE_CANTOPEN, with the same message — the code says what kind of failure, the message says which constraint or table, and you want both in the exception. And the two constraint failures share code 19 (SQLITE_CONSTRAINT) while the text tells them apart; code that needs to react to “duplicate key” specifically should look at SQLite’s extended result codes, for which I included sqlite3_extended_errcode in the bindings but did not exercise it.
Check the code, then read the message — in that order, on that connection. sqlite3_errmsg describes the most recent failure on a connection, and the next successful call on it may overwrite what it says. The wrapper reads the message immediately after the failing call, before anything else touches the connection. Doing it later, or from another thread sharing the connection, can give you the wrong text.>
Callbacks, briefly: C calling a Java lambda
sqlite3_exec can take a function pointer and calls it once per result row. In JNI that is a fiddly corner (you typically need the JavaVM, attach the calling thread and look up method IDs). In jextract it is an interface and one method call. jextract generated generated/demo/sqlite/sqlite3_exec$callback.java with a functional interface matching the C function-pointer type; allocate(lambda, arena) turns a Java lambda into a real C function pointer (a “stub”). From src/demo/Callbacks.java:
sqlite3_exec$callback.Function onRow = (user, argc, values, names) -> {
if (explode) {
throw new IllegalStateException("thrown inside the callback");
}
// values and names are char** : argc pointers each. Read them as an array of pointers.
MemorySegment vals = values.reinterpret((long) argc * ValueLayout.ADDRESS.byteSize());
MemorySegment nms = names.reinterpret((long) argc * ValueLayout.ADDRESS.byteSize());
StringBuilder sb = new StringBuilder("row:");
for (int i = 0; i < argc; i++) {
MemorySegment v = vals.getAtIndex(C_POINTER, i);
String value = v.address() == 0 ? "NULL" : v.getString(0);
sb.append(' ').append(nms.getAtIndex(C_POINTER, i).getString(0)).append('=').append(value);
}
System.out.println(sb);
return 0; // non-zero would abort the query with SQLITE_ABORT
};
MemorySegment stub = sqlite3_exec$callback.allocate(onRow, arena); // the C function pointer
MemorySegment errOut = arena.allocate(C_POINTER);
int rc = sqlite3_exec(db.handle(), arena.allocateFrom("SELECT id, name FROM t"), stub, MemorySegment.NULL, errOut);
$ java --enable-native-access=ALL-UNNAMED -cp build/classes demo.Callbacks
row: id=1 name=one
row: id=2 name=two
row: id=3 name=NULL
sqlite3_exec returned 0
The two char ** arguments are arrays of pointers, argc long; the reinterpret calls give those zero-length segments a size so getAtIndex can index them. A NULL column value arrives as a NULL pointer again — same check as before. Returning non-zero aborts the query with SQLITE_ABORT, per the header.
Never let an exception escape a callback. Run the same program with an argument that makes the lambda throw and the JVM does not unwind through SQLite’s C frames, because it cannot. It prints the stack trace and exits (output/09-callbacks.txt):
$ java --enable-native-access=ALL-UNNAMED -cp build/classes demo.Callbacks explode
java.lang.IllegalStateException: thrown inside the callback
at demo.Callbacks.lambda$main$0(Callbacks.java:23)
at demo.sqlite.sqlite3_h.sqlite3_exec(sqlite3_h.java:417)
at demo.Callbacks.main(Callbacks.java:39)
Unrecoverable uncaught exception encountered. The VM will now exit
exit code: 1
The whole process ends with exit code 1 and the message “Unrecoverable uncaught exception encountered. The VM will now exit”. Wrap the body of every upcall in a try/catch and translate failures into a return code, the way C code expects.
The mechanics of upcall stubs — arena lifetime of the stub, descriptors built by hand, the qsort comparator — are in Foreign Function & Memory API: Upcalls, errno, and JEP 472 Explained; this section is only the jextract-flavoured shortcut.
Is it slower than JNI? Measured, with a big asterisk
The FFM API was designed so that a downcall costs about what a JNI call costs; the “Risks and Assumptions” section of JEP 454 says the JIT work must ensure the API’s native method handles are “at least as efficient and optimizable as uses of existing JNI native methods”. I wanted to see that with my own eyes on this library rather than quote it, so I wrote the JNI equivalent: jni/SqliteJni.java and jni/sqlite_jni.c (compiled by scripts/build-jni.sh with gcc 13.3.0), and a JMH benchmark, bench/src/bench/SqliteBench.java, that calls the same SQLite functions three ways. It uses JMH 1.37 with five forks of three one-second warm-up and five one-second measurement iterations each, and a sanity check in@Setup that all three paths return the same answers.
Read these numbers as a shape, not a result. The machine has 2 CPUs and was shared with other builds the whole time: the load average was 4.00 before the run and 5.97 after (output/10-jmh.txt records both). The error columns, JMH’s 99.9% confidence interval, are consequently wide — between a fifth and a third of the score. An earlier three-fork run on the same machine, at a similar load, gave different scores (for example JNI strlike 150 ns instead of 245 ns), which I did not keep because it was superseded. Where two intervals overlap below, I treat the two paths as indistinguishable.
First the cheapest possible call, sqlite3_libversion_number(), which takes nothing and returns an int; then sqlite3_strlike(), which takes two C strings; the bars show the means.
GetStringUTFChars (244.9 ± 82.3 ns). The honest summary is “no meaningful difference except that you can avoid per-call allocation in FFM”, and I would not read more into it than that on this box. The raw JMH rows, as written to output/10-jmh.txt:
Benchmark Mode Cnt Score Error Units
SqliteBench.insert1000_ffm avgt 25 653368.858 ± 161095.825 ns/op
SqliteBench.insert1000_jni avgt 25 544963.964 ± 97090.203 ns/op
SqliteBench.strlike_ffm_arenaPerCall avgt 25 192.233 ± 62.055 ns/op
SqliteBench.strlike_ffm_preallocated avgt 25 82.660 ± 16.253 ns/op
SqliteBench.strlike_jni avgt 25 244.929 ± 82.330 ns/op
SqliteBench.versionNumber_ffm avgt 25 16.819 ± 4.063 ns/op
SqliteBench.versionNumber_jni avgt 25 14.054 ± 4.949 ns/op
SqliteBench.versionNumber_plainJava avgt 25 0.651 ± 0.162 ns/op
load average after the run: 5.97 6.05 4.10
Last, a call that does real work: 1,000 prepared INSERTs into an in-memory table (a transaction that is rolled back so each operation starts from the same state). The FFM version goes through the Db wrapper — error checks, a per-row allocateFrom for the text, an arena per operation — and the JNI version through the shim. That gives 653,369 ± 161,096 ns for FFM and 544,964 ± 97,090 ns for JNI per 1,000 inserts, i.e. about 650 ns and 545 ns per row. The means differ by roughly 20%, the intervals overlap, and the FFM side carries more wrapper than the JNI side, so I will not call it a loss. What it does show is scale: one insert costs about forty times the trivial native call, so for SQLite the call overhead is not where the time goes.
For my earlier measurement of an FFM downcall against a plain Java call, with getpid() and abs(), see the FFM upcalls post; it reports about 12 ns per FFM call to abs(), which is in the same band as the roughly 17 ns I measured here, though the two runs used different machines and harnesses, so do not line them up exactly.
Going deeper: what the benchmark does and does not control for
Things I did: all three variants return the same values in @Setup, the plain-Java baseline reads a non-final field so the JIT cannot constant-fold it, the FFM strlike comes in two variants to separate marshalling from call cost, and JNI uses the idiomatic GetStringUTFChars/ReleaseStringUTFChars pair. Things I did not do: pin CPUs or isolate the machine, vary string lengths (the strings are 6 and 6 characters), try direct ByteBuffers or JNI critical sections, compare against the sqlite-jdbc driver, test other JDK versions, or run on a quiet machine. A quiet machine would narrow the intervals; whether it would separate the means I cannot say.
- output/10-jmh.txt with load averages before and after
- JMH project page
Should you even do this?
Often the answer is “use the Java driver”. If the library you want has a maintained Java binding — and SQLite does, in the form of JDBC drivers — use it. A binding generated by you is a binding you maintain: you re-run jextract when the header changes, you own the ownership rules (who frees what), and every mistake can crash the whole JVM instead of throwing. FFM earns its place when there is no Java library, when the native library is the point (a codec, a GPU runtime, an OS API, a vendor SDK), or when you want to drop a hand-written JNI layer you already maintain. The performance numbers above do not change this: for SQLite, the cost of the SQL dwarfs the cost of the call.
And jextract is early-access. There is no GA release of jextract; the build I used is labelled early-access and the download page says such builds are not tested to the same level as GA builds. The generated code, once produced, depends only on the JDK and is yours to commit — which is the practical mitigation: pin the jextract build you used in a script, commit the output, and review the diff when you regenerate.
What I did not test
- macOS and Windows: the commands, library names (
.dylib,.dll) and framework options of jextract were not run. Only Linux x64. - JDK 26 and 27: everything ran on JDK 25.0.5 only. JEP 472 says restrictions will tighten in a future release; I measured JDK 25’s warn-by-default and the explicit
denymode, nothing else. - A SQLite newer than 3.45.1, SQLite built with different options, and thread safety: every demo is single-threaded apart from the arena misuse examples.
- Structs, unions and global variables in generated code: SQLite’s public API is almost all functions on opaque pointers, so the struct accessors jextract generates were not exercised. The unfiltered run does generate such classes for the FTS5 extension structs; I did not use them.
SQLITE_STATICused with a freed arena: described from the SQLite header, not run.- jextract flags other than the ones in
gen.sh, including--use-system-load-libraryand the macOS-F/--frameworkoptions. - A GraalVM native image, jlink or jpackage build with a native library: not tried. For the packaging side of a Java app, see Building Native Installers for Java Applications with jpackage.
Further reading
- The companion module: javademos/jextract — README, gen.sh, Db.java, every captured transcript
- Foreign Function & Memory API: Upcalls, errno, and JEP 472 Explained — hand-written downcalls,
qsortupcalls,errnocapture - Java 21 to 25 LTS Features — where FFM sits in the JDK 21-to-25 upgrade
- JEP 454: Foreign Function & Memory API — final in JDK 22
- JEP 472: Prepare to Restrict the Use of JNI — the warn-then-deny timeline for native access
- jdk.java.net/jextract — the early-access builds
- The jextract guide — options, library loading, structs and callbacks
- SQLite C interface reference — the functions wrapped here
- JMH — the benchmark harness
No Comments yet!