The Foreign Function and Memory (FFM) API in java.lang.foreign became a final, supported part of the platform in JDK 22 through JEP 454, and it is in the JDK 25 long-term-support release. It lets plain Java code call C functions and read or write native memory with no JNI glue, no C compiler and no sun.misc.Unsafe. Real bindings need more than a one-line strlen: structs, out-parameters, errno, callbacks, and a clear answer to who frees what.
This article is the programmer's view of the API. It maps C declarations to Java types, then builds one small binding to the C standard library step by step: strlen, clock_gettime with a struct out-parameter, chdir with errno captured correctly, and qsort calling back into Java. The architecture behind it, arenas, the safety model and the comparison with JNI, is covered in Java FFM architecture; migrating an existing JNI library and generating bindings with jextract are covered in Project Panama, in depth. The examples assume 64-bit Linux.
The types you will actually use
Eight types carry almost every binding.
| Type | Responsibility | Typical call |
|---|---|---|
| Arena | Owns the lifetime of memory; closing it frees everything it allocated | Arena.ofConfined(), Arena.ofShared(), Arena.ofAuto(), Arena.global() |
| MemorySegment | A bounded region of memory, native or heap, with spatial and temporal checks | seg.get(JAVA_INT, 0), seg.asSlice(off, len) |
| SegmentAllocator | Anything that can hand out segments; every Arena is one | arena.allocateFrom("text") |
| MemoryLayout, ValueLayout | Shape of data: sizes, alignment, struct members, sequences | structLayout(...), JAVA_LONG |
| SymbolLookup | Finds the address of a named native symbol | linker.defaultLookup().find("strlen") |
| FunctionDescriptor | A C signature written as layouts | FunctionDescriptor.of(JAVA_LONG, ADDRESS) |
| Linker | Turns address plus descriptor into a MethodHandle using the platform ABI | Linker.nativeLinker().downcallHandle(...) |
| Linker.Option | Per-call adjustments to the generated stub | captureCallState("errno") |
The rule that saves most bugs is to build method handles once, in static final fields, and allocate memory per call or per session. A constant handle lets the JIT inline the path to the native call.
From a C signature to a FunctionDescriptor
A descriptor is a C prototype rewritten as layouts: a return layout (omitted for void) and one layout per argument. The mapping is mechanical once you know the platform data model.
| C type | Layout | Java carrier |
|---|---|---|
char, short, int | JAVA_BYTE, JAVA_SHORT, JAVA_INT | byte, short, int |
long | JAVA_LONG on 64-bit Linux and macOS, JAVA_INT on Windows | long or int |
size_t, ssize_t | JAVA_LONG on 64-bit platforms | long |
float, double | JAVA_FLOAT, JAVA_DOUBLE | float, double |
| any pointer, including function pointers | ADDRESS | MemorySegment |
| struct passed or returned by value | a StructLayout | MemorySegment |
Do not hard-code the width of long or size_t if the binding must be portable. Linker.nativeLinker().canonicalLayouts() returns a map from C type names such as "long" and "size_t" to the right layout for the running platform. Signedness is not part of a layout; convert unsigned values with Integer.toUnsignedLong.
A wrong descriptor does not produce a Java exception; it produces garbage values or a crash inside native code.
Step 1: a first downcall
Start with the C library's strlen. The default lookup exposes commonly used libraries of the platform, which includes the C standard library on Linux and macOS.
import java.lang.foreign.*;
import java.lang.invoke.*;
import static java.lang.foreign.ValueLayout.*;
public final class LibC {
private static final Linker LINKER = Linker.nativeLinker();
private static final SymbolLookup STD = LINKER.defaultLookup();
private static final MethodHandle STRLEN = LINKER.downcallHandle(
STD.find("strlen").orElseThrow(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
public static long strlen(String s) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment cstr = arena.allocateFrom(s); // UTF-8, NUL-terminated
return (long) STRLEN.invokeExact(cstr);
} catch (Throwable t) {
throw new AssertionError(t);
}
}
}invokeExact requires the call-site types to match the handle exactly, which is why the result is cast to long and the argument is a MemorySegment. A mismatch throws WrongMethodTypeException. The confined arena frees the C string when the try block ends.
Step 2: structs and out-parameters
int clock_gettime(clockid_t clk, struct timespec *tp) writes into a caller-supplied struct. On 64-bit Linux, struct timespec holds two 8-byte fields, tv_sec and tv_nsec, and CLOCK_MONOTONIC is 1. Describe the struct once, derive VarHandles for its fields, allocate it in an arena and pass its address.
static final StructLayout TIMESPEC = MemoryLayout.structLayout(
JAVA_LONG.withName("tv_sec"),
JAVA_LONG.withName("tv_nsec"));
static final VarHandle TV_SEC =
TIMESPEC.varHandle(MemoryLayout.PathElement.groupElement("tv_sec"));
static final VarHandle TV_NSEC =
TIMESPEC.varHandle(MemoryLayout.PathElement.groupElement("tv_nsec"));
static final MethodHandle CLOCK_GETTIME = LINKER.downcallHandle(
STD.find("clock_gettime").orElseThrow(),
FunctionDescriptor.of(JAVA_INT, JAVA_INT, ADDRESS));
static long monotonicNanos() throws Throwable {
try (Arena arena = Arena.ofConfined()) {
MemorySegment ts = arena.allocate(TIMESPEC);
int rc = (int) CLOCK_GETTIME.invokeExact(1 /* CLOCK_MONOTONIC on Linux */, ts);
if (rc != 0) throw new IllegalStateException("clock_gettime failed");
long sec = (long) TV_SEC.get(ts, 0L); // second coordinate: base offset
long nsec = (long) TV_NSEC.get(ts, 0L);
return sec * 1_000_000_000L + nsec;
}
}Two details matter. First, layout-derived VarHandles in the final API take an extra long base-offset coordinate after the segment, which is what lets the same handle address the struct at index i of an array by passing i * TIMESPEC.byteSize(). Second, the layout must match the C compiler's padding. structLayout does not insert padding for you; if a 4-byte field is followed by an 8-byte one, you add MemoryLayout.paddingLayout(4) yourself, and the API rejects a layout whose members are misaligned.
Step 3: errno, captured the only way that works
Many C functions report failure by returning -1 and setting errno. Calling a second function afterwards to read it is unreliable, because the JVM itself runs native code between your call and the next, and any of it may overwrite errno. The FFM answer is Linker.Option.captureCallState: the generated stub copies the named state into a segment you supply immediately after the native function returns.
static final StructLayout CAPTURE = Linker.Option.captureStateLayout();
static final VarHandle ERRNO =
CAPTURE.varHandle(MemoryLayout.PathElement.groupElement("errno"));
static final MethodHandle CHDIR = LINKER.downcallHandle(
STD.find("chdir").orElseThrow(),
FunctionDescriptor.of(JAVA_INT, ADDRESS),
Linker.Option.captureCallState("errno"));
static final MethodHandle STRERROR = LINKER.downcallHandle(
STD.find("strerror").orElseThrow(),
FunctionDescriptor.of(ADDRESS, JAVA_INT));
static void chdir(String path) throws Throwable {
try (Arena arena = Arena.ofConfined()) {
MemorySegment state = arena.allocate(CAPTURE);
int rc = (int) CHDIR.invokeExact(state, arena.allocateFrom(path));
if (rc == -1) {
int err = (int) ERRNO.get(state, 0L);
MemorySegment msg = (MemorySegment) STRERROR.invokeExact(err);
throw new java.io.IOException(path + ": " + msg.reinterpret(1024).getString(0));
}
}
}Calling chdir("/does/not/exist") now raises an IOException carrying "No such file or directory", with err equal to 2 on Linux. Windows can also capture "GetLastError". Note the reinterpret(1024) on the string returned by strerror: that is the next topic.
Step 4: callbacks with upcalls
qsort takes a function pointer. An upcall stub wraps a Java method handle in a native function pointer whose lifetime is tied to an arena.
static final MethodHandle QSORT = LINKER.downcallHandle(
STD.find("qsort").orElseThrow(),
FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS));
static int compareInts(MemorySegment a, MemorySegment b) {
return Integer.compare(a.get(JAVA_INT, 0), b.get(JAVA_INT, 0));
}
static int[] sort(int[] values) throws Throwable {
FunctionDescriptor cmpDesc = FunctionDescriptor.of(JAVA_INT,
ADDRESS.withTargetLayout(JAVA_INT), ADDRESS.withTargetLayout(JAVA_INT));
MethodHandle cmp = MethodHandles.lookup().findStatic(LibC.class, "compareInts",
cmpDesc.toMethodType());
try (Arena arena = Arena.ofConfined()) {
MemorySegment fn = LINKER.upcallStub(cmp, cmpDesc, arena);
MemorySegment arr = arena.allocateFrom(JAVA_INT, values);
QSORT.invokeExact(arr, (long) values.length, JAVA_INT.byteSize(), fn);
return arr.toArray(JAVA_INT);
}
}Three rules apply to upcalls. The stub is freed when its arena closes, so native code must never keep the pointer longer, a real risk with libraries that register callbacks for later. An exception escaping the Java method cannot unwind through C frames, so the JVM terminates; catch everything inside the callback and return an error code. And upcalls run on whatever thread the library uses, so do not rely on thread-locals set elsewhere.
Pointers coming back from native code
When C returns a pointer, Java cannot know how large the memory behind it is or how long it lives. The API encodes that honestly: an ADDRESS return value is a zero-length segment. Reading from it throws IndexOutOfBoundsException, which is exactly right until you assert what you know. You have two tools. ADDRESS.withTargetLayout(layout) in the descriptor tells the linker the pointer refers to one instance of that layout, as the qsort comparator does. segment.reinterpret(size) resizes a segment after the fact, and reinterpret(size, arena, cleanup) also attaches it to an arena with an action to run on close.
// A C API that returns a malloc'd buffer the caller must free().
static final MethodHandle FREE = LINKER.downcallHandle(
STD.find("free").orElseThrow(), FunctionDescriptor.ofVoid(ADDRESS));
static MemorySegment adopt(MemorySegment raw, long size, Arena owner) {
return raw.reinterpret(size, owner, p -> {
try { FREE.invokeExact(p); } catch (Throwable t) { throw new AssertionError(t); }
});
}Every reinterpret is a promise to the JVM: claim too large a size and bounds checks stop protecting you; attach memory to the wrong arena and it is freed twice or never. Keep these calls in one small, audited layer.
Allocation strategies for hot paths
A confined arena per call is correct and fast enough for most bindings. In tight loops, two allocators help. SegmentAllocator.slicingAllocator(block) hands out consecutive slices of one pre-allocated segment and throws when it runs out. SegmentAllocator.prefixAllocator(block) always returns the start of the same block, which suits a scratch buffer reused for every call.
Choose the arena kind by who touches the memory: ofConfined for one thread, ofShared for many at a higher closing cost, and ofAuto only when you accept that the garbage collector decides when native memory is freed, which hides native pressure from heap-based tuning; see JVM memory: heap, stack, metaspace and off-heap for why that matters.
Linker options you will need sooner or later
| Option | Use it when | Cost or risk |
|---|---|---|
captureCallState(names...) | the function reports failure through errno or GetLastError | adds a leading segment parameter to every call |
firstVariadicArg(index) | calling a variadic function such as printf; variadic arguments start at that index | variadic arguments must use promoted types, int rather than short, double rather than float |
critical(allowHeapAccess) | a very short call with no upcalls and no blocking, such as a checksum over a buffer | the thread does not transition to native state; a long call stalls safepoints and garbage collection |
With critical(true), a heap segment from MemorySegment.ofArray(bytes) may be passed directly, saving a copy; without it, passing a heap segment throws.
Restricted methods and enabling native access
Some methods can break memory safety if misused: creating downcall handles and upcall stubs, reinterpret, withTargetLayout on address layouts and SymbolLookup.libraryLookup. These are restricted. Calling them without granted native access prints a warning. Grant it narrowly with --enable-native-access=com.example.nativebinding for a named module, --enable-native-access=ALL-UNNAMED for the class path, or an Enable-Native-Access: ALL-UNNAMED attribute in an executable JAR's manifest. JDK 24 extended the same policy to JNI through JEP 472 and added --illegal-native-access with values allow, warn and deny. Run your tests with deny so that an unexpected native caller fails in CI rather than in production, as the Panama migration guide describes.
Failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
IndexOutOfBoundsException reading a returned pointer | zero-length segment from an ADDRESS return | use withTargetLayout or a reinterpret with the documented size |
IllegalStateException: Already closed | segment used after its arena closed | widen the arena scope or copy the data out before closing |
| JVM crash inside libc | descriptor width wrong, or native code kept a pointer to freed memory | check descriptors against headers with canonicalLayouts; audit pointer retention |
| JVM terminates during a callback | exception escaped an upcall | catch Throwable in the callback and return an error code |
Structuring and testing a binding
Keep three layers: static final handles and layouts; a thin wrapper per function that handles arenas, errno and pointer ownership; and the Java API your application uses, with no MemorySegment in its signatures. For large headers, generate the first layer with jextract.
Test the wrapper on every platform you ship: layout sizes against a C program, struct round trips, each error path, and a callback that throws on purpose. Run the suite under --illegal-native-access=deny. Benchmark with JMH, and compare against pure-Java options such as the Vector API before assuming native is faster.
Trade-offs
FFM removes the C build, the per-platform JNI shim and most of the crash surface of JNI, and it lets the JIT optimise across the call. It does not make native code safe: a wrong descriptor is as dangerous as a wrong JNI signature. Very chatty APIs with many tiny calls may still be better served by moving the loop to one side of the boundary. And the API is only final from JDK 22, so libraries that must support JDK 17 or 21 need a JNI fallback or a multi-release JAR.
What to do next
- Pick one native function your code reaches today through JNI or a subprocess, and write its FunctionDescriptor from the header using canonicalLayouts.
- Build the handle in a static final field and call it with invokeExact inside a confined arena.
- For every function that uses errno, add captureCallState and a negative test that asserts the decoded error.
- Write down the owner of every pointer the library returns, then encode it with withTargetLayout or reinterpret with a cleanup action.
- Wrap every upcall body in a catch-all that converts exceptions into return codes.
- Grant native access to exactly one module and run CI with --illegal-native-access=deny.
- Add layout-size tests per target platform and a JMH benchmark before switching production traffic.