You need one function out of a native library — a codec, a hash, a vendor SDK. The old answer was a native method, a generated C header, a C shim, a compiler per platform, and a build that breaks every time the library moves.

Java 22 turns that into a pure-Java problem. The java.lang.foreign package lets you allocate off-heap memory with a lifetime you control and bind a C function to a MethodHandle — no C source, no header generation, no second toolchain:

Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
        linker.defaultLookup().find("strlen").orElseThrow(),
        FunctionDescriptor.of(JAVA_LONG, ADDRESS));

try (Arena arena = Arena.ofConfined()) {
    MemorySegment text = arena.allocateFrom("GeekMonks");
    long length = (long) strlen.invokeExact(text); // 9
}

That is the whole shape of the Foreign Function & Memory (FFM) API, finalized by JEP 454: describe the C signature in Java, get a method handle, invoke it.

When it shipped

FFM previewed for three releases before standing still:

ReleaseStatusSpec
Java 19PreviewJEP 424
Java 20Second previewJEP 434
Java 21Third previewJEP 442
Java 22FinalJEP 454
Java 24Native-access rules aligned with JNIJEP 472

Note: The API lives in java.lang.foreign, inside java.base. On Java 22+ there is no preview flag, no --add-modules, and no dependency to add. What you do need is a runtime flag for the unsafe parts — covered below.

Mental model

A handful of types cover almost everything, and each owns a single job.

JobTypeReads as
Own a lifetimeArena”when does this memory die?”
Point at bytesMemorySegment”where is it, and how big?”
Describe a shapeMemoryLayout / ValueLayout / StructLayout”what does a C struct look like?”
Read and write fieldsVarHandle”get x out of that struct”
Find a symbolSymbolLookup”where is strlen?”
Describe a C signatureFunctionDescriptor”takes char*, returns size_t”
Bind to a functionLinker → MethodHandle”call it”

Every segment carries two kinds of bounds, and they are what make this safe:

  • Spatial bounds — the size. Reading past the end throws IndexOutOfBoundsException instead of corrupting the heap.
  • Temporal bounds — the lifetime, set by the arena. Reading after the arena closes throws IllegalStateException instead of returning garbage.

No use-after-free, checked at runtime — that is the difference from a long address passed through Unsafe.

Arenas: pick a lifetime first

Every allocation belongs to an arena, and the arena decides both when the memory dies and which threads may touch it. There are four.

ArenaDeallocatedCloseableMulti-thread access
Arena.global()NeverNoYes
Arena.ofAuto()By the GC, at some later pointNoYes
Arena.ofConfined()On close()YesNo — owner thread only
Arena.ofShared()On close()YesYes

Arena extends AutoCloseable, so a confined arena in try-with-resources is the default choice for a bounded piece of work:

MemorySegment leaked;
try (Arena arena = Arena.ofConfined()) {
    MemorySegment counters = arena.allocate(ValueLayout.JAVA_INT, 16);
    counters.setAtIndex(ValueLayout.JAVA_INT, 0, 42);
    leaked = counters;
} // 64 bytes freed here
leaked.get(ValueLayout.JAVA_INT, 0); // IllegalStateException

Closing invalidates every segment from that arena atomically, and segments are zero-initialized, so you never read a previous tenant’s bytes.

Reach for ofShared() only when you must. Closing a shared arena has to cancel concurrent access on other threads — a synchronization cost a confined arena skips entirely.

Lab: downcall strlen

strlen is the cheapest real downcall available: it lives in the default lookup on every platform and takes a single char*. Put this in StrLen.java.

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

import static java.lang.foreign.ValueLayout.ADDRESS;
import static java.lang.foreign.ValueLayout.JAVA_LONG;

public class StrLen {
    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();

        // size_t strlen(const char *s);
        MethodHandle strlen = linker.downcallHandle(
                linker.defaultLookup().find("strlen").orElseThrow(),
                FunctionDescriptor.of(JAVA_LONG, ADDRESS));

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment text = arena.allocateFrom("GeekMonks");
            long length = (long) strlen.invokeExact(text);

            System.out.println("segment bytes: " + text.byteSize());
            System.out.println("strlen says:   " + length);
        }
    }
}

Three details do the work. defaultLookup() searches the C libraries the Java runtime already loaded. FunctionDescriptor.of(JAVA_LONG, ADDRESS) says “returns size_t, takes a pointer”, since size_t maps to JAVA_LONG on any 64-bit platform. And allocateFrom(String) writes the UTF-8 bytes plus the NUL terminator, which is why the segment is one byte longer than the string.

Run it with single-file source launch:

java --enable-native-access=ALL-UNNAMED StrLen.java
segment bytes: 10
strlen says:   9

Passing the MemorySegment hands strlen the segment’s base address as its char *. There is no marshalling layer to write and nothing to recompile when you move to another machine.

Restricted methods and --enable-native-access

Most of FFM is safe. A handful of methods are not, because nothing in the runtime can check them: if your FunctionDescriptor lies about the C signature, you get the same undefined behavior JNI gives you. Those methods are restricted — here are the ones you will actually meet:

Restricted methodWhy it cannot be checked
Linker::downcallHandleThe descriptor is your claim about the C signature
Linker::upcallStubNative code will call back into Java
SymbolLookup::libraryLookupLoading a library runs its initializers
MemorySegment::reinterpretYou supply bounds the runtime cannot verify
AddressLayout::withTargetLayoutSame — you assert the pointee’s size

Note what is not there: Linker.nativeLinker(), Arena allocation, and ordinary segment reads and writes are bounds-checked, so they need no permission.

Drop the flag from the lab and Java 22 still runs the program, but complains once per module:

WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: Linker::downcallHandle has been called by StrLen in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

Grant access by module name, or use ALL-UNNAMED for everything on the class path:

java --enable-native-access=com.acme.codec,com.acme.crypto -m com.acme.app/com.acme.Main
java --enable-native-access=ALL-UNNAMED -cp app.jar com.acme.Main

For an executable JAR launched with java -jar, add Enable-Native-Access: ALL-UNNAMED to the manifest instead — that is the only value the attribute accepts.

This is the application’s call, not the library’s. If you ship a library that uses FFM, document the flag your users must pass; you cannot enable it on their behalf.

JDK 24 tightened the default

JEP 472 put JNI under the same native-access rules as FFM, so library authors can migrate between them without their users changing command lines. It also added --illegal-native-access to control what happens when native access was never enabled.

ModeBehavior
--illegal-native-access=allowProceed silently. Will be removed
--illegal-native-access=warnWarn once per module. Default in JDK 24
--illegal-native-access=denyThrow IllegalCallerException every time. Will become the default

Run your suite in deny mode to find every module that quietly needs native access, and use JDK 24’s jnativescan to find restricted calls statically, without hitting the code path first:

java --illegal-native-access=deny -cp app.jar com.acme.Main
jnativescan --class-path app.jar

Fix the deny failures while they are still warnings. When deny becomes the default, an unflagged application stops starting.

Structured access: layouts instead of offset math

Reading a C struct by hand means computing byte offsets, and that arithmetic rots the moment a field moves. Describe the struct once as a MemoryLayout and let a VarHandle do the math.

StructLayout POINT = MemoryLayout.structLayout(
        JAVA_INT.withName("x"),
        JAVA_INT.withName("y"));

VarHandle pointX = POINT.varHandle(PathElement.groupElement("x"));
VarHandle pointY = POINT.varHandle(PathElement.groupElement("y"));

try (Arena arena = Arena.ofConfined()) {
    MemorySegment point = arena.allocate(POINT); // 8 bytes, 4-byte aligned
    pointX.set(point, 0L, 3);
    pointY.set(point, 0L, 4);
    int x = (int) pointX.get(point, 0L); // 3
}

The 0L is the base offset coordinate: a handle from varHandle(PathElement...) takes the segment, then a base offset, then one long index for each open path element in the path. groupElement("x") is not open, so this handle stops at the base offset. Add sequenceElement() for an array of structs and you get exactly one more coordinate:

SequenceLayout POINTS = MemoryLayout.sequenceLayout(10, POINT);
VarHandle xs = POINTS.varHandle(
        PathElement.sequenceElement(), PathElement.groupElement("x"));

MemorySegment points = arena.allocate(POINTS); // 80 bytes, in some arena
xs.set(points, 0L, 3L, 42);                    // points[3].x = 42

arena.allocate(MemoryLayout) takes the layout’s size and its alignment, which a hand-rolled allocate(80) does not. But padding is yours to model: the layout must match what the C compiler produced, so add MemoryLayout.paddingLayout(n) members wherever the ABI leaves holes.

Pointers come back as zero-length segments

When a C function returns char* or void*, the runtime learns an address and nothing else — not the size, not the lifetime. FFM represents that honestly as a zero-length segment, and any attempt to read one fails.

MethodHandle malloc = linker.downcallHandle(
        linker.defaultLookup().find("malloc").orElseThrow(),
        FunctionDescriptor.of(ADDRESS, JAVA_LONG));

MemorySegment raw = (MemorySegment) malloc.invokeExact(100L);
raw.byteSize();          // 0
raw.get(JAVA_INT, 0);    // IndexOutOfBoundsException

That refusal is the safety feature. To dereference the pointer you must supply the bounds yourself with the restricted reinterpret, which also lets you attach the region to an arena — so a free handle, built the same way from FunctionDescriptor.ofVoid(ADDRESS), runs on close:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment block = raw.reinterpret(100, arena, segment -> {
        try {
            free.invokeExact(segment);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    });
    block.set(JAVA_INT, 0, 42); // now in bounds
} // free(block) runs here

You have just given C-allocated memory a Java lifetime. Overstate the size and you are back to a JVM crash or silent corruption — which is precisely why reinterpret is restricted.

Gotchas

invokeExact is strictly typed, and a descriptor of (JAVA_LONG) -> ADDRESS means the handle takes a long. An int literal is a WrongMethodTypeException, not a widening conversion:

MemorySegment bad = (MemorySegment) malloc.invokeExact(100);   // WrongMethodTypeException
MemorySegment ok = (MemorySegment) malloc.invokeExact(100L);   // correct

C’s long is not portable, so neither is your descriptor. It maps to JAVA_LONG on Linux/x64 and macOS but to JAVA_INT on Windows/x64, which changes the method handle’s Java signature. Ask the linker instead of guessing:

Map<String, MemoryLayout> layouts = Linker.nativeLinker().canonicalLayouts();
MemoryLayout cLong = layouts.get("long");   // JAVA_LONG or JAVA_INT

Every native linker guarantees canonical layouts for bool, char, short, int, long, long long, float, double, size_t, wchar_t, and void*. Pointers are the easy case: any C pointer type is ADDRESS, whatever the word size. A few more edges worth knowing before you hit them:

  • Touching a confined arena’s segment from another thread throws WrongThreadException — including close(). Confinement is enforced, not advisory.
  • Arena.close() is not idempotent: closing twice throws IllegalStateException, deliberately, because a double close usually means a lifetime bug.
  • Passing an on-heap segment where the layout is ADDRESS throws IllegalArgumentException unless you opt in with Linker.Option.critical(true).
  • Variadic functions need Linker.Option.firstVariadicArg(int) and a descriptor spelling out the real arguments, so a three-int printf is FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT) with firstVariadicArg(1).
  • Arena.global() and Arena.ofAuto() reject close() with UnsupportedOperationException.

What you are actually leaving behind

The contrast with JNI is not about the calling convention; it is about how many artifacts you own.

FFM (Java 22+)JNI
ArtifactsOne Java classnative method + C header + C source + per-platform build
Passing a structStructLayout describes it; the linker packs itNative code unpacks the object field by field
Off-heap lifetimeArena — deterministic on close()Unsafe, or a long address you track yourself
Out-of-bounds readIndexOutOfBoundsExceptionUndefined behavior

JNI is not deprecated and is not going away, and since JDK 24 both sit behind the same --enable-native-access flag. But for calling into a native library, FFM deletes the glue layer entirely — and the JDK’s jextract tool can generate the downcall handles straight from the library’s header files.

Cheat sheet

Java 22 / JEP 454: final. Package java.lang.foreign, module java.base.

LIFETIME
  Arena.ofConfined()   close() frees; owner thread only    <- default choice
  Arena.ofShared()     close() frees; any thread
  Arena.ofAuto()       GC frees, eventually; not closeable
  Arena.global()       never freed; not closeable

ALLOCATE
  arena.allocate(POINT)                  layout size + alignment
  arena.allocate(JAVA_INT, 16)           16 ints
  arena.allocateFrom("text")             UTF-8 + NUL terminator
  arena.allocateFrom(JAVA_INT, 1, 2)     copied from a Java array

DOWNCALL / LAYOUTS
  linker.defaultLookup().find("strlen").orElseThrow()   symbol
  linker.downcallHandle(sym, FunctionDescriptor.of(JAVA_LONG, ADDRESS))
  MemoryLayout.structLayout(JAVA_INT.withName("x"), ...)
  MemoryLayout.sequenceLayout(10, POINT) / paddingLayout(4)
  layout.varHandle(PathElement.groupElement("x"))  (MemorySegment, long) -> int

FAILURES
  IllegalStateException      access after arena close
  IndexOutOfBoundsException  out of bounds, or any zero-length segment
  WrongThreadException       confined arena touched by another thread
  WrongMethodTypeException   invokeExact argument type mismatch
  IllegalCallerException     restricted call under --illegal-native-access=deny

FLAGS
  --enable-native-access=ALL-UNNAMED     class path
  --enable-native-access=M1,M2           named modules
  Enable-Native-Access: ALL-UNNAMED      executable JAR manifest
  --illegal-native-access=warn|deny      JDK 24+; warn is today's default
  jnativescan --class-path app.jar       JDK 24+; find restricted calls

Do:

  • Open a confined arena in try-with-resources and let close() free everything.
  • Describe structs as layouts and read them through VarHandles.
  • Ask Linker.canonicalLayouts() for long and size_t instead of assuming.
  • Pass --enable-native-access from the application, naming modules rather than ALL-UNNAMED when you can.
  • Test under --illegal-native-access=deny before it becomes the default.

Don’t:

  • Return a MemorySegment from a method that closes its arena.
  • reinterpret a pointer to a size the native side never promised.
  • Hand-compute struct offsets, or forget the padding a C compiler inserted.
  • Assume long is 8 bytes because it is on your laptop.

Wrap-up

FFM replaces the JNI stack with one Java file. An Arena owns the lifetime, a MemorySegment carries the bounds, a MemoryLayout and VarHandle handle the shape, and Linker turns a C symbol into a MethodHandle you invoke like any other. The safe parts are safe by construction: bounds are checked, lifetimes are enforced, and a pointer of unknown size arrives as a zero-length segment you cannot accidentally read. The genuinely unsafe parts — linking, upcalls, loading libraries, reinterpret — are restricted, so they surface in one command-line flag rather than hiding in a shared object.

Start with a confined arena and a single downcall, keep every descriptor honest about the C signature, and run your suite under --illegal-native-access=deny today. The default is heading that way.