10 Java Foreign Function and Memory API Patterns for Fast, Safe Native Code Without JNI

Master Java's Foreign Function and Memory API with 10 practical patterns for native calls, off-heap memory, and performance—no JNI required.

10 Java Foreign Function and Memory API Patterns for Fast, Safe Native Code Without JNI

I still remember the first time I called a C function from Java without writing a single line of C. No javac -h, no compiling a shared library with gcc, no System.loadLibrary ceremony. Just pure Java talking straight to native code. That moment changed how I thought about the boundary between the JVM and the operating system.

For twenty years, the Java Native Interface was the only real door into native land. It worked, but it demanded a toll: you wrote C glue code, you compiled it for every platform, and one wrong pointer could crash your whole process with no stack trace. The Foreign Function and Memory API, finished in Java 22, closed that door and opened a cleaner one.

I want to walk you through ten patterns I use constantly. Each one solves a real problem, and I’ll show you the code. Nothing here is theoretical. I’ve used all of it in production systems that process gigabytes of data outside the garbage-collected heap.

Before we get into the patterns, you need to know four names. A MemorySegment is a slice of memory. Think of it like a byte array, except it might live outside Java’s heap. An Arena controls the lifetime of memory segments. When the arena closes, its segments die. The Linker connects Java to native functions. A MemoryLayout describes how bytes are arranged so Java and C agree on what they’re looking at.

import java.lang.foreign.*;

try (Arena arena = Arena.ofConfined()) {
    MemorySegment segment = arena.allocate(100);
    segment.set(ValueLayout.JAVA_INT, 0, 42);
    int value = segment.get(ValueLayout.JAVA_INT, 0);
    System.out.println(value); // 42
}

That’s it. One hundred bytes, off the heap, freed automatically when the try block ends.

Memory is the first problem, so let’s start there.

Most of the time I want memory that belongs to one thread and dies when I’m done with it. That’s Arena.ofConfined(). It’s the cheapest option, and it refuses to let other threads touch its segments. If a different thread tries, you get an exception instead of a corrupted heap.

try (Arena arena = Arena.ofConfined()) {
    MemorySegment buffer = arena.allocate(1024);
    // only this thread can touch buffer
    buffer.setString(0, "hello off-heap");
    System.out.println(buffer.getString(0));
}

The pattern here matters more than the syntax. The arena is the parent, the segment is the child, and when the parent dies, the children die. No free(), no dangling pointers. The compiler and the runtime enforce this with a liveness check on every access.

There’s a cost. Every access to a confined segment goes through a thread check. It’s small, but in a tight loop processing millions of elements, you notice it. When that overhead matters, I capture the segment’s base address once and work with raw offsets.

try (Arena arena = Arena.ofConfined()) {
    MemorySegment buffer = arena.allocate(ValueLayout.JAVA_LONG, 1_000_000);
    for (long i = 0; i < 1_000_000; i++) {
        buffer.setAtIndex(ValueLayout.JAVA_LONG, i, i * 2);
    }
}

This loop writes eight megabytes without ever touching the heap. No GC pressure, no promotion of long-lived arrays. The memory is returned to the OS the instant the arena closes.

The second pattern is for when you can’t keep everything on one thread. Arena.ofShared() lets multiple threads read and write the same memory. That sounds dangerous, and it is if you’re careless. But it’s also exactly what you need for a producer-consumer pipeline where one thread reads from a socket and another writes to a file.

Arena sharedArena = Arena.ofShared();
MemorySegment shared = sharedArena.allocate(4096);

Thread writer = new Thread(() -> {
    shared.set(ValueLayout.JAVA_INT, 0, 999);
});

Thread reader = new Thread(() -> {
    System.out.println(shared.get(ValueLayout.JAVA_INT, 0));
});

writer.start();
reader.start();
writer.join();
reader.join();
sharedArena.close();

Here’s what you have to understand. The API will not save you from race conditions. The arena only guarantees that the memory stays alive while both threads are using it. If you need ordering guarantees, you add them yourself with VarHandle acquire and release fences.

The third pattern is the laziest one, and I mean that as a compliment. Arena.ofAuto() ties memory lifetime to the garbage collector. You never close it. You just let go, and the collector cleans up when it runs.

MemorySegment auto = Arena.ofAuto().allocate(512);
auto.set(ValueLayout.JAVA_DOUBLE, 0, 3.14159);
// no close needed

I use this for small, short-lived allocations where the cleanup cost of a confined arena isn’t worth the code. The tradeoff is timing. The memory isn’t freed when you stop using it; it’s freed when the GC decides to run. If you’re allocating hundreds of megabytes, that delay can hurt.

There’s a fourth arena type called Arena.ofGlobal() that never frees its memory. I mention it only to warn you away. It’s a leak by design.

Now we get to the fun part. Calling C functions.

The first step is finding the function. SymbolLookup does that job. On most Unix systems, you can load symbols from the standard C library directly.

Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();

MemorySegment strlenAddr = stdlib.find("strlen").orElseThrow();

MethodHandle strlen = linker.downcallHandle(
    strlenAddr,
    FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);

try (Arena arena = Arena.ofConfined()) {
    MemorySegment cString = arena.allocateFrom("hello");
    long length = (long) strlen.invoke(cString);
    System.out.println(length); // 5
}

Read that again slowly. I found strlen in the C library, described its signature, and called it. There is no C file. There is no build step. When C wants a size_t, I say JAVA_LONG. When C wants a const char *, I say ADDRESS.

The descriptor is a contract. If you get it wrong, you get a crash, not a friendly exception. The signature is your responsibility, and the API trusts you completely.

I always wrap native calls in small Java methods. That way the MethodHandle lives in one place, and the rest of my code never thinks about descriptors.

public final class CLib {
    private static final Linker LINKER = Linker.nativeLinker();
    private static final MethodHandle STRLEN = LINKER.downcallHandle(
        LINKER.defaultLookup().find("strlen").orElseThrow(),
        FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
    );

    public static long strlen(MemorySegment s) throws Throwable {
        return (long) STRLEN.invoke(s);
    }
}

Structs are where layouts earn their keep. C and Java have to agree on where each field sits in memory, and MemoryLayout is the contract.

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

VarHandle xHandle = pointLayout.varHandle(
    MemoryLayout.PathElement.groupElement("x"));
VarHandle yHandle = pointLayout.varHandle(
    MemoryLayout.PathElement.groupElement("y"));

try (Arena arena = Arena.ofConfined()) {
    MemorySegment point = arena.allocate(pointLayout);
    xHandle.set(point, 0L, 10);
    yHandle.set(point, 0L, 20);
    System.out.println(xHandle.get(point, 0L)); // 10
}

The varHandle is the magic. It knows the offset of x and y inside the struct, and it applies that offset for you. Without it, you’d be counting bytes by hand, and you’d get it wrong.

Padding is the silent killer here. C compilers insert padding to align fields. Java’s structLayout does the same thing by default, but if you turn on withByteAlignment or mix types carelessly, your layout can drift from what C expects. When that happens, you read garbage from a field that looks fine.

Arrays inside structs use MemoryLayout.sequenceLayout. If C declares int values[8], you write MemoryLayout.sequenceLayout(8, ValueLayout.JAVA_INT). The math is the same, and you can index into it with setAtIndex.

Strings cross the boundary in two directions. Going out, allocateFrom handles the null terminator and encoding.

MemorySegment name = arena.allocateFrom("Java", StandardCharsets.UTF_8);

Coming back, getString(0) reads until it hits a null byte. The catch is that this only works for null-terminated strings. If you have a C buffer with a known length but no terminator, you use getString(0, length). And if the string is UTF-8 and the native library expects Latin-1, you’ll get mojibake. I’ve spent an embarrassing amount of time tracking down encoding bugs that looked like memory corruption.

Byte arrays are simpler. You copy them with a single call.

byte[] data = {1, 2, 3, 4, 5};
MemorySegment heapArray = MemorySegment.ofArray(data);
MemorySegment offHeap = arena.allocate(data.length);
MemorySegment.copy(heapArray, 0, offHeap, 0, data.length);

That copy is one direction of a very common pattern: read bytes off a socket into a heap array, then copy them off-heap for processing so the GC ignores them.

Callbacks are the reverse direction. Java gives C a function pointer, and C calls back into Java. This is where upcallStub comes in.

interface Comparator {
    int compare(MemorySegment a, MemorySegment b);
}

MethodHandle target = MethodHandles.lookup().findVirtual(
    Comparator.class, "compare",
    MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class)
).bindTo(myComparator);

MemorySegment callback = linker.upcallStub(
    target,
    FunctionDescriptor.of(ValueLayout.JAVA_INT,
        ValueLayout.ADDRESS, ValueLayout.ADDRESS),
    arena
);

// pass callback to a C function like qsort

There’s a huge source of bugs hiding here. The Java object behind the callback must stay alive for as long as C holds the pointer. If the GC collects it, the next call from C jumps into freed memory. The arena helps, but only if the arena outlives the native code using the callback. I’ve seen this crash in production at 3 a.m., and it’s never fun.

Now let’s talk performance, because this is why many of us came.

The question I get asked most is simple: when is off-heap faster than a Java array? The honest answer is: when you’d otherwise copy data. A heap array is fast to read and write. The problem is getting data into it from the outside world. Sockets, files, and native libraries all hand you bytes in their own buffer. With a heap array, you copy. With a MemorySegment, you point.

// Heap-to-heap: one copy
byte[] heap = new byte[8192];
inputStream.read(heap);

// Off-heap: read directly into the segment
try (Arena arena = Arena.ofConfined()) {
    MemorySegment direct = arena.allocate(8192);
    // native read writes straight into direct memory
}

The second version skips a copy. For a single buffer, that’s nothing. For ten thousand buffers a second, that’s real time and real GC pressure.

Memory-mapped files are the sharpest tool in the box for large datasets.

try (FileChannel channel = FileChannel.open(Path.of("big.bin"), READ);
     Arena arena = Arena.ofConfined()) {
    MemorySegment mapped = channel.map(
        FileChannel.MapMode.READ_ONLY, 0, channel.size(), arena);
    long firstLong = mapped.get(ValueLayout.JAVA_LONG, 0);
}

I’ve used this to process four-gigabyte files on a laptop. The operating system pages the data in as I touch it. The JVM heap stays flat. No OutOfMemoryError, no stop-the-world pause from a giant array.

When I need to crunch that data fast, I combine it with the Vector API. The layouts and the vector species line up because both work in terms of element counts.

var species = IntVector.SPECIES_256;
for (long i = 0; i < mapped.byteSize(); i += species.vectorByteSize()) {
    IntVector v = IntVector.fromMemorySegment(
        species, mapped, i, ByteOrder.nativeOrder());
    IntVector result = v.mul(2);
    result.intoMemorySegment(mapped, i, ByteOrder.nativeOrder());
}

That loop doubles every integer in a memory-mapped file, eight at a time, using SIMD instructions. On the heap, the same code would still work, but the incoming data would have to be copied first.

Three pitfalls eat people alive here.

Alignment is the first. Some CPUs hate unaligned access, and some native libraries silently misbehave. I use withByteAlignment on my layouts when the native side demands it, and I test with exact offsets.

Endianness is the second. Java defaults to big-endian when you don’t say otherwise, but nearly every x86 and ARM machine is little-endian. If you forget ByteOrder.nativeOrder(), your integers come back byte-swapped, and they look like random noise.

Memory ordering is the third. Shared segments are not volatile. If two threads are communicating through them, you need explicit acquire and release fences via VarHandle, or you get stale reads that pass tests and fail in production.

When things go wrong, you need tools. Java Flight Recorder has events for foreign memory access. I turn them on before I write a line of tricky native code.

java -XX:StartFlightRecording:filename=ffm.jfr,settings=profile MyApp

Then I open the recording in JDK Mission Control and filter for jdk.ForeignMemory. It shows me every allocation, every access, and every free. The first time I used this, I found a segment I’d forgotten to close in a warmup path. The leak was invisible for days.

Segmentation faults are the other disaster. When native code writes past the end of a segment, the JVM dies with a core dump or an hs_err_pid file. The stack trace in that file points at the native frame. I read the crashing address, subtract the segment’s base address, and I know exactly how many bytes over the line I went. Ninety percent of the time, it’s an off-by-one in a length calculation.

You can also test native calls without building a C library. Most of what you need lives in libc already.

@Test
void strlenMatchesJavaLength() throws Throwable {
    try (Arena arena = Arena.ofConfined()) {
        MemorySegment s = arena.allocateFrom("testing");
        assertEquals(7, CLib.strlen(s));
    }
}

For libraries that aren’t in libc, I write a tiny C file with one function, compile it with a single gcc command in a test setup script, and load it in a JUnit extension. No Maven plugin, no code generation, no rebuilding the JVM.

Wrapping all of this in a clean Java API is the final pattern I’d put in any serious codebase. The goal is that no caller ever sees a MemorySegment or a MethodHandle. They see a normal Java object that happens to be backed by native code.

public final class NativeAdder implements AutoCloseable {
    private final Arena arena;

    public NativeAdder() throws Throwable {
        this.arena = Arena.ofConfined();
        MethodHandle h = LINKER.downcallHandle(
            LINKER.defaultLookup().find("add").orElseThrow(),
            FunctionDescriptor.of(ValueLayout.JAVA_INT,
                ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
        this.add = h;
    }

    public int add(int a, int b) throws Throwable {
        return (int) this.add.invoke(a, b);
    }

    @Override
    public void close() {
        arena.close();
    }
}

The AutoCloseable interface is the key. Callers use try-with-resources, and the arena’s lifetime tracks the object’s lifetime. Native allocations are scoped to a normal Java block, which is the same mental model everyone already has for files and sockets.

If you’ve written JNI, you know the old pain. You wrote a C header, implemented a stub with awkward names like Java_com_example_Foo_bar, and shipped a shared library for every platform. A typo in a function name failed at runtime, not compile time. The Foreign Function and Memory API removes all of that. There’s no glue code, no name mangling, and no separate build step. Signatures are checked when you build the descriptor, and lifetimes are enforced by the runtime. The performance is comparable to JNI for calls, and often better for bulk data because you can skip copies entirely.

Several libraries already sit on top of this API. Netty is moving its buffer code toward memory segments. The JDK’s own file and socket internals use it. Database drivers and machine-learning runtimes are adopting it because they need to pass tensors and pages to native code without copying.

So when should you reach for off-heap memory instead of a Java array? Three cases, in my experience.

First, when data comes from or goes to a native source and you want zero copies. Second, when you’re working with datasets larger than what you want sitting in the heap, because heap memory has to be managed by the collector and off-heap memory doesn’t. Third, when you have latency-sensitive work and can’t afford a GC pause interrupting it.

For everything else, use a normal array. Heap memory is faster to allocate, easier to debug, and handled by the best memory manager most of us will ever use. The Foreign Function and Memory API is a sharp tool, and sharp tools deserve respect.

Ten patterns, one idea: the boundary between Java and native code no longer needs a translator. You describe what you want, the runtime enforces the rules, and the memory goes away when you say so. That’s a better Java than the one I started with, and I’m glad I get to write it.


// Keep Reading

Similar Articles