Contents

Java heap memory is managed by the garbage collector — convenient, but every object you allocate adds pressure to the GC. For workloads that process gigabytes of data, require ultra-low latency, or need to share memory with native C/C++ libraries, the GC overhead is a real problem. Foreign memory — memory that lives outside the Java heap — solves this at the cost of requiring explicit lifetime management.

Before the FFM API the options were all painful:

The FFM API (java.lang.foreign, finalized in Java 22 via JEP 454) provides a safe, fully-public alternative. It adds bounds checking on every access, deterministic release via Arena.close(), and a structured layout system for describing C-struct-like memory regions.

// Main use cases for foreign (off-heap) memory: // 1. Large datasets (GB-scale) that would pressure the GC // 2. Zero-copy I/O — memory-mapped files without copying to heap // 3. Interoperability with native libraries (BLAS, OpenSSL, SQLite, etc.) // 4. Latency-sensitive systems where GC pauses are unacceptable import java.lang.foreign.*; // MemorySegment, Arena, MemoryLayout, ValueLayout import java.lang.invoke.*; // VarHandle, MethodHandle (used with layouts and downcalls) The FFM API is in the java.lang.foreign package (no extra dependency). It replaced the incubator jdk.incubator.foreign module from earlier Java versions.

A MemorySegment represents a contiguous region of memory — off-heap, heap-backed, or mapped from a file. You allocate off-heap segments through an Arena, then read and write values using ValueLayout constants that specify both the Java type and the byte width.

import java.lang.foreign.*; // Allocate off-heap memory — 100 bytes try (Arena arena = Arena.ofConfined()) { MemorySegment seg = arena.allocate(100); // 100 bytes, zeroed // Write values at byte offsets seg.set(ValueLayout.JAVA_INT, 0, 42); // write int at offset 0 seg.set(ValueLayout.JAVA_DOUBLE, 8, 3.14); // write double at offset 8 seg.set(ValueLayout.JAVA_BYTE, 16, (byte) 0xFF);// write byte at offset 16 // Read values int i = seg.get(ValueLayout.JAVA_INT, 0); // 42 double d = seg.get(ValueLayout.JAVA_DOUBLE, 8); // 3.14 System.out.println(i + " " + d); // Heap segment — wraps a Java array as a MemorySegment int[] arr = {1, 2, 3, 4, 5}; MemorySegment heapSeg = MemorySegment.ofArray(arr); int first = heapSeg.get(ValueLayout.JAVA_INT, 0); // 1 // Slice — sub-region of a segment MemorySegment slice = seg.asSlice(8, 8); // 8 bytes starting at offset 8 double dSlice = slice.get(ValueLayout.JAVA_DOUBLE, 0); // 3.14 // Fill — set all bytes to a value seg.fill((byte) 0); // zero all bytes // Copy between segments MemorySegment dest = arena.allocate(8); MemorySegment.copy(seg, 0, dest, 0, 8); // copy 8 bytes from seg to dest // Segment size and address System.out.println(seg.byteSize()); // 100 } // Memory automatically freed when Arena closes All memory segment accesses include bounds checking. Accessing outside the segment's bounds throws IndexOutOfBoundsException, not a native crash. This makes foreign memory much safer than Unsafe.

An Arena is the scope that owns and releases off-heap memory. When you allocate a segment through an arena, the segment is valid for exactly as long as that arena is open. Closing the arena frees all memory it owns in one shot — no GC needed, no finalizers.

Java 22 provides four arena types, each with a different lifetime policy:

// Arena controls when allocated memory is freed // Different Arena types for different lifetime needs: // 1. Confined Arena — single-threaded, freed on close() — most common try (Arena arena = Arena.ofConfined()) { MemorySegment s1 = arena.allocate(100); MemorySegment s2 = arena.allocate(200); // both s1 and s2 freed when arena closes } // freed here // 2. Shared Arena — multi-threaded, freed on close() try (Arena arena = Arena.ofShared()) { MemorySegment shared = arena.allocate(1000); // Multiple threads can access 'shared' safely // Freed when arena closes } // 3. Auto Arena — freed by GC (when no references remain) // No try-with-resources needed, but non-deterministic release MemorySegment autoSeg = Arena.ofAuto().allocate(100); // Freed when autoSeg is GC'd — useful for long-lived memory // 4. Global Arena — memory lives for JVM lifetime // For static native buffers that must always exist MemorySegment globalSeg = Arena.global().allocate(1024); // Never freed — use sparingly // Checking if a segment is still alive Arena confined = Arena.ofConfined(); MemorySegment seg = confined.allocate(10); System.out.println(seg.isNative()); // true — off-heap confined.close(); // seg.get(ValueLayout.JAVA_INT, 0); // IllegalStateException — segment freed! // Allocate with alignment (important for SIMD instructions) try (Arena arena = Arena.ofConfined()) { MemorySegment aligned = arena.allocate(64, 64); // 64 bytes, 64-byte aligned }

Raw byte offsets work for trivial cases, but when you need to model a C struct you want something declarative. MemoryLayout lets you describe the structure of a memory region — field names, types, padding, and total size — in Java code. The API then derives byte offsets for you and lets you create type-safe VarHandle accessors for each field.

import java.lang.foreign.*; import java.lang.invoke.*; // MemoryLayout describes the structure of a memory segment (like a C struct) // Think: sizeof() and offsetof() from C, but declarative // Simple C-like struct: { int x; int y; double z; } StructLayout pointLayout = MemoryLayout.structLayout( ValueLayout.JAVA_INT.withName("x"), ValueLayout.JAVA_INT.withName("y"), ValueLayout.JAVA_DOUBLE.withName("z") ); System.out.println(pointLayout.byteSize()); // 16 bytes (4 + 4 + 8) // VarHandle — type-safe accessor for a named field VarHandle xHandle = pointLayout.varHandle(MemoryLayout.PathElement.groupElement("x")); VarHandle yHandle = pointLayout.varHandle(MemoryLayout.PathElement.groupElement("y")); VarHandle zHandle = pointLayout.varHandle(MemoryLayout.PathElement.groupElement("z")); try (Arena arena = Arena.ofConfined()) { MemorySegment seg = arena.allocate(pointLayout); xHandle.set(seg, 0L, 10); // set x=10 at first element (index 0) yHandle.set(seg, 0L, 20); // set y=20 zHandle.set(seg, 0L, 3.14); // set z=3.14 int x = (int) xHandle.get(seg, 0L); // 10 int y = (int) yHandle.get(seg, 0L); // 20 double z = (double) zHandle.get(seg, 0L); // 3.14 } // Sequence layout — array of structs SequenceLayout arrayLayout = MemoryLayout.sequenceLayout(10, pointLayout); // 10 points System.out.println(arrayLayout.byteSize()); // 160 bytes // Padding — add explicit padding bytes (for alignment) StructLayout paddedLayout = MemoryLayout.structLayout( ValueLayout.JAVA_INT.withName("id"), MemoryLayout.paddingLayout(4), // 4 bytes padding to align next double ValueLayout.JAVA_DOUBLE.withName("value") );

The FFM API can call native C functions directly from Java — no JNI, no C glue code. You describe the function signature with a FunctionDescriptor, look up the native symbol with SymbolLookup, and ask the Linker to produce a MethodHandle you invoke like any other Java method. Going the other direction, an upcall stub lets native code call back into Java.

import java.lang.foreign.*; import java.lang.invoke.*; // SymbolLookup — find native library functions by name // Linker — links Java MethodHandle to native function Linker linker = Linker.nativeLinker(); // Find strlen from the C standard library SymbolLookup stdlib = linker.defaultLookup(); MemorySegment strlenAddr = stdlib.find("strlen").orElseThrow(); // Describe the function signature: size_t strlen(const char* s) FunctionDescriptor strlenDesc = FunctionDescriptor.of( ValueLayout.JAVA_LONG, // return type: size_t ValueLayout.ADDRESS // arg: const char* ); // Create a MethodHandle to call strlen MethodHandle strlen = linker.downcallHandle(strlenAddr, strlenDesc); // Use it try (Arena arena = Arena.ofConfined()) { MemorySegment cStr = arena.allocateFrom("Hello, World!"); // null-terminated C string long len = (long) strlen.invoke(cStr); System.out.println("strlen = " + len); // 13 } // Upcall — Java code called from native (callback) // FunctionDescriptor describes the callback signature // MethodHandles.lookup().findStatic(...) provides the Java method // linker.upcallStub(handle, descriptor, arena) creates a native function pointer // LoadLibrary — load a native .so / .dll System.loadLibrary("mylib"); // loads libmylib.so / mylib.dll SymbolLookup myLib = SymbolLookup.loaderLookup(); // find and call functions from myLib as above The FFM API (java.lang.foreign) is powerful but low-level. Incorrect use of pointer arithmetic or mismatched layouts can cause native crashes. Always test thoroughly and validate memory layouts against the actual native library headers.