The Foreign Function & Memory API is standard as of Java 22. JNI is now officially the legacy path. Most tutorials stop at “call printf from Java.” This one goes further — real library integration, memory lifecycle management, and a side-by-side comparison that shows exactly what you gain and what you still need to watch out for.
JNI has been with us since Java 1.1. For three decades it was the only official way to call native code from the JVM — and it was, by almost every account, a painful experience. You needed Java, C headers, a matching C implementation, platform-specific build toolchains, manual memory management without any bounds checking, and an intimate knowledge of JVM internals just to call a library function. The Foreign Function & Memory API is the answer that took eight years of incubation to get right. And now that it’s final, the question isn’t whether to use it — it’s how.
Throughout this article, we’ll move from first principles to production patterns. We’ll look at every major API concept, work through a real side-by-side comparison of the same native call in JNI vs FFM, explore MemorySegment lifecycle management with Arena in depth, and use actual benchmark data to ground the performance story. Let’s start with why JNI was never a good fit.
1. Why JNI Was Always the Wrong Abstraction
JNI wasn’t designed for general-purpose native interop. It was designed to give the JVM a controlled escape hatch — one that would be difficult enough to use that developers would only reach for it when absolutely necessary. That design philosophy turned into thirty years of boilerplate, platform-specific build pain, and some of the most cryptic error messages in the Java ecosystem.
The specific problems were well-documented by the time Project Panama started. JEP 454 articulates them clearly: JNI requires three separate artefacts (a Java API with native methods, a C header file derived from it, and a C implementation), all of which must be kept in sync across API changes. It only works with languages using the OS/CPU calling convention. It provides no safe way to manage off-heap memory — one bad pointer and you crash the JVM with no diagnostic information. And JNI calls involve a state transition between the JVM and native code that carries measurable overhead for hot paths.
JNI’s biggest hidden cost: The most underappreciated JNI problem is the build and distribution burden. Every native method requires compiling a
.so(Linux),.dylib(macOS), and.dll(Windows) — and shipping all three inside your JAR or alongside your application. With FFM, if the native library is already present on the target system (OpenSSL, system libc, platform math libraries, GPU drivers), you bind to it directly in pure Java with no extra native build step at all.
Consequently, even teams that genuinely needed native interop often reached instead for JNA (Java Native Access) — a third-party library that uses dynamic dispatch to avoid writing C code — accepting its roughly 13× performance overhead compared to raw JNI just to escape the boilerplate. That trade-off was real, and it speaks volumes about how broken the ergonomics were. Benchmarks from zakgof’s java-native-benchmark project confirm this story in numbers.
Native Call Overhead: JNA vs JNR vs JNI vs FFM (ns/op)

2. The FFM API: Five Concepts You Need to Know
The FFM API lives entirely in java.lang.foreign, which is part of java.base — no extra module dependency needed. Before writing any code, it helps to understand the five core building blocks and how they fit together. They are conceptually distinct but work as a pipeline in practice.
| Concept | Class / Interface | What it does | JNI equivalent |
|---|---|---|---|
| Memory region | MemorySegment | A contiguous region of on-heap or off-heap memory with spatial & temporal bounds | Raw pointer / jobject |
| Memory lifetime | Arena | Controls when a MemorySegment‘s backing memory is freed; prevents use-after-free | Manual malloc / free |
| Memory shape | MemoryLayout | Describes a C struct or array layout so you can access fields by name, not by offset | Manual offset arithmetic |
| Function signature | FunctionDescriptor | Describes the C parameter types and return type of a foreign function | JNI method signature string |
| Callable handle | MethodHandle (downcall) | A strongly-typed, JIT-optimisable reference to a native function produced by Linker | native method declaration |
With these five pieces in mind, the flow of any FFM integration is always the same: find the symbol → describe its signature → allocate off-heap memory in an Arena → invoke via MethodHandle → memory freed when Arena closes. Once that pattern clicks, the rest is just filling in the details.
3. Side-by-Side: The Same Native Call in JNI and FFM
To make the contrast concrete, let’s look at calling C’s strlen from Java — a deliberately simple example that isolates the structural differences without introducing complexity. The JNI version requires three separate files; the FFM version is entirely self-contained in one.
- JNI — Legacy Path
// File 1: StrlenExample.java
public class StrlenExample {
static {
System.loadLibrary("strwrap");
}
// native method declaration
public native long strlen(String s);
}
// File 2: strwrap.h (javah-generated)
// JNIEXPORT jlong JNICALL
// Java_StrlenExample_strlen
// (JNIEnv *, jobject, jstring);
// File 3: strwrap.c
#include <jni.h>
#include <string.h>
JNIEXPORT jlong JNICALL
Java_StrlenExample_strlen(
JNIEnv *env,
jobject obj,
jstring jStr) {
const char *str =
(*env)->GetStringUTFChars(
env, jStr, 0);
jlong len = strlen(str);
(*env)->ReleaseStringUTFChars(
env, jStr, str);
return len;
2. FFM — Modern Path (Java 22+)
// File: StrlenExample.java — all in Java
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import static java.lang.foreign.ValueLayout.*;
public class StrlenExample {
// Initialise once, reuse many times
private static final MethodHandle STRLEN;
static {
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib =
linker.defaultLookup();
STRLEN = linker.downcallHandle(
stdlib.find("strlen").orElseThrow(),
FunctionDescriptor.of(
JAVA_LONG, ADDRESS));
}
public long strlen(String input)
throws Throwable {
try (Arena arena = Arena.ofConfined()) {
MemorySegment str =
arena.allocateFrom(input);
return (long) STRLEN.invokeExact(str);
} // off-heap memory freed here
}
}
The difference speaks for itself. The FFM version requires no C toolchain, no platform-specific build step, no native library to ship, and no manual ReleaseStringUTFChars — the memory is freed deterministically when the try-with-resources block closes. Furthermore, the MethodHandle is cached statically so the lookup cost is paid only once, and the JIT compiler can inline and optimise the downcall path just like any other Java method handle.
Always cache your MethodHandles:The
Linker.downcallHandle()call involves symbol resolution and descriptor validation. It should be performed once at class-load time in astaticblock, not per-call. Failing to cache it will introduce hundreds of nanoseconds of overhead on every invocation — erasing the performance advantage FFM has over JNI.
4. Memory Lifecycle Management: Understanding Arena in Depth
The single most important concept in FFM for production use is Arena. It is the lifetime manager for off-heap memory — and getting it right is the difference between safe, predictable code and subtle memory leaks or use-after-free bugs. Fortunately, the FFM API provides a hard guarantee: a MemorySegment cannot be accessed after its backing arena has been closed. Attempting to do so throws an exception rather than crashing the JVM.
There are four arena types, each suited to a different lifetime pattern. Understanding which one to reach for is, therefore, one of the most impactful decisions in any FFM integration.
| Arena Type | Thread Safety | Lifetime Control | Memory Freed | Best For | Watch Out |
|---|---|---|---|---|---|
Arena.ofConfined() | Single-thread only | Manual — must call close() | Deterministically on close() | Per-request off-heap buffers, try-with-resources scopes | Throws WrongThreadException if accessed from another thread |
Arena.ofShared() | Multi-thread safe | Manual — must call close() | On close() via thread-local handshake (JEP 312) | Shared native resources accessed across threads | Slightly higher access overhead due to thread-safety checks |
Arena.ofAuto() | Multi-thread safe | GC-managed — no explicit close | When segment becomes GC-unreachable | Exploratory code, test fixtures, short scripts | Timing is non-deterministic — not suitable for latency-sensitive paths |
Arena.global() | Multi-thread safe | Unbounded — never closed | Never freed | Global native handles that must outlive any scope | Memory is permanently allocated — use only for truly global resources |
Production rule: prefer ofConfined(): In production HTTP request handlers or service methods, Arena.ofConfined() inside a try-with-resources block is almost always the right choice. It guarantees deterministic deallocation at the end of the request scope, avoids GC non-determinism, and prevents any cross-thread aliasing of native memory. Arena.ofAuto() is convenient but should be reserved for non-production or low-volume code paths.
4.1 Accessing C Structs Without Offset Arithmetic
One of the more powerful FFM features for real library integration is MemoryLayout — the ability to describe a C struct declaratively and access its fields by name rather than by manually calculated byte offsets. This eliminates an entire class of subtle bugs where offset values drift out of sync with the native struct definition. The following example maps a simple C Point2D struct into Java, as demonstrated at JavaOne 2025 by Per-Åke Minborg of Oracle’s Java Core Libraries team.
Java 22+: Mapping a C struct with MemoryLayout — no manual offsets
import java.lang.foreign.*;
import java.lang.invoke.VarHandle;
import static java.lang.foreign.ValueLayout.*;
// Describes: struct Point2D { double x; double y; }
// Works on any platform — layout handles padding automatically
MemoryLayout POINT_2D = MemoryLayout.structLayout(
JAVA_DOUBLE.withName("x"),
JAVA_DOUBLE.withName("y")
);
// VarHandles derived from the layout — these are final for JIT performance
static final VarHandle X_HANDLE =
POINT_2D.varHandle(MemoryLayout.PathElement.groupElement("x"));
static final VarHandle Y_HANDLE =
POINT_2D.varHandle(MemoryLayout.PathElement.groupElement("y"));
// Usage — notice: no manual offset calculation anywhere
try (Arena arena = Arena.ofConfined()) {
MemorySegment point = arena.allocate(POINT_2D);
X_HANDLE.set(point, 0L, 3.0); // set x = 3.0
Y_HANDLE.set(point, 0L, 4.0); // set y = 4.0
double x = (double) X_HANDLE.get(point, 0L);
double y = (double) Y_HANDLE.get(point, 0L);
double distance = Math.sqrt(x * x + y * y);
System.out.println("Distance from origin: " + distance); // 5.0
} // native memory freed here
5. jextract: Automating Bindings for Large Libraries
Writing FunctionDescriptor by hand is practical for a handful of functions. For a library like OpenSSL (with hundreds of exported symbols), libpng, or a GPU compute library, it becomes untenable. That’s where jextract enters — a command-line tool that parses C header files and mechanically generates all the FFM bindings for you in pure Java.
Importantly, jextract is developed separately from the JDK and must be downloaded independently from jdk.java.net/jextract. However, it depends on the finalised FFM API, so it’s fully stable for production use.
Generating Java bindings for stdlib.h with jextract (macOS)
# Download jextract from https://jdk.java.net/jextract/ # Then run against the system header: SDK="$(xcrun --sdk macosx --show-sdk-path)" jextract \ --output src/generated \ --target-package org.stdlib \ -l :/usr/lib/libSystem.B.dylib \ -I "$SDK/usr/include" \ "$SDK/usr/include/stdlib.h" # On Linux, a simpler form works: # jextract --output src/generated --target-package org.stdlib /usr/include/stdlib.h
Calling qsort via jextract-generated bindings — no FunctionDescriptor written by hand
// Import the generated class — jextract creates all MethodHandles automatically
import static org.stdlib.stdlib_h.*;
import java.lang.foreign.*;
import java.lang.invoke.*;
// Comparator: must match C's int (*compar)(const void *, const void *)
static int intCompare(MemorySegment a, MemorySegment b) {
int x = a.reinterpret(C_INT.byteSize()).get(C_INT, 0);
int y = b.reinterpret(C_INT.byteSize()).get(C_INT, 0);
return Integer.compare(x, y);
}
public static void main(String[] args) throws Throwable {
// qsort() is a generated method handle from stdlib_h — call it directly
try (Arena arena = Arena.ofConfined()) {
// Allocate native int array: {5, 2, 8, 1, 9}
MemorySegment array = arena.allocateFrom(C_INT, 5, 2, 8, 1, 9);
// Wrap our Java comparator as an upcall function pointer
MethodHandle comparatorHandle = MethodHandles.lookup().findStatic(
Main.class, "intCompare",
MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class));
MemorySegment compareFuncPtr = Linker.nativeLinker()
.upcallStub(comparatorHandle,
FunctionDescriptor.of(C_INT, ADDRESS, ADDRESS), arena);
// Call qsort — generated binding handles all the FunctionDescriptor plumbing
qsort(array, 5L, C_INT.byteSize(), compareFuncPtr);
int[] sorted = array.toArray(C_INT);
System.out.println(Arrays.toString(sorted)); // [1, 2, 5, 8, 9]
}
}
Upcalls: calling Java from native code: The
qsortexample above demonstrates an upcall — native code calling back into a Java method.Linker.nativeLinker().upcallStub()takes a JavaMethodHandle, wraps it in a native function pointer, and passes it to the native function. TheArenapassed in controls the lifetime of that stub — once the arena closes, the function pointer becomes invalid. This is a common pattern for native callbacks such as comparators, event handlers, and progress listeners.
6. Performance Reality: What the Benchmarks Actually Show
Given all the ergonomic improvements, it’s natural to wonder whether FFM trades performance for safety. The benchmark data answers this clearly: it does not. In most scenarios, FFM is at least as fast as JNI and in some cases measurably faster. The performance story comes in two parts.
For raw call overhead, zakgof’s JMH benchmarks show FFM at approximately 49.7 ns/op versus JNI’s 56.6 ns/op on the call-only path — roughly a 12% improvement. This is because FFM downcall handles are first-class Java MethodHandle objects that the JIT compiler can analyse and optimise in ways that JNI native stubs — which are effectively opaque to the JIT — cannot be.
For string-heavy interop, the advantage is even larger. Per-Åke Minborg’s benchmarks show FFM converting a 100-character C string to a Java String in approximately 43 ns/op, versus JNI’s 144 ns/op — a 3.4× improvement. Java-to-C string conversion at the same size shows a 2.2× improvement. The reason is that FFM can use custom allocators and reuse MemorySegment instances across calls, whereas JNI’s GetStringUTFChars always allocates per-call.
String Conversion Performance: FFM vs JNI (ns/op at 30 JMH iterations)
There is, however, one important nuance. A.N.M. Bazlur Rahman’s experiments demonstrate that when the native call itself is very fast — say, simple arithmetic — and the payload is tiny, the overhead of Arena.ofConfined() allocation can dominate. For very high-frequency, tiny-payload calls, it pays to reuse a segment allocated in a longer-lived arena rather than creating a fresh confined arena on every call. That’s a micro-optimisation, and it rarely matters for typical service code, but it’s worth being aware of in latency-sensitive hot paths.
Performance rule of thumb: For production HTTP services calling native libraries on the request path, FFM with a confined arena per-request will typically outperform or match equivalent JNI code with meaningfully less risk. Only if you’re calling a native function millions of times per second with sub-microsecond payloads should you consider segment reuse as an explicit optimisation.
7. Safety Guarantees: What FFM Protects and What It Doesn’t
It’s worth being precise about what FFM’s safety guarantees actually cover, because there are still scenarios where things can go wrong. Knowing the boundaries matters for production usage.
| Safety property | JNI | FFM |
|---|---|---|
| Out-of-bounds memory access | JVM crash | IndexOutOfBoundsException |
| Use-after-free | Undefined behaviour / crash | IllegalStateException guaranteed |
| Wrong FunctionDescriptor types | VM crash or undefined behaviour | Same — crash or UB |
| Native library crashes / seg-faults | JVM process terminated | JVM process terminated |
| Memory leak from unclosed arena | Silent leak | ofConfined: leak until GC; global: permanent |
| Thread-safety of shared memory | No enforcement | ofConfined() enforces single-thread access |
| Restricted API visibility | None | –enable-native-access or manifest attribute |
The key takeaway is that FFM eliminates the two most common JNI failure modes — out-of-bounds access and use-after-free — by converting them from JVM crashes into catchable Java exceptions. However, if you provide an incorrect FunctionDescriptor (wrong parameter types or calling convention), the result is still a potential VM crash, because there is genuinely nothing the Java runtime can do to validate that your declared types match what the native function expects at runtime. This is precisely why FFM marks Linker methods as restricted — they require explicit opt-in via --enable-native-access=ALL-UNNAMED or the Enable-Native-Access JAR manifest attribute.
7.1 The Honest Verdict
The Foreign Function & Memory API is not just a better JNI — it is a fundamentally different mental model for native interoperability. Where JNI forced you to work across three languages and two build systems, FFM keeps everything in Java. Where JNI turned memory errors into opaque JVM crashes, FFM turns them into catchable exceptions. Where JNI was opaque to the JIT, FFM downcall handles are optimisable MethodHandles. And where JNI required a new native stub for every library function, jextract generates the entire binding layer from a header file in seconds. The remaining sharp edge — that an incorrect FunctionDescriptor can still crash the JVM — is a fundamental limitation of calling native code from any language, not a limitation of the API design. JNI has the same problem, and without any of the mitigations. For any new Java code that needs native interop, there is now no good reason to reach for JNI.
8. What We Have Learned
This article has taken Project Panama’s FFM API from its conceptual foundations all the way through to production patterns. We started by establishing why JNI was always the wrong abstraction — not just ergonomically painful but structurally flawed, with no memory bounds checking, no use-after-free protection, and a multi-toolchain build requirement that created ongoing maintenance overhead.
We then walked through the five core FFM building blocks — MemorySegment, Arena, MemoryLayout, FunctionDescriptor, and downcall MethodHandle — and saw how they form a consistent pipeline that replaces all the moving parts of JNI with a single Java API. The side-by-side comparison of calling C’s strlen in JNI versus FFM made the ergonomic improvement concrete: three files and a C compiler versus one self-contained Java class.
On memory management, we examined all four Arena types and established that Arena.ofConfined() inside try-with-resources is the right default for production service code. We also explored MemoryLayout and how it eliminates manual offset arithmetic when working with C structs — removing a class of subtle, hard-to-debug errors entirely.
Finally, real benchmark data confirmed that FFM is not a safety-for-performance trade-off. It matches or outperforms JNI on raw call overhead and achieves up to 3.4× better throughput on string-heavy interop paths. The remaining unsafe surface — incorrect FunctionDescriptor types — is shared with JNI, but FFM makes it more visible by marking those APIs as restricted. For production Java running on Java 22+, FFM is the only reasonable path forward for native interoperability.
























