Most Foreign Function & Memory API tutorials stop at the same place: load a library, call strlen, map a struct, mention jextract, done. That is a fine introduction, but it skips the parts that actually come up once you try to use the FFM API for something real — how do you let native code call back into Java? How do you read a C library’s error code instead of just getting a mysterious -1? What does the JDK 24+ crackdown on unrestricted native access actually mean for your JAR file? This post answers those questions with runnable code and real terminal output, and assumes you already know (or can quickly pick up) the FFM basics — Linker, Arena, MemorySegment, and MemoryLayout — from the JEP itself or any introductory tutorial.
Status (July 2026): The Foreign Function & Memory API (JEP 454) has been a final, standard part of Java SE since JDK 22 (March 2024) — no preview flags required. Every example below was compiled and run on OpenJDK 25 against
java.lang.foreign, which has not changed shape since GA; the current release as of this writing is JDK 26 (March 2026). The one moving part is JEP 472, the native-access lockdown, which is still tightening release by release — covered in detail below.
A 60-second recap, on purpose kept brief
If you have never touched java.lang.foreign, here is the whole mental model in five names. Linker is the bridge to the platform ABI and produces MethodHandles for native functions (a “downcall”). SymbolLookup finds a symbol by name in a loaded library. MemorySegment is a bounds-checked view over a region of memory, on- or off-heap. MemoryLayout (and its subtypes StructLayout, SequenceLayout) describes the shape of that memory — field offsets, padding, array strides — so you get VarHandle accessors instead of manual pointer arithmetic. Arena owns the lifetime of the segments it allocates and decides who may close them. If any of those five words are unfamiliar, Oracle’s own Core Libraries Guide chapter on the FFM API is the right place to start before continuing here.
A brief history, and where this fits in Project Panama
The FFM API is one deliverable of the broader Project Panama, which also includes the Vector API for explicit SIMD programming. Panama’s roadmap has moved through eleven-plus preview cycles since JDK 16:

The FFM half of that roadmap (JEP 454) finished first and went final in JDK 22. The Vector API, its sibling, is still incubating — now in its 11th round as of JDK 26 (JEP 529) — because it is being reworked around Project Valhalla’s value types before finalizing. If your workload needs both native interop and SIMD (image codecs, scientific computing), it is worth knowing they are developed together but ship on different timelines.
A cleaner first example: getting a string back out of C
Every strlen-style tutorial shows Java pushing a string into C. Fewer show the reverse — reading a native, null-terminated string back into Java — which is arguably the more common case (environment variables, config lookups, error messages). Here it is with libc’s getenv:
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
public class GetEnvDemo {
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
MethodHandle getenv = linker.downcallHandle(
linker.defaultLookup().find("getenv").orElseThrow(),
FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS)
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment name = arena.allocateFrom("HOME");
MemorySegment result = (MemorySegment) getenv.invokeExact(name);
if (result.equals(MemorySegment.NULL)) {
System.out.println("HOME not set");
} else {
// getenv returns a zero-length pointer; reinterpret it generously,
// then getString() reads up to the first NUL byte for you.
String value = result.reinterpret(1024).getString(0);
System.out.println("HOME = " + value);
}
MemorySegment missing = arena.allocateFrom("DOES_NOT_EXIST_VAR_XYZ");
MemorySegment result2 = (MemorySegment) getenv.invokeExact(missing);
System.out.println("DOES_NOT_EXIST_VAR_XYZ is null? " + result2.equals(MemorySegment.NULL));
}
}
}
Compile and run — no preview flags, but note the --enable-native-access flag, explained in the JEP 472 section below:
$ javac GetEnvDemo.java
$ java --enable-native-access=ALL-UNNAMED GetEnvDemo
HOME = /sessions/jolly-admiring-einstein
DOES_NOT_EXIST_VAR_XYZ is null? true
Two details worth internalizing: getenv returns a zero-length MemorySegment, because the FFM API has no way to know how long the C string is until it scans for the terminator, so you must reinterpret() it before reading. And MemorySegment.getString(long) — not getUtf8String, an older incubator-era name — reads bytes as UTF-8 up to the first NUL byte, mirroring exactly what allocateFrom(String) writes on the way in.
Upcalls: letting native code call back into Java
A downcall is Java calling C. An upcall is the reverse: C code invoking a function pointer that is secretly a Java method. This is the mechanism behind every native callback API — sort comparators, signal handlers, iterator visitor functions — and it is the piece almost every “getting started” FFM article skips. The classic demonstration is libc’s qsort, which takes a comparator function pointer:
// C signature: qsort needs a pointer to a comparison function
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Arrays;
public class QsortUpcallDemo {
// This Java method is what C will call back into for every comparison
public static int compare(MemorySegment aPtr, MemorySegment bPtr) {
int a = aPtr.reinterpret(4).get(ValueLayout.JAVA_INT, 0);
int b = bPtr.reinterpret(4).get(ValueLayout.JAVA_INT, 0);
return Integer.compare(a, b);
}
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
MethodHandle qsort = linker.downcallHandle(
linker.defaultLookup().find("qsort").orElseThrow(),
FunctionDescriptor.ofVoid(
ValueLayout.ADDRESS, ValueLayout.JAVA_LONG,
ValueLayout.JAVA_LONG, ValueLayout.ADDRESS
)
);
try (Arena arena = Arena.ofConfined()) {
int[] data = {42, 7, 19, 3, 88, 1, 56};
MemorySegment array = arena.allocateFrom(ValueLayout.JAVA_INT, data);
MethodHandle comparator = MethodHandles.lookup().findStatic(
QsortUpcallDemo.class, "compare",
MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class)
);
// Wrap the Java method as a genuine C function pointer
MemorySegment comparatorStub = linker.upcallStub(
comparator,
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS),
arena
);
System.out.println("Before: " + Arrays.toString(data));
qsort.invokeExact(array, (long) data.length, (long) ValueLayout.JAVA_INT.byteSize(), comparatorStub);
System.out.println("After: " + Arrays.toString(array.toArray(ValueLayout.JAVA_INT)));
}
}
}
Real output, unedited:
$ javac QsortUpcallDemo.java
$ java --enable-native-access=ALL-UNNAMED QsortUpcallDemo
Before: [42, 7, 19, 3, 88, 1, 56]
After: [1, 3, 7, 19, 42, 56, 88]
Add a print statement inside compare() and you can watch libc’s own sort algorithm drive the comparisons — the JVM is genuinely suspended inside the C call while it dispatches back in:
comparing 7 vs 19
comparing 42 vs 7
comparing 42 vs 19
comparing 3 vs 88
comparing 1 vs 56
comparing 3 vs 1
comparing 3 vs 56
comparing 88 vs 56
comparing 7 vs 1
comparing 7 vs 3
comparing 7 vs 56
comparing 19 vs 56
comparing 42 vs 56
The important line is linker.upcallStub(comparator, descriptor, arena) — it turns a MethodHandle into a MemorySegment that is a valid C function pointer for as long as its arena stays open. Close that arena (or let an Arena.ofAuto() get collected) while native code still holds the pointer, and you have a use-after-free bug in a language that was supposed to protect you from exactly that — so upcall stubs almost always want a shared or process-lifetime arena, not a short-lived confined one, unless you can prove the native side never retains the pointer past your call.
Capturing errno without losing your mind
C functions signal failure two ways: a sentinel return value (-1, NULL) and, separately, the thread-local errno variable that says why. Naively calling errno as if it were an ordinary global from Java does not work — by the time your Java code runs again, an unrelated JVM-internal native call may have already overwritten it. The FFM API’s answer is Linker.Option.captureCallState(), which atomically captures errno (or, on Windows, GetLastError()) in the same native transition that makes the call:
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.VarHandle;
public class ErrnoDemo {
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
Linker.Option ccs = Linker.Option.captureCallState("errno");
StructLayout capturedStateLayout = Linker.Option.captureStateLayout();
VarHandle errnoHandle = capturedStateLayout.varHandle(
MemoryLayout.PathElement.groupElement("errno"));
MethodHandle open = linker.downcallHandle(
linker.defaultLookup().find("open").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT),
ccs // NOTE: the linker option must be supplied here, at bind time
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment capturedState = arena.allocate(capturedStateLayout);
MemorySegment badPath = arena.allocateFrom("/no/such/directory/file.txt");
int fd1 = (int) open.invokeExact(capturedState, badPath, 0);
int errno1 = (int) errnoHandle.get(capturedState, 0L);
System.out.println("open(missing file) result: fd=" + fd1 + ", errno=" + errno1);
MemorySegment goodPath = arena.allocateFrom("/etc/hostname");
int fd2 = (int) open.invokeExact(capturedState, goodPath, 0);
System.out.println("open(/etc/hostname) result: fd=" + fd2);
}
}
}
Real output:
$ javac ErrnoDemo.java
$ java --enable-native-access=ALL-UNNAMED ErrnoDemo
open(missing file) result: fd=-1, errno=2
open(/etc/hostname) result: fd=4
errno=2 is ENOENT — exactly right, and captured reliably even though the JVM itself makes plenty of native calls between your invocations. Two things are easy to get wrong here the first time: the capture-state segment (capturedState) becomes an extra first argument to every downcall handle built with a captureCallState option — forget it and you get a confusing WrongMethodTypeException — and the option must be supplied when you build the handle with downcallHandle(...), not when you invoke it.
How much does a downcall actually cost?
Numbers beat folklore. Two microbenchmarks, run back to back on the same JVM (OpenJDK 25, warmed up for 200,000–500,000 iterations before timing):
| Comparison | Plain Java call | FFM downcall | Ratio |
|---|---|---|---|
getpid() — a real syscall, 20M calls |
1.01 ns/call | 686.59 ns/call | ~678x |
abs() — pure computation, no syscall, 50M calls |
1.15 ns/call | 12.37 ns/call | ~10.8x |
The two rows tell different stories on purpose. getpid() crosses into the kernel, so the ~680 ns is overwhelmingly the cost of the syscall itself (context switch, ring transition) — the same cost you would pay from C or through JNI. abs() never leaves user space, so its ~12 ns isolates the FFM dispatch overhead alone: argument marshaling, the native call stub, and the safepoint bookkeeping the JVM does around any native transition. That is the number to compare against JNI, and it lines up with what the JEP 454 authors and independent benchmarks report — single-digit-to-low-double-digit nanoseconds per call on JDK 24+, after the JIT learned to inline and specialize downcall stubs more aggressively than it did at JDK 22 GA. Practical takeaway: FFM overhead is noise next to almost any real native workload (file I/O, compression, crypto, image decoding); it only matters if you are calling a trivial native function in an extremely hot loop, in which case the fix is usually to batch the work on the native side, not to avoid FFM.
JEP 472: the JNI (and FFM) lockdown you need to prepare for
Every restricted FFM operation — downcallHandle, upcallStub, reinterpret, SymbolLookup.libraryLookup — and, as of JDK 24, every JNI native-library load, is governed by Java’s “integrity by default” policy. JEP 472 is the vehicle tightening that policy in stages:
| Release | Default behavior | What changed |
|---|---|---|
| JDK 22–23 | Opt-in warning | FFM restricted calls warn once per module unless listed in --enable-native-access. JNI was untouched. |
| JDK 24–26 (current) | --illegal-native-access=warn |
JNI and FFM now behave uniformly. jnativescan ships as a new JDK tool. |
| A future release | --illegal-native-access=deny |
Illegal native access throws IllegalCallerException instead of warning. Date not yet fixed by OpenJDK. |
The three modes of --illegal-native-access are allow (silence, no enforcement — a stopgap, not a destination), warn (today’s default — prints a one-time warning per module), and deny (throws immediately). The message you will see without opting in looks like this:
WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: Linker::downcallHandle has been called by com.example.Server in module com.example (file:/app.jar)
WARNING: Use --enable-native-access=com.example to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
Two practical tools make this manageable instead of alarming. First, jnativescan, new in JDK 24, statically scans a classpath or module path and tells you exactly which classes and methods touch restricted functionality — you do not have to run the code and watch for warnings. Run against the four demo classes from this article:
$ jnativescan --class-path out
out (ALL-UNNAMED):
DowncallBenchmark2:
DowncallBenchmark2::main(String[])void references restricted methods:
java.lang.foreign.Linker::downcallHandle(MemorySegment,FunctionDescriptor,Linker$Option[])MethodHandle
ErrnoDemo:
ErrnoDemo::main(String[])void references restricted methods:
java.lang.foreign.Linker::downcallHandle(MemorySegment,FunctionDescriptor,Linker$Option[])MethodHandle
GetEnvDemo:
GetEnvDemo::main(String[])void references restricted methods:
java.lang.foreign.Linker::downcallHandle(MemorySegment,FunctionDescriptor,Linker$Option[])MethodHandle
java.lang.foreign.MemorySegment::reinterpret(long)MemorySegment
QsortUpcallDemo:
QsortUpcallDemo::compare(MemorySegment,MemorySegment)int references restricted methods:
java.lang.foreign.MemorySegment::reinterpret(long)MemorySegment
QsortUpcallDemo::main(String[])void references restricted methods:
java.lang.foreign.Linker::downcallHandle(MemorySegment,FunctionDescriptor,Linker$Option[])MethodHandle
java.lang.foreign.Linker::upcallStub(MethodHandle,FunctionDescriptor,Arena,Linker$Option[])MemorySegment
$ jnativescan --class-path out --print-native-access
ALL-UNNAMED
That second invocation, with --print-native-access, is the one to wire into CI: it prints a comma-separated list of modules needing native access, which you feed straight into a launch script (--enable-native-access=$(jnativescan ...)) instead of hand-maintaining the list. Second, for a distributable executable JAR, you can skip command-line flags entirely by adding one line to META-INF/MANIFEST.MF:
Enable-Native-Access: ALL-UNNAMED
This Enable-Native-Access attribute — available since JDK 24 — lets an executable JAR self-declare that its unnamed-module code needs native access, so end users of your tool never see the warning or have to know the flag exists. Oracle’s own guidance is to prefer moving FFM-using code to the module path and scoping --enable-native-access to a named module where you can, since ALL-UNNAMED is an all-or-nothing grant for the entire classpath. The recommended production launch line, from the JEP authors directly, is:
java --enable-native-access=$MODULES --illegal-native-access=deny ...
Opting into deny now, on JDK 26, rather than waiting for it to become mandatory, is the cheapest way to find out today whether some transitive dependency is quietly relying on unrestricted native access.
Diagnosing native memory with NMT
Off-heap MemorySegments do not show up in a heap dump or in -Xmx pressure — which is exactly why a leaking Arena (one that never gets closed, or an Arena.ofAuto() waiting on a GC that never comes) can quietly grow resident memory while every JVM heap metric looks perfectly healthy. The JDK’s built-in answer is Native Memory Tracking:
$ java -XX:NativeMemoryTracking=summary -jar app.jar &
$ jcmd PID VM.native_memory summary
The summary breaks resident native memory down by JVM-internal category (Java Heap, Thread, Code, GC, Compiler, and so on); memory allocated through Arena shows up under Internal or, on newer JDKs with better attribution, a dedicated Native Memory Tracking bucket that traces back to the allocation site. Compare two summaries taken minutes apart — jcmd PID VM.native_memory summary.diff after a baseline — and a category that only grows is your leak signature. It costs a small, constant overhead to enable (turn it on for staging and load tests, not necessarily production by default) but there is no substitute for it once off-heap memory is in play: heap profilers cannot see this memory at all.
FFM vs. JNI vs. JNA — the honest comparison
| JNI | JNA | FFM API | |
|---|---|---|---|
| Native glue code required | Yes — hand-written or javah-generated C, compiled per platform |
No — reflection-based dispatch at runtime | No — pure Java, or jextract-generated bindings |
| Memory safety | None — raw pointers, manual lifetime tracking | Partial — still exposes raw Pointer, easy to misuse |
Spatial + temporal safety by construction; a handful of explicitly restricted escape hatches |
| Call overhead | Low, but pays JNI transition cost | Noticeably higher — reflection + libffi indirection per call | Low; ~10 ns dispatch overhead measured above, competitive with JNI on JDK 24+ |
| Standard / no extra dependency | Yes — part of the JDK since 1.1 | No — third-party library (net.java.dev.jna) |
Yes — java.lang.foreign, part of java.base since JDK 22 |
| Struct / layout definition | Manual, via generated headers | Java classes implementing Structure, reflective field mapping |
Declarative MemoryLayout, or generated by jextract |
| Trajectory | Being actively restricted (JEP 472); not removed | Community-maintained, no JDK roadmap | The JDK team’s stated long-term direction for native interop |
JNA earned its popularity by removing the “write and compile C” step JNI demanded, at the cost of reflection overhead and weaker safety guarantees. FFM keeps JNA’s ergonomic win (no glue code) while adding memory safety and closing most of the performance gap — which is why new native-interop code in 2026 has very little reason to reach for either of the older two.
Migrating an existing JNI codebase: a checklist
You do not need a rewrite, and FFM and JNI can coexist in the same JVM indefinitely — migrate incrementally, starting at whichever boundary changes next anyway:
- Inventory first. Run
jnativescanagainst your existing JARs (it also flags plain JNInativemethod declarations, not just FFM) to get a concrete list instead of guessing. - Retire the C glue layer, not the native library. The C/C++ library itself (OpenSSL, a hardware SDK, a codec) usually does not need to change; only the hand-written JNI wrapper around it does.
- Prefer
jextractover hand-written layouts for any header with more than a handful of structs — hand-transcribing padding is the single most common source of silent bugs in FFM code, and it is exactly the tedious, error-prone workjextractautomates. - Replace callback interfaces with upcalls. Any JNI code using
JNIEnv::CallStaticVoidMethodfrom C to call back into Java maps directly onto anupcallStub, usually more simply. - Swap ad hoc error codes for
captureCallStatewherever your JNI layer was manually plumbingerrnoorGetLastError()through a return struct. - Turn on
--illegal-native-access=denyin CI before you turn it on in production, so a forgotten--enable-native-accessentry fails a build, not a customer’s deployment.
Quick reference
// Setup
Linker linker = Linker.nativeLinker();
SymbolLookup lib = linker.defaultLookup(); // or SymbolLookup.libraryLookup(path, arena)
// Downcall (Java calls C)
MethodHandle fn = linker.downcallHandle(
lib.find("fnName").orElseThrow(),
FunctionDescriptor.of(returnLayout, paramLayouts...),
Linker.Option.captureCallState("errno") // optional: capture errno/GetLastError
);
// Upcall (C calls Java)
MethodHandle javaMethod = MethodHandles.lookup().findStatic(...);
MemorySegment fnPointer = linker.upcallStub(javaMethod, descriptor, arena);
// Memory
try (Arena arena = Arena.ofConfined()) { // ofShared() for cross-thread, ofAuto() to avoid in prod
MemorySegment seg = arena.allocate(layout);
MemorySegment str = arena.allocateFrom("text"); // Java String to NUL-terminated C string
String back = ptr.reinterpret(n).getString(0); // C string to Java String
}
// Tooling
// $ jnativescan --class-path app.jar --print-native-access
// $ java --enable-native-access=$MODULES --illegal-native-access=deny -jar app.jar
// Manifest: Enable-Native-Access: ALL-UNNAMED
Closing thought
The FFM API’s real pitch was never “call strlen without JNI” — it is that native interop can be memory-safe, standard, and fast at the same time, three properties JNI never offered together. Upcalls close the loop so native libraries can drive Java code, not just be driven by it. captureCallState means you stop guessing why a native call failed. And JEP 472 means the safety net that used to be opt-in for FFM alone is becoming mandatory for all native access, JNI included — which is a genuinely good thing to get ahead of with jnativescan now, rather than discover the hard way when deny becomes the default.
References and further reading
- JEP 454: Foreign Function & Memory API — OpenJDK
- JEP 472: Prepare to Restrict the Use of JNI — OpenJDK
- Integrity by Default — OpenJDK
- Foreign Function and Memory API — JDK 25 Core Libraries Guide — Oracle
- Upcalls: Passing Java Code as a Function Pointer — Oracle
- Checking for Native Errors Using errno — Oracle
- JDK 24 Prepares Restricted Native Access — Nicolai Parlog, Inside.java
- Project Panama for Newbies (Part 1) — Carl Dea, foojay.io