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.

Advertisement

The types you will actually use

Eight types carry almost every binding.

TypeResponsibilityTypical call
ArenaOwns the lifetime of memory; closing it frees everything it allocatedArena.ofConfined(), Arena.ofShared(), Arena.ofAuto(), Arena.global()
MemorySegmentA bounded region of memory, native or heap, with spatial and temporal checksseg.get(JAVA_INT, 0), seg.asSlice(off, len)
SegmentAllocatorAnything that can hand out segments; every Arena is onearena.allocateFrom("text")
MemoryLayout, ValueLayoutShape of data: sizes, alignment, struct members, sequencesstructLayout(...), JAVA_LONG
SymbolLookupFinds the address of a named native symbollinker.defaultLookup().find("strlen")
FunctionDescriptorA C signature written as layoutsFunctionDescriptor.of(JAVA_LONG, ADDRESS)
LinkerTurns address plus descriptor into a MethodHandle using the platform ABILinker.nativeLinker().downcallHandle(...)
Linker.OptionPer-call adjustments to the generated stubcaptureCallState("errno")
The FFM API types and how a downcall uses themSymbolLookupname to native addressFunctionDescriptorC signature as layoutsLinkerplatform calling conventionaddressshapeMethodHandlethe downcall you invokeArenalifetime and deallocationMemorySegmentbounded, lifetime-checked memoryallocatesargumentsMemoryLayout / ValueLayoutstruct shape, offsets, VarHandlesLinker.OptioncaptureCallState, firstVariadicArg, criticalLookup finds the code, the descriptor describes it, the linker builds a handle;arenas own every byte you pass through it.
A downcall is built once from three inputs and then invoked many times; the arena is the only thing that must be threaded through each call.

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 typeLayoutJava carrier
char, short, intJAVA_BYTE, JAVA_SHORT, JAVA_INTbyte, short, int
longJAVA_LONG on 64-bit Linux and macOS, JAVA_INT on Windowslong or int
size_t, ssize_tJAVA_LONG on 64-bit platformslong
float, doubleJAVA_FLOAT, JAVA_DOUBLEfloat, double
any pointer, including function pointersADDRESSMemorySegment
struct passed or returned by valuea StructLayoutMemorySegment

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.

Advertisement

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.

Capturing errno: why it cannot be read after the call returnsJava callerinvokeExact(state, path)downcall stubnative transitionchdir()returns -1, sets errnoargscallretcapture segmenterrno copied herecopy before any JVM workJVM runtimemay clobber errnoread ERRNO handleJava callererrno = 2 (ENOENT)Linker.Option.captureCallState("errno") makes the stub save errno immediately,before GC, safepoints or other native calls in the JVM can overwrite it.
The capture segment is an extra leading argument to the downcall handle; the stub fills it before control returns to Java.
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

OptionUse it whenCost or risk
captureCallState(names...)the function reports failure through errno or GetLastErroradds a leading segment parameter to every call
firstVariadicArg(index)calling a variadic function such as printf; variadic arguments start at that indexvariadic 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 bufferthe 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

SymptomLikely causeFix
IndexOutOfBoundsException reading a returned pointerzero-length segment from an ADDRESS returnuse withTargetLayout or a reinterpret with the documented size
IllegalStateException: Already closedsegment used after its arena closedwiden the arena scope or copy the data out before closing
JVM crash inside libcdescriptor width wrong, or native code kept a pointer to freed memorycheck descriptors against headers with canonicalLayouts; audit pointer retention
JVM terminates during a callbackexception escaped an upcallcatch 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

  1. Pick one native function your code reaches today through JNI or a subprocess, and write its FunctionDescriptor from the header using canonicalLayouts.
  2. Build the handle in a static final field and call it with invokeExact inside a confined arena.
  3. For every function that uses errno, add captureCallState and a negative test that asserts the decoded error.
  4. Write down the owner of every pointer the library returns, then encode it with withTargetLayout or reinterpret with a cleanup action.
  5. Wrap every upcall body in a catch-all that converts exceptions into return codes.
  6. Grant native access to exactly one module and run CI with --illegal-native-access=deny.
  7. Add layout-size tests per target platform and a JMH benchmark before switching production traffic.
Key takeaway: The FFM API is a small set of types with clear jobs: a lookup finds a symbol, a FunctionDescriptor states its C signature, the Linker produces a MethodHandle, and arenas own every byte that crosses the boundary. Real bindings add structs described by layouts, errno captured by the call stub rather than read afterwards, upcalls that never let exceptions escape, and explicit decisions about the size and owner of every pointer that comes back from C. Build handles once, keep reinterpret in one audited layer, grant native access narrowly, and test layouts on every platform you ship.