Guide contents

Lesson 49 of 71

The Foreign Function and Memory API

The Foreign Function and Memory (FFM) API in java.lang.foreign reads and writes memory outside the Java heap and calls native functions without JNI. It became final in Java 22 (JEP 454). It fixes the two gaps of ByteBuffer and memory-mapped files: memory is freed at a known point, and segments are indexed by long, not int. After this lesson, a reader can allocate and lay out off-heap data, map files with deterministic unmapping, and call a C function.

API Purpose
Arena Controls the lifetime of memory: when it is freed and which threads may use it
MemorySegment A bounded region of memory, on or off the heap
ValueLayout Size, alignment, and byte order of one value (JAVA_INT, JAVA_LONG, ADDRESS)
MemoryLayout.structLayout Named fields with offsets, like a C struct
FileChannel.map(..., arena) A mapped file as a segment, unmapped when the arena closes
Linker, FunctionDescriptor Calls to native functions (downcalls)

Example 1: allocate, write, read, and bounds

An arena allocates segments. A confined arena (Arena.ofConfined()) belongs to the thread that created it and frees all its memory when close runs, so try-with-resources gives it a clear lifetime. Every access is checked against the segment's size.

Java
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;

public class Main {
    public static void main(String[] args) {
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment prices = arena.allocate(ValueLayout.JAVA_INT, 5);
            for (int i = 0; i < 5; i++) {
                prices.setAtIndex(ValueLayout.JAVA_INT, i, 100 + i);
            }
            long sum = 0;
            for (int i = 0; i < 5; i++) {
                sum += prices.getAtIndex(ValueLayout.JAVA_INT, i);
            }
            System.out.println("bytes " + prices.byteSize() + " sum " + sum);
            System.out.println("native: " + prices.isNative());

            try {
                prices.getAtIndex(ValueLayout.JAVA_INT, 5);
            } catch (IndexOutOfBoundsException e) {
                System.out.println("out of bounds access rejected");
            }
        }
    }
}
Output
bytes 20 sum 510
native: true
out of bounds access rejected

allocate(JAVA_INT, 5) returned 20 bytes, zero-filled and aligned for int. getAtIndex multiplies the index by the layout size; get(layout, byteOffset) takes a byte offset instead. Reading index 5 threw instead of reading the memory after the segment, which is what sun.misc.Unsafe would do.

Example 2: the four arena kinds

The arena kind decides who may access a segment and when it is freed. Access after the arena closes throws IllegalStateException, and a confined segment used from another thread throws WrongThreadException.

Java
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;

public class Main {
    public static void main(String[] args) throws Exception {
        MemorySegment leaked;
        try (Arena confined = Arena.ofConfined()) {
            MemorySegment s = confined.allocate(8);
            leaked = s;
            Thread t = new Thread(() -> {
                try {
                    s.get(ValueLayout.JAVA_LONG, 0);
                } catch (WrongThreadException e) {
                    System.out.println("confined: other thread rejected");
                }
            });
            t.start();
            t.join();
        }
        try {
            leaked.get(ValueLayout.JAVA_LONG, 0);
        } catch (IllegalStateException e) {
            System.out.println("confined: access after close rejected");
        }

        try (Arena shared = Arena.ofShared()) {
            MemorySegment s = shared.allocate(8);
            Thread t = new Thread(() -> s.set(ValueLayout.JAVA_LONG, 0, 99L));
            t.start();
            t.join();
            System.out.println("shared: value from other thread " + s.get(ValueLayout.JAVA_LONG, 0));
        }

        MemorySegment auto = Arena.ofAuto().allocate(8);   // freed by GC when unreachable
        auto.set(ValueLayout.JAVA_LONG, 0, 1L);
        System.out.println("auto: usable " + (auto.get(ValueLayout.JAVA_LONG, 0) == 1L));

        try {
            Arena.global().close();
        } catch (UnsupportedOperationException e) {
            System.out.println("global and auto arenas cannot be closed");
        }
    }
}
Output
confined: other thread rejected
confined: access after close rejected
shared: value from other thread 99
auto: usable true
global and auto arenas cannot be closed
Kind Threads Freed Typical use
ofConfined() Owner thread only On close Per-request or per-call scratch memory
ofShared() Any thread On close Memory shared by worker threads
ofAuto() Any thread By the GC, when no segment is reachable Replacement for direct ByteBuffer
global() Any thread Never Memory that lives as long as the process

Closing a shared arena is slower than closing a confined one, because the JVM must make sure no other thread is in the middle of an access (HotSpot uses a handshake with the other threads).

Example 3: struct layouts and VarHandle access paths

MemoryLayout.structLayout describes fields in order with their names. The layout computes offsets and total size, and varHandle(PathElement.groupElement(name)) returns a VarHandle (the typed accessor from VarHandles and Fences) for one field. The handle takes the segment and a base offset as its first two coordinates.

Java
import java.lang.foreign.Arena;
import java.lang.foreign.MemoryLayout;
import java.lang.foreign.MemoryLayout.PathElement;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.StructLayout;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.VarHandle;

public class Main {
    static final StructLayout ORDER = MemoryLayout.structLayout(
            ValueLayout.JAVA_LONG.withName("id"),
            ValueLayout.JAVA_INT.withName("qty"),
            ValueLayout.JAVA_INT.withName("price"));
    static final VarHandle ID = ORDER.varHandle(PathElement.groupElement("id"));
    static final VarHandle QTY = ORDER.varHandle(PathElement.groupElement("qty"));
    static final VarHandle PRICE = ORDER.varHandle(PathElement.groupElement("price"));

    public static void main(String[] args) {
        System.out.println("struct size " + ORDER.byteSize() + ", alignment " + ORDER.byteAlignment());
        System.out.println("price offset " + ORDER.byteOffset(PathElement.groupElement("price")));
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment order = arena.allocate(ORDER);
            ID.set(order, 0L, 7L);
            QTY.set(order, 0L, 300);
            PRICE.set(order, 0L, 10_050);
            System.out.println("id=" + (long) ID.get(order, 0L)
                    + " qty=" + (int) QTY.get(order, 0L)
                    + " price=" + (int) PRICE.get(order, 0L));
        }
    }
}
Output
struct size 16, alignment 8
price offset 12
id=7 qty=300 price=10050

The handles are static final, so the JIT treats them as constants and compiles each access to a plain load or store with a bounds check. The casts on get matter: a VarHandle call with the wrong static types fails at run time with WrongMethodTypeException.

Example 4: arrays of structs and padding

A sequenceLayout repeats an element layout. A path with sequenceElement() adds an index coordinate to the handle. C compilers insert padding so each field sits at an offset that is a multiple of its size; the FFM API does not insert it, so a layout with misaligned fields is rejected and padding must be written with paddingLayout.

Java
import java.lang.foreign.Arena;
import java.lang.foreign.MemoryLayout;
import java.lang.foreign.MemoryLayout.PathElement;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SequenceLayout;
import java.lang.foreign.StructLayout;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.VarHandle;

public class Main {
    static final StructLayout TICK = MemoryLayout.structLayout(
            ValueLayout.JAVA_INT.withName("symbol"),
            MemoryLayout.paddingLayout(4),          // align the long to 8
            ValueLayout.JAVA_LONG.withName("price"));
    static final SequenceLayout TICKS = MemoryLayout.sequenceLayout(4, TICK);
    static final VarHandle PRICE = TICKS.varHandle(
            PathElement.sequenceElement(), PathElement.groupElement("price"));

    public static void main(String[] args) {
        System.out.println("tick size " + TICK.byteSize() + ", array size " + TICKS.byteSize());
        try {
            MemoryLayout.structLayout(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG);
        } catch (IllegalArgumentException e) {
            System.out.println("struct without padding rejected");
        }
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment ticks = arena.allocate(TICKS);
            for (long i = 0; i < 4; i++) PRICE.set(ticks, 0L, i, 100L * (i + 1));
            long total = 0;
            for (long i = 0; i < 4; i++) total += (long) PRICE.get(ticks, 0L, i);
            System.out.println("total price " + total);
        }
    }
}
Output
tick size 16, array size 64
struct without padding rejected
total price 1000

Each tick takes 16 bytes: 4 for the symbol, 4 of padding, 8 for the price. Matching a C struct requires writing the same padding the C compiler adds, which is why tools such as jextract generate layouts from header files.

Example 5: mapping a file into an arena

FileChannel.map(mode, offset, size, arena) (Java 22) returns a MemorySegment instead of a MappedByteBuffer. Closing the arena unmaps the file at once, which solves the unmapping problem of MappedByteBuffer, and the segment can be larger than 2 GB.

Java
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

public class Main {
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("ticks", ".dat");
        MemorySegment escaped;
        try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE);
             Arena arena = Arena.ofConfined()) {
            MemorySegment map = ch.map(FileChannel.MapMode.READ_WRITE, 0, 4096, arena);
            for (int i = 0; i < 8; i++) {
                map.setAtIndex(ValueLayout.JAVA_LONG, i, 1_000L + i);
            }
            map.force();
            escaped = map;
            System.out.println("mapped bytes " + map.byteSize() + ", file size " + ch.size());
        }
        try {
            escaped.get(ValueLayout.JAVA_LONG, 0);
        } catch (IllegalStateException e) {
            System.out.println("access after unmap rejected");
        }
        try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ);
             Arena arena = Arena.ofConfined()) {
            MemorySegment ro = ch.map(FileChannel.MapMode.READ_ONLY, 0, ch.size(), arena);
            long sum = 0;
            for (int i = 0; i < 8; i++) sum += ro.getAtIndex(ValueLayout.JAVA_LONG, i);
            System.out.println("sum of 8 longs " + sum + ", read-only " + ro.isReadOnly());
        }
        Files.delete(file);
    }
}
Output
mapped bytes 4096, file size 4096
access after unmap rejected
sum of 8 longs 8028, read-only true

After the arena closed, the old segment threw instead of touching unmapped memory, which with Unsafe.invokeCleaner would crash the process. Values written through one mapping are in the page cache and visible to the next mapping.

Example 6: a downcall to strlen

A downcall is a call from Java into a native function. Linker.nativeLinker() knows the platform's calling convention, defaultLookup() finds symbols in the C standard library, and a FunctionDescriptor gives the return and parameter layouts. arena.allocateFrom(String) copies a Java string into native memory as a NUL-terminated UTF-8 C string.

Calling native code is a restricted operation, because a wrong descriptor can crash the JVM. Without --enable-native-access=ALL-UNNAMED (or a module name), the JVM prints a warning to standard error on the first restricted call; the example below runs with the flag.

Java
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;

public class Main {
    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();
        MethodHandle strlen = linker.downcallHandle(
                linker.defaultLookup().find("strlen").orElseThrow(),
                FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateFrom("order-12345");
            System.out.println("segment bytes " + cString.byteSize());
            System.out.println("strlen " + (long) strlen.invokeExact(cString));
            System.out.println("back to Java: " + cString.getString(0));
        }
    }
}
Output
segment bytes 12
strlen 11
back to Java: order-12345

The segment has 12 bytes because of the terminating NUL, and strlen counted 11. invokeExact requires the exact static types of the descriptor, so the result is cast to long and the argument is a MemorySegment. A native function that returns a pointer gives back a zero-length segment; reinterpret(size) gives it a size and is also restricted.

Example 7: a realistic off-heap order book side

This program stores price levels in a struct array in native memory, sorted by price, with a binary search over the segment. No objects are created per level, so the GC does not scan or copy the data.

Java
import java.lang.foreign.Arena;
import java.lang.foreign.MemoryLayout;
import java.lang.foreign.MemoryLayout.PathElement;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SequenceLayout;
import java.lang.foreign.StructLayout;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.VarHandle;

public class Main {
    static final StructLayout LEVEL = MemoryLayout.structLayout(
            ValueLayout.JAVA_LONG.withName("price"),
            ValueLayout.JAVA_LONG.withName("qty"));
    static final int MAX = 1024;
    static final SequenceLayout LEVELS = MemoryLayout.sequenceLayout(MAX, LEVEL);
    static final VarHandle PRICE = LEVELS.varHandle(PathElement.sequenceElement(), PathElement.groupElement("price"));
    static final VarHandle QTY = LEVELS.varHandle(PathElement.sequenceElement(), PathElement.groupElement("qty"));

    final MemorySegment seg;
    int size;

    Main(Arena arena) { seg = arena.allocate(LEVELS); }

    int find(long price) {                    // binary search, returns insertion point if absent
        int lo = 0, hi = size - 1;
        while (lo <= hi) {
            int mid = (lo + hi) >>> 1;
            long p = (long) PRICE.get(seg, 0L, (long) mid);
            if (p < price) lo = mid + 1; else if (p > price) hi = mid - 1; else return mid;
        }
        return -(lo + 1);
    }

    void add(long price, long qty) {
        int i = find(price);
        if (i >= 0) {
            QTY.set(seg, 0L, (long) i, (long) QTY.get(seg, 0L, (long) i) + qty);
            return;
        }
        int at = -i - 1;
        long bytes = LEVEL.byteSize();
        MemorySegment.copy(seg, at * bytes, seg, (at + 1) * bytes, (size - at) * bytes); // shift right
        PRICE.set(seg, 0L, (long) at, price);
        QTY.set(seg, 0L, (long) at, qty);
        size++;
    }

    public static void main(String[] args) {
        try (Arena arena = Arena.ofConfined()) {
            Main book = new Main(arena);
            long[][] orders = {{10_050, 100}, {10_020, 50}, {10_080, 70}, {10_050, 25}, {10_010, 10}};
            for (long[] o : orders) book.add(o[0], o[1]);
            System.out.println("levels " + book.size + ", memory " + book.seg.byteSize() + " bytes");
            for (int i = 0; i < book.size; i++) {
                System.out.println("  " + (long) PRICE.get(book.seg, 0L, (long) i)
                        + " x " + (long) QTY.get(book.seg, 0L, (long) i));
            }
        }
    }
}
Output
levels 4, memory 16384 bytes
  10010 x 10
  10020 x 50
  10050 x 125
  10080 x 70

MemorySegment.copy handles overlapping source and target ranges, like System.arraycopy, so it can shift levels inside one segment. The whole book is one 16 KB allocation freed when the arena closes.

Comparison with Unsafe and ByteBuffer

Property sun.misc.Unsafe ByteBuffer (direct, mapped) FFM MemorySegment
Bounds checks None Yes Yes
Use after free Crash or silent corruption Not possible: freed only by GC IllegalStateException
Deterministic free freeMemory No public way Arena.close
Maximum size long int (2 GB) long
Struct layouts Manual offsets Manual offsets MemoryLayout, VarHandle paths
Native calls No (JNI needed) No Linker
Status Memory-access methods deprecated for removal (Java 23) Supported Final in Java 22

A segment can also wrap a ByteBuffer (MemorySegment.ofBuffer) and a segment can be viewed as one (asByteBuffer, if it is smaller than 2 GB), so code can move to FFM one component at a time.

Interview points

  • FFM became final in Java 22 (JEP 454); it replaces Unsafe memory access and most JNI use.
  • Arena kinds: confined (one thread, close), shared (any thread, close), auto (GC), global (never freed).
  • Every segment access checks bounds, lifetime, and thread confinement.
  • Struct layouts do not add padding; misaligned fields are rejected, and padding is explicit.
  • static final VarHandles from layout paths compile to plain loads and stores.
  • FileChannel.map(..., arena) unmaps on close, unlike MappedByteBuffer.
  • Downcalls and reinterpret are restricted; --enable-native-access removes the warning.
  • invokeExact on a downcall handle needs exact static types.

Summary

  • Arena owns memory; MemorySegment is a bounded view; ValueLayout describes one value.
  • Access after close throws IllegalStateException; access from a wrong thread throws WrongThreadException.
  • structLayout, sequenceLayout, and paddingLayout describe C-like data.
  • A mapped segment can exceed 2 GB and is unmapped when its arena closes.
  • Linker.nativeLinker().downcallHandle turns a native symbol into a MethodHandle.
  • allocateFrom(String) writes a NUL-terminated UTF-8 string.