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:
| Release | Status | Spec |
|---|---|---|
| Java 19 | Preview | JEP 424 |
| Java 20 | Second preview | JEP 434 |
| Java 21 | Third preview | JEP 442 |
| Java 22 | Final | JEP 454 |
| Java 24 | Native-access rules aligned with JNI | JEP 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.
| Job | Type | Reads as |
|---|---|---|
| Own a lifetime | Arena | ”when does this memory die?” |
| Point at bytes | MemorySegment | ”where is it, and how big?” |
| Describe a shape | MemoryLayout / ValueLayout / StructLayout | ”what does a C struct look like?” |
| Read and write fields | VarHandle | ”get x out of that struct” |
| Find a symbol | SymbolLookup | ”where is strlen?” |
| Describe a C signature | FunctionDescriptor | ”takes char*, returns size_t” |
| Bind to a function | Linker → 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
IndexOutOfBoundsExceptioninstead of corrupting the heap. - Temporal bounds — the lifetime, set by the arena. Reading after the arena closes throws
IllegalStateExceptioninstead 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.
| Arena | Deallocated | Closeable | Multi-thread access |
|---|---|---|---|
Arena.global() | Never | No | Yes |
Arena.ofAuto() | By the GC, at some later point | No | Yes |
Arena.ofConfined() | On close() | Yes | No — owner thread only |
Arena.ofShared() | On close() | Yes | Yes |
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 method | Why it cannot be checked |
|---|---|
Linker::downcallHandle | The descriptor is your claim about the C signature |
Linker::upcallStub | Native code will call back into Java |
SymbolLookup::libraryLookup | Loading a library runs its initializers |
MemorySegment::reinterpret | You supply bounds the runtime cannot verify |
AddressLayout::withTargetLayout | Same — 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.
| Mode | Behavior |
|---|---|
--illegal-native-access=allow | Proceed silently. Will be removed |
--illegal-native-access=warn | Warn once per module. Default in JDK 24 |
--illegal-native-access=deny | Throw 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— includingclose(). Confinement is enforced, not advisory. Arena.close()is not idempotent: closing twice throwsIllegalStateException, deliberately, because a double close usually means a lifetime bug.- Passing an on-heap segment where the layout is
ADDRESSthrowsIllegalArgumentExceptionunless you opt in withLinker.Option.critical(true). - Variadic functions need
Linker.Option.firstVariadicArg(int)and a descriptor spelling out the real arguments, so a three-intprintfisFunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT)withfirstVariadicArg(1). Arena.global()andArena.ofAuto()rejectclose()withUnsupportedOperationException.
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 | |
|---|---|---|
| Artifacts | One Java class | native method + C header + C source + per-platform build |
| Passing a struct | StructLayout describes it; the linker packs it | Native code unpacks the object field by field |
| Off-heap lifetime | Arena — deterministic on close() | Unsafe, or a long address you track yourself |
| Out-of-bounds read | IndexOutOfBoundsException | Undefined 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()forlongandsize_tinstead of assuming. - Pass
--enable-native-accessfrom the application, naming modules rather thanALL-UNNAMEDwhen you can. - Test under
--illegal-native-access=denybefore it becomes the default.
Don’t:
- Return a
MemorySegmentfrom a method that closes its arena. reinterpreta pointer to a size the native side never promised.- Hand-compute struct offsets, or forget the padding a C compiler inserted.
- Assume
longis 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.