How to use Java 21's Foreign Function and Memory API — MemorySegment, Arena, native function calls via MethodHandles, jextract, and when to use FFM over JNI or JNA.
The moment I first had to call a native C library from Java, I reached for JNI. Everyone did. And then you spend a day writing C glue code, another half day dealing with javah and header files, your first UnsatisfiedLinkError appears at runtime rather than compile time, and you have a segfault in production that the JVM cannot catch or report meaningfully. JNI works, eventually, but the developer experience is genuinely painful and the safety guarantees are minimal.
Java 21 finalised the Foreign Function and Memory API (FFM), the outcome of Project Panama. It replaces JNI for the use case of calling native code and working with off-heap memory from Java. The API is type-safe, the memory lifetime is explicit, and most importantly it is pure Java — no C compilation step, no separate header files to keep in sync. This is how it works.
Everything in FFM revolves around two abstractions.
A MemorySegment is a region of memory — it can be on-heap Java memory, off-heap native memory, or memory-mapped file contents. It knows its size, tracks its lifetime, and prevents you from reading outside its bounds. No more buffer overflows silently corrupting memory.
An Arena controls the lifetime of native memory segments. When the arena closes, all memory allocated through it is freed. This is explicit, deterministic resource management — unlike JVM garbage collection, you decide when native memory is released:
try (Arena arena = Arena.ofConfined()) {
// Memory allocated here lives until the try block exits
MemorySegment segment = arena.allocate(1024); // 1KB of native memory
segment.set(ValueLayout.JAVA_INT, 0, 42);
int value = segment.get(ValueLayout.JAVA_INT, 0);
System.out.println(value); // 42
} // arena.close() frees the native memory — no GC involvement
Arena.ofConfined() creates a confined arena: it can only be accessed from the thread that created it. Arena.ofShared() allows multi-threaded access. Arena.ofAuto() ties the lifetime to garbage collection — useful for simple cases, but you lose the determinism that makes FFM attractive for native interop.
To call a C function, you need three things: the function’s address (found via a SymbolLookup), its signature (described as a FunctionDescriptor), and a MethodHandle to invoke it.
Here’s a complete example calling the POSIX strlen function:
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
public class NativeStrlen {
public static void main(String[] args) throws Throwable {
// 1. Find the function in the C standard library
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MemorySegment strlenAddress = stdlib.find("strlen")
.orElseThrow(() -> new RuntimeException("strlen not found"));
// 2. Describe the function signature: takes a pointer, returns a long
FunctionDescriptor descriptor = FunctionDescriptor.of(
ValueLayout.JAVA_LONG, // return type: size_t (C long)
ValueLayout.ADDRESS // parameter: const char*
);
// 3. Create a MethodHandle for the function
MethodHandle strlen = linker.downcallHandle(strlenAddress, descriptor);
// 4. Call it
try (Arena arena = Arena.ofConfined()) {
MemorySegment cString = arena.allocateFrom("Hello, Panama!");
long length = (long) strlen.invoke(cString);
System.out.println("Length: " + length); // 14
}
}
}
Arena.allocateFrom(String) allocates native memory, copies the Java string into it as null-terminated UTF-8, and returns a MemorySegment pointing to it. This replaces the manual GetStringUTFChars / ReleaseStringUTFChars dance from JNI.
Calling a real library function with both input and output buffers:
public class NativeLz4Compressor {
private static final MethodHandle LZ4_COMPRESS_DEFAULT;
private static final MethodHandle LZ4_DECOMPRESS_SAFE;
static {
Linker linker = Linker.nativeLinker();
SymbolLookup lz4 = SymbolLookup.libraryLookup("liblz4.so.1", Arena.ofAuto());
FunctionDescriptor compressDesc = FunctionDescriptor.of(
ValueLayout.JAVA_INT, // return: compressed size (-1 on failure)
ValueLayout.ADDRESS, // src: const char*
ValueLayout.ADDRESS, // dst: char*
ValueLayout.JAVA_INT // srcSize: int
);
FunctionDescriptor decompressDesc = FunctionDescriptor.of(
ValueLayout.JAVA_INT, // return: decompressed size (-1 on failure)
ValueLayout.ADDRESS, // src: const char*
ValueLayout.ADDRESS, // dst: char*
ValueLayout.JAVA_INT, // compressedSize: int
ValueLayout.JAVA_INT // maxDecompressedSize: int
);
try {
LZ4_COMPRESS_DEFAULT = linker.downcallHandle(
lz4.find("LZ4_compress_default").orElseThrow(), compressDesc);
LZ4_DECOMPRESS_SAFE = linker.downcallHandle(
lz4.find("LZ4_decompress_safe").orElseThrow(), decompressDesc);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
public byte[] compress(byte[] input) throws Throwable {
try (Arena arena = Arena.ofConfined()) {
int maxOutputSize = input.length + (input.length / 255) + 16;
MemorySegment src = arena.allocate(input.length);
MemorySegment dst = arena.allocate(maxOutputSize);
MemorySegment.copy(input, 0, src, ValueLayout.JAVA_BYTE, 0, input.length);
int compressedSize = (int) LZ4_COMPRESS_DEFAULT.invoke(src, dst, input.length);
if (compressedSize < 0) throw new RuntimeException("LZ4 compression failed");
return dst.asSlice(0, compressedSize).toArray(ValueLayout.JAVA_BYTE);
}
}
}
Compare this to the JNI equivalent: no C file, no javah, no System.loadLibrary buried in a static initialiser with UnsatisfiedLinkError at runtime. The SymbolLookup.libraryLookup loads the native library, and everything else is Java.
Writing FunctionDescriptor by hand for complex C headers with structs, unions, and callbacks is tedious and error-prone. The jextract tool (distributed separately from the JDK) generates Java bindings from C header files automatically:
jextract \
--output src/generated \
--target-package com.example.native.lz4 \
-l lz4 \
/usr/include/lz4.h
This generates Java classes with static methods, layout constants, and MemorySegment-based struct accessors for everything declared in the header. For a library with dozens of functions and complex structs (OpenSSL, libcurl, BLAS), jextract saves hours and eliminates transcription errors.
The generated bindings use the same MemorySegment / Arena abstractions — you’re not locked into a different programming model, just freed from the boilerplate.
JNI is the lowest-level option. Direct access to the JVM internals, maximum performance, but requires C glue code, manual type marshalling, and no bounds checking on native memory. Segfaults crash the JVM process. Error messages at the C level are unhelpful from a Java perspective. Use JNI only when you need maximum performance and are willing to invest in the tooling.
JNA (Java Native Access) provides a Java-only alternative to JNI using dynamic proxies and reflection. Easier to use than JNI and no C compilation required, but slower (reflection overhead), and it uses sun.misc.Unsafe under the hood — not a stable API. JNA works well for occasional calls to simple functions but is not the right choice for performance-sensitive or long-term code.
FFM is the supported, performant, Java-only path for Java 21+. The MethodHandle invocation path is optimised by the JIT and approaches JNI performance for hot call sites. Memory access via MemorySegment is bounds-checked and lifetime-tracked. The API is part of the stable JDK — not a third-party library to manage.
For any new code targeting Java 21 or later, FFM is the correct choice unless you have an existing JNI integration that would be expensive to migrate. JNA is a reasonable stepping stone on older Java versions.
Calling functions that take or return C structs requires describing the struct layout:
// C struct: struct Point { int x; int y; };
StructLayout pointLayout = MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")
);
VarHandle xHandle = pointLayout.varHandle(MemoryLayout.PathElement.groupElement("x"));
VarHandle yHandle = pointLayout.varHandle(MemoryLayout.PathElement.groupElement("y"));
try (Arena arena = Arena.ofConfined()) {
MemorySegment point = arena.allocate(pointLayout);
xHandle.set(point, 0L, 10);
yHandle.set(point, 0L, 20);
int x = (int) xHandle.get(point, 0L);
int y = (int) yHandle.get(point, 0L);
System.out.printf("Point(%d, %d)%n", x, y);
}
For structs with padding, MemoryLayout.paddingLayout(bytes) adds the padding explicitly. jextract handles this automatically from the C header.
The FFM API was finalised in Java 21 under java.lang.foreign. There is no --enable-preview flag required, no incubator module to add — it is a standard part of the platform. The key classes are all in the java.lang.foreign package: Arena, MemorySegment, Linker, FunctionDescriptor, SymbolLookup, ValueLayout, and MemoryLayout.
If you are on Java 17–20, the API was present but in preview/incubator form with slightly different class names. The Java 21 API is the stable form, and it’s worth the upgrade if native interop is important to your project.
If you’re working on a Java system that needs native library integration and want experienced input on the design, get in touch.