For twenty-five years, calling native code from Java meant JNI — C glue, a separate build, and a wide-open door to segfaults — and managing off-heap memory meant ByteBuffer or the unsupported sun.misc.Unsafe. The Foreign Function & Memory (FFM) API, finalized in JDK 22 under Project Panama (JEP 454), replaces both with a single, pure-Java, memory-safe interface. You allocate and access native memory through MemorySegment, control its lifetime with an Arena, describe native data with layouts and access it with var handles, and call C functions directly through the Linker — no C, no boilerplate, and with bounds and liveness checked on every access. This piece walks the architecture end to end: segments, the four kinds of arena, layouts and var handles, downcall and upcall stubs, the safety model that makes it trustworthy, and the one seam where you can still shoot yourself.
Why FFM exists: retiring JNI and Unsafe
The two problems FFM solves have plagued Java for decades. Calling native code via JNI required writing C shim functions with generated headers, compiling them per platform, and accepting that any mistake — a wrong type, a stale pointer — crashes the JVM with no Java stack trace. Off-heap memory was worse: ByteBuffer is capped at 2 GB and has no deterministic free, while sun.misc.Unsafe gave raw power with raw danger (use-after-free, out-of-bounds, all undefined behavior) and was never a supported API.
FFM replaces both with one coherent, safe, pure-Java model. There is no separate native compilation step, no C boilerplate, and — the headline — the runtime checks bounds and lifetime on every access, converting what used to be a JVM crash into an ordinary Java exception. It is the intended successor to JNI and the sanctioned replacement for Unsafe, which the JDK is actively moving to restrict.
MemorySegment: bounded, checked memory
A MemorySegment is the core abstraction: a contiguous region of memory with a known size and lifetime. It can wrap native (off-heap) memory, on-heap arrays, or a memory-mapped file — the same API over all three. What makes it safe is that a segment is not a raw pointer: it carries its bounds, and every read or write is checked against them.
try (Arena arena = Arena.ofConfined()) {
MemorySegment seg = arena.allocate(16); // 16 bytes off-heap
seg.set(ValueLayout.JAVA_INT, 0, 42); // write int at offset 0
int v = seg.get(ValueLayout.JAVA_INT, 0); // read it back
// seg.get(ValueLayout.JAVA_INT, 20); -> IndexOutOfBoundsException, not a crash
} // arena closes here: the 16 bytes are freed deterministicallyAccessing offset 20 in a 16-byte segment throws an exception rather than corrupting memory — spatial safety. And notice the segment’s memory is freed when the arena closes, not by the garbage collector and not by a manual free() you might forget. That lifetime control is the arena’s job.
Arenas: who owns the lifetime
Every segment is tied to an Arena, which owns the memory’s lifetime and its access rules. When an arena closes, all segments it allocated become invalid simultaneously, and their native memory is released. Crucially, accessing a segment after its arena has closed throws IllegalStateException — the runtime enforces temporal safety, catching the use-after-free that Unsafe would have turned into a silent crash or corruption.
Because Arena is AutoCloseable, the idiomatic pattern is try-with-resources: allocate inside the block, and the close() at the end frees everything deterministically. This gives C-like control over when memory is released — no waiting for GC — with none of C’s danger. The design decision to make lifetime a first-class object, rather than a property of individual pointers, is what lets the runtime reason about safety at all.
Confined, shared, automatic, and global arenas
FFM offers four arena flavors, trading control for convenience:
| Arena | Lifetime | Threading | Use when |
|---|---|---|---|
ofConfined() | Deterministic (close) | One thread only | Default — fastest access, clear ownership |
ofShared() | Deterministic (close) | Any thread | Segment accessed from multiple threads |
ofAuto() | GC-managed | Any thread | Lifetime is unclear; let the GC free it |
global() | Never freed | Any thread | Constants that live for the whole program |
A confined arena restricts access to the thread that created it, which lets the JVM skip synchronization and makes it the fastest and the default choice. A shared arena permits multi-threaded access but pays for it: closing one requires a thread handshake to guarantee no other thread is mid-access, which is comparatively expensive. Automatic hands lifetime to the garbage collector — safe and convenient, but you lose deterministic release. Global never frees, suitable only for genuinely permanent data. Choosing the arena is the single most consequential design decision in FFM code: it sets both your threading model and your memory-release semantics.
Memory layouts: describing native structs
Native code rarely deals in bare bytes — it deals in structs with typed, aligned fields. MemoryLayout is how you describe that shape to Java. ValueLayout covers scalars (JAVA_INT, JAVA_LONG, ADDRESS…), StructLayout composes them into a struct with correct padding and alignment, and SequenceLayout describes arrays.
// struct Point { int x; int y; }
MemoryLayout POINT = MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")
);The layout encodes size, alignment, and named path elements — enough for the runtime to compute field offsets correctly, including the padding a C compiler would insert. Getting the layout right is what makes Java and native code agree on memory representation; a layout that doesn’t match the real C struct is one of the ways to reach the unsafe seam discussed below.
VarHandles: typed access into a layout
Given a layout, you access its fields through a VarHandle derived from a layout path. The var handle is a precompiled, efficient accessor that knows the field’s type and offset, so reads and writes are both type-safe and fast — no manual offset arithmetic.
VarHandle xh = POINT.varHandle(MemoryLayout.PathElement.groupElement("x"));
VarHandle yh = POINT.varHandle(MemoryLayout.PathElement.groupElement("y"));
try (Arena arena = Arena.ofConfined()) {
MemorySegment p = arena.allocate(POINT);
xh.set(p, 0L, 3); // p.x = 3
yh.set(p, 0L, 7); // p.y = 7
int x = (int) xh.get(p, 0L);
}Because the var handle is bound to the layout’s offsets, you never write offset + 4 by hand — the source of countless native bugs. Layouts plus var handles turn ‘a blob of bytes’ into ‘a typed struct’ while keeping every access bounds-checked against the segment.
Downcalls: calling a C function from Java
The ‘foreign function’ half of FFM is the Linker. A downcall — Java calling into native — is built by looking up a symbol and describing its signature with a FunctionDescriptor, which yields an ordinary MethodHandle you invoke like any Java method.
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
// long strlen(const char *s);
MethodHandle strlen = linker.downcallHandle(
stdlib.find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment cstr = arena.allocateUtf8String("hello");
long len = (long) strlen.invoke(cstr); // -> 5
}No C shim, no native methods, no separate compile — the JVM generates the call stub at runtime from the descriptor. The FunctionDescriptor is the contract: it must describe the C function’s argument and return layouts exactly, because it is what the runtime uses to marshal arguments across the ABI boundary.
Upcalls: handing Java to native as a callback
The reverse direction — native code calling back into Java — is an upcall. Many C APIs take a function pointer (a comparator for qsort, an event handler, a cleanup callback). FFM lets you wrap a Java MethodHandle as a native function pointer via Linker.upcallStub, passing a MemorySegment that native code can call as if it were a C function.
The stub is itself tied to an arena, so its lifetime is managed like any other segment — an upcall stub must outlive every native call that might invoke it, or you get a use-after-free when native code calls a freed stub. Upcalls complete the story: with downcalls Java drives native code, and with upcalls native code drives Java, so FFM can integrate with callback-heavy C libraries that JNI could only handle with significant hand-written glue.
jextract: generating bindings from headers
Writing FunctionDescriptors and layouts by hand for a large native library is tedious and error-prone — and every hand-written descriptor is a chance to mismatch the ABI. jextract is the tool that removes that toil: point it at a C header file and it generates the Java bindings — method handles for the functions, layouts for the structs, constants for the macros — automatically.
jextract --output src -t org.example.lib \
-l mylib /usr/include/mylib.hFor binding a real-world library (OpenGL, SQLite, a system API), jextract is the intended workflow: it produces correct descriptors derived from the actual header, sidestepping the hand-transcription errors that are the most common source of FFM crashes. You write descriptors by hand only for small, one-off calls.
The safety model: spatial and temporal
FFM’s central promise is that pure-Java access to native memory is safe by default, enforced along two axes. Spatial safety: every access is bounds-checked against the segment’s size, so an out-of-range read or write throws IndexOutOfBoundsException instead of corrupting adjacent memory. Temporal safety: access is checked against the arena’s liveness, so touching a segment after its arena closed throws IllegalStateException instead of a use-after-free. Confined arenas add a third guarantee — thread confinement — so a segment can’t be raced from another thread; a violation throws WrongThreadException.
Together these turn the classic native-memory bug classes — buffer overrun, use-after-free, data race — from undefined behavior that crashes or silently corrupts the JVM into ordinary, catchable Java exceptions. That is the entire reason FFM can be offered as a supported replacement for Unsafe: the danger is checked, not merely documented.
Failure modes and the unsafe seam
Most FFM mistakes surface as clean exceptions: an out-of-bounds access (IndexOutOfBoundsException), a use-after-free from an already-closed arena (IllegalStateException), or a confined segment touched off-thread (WrongThreadException). Those are the safety net working as designed — bugs, but survivable, debuggable ones.
There is exactly one place the net has a hole: the native call boundary itself. If your FunctionDescriptor or memory layout does not match the real C ABI — wrong argument type, wrong struct padding, a pointer where an int was expected — the JVM marshals arguments incorrectly and native code can crash or corrupt memory, exactly as JNI could. The runtime cannot verify your description against a C function it cannot see. This is why jextract matters (it derives descriptors from the header) and why FFM call sites must be treated as the one unsafe seam. Operationally, native access is also gated: you must grant it with --enable-native-access, and the JDK increasingly warns or fails without it, keeping this power explicit.
Strings, arrays, and structs across the boundary
Most real native calls hinge on a few recurring marshaling patterns, and FFM gives each a first-class idiom. Strings: C expects null-terminated char*, so you materialize a Java string into native memory with arena.allocateUtf8String(s) and read one back with segment.getUtf8String(offset) — the encoding and terminator are handled for you. Arrays: a native array is a SequenceLayout over a segment; you index it with a var handle carrying a sequence path element, and bulk copies move between Java arrays and segments in one call. Structs: as shown earlier, a StructLayout plus per-field var handles reads and writes fields at the correct padded offsets.
The unifying idea is that the layout is the single source of truth for how bytes are interpreted, and every accessor derives from it — so once the layout matches the C definition, strings, arrays, and nested structs all fall out of the same mechanism. Pointers, represented as ADDRESS, are themselves just segments (often zero-length until you give them bounds via reinterpret), which is how you follow a pointer a native function hands back. These patterns cover the vast majority of practical FFM code.
FFM vs JNI, head to head
The clearest way to see what FFM changes is to line it up against JNI on the dimensions that matter in practice:
| Dimension | JNI | FFM |
|---|---|---|
| Glue code | Hand-written C shims per function | None — pure Java |
| Build | Separate native compile + platform toolchain | No native build step |
| Memory safety | Mistakes crash the JVM | Bounds & liveness checked → exceptions |
| Off-heap memory | Manual, via Unsafe/ByteBuffer | MemorySegment + Arena |
| Binding a library | Write shims by hand | jextract from headers |
| Unsafe surface | The entire boundary | Only the ABI descriptor |
The net effect is that the enormous surface area JNI exposed — every shim a chance to mishandle a pointer — collapses to a single, well-defined seam: the FunctionDescriptor that must match the C ABI. Everything else moves inside the JVM’s safety guarantees.
Performance: fewer transitions, more inlining
FFM is not just safer than JNI; it is frequently faster, for structural reasons. A JNI call crosses a fixed boundary with its own state transition and conventions, and it is opaque to the JIT — the compiler cannot see through it to optimize. An FFM downcall handle is an ordinary MethodHandle the JIT understands; in many cases it can be inlined and the argument marshaling optimized away, so a small, hot native call carries far less overhead than the equivalent JNI transition.
On the memory side, MemorySegment access compiles down to efficient, bounds-checked loads and stores that the JIT can hoist and vectorize much like array access — and the checks themselves are often eliminated when the compiler can prove them redundant. The result is that the safe path is usually the fast path too, removing the old temptation to reach for Unsafe ‘for performance.’ You keep the safety and typically keep the speed.
Mapped files and heap segments
The same MemorySegment abstraction stretches beyond freshly-allocated off-heap memory, which is part of what makes FFM a genuine replacement for the older APIs. A segment can wrap an on-heap array (letting native code read a Java array’s contents under the same bounds checks) and, importantly, a memory-mapped file: FileChannel.map yields a segment backed by the file, tied to an arena for lifetime, and — unlike MappedByteBuffer — free of the 2 GB size ceiling.
That unlocks working with multi-gigabyte files as a single addressable segment, accessed with the same layouts and var handles you use everywhere else, with deterministic unmapping when the arena closes. One API — segment plus arena — thus spans off-heap allocation, on-heap arrays, and mapped files, replacing the patchwork of ByteBuffer, MappedByteBuffer, and Unsafe that Java programs previously stitched together for these jobs.
An operational playbook
Distilling the architecture into practice: default to confined arenas with try-with-resources — fastest access, deterministic free, clearest ownership — and reach for shared only when a segment genuinely crosses threads, accepting the handshake cost on close. Use automatic arenas when a lifetime truly can’t be scoped, and global only for permanent constants. Prefer jextract over hand-written descriptors for any non-trivial library, since the native-call boundary is the one place a mistake still crashes. Keep segments alive as long as native code holds pointers into them — especially upcall stubs. Grant --enable-native-access deliberately, not blanket. And remember the win: this is all ordinary Java — no C, no separate build, no Unsafe — with the JVM checking the bounds and lifetimes JNI never did.
Unsafe. Allocate native memory as a bounded, bounds-checked MemorySegment; control its lifetime with an Arena — confined (default, single-thread, deterministic), shared (multi-thread, handshake on close), automatic (GC-managed), or global (permanent). Describe native structs with layouts and access them with var handles; call C functions with downcalls through the Linker and let native call back via upcalls, generating bindings with jextract. Spatial and temporal checks turn buffer overruns and use-after-free into catchable exceptions — with one exception: a FunctionDescriptor that mismatches the C ABI is the single unsafe seam, so derive descriptors from headers and gate native access with --enable-native-access.