Engineering

Project Panama: Foreign Function & Memory API

Calling native code and managing off-heap memory without JNI or C glue

Learning Objectives

By the end of this module you will be able to:

  • Explain why JNI was replaced and what specific safety and performance problems the FFM API addresses.
  • Allocate and dereference off-heap memory using MemorySegment and an appropriate Arena scope.
  • Call a native C function from Java using the Linker API without writing any C or C++ wrapper code.
  • Use jextract to generate Java bindings automatically from a C header file.
  • Describe how FFM's spatial and temporal safety guarantees work, and where they stop.
  • Compare the FFM model to Rust's extern "C" / unsafe FFI and .NET's P/Invoke.

Core Concepts

The problem with JNI

Java has always needed a way to call native code. The Java Native Interface (JNI), introduced in Java 1.1, answered that question — but at a steep cost.

  • JNI requires you to write C or C++ glue code alongside your Java code: one .h header per Java class, one .c file implementing each native method, compiled into a platform-specific shared library.
  • The glue layer is error-prone and opaque to the JIT compiler; the JVM cannot reason across the native boundary, which blocks many optimizations.
  • Memory unsafety bugs (buffer overflows, use-after-free) in native glue code could silently corrupt the JVM heap or trigger hard crashes with no Java stack trace.

Project Panama's Foreign Function & Memory API was explicitly designed and positioned as a comprehensive replacement for JNI, eliminating the need for C/C++ glue code while providing stronger safety guarantees. JEP 454 was finalized in Java 22 (March 2024), after progressing through multiple preview iterations since Java 19.

MemorySegment — the unit of memory

MemorySegment is the central abstraction. It models a contiguous region of memory — either on the Java heap (backed by a Java array or ByteBuffer) or off the heap in native memory outside JVM management. Every MemorySegment carries three pieces of information:

FieldMeaning
AddressWhere the region starts in memory
Size (bytes)How large the region is
Arena / SessionWhich lifecycle scope owns this region

This triple enables two of FFM's core safety properties, discussed in the safety section below.

Arena — deterministic lifecycle management

Arena controls when native memory is allocated and freed. Unlike GC-managed objects, Arena provides deterministic deallocation: memory is freed when the Arena is closed, not whenever the garbage collector decides to run. This gives predictable latency characteristics important for performance-sensitive code.

The FFM API ships with two primary Arena implementations:

Arena typeHow to createThread accessDeallocation trigger
ConfinedArena.ofConfined()Single owning thread onlyExplicit close() call (or try-with-resources)
AutomaticArena.ofAuto()Any threadGarbage collection

Arena.ofConfined() is the right choice in most low-latency paths: it is AutoCloseable, so a try-with-resources block makes the memory lifetime as lexically explicit as a Rust scope. Arena.ofAuto() trades control for convenience in multithreaded contexts.

Arena implementations also satisfy the SegmentAllocator interface, providing a unified, type-safe allocation pattern across both heap and native memory.

Linker and FunctionDescriptor — calling native functions

The Linker bridges Java calling code and native functions. The call sequence has three steps:

  1. Describe the signature. A FunctionDescriptor describes a C function's parameter and return types using Java memory layout objects. This provides the ABI-compatible type mapping that JNI required you to encode in mangled method names and signature strings.
  2. Look up the symbol. The Linker resolves a function name in a loaded native library and returns a MemorySegment pointing to that function's address.
  3. Create a downcall handle. Linker.downcallHandle(address, descriptor) returns a MethodHandle that, when invoked, performs the native call.

The entire foreign-function interface is expressed in pure JavaMemorySegment, Linker, FunctionDescriptor — with no C/C++ intermediate code required.

MethodHandle as the invocation vehicle

FFM expresses foreign function invocations as MethodHandle objects — first-class Java constructs that the JIT compiler can analyze and optimize inline. This is a structural improvement over JNI: JNI native stubs are opaque to the compiler, so the JVM cannot cross the boundary to perform constant folding, inlining, or escape analysis. The MethodHandle path is transparent to the same optimization pipeline used for all Java code.

Benchmarks show FFM approximately 12% faster than JNI on call paths (49.7 ns/op vs 56.6 ns/op), directly attributable to this JIT integration.

jextract — mechanical binding generation

For large C APIs, hand-crafting FunctionDescriptor and memory layout objects for every function is tedious. jextract reads C header files (.h) and generates complete Java binding classes that handle symbol lookups, function descriptors, and memory layout definitions. The result is a typed Java API surface over the native library, without any manual boilerplate.

jextract is a separate download from the JDK but is part of the Project Panama umbrella.

Spatial and temporal safety

FFM provides two specific safety properties that JNI lacked entirely.

Spatial safety — every MemorySegment carries its size. Every dereference is bounds-checked. Attempting to read or write beyond the segment's known size raises IndexOutOfBoundsException, not silent heap corruption or a native segfault that crashes the JVM with no Java stack trace.

Temporal safety — every MemorySegment is tied to an Arena scope. When the Arena is closed, all segments it owns become "dead." Any subsequent access to a dead segment raises IllegalStateException, preventing use-after-free bugs that were silent and catastrophic under JNI.

Safety limits

These guarantees apply to memory that Java allocated through the FFM API. If you call a native function that internally allocates memory (e.g., malloc in a C library) and returns a raw pointer, FFM cannot know the bounds or lifetime of that externally-managed region — it only wraps the pointer in a MemorySegment of unknown size. Bounds checking applies only to what FFM can observe.


Step-by-Step Procedure

The steps to call a C function from Java with the FFM API are:

1. Load the native library

System.loadLibrary("mylib"); // or System.load("/path/to/libmylib.so")

2. Get the platform Linker and a symbol lookup

Linker linker = Linker.nativeLinker();
SymbolLookup lookup = SymbolLookup.loaderLookup()
        .or(linker.defaultLookup());

3. Describe the C function signature with FunctionDescriptor

For a C function int add(int a, int b):

FunctionDescriptor descriptor = FunctionDescriptor.of(
        ValueLayout.JAVA_INT,   // return type
        ValueLayout.JAVA_INT,   // first parameter
        ValueLayout.JAVA_INT    // second parameter
);

For a void function, use FunctionDescriptor.ofVoid(...).

4. Resolve the symbol and create a downcall handle

MethodHandle addHandle = linker.downcallHandle(
        lookup.find("add").orElseThrow(),
        descriptor
);

5. Invoke the handle

int result = (int) addHandle.invoke(3, 4); // returns 7

Decision point — when to use confined vs automatic Arena:

  • Use Arena.ofConfined() inside a try-with-resources block when you control the memory lifetime and want deterministic deallocation (typical for request-scoped or transaction-scoped buffers).
  • Use Arena.ofAuto() when segments must outlive the creating thread or be shared across threads, and GC-based reclamation is acceptable.

6. Allocate off-heap memory for pointer arguments

When a C function expects a pointer to a struct or buffer:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment buffer = arena.allocate(ValueLayout.JAVA_LONG);
    buffer.set(ValueLayout.JAVA_LONG, 0, 42L);
    someHandle.invoke(buffer);
} // buffer memory freed here

7. (Optional) Use jextract to skip steps 2–4

jextract --output src/generated -t com.example.bindings mylib.h

The generated class provides static methods that encapsulate all the above boilerplate, leaving only the business-logic call in your Java code.


Worked Example

Calling strlen from the C standard library

strlen has the signature size_t strlen(const char *s). On 64-bit systems, size_t maps to a 64-bit unsigned integer, which we approximate with ValueLayout.JAVA_LONG.

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

public class StrlenDemo {
    public static void main(String[] args) throws Throwable {

        // 1. Acquire the platform Linker
        Linker linker = Linker.nativeLinker();

        // 2. Look up strlen in the C standard library (always present in defaultLookup)
        MemorySegment strlenAddr = linker.defaultLookup()
                .find("strlen")
                .orElseThrow(() -> new RuntimeException("strlen not found"));

        // 3. Describe the signature: long strlen(MemorySegment pointer)
        FunctionDescriptor descriptor = FunctionDescriptor.of(
                ValueLayout.JAVA_LONG,
                ValueLayout.ADDRESS     // const char* — a pointer
        );

        // 4. Link the downcall handle
        MethodHandle strlen = linker.downcallHandle(strlenAddr, descriptor);

        // 5. Allocate a native string in a confined arena
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateFrom("hello, panama");
            long len = (long) strlen.invoke(cString);
            System.out.println("Length: " + len); // 13
        } // native memory freed here

    }
}

Key points to observe:

  • No C file was written, compiled, or linked.
  • The try-with-resources block makes the memory lifetime explicit at the syntax level — analogous to a Rust lexical borrow scope.
  • If you added , 0) to the cString.get(...) call after the arena closes, you would get IllegalStateException immediately — temporal safety in action.

Compare & Contrast

FFM vs JNI

DimensionJNIFFM API
C/C++ glue code requiredYes — one .c file per native methodNo — pure Java
Binding type safetyManual signature strings ("(I)V")Typed FunctionDescriptor
Memory bounds checkingNoneYes — IndexOutOfBoundsException
Use-after-free detectionNoneYes — IllegalStateException on dead segment
JIT visibilityOpaque native stubTransparent MethodHandle
Call overheadBaseline~12% faster than JNI
Toolingjavah (deprecated)jextract

FFM vs Rust FFI

Engineers coming from Rust will find strong conceptual parallels.

In Rust, you declare an extern "C" block and call functions inside an unsafe block. The type system enforces that you cannot forget the unsafe marker. In Java FFM, the analogous boundary is the JVM module system: FFM requires --enable-native-access at startup, which serves as an explicit opt-in to the unsafe perimeter.
DimensionRust FFIJava FFM
Memory region type*mut T / raw slicesMemorySegment
Lifetime scopingRust borrow checker (compile-time)Arena close (runtime)
Out-of-bounds accessUndefined behaviour (no runtime check by default)IndexOutOfBoundsException (always checked)
Use-after-freePrevented at compile time by borrow checkerIllegalStateException at runtime
Binding generationbindgen (from C headers)jextract (from C headers)
Opt-in markerunsafe {} block--enable-native-access JVM flag

The key difference is when safety is enforced: Rust's borrow checker eliminates temporal errors at compile time, while FFM catches them at runtime. For engineers used to Rust, this is a trade-off: you lose compile-time proof, but you gain interoperability with the broader JVM ecosystem and don't need to reason about lifetime annotations.

FFM vs .NET P/Invoke

.NET's Platform Invoke (P/Invoke) shares the same goal — calling native DLL functions from managed code — and also performs automatic argument marshaling across the managed/unmanaged boundary. Like FFM's MethodHandle downcall path, P/Invoke inserts "thunk" sequences at each crossing that incur overhead proportional to call frequency and argument complexity. FFM's design explicitly targets these crossing costs with JIT-transparent MethodHandle invocations, and the benchmark results (12% faster than JNI) suggest the approach is effective.

A practical difference: P/Invoke relies heavily on declarative attributes ([DllImport(...)]) with the runtime inferring marshal layouts, while FFM requires you to be explicit about layouts via ValueLayout and MemoryLayout — more verbose, but less likely to produce silent ABI mismatches.

FFM vs Python ctypes / cffi

Python's ctypes and cffi serve the same interop niche for Python code. Both use runtime-only type information (Python type objects or C declarations parsed at startup) with no compile-time checking and no JIT optimization. FFM is closer to cffi than ctypes in spirit — cffi also parses C declarations to derive ABI-compatible layouts, analogous to how jextract processes headers into typed Java descriptors. However, cffi still carries the overhead of a CPython interpreter boundary at every call, while FFM's MethodHandle path can be inlined by the JVM JIT.


Boundary Conditions

1. Externally-allocated memory loses size information. When a native function returns a malloc-allocated pointer, the FFM API wraps it in a zero-size MemorySegment by default. You must call reinterpret(size) to attach a size, at which point spatial safety depends entirely on your own knowledge of the native allocation — FFM cannot verify it.

2. Struct layout must match the platform ABI. FunctionDescriptor and MemoryLayout must exactly mirror the C struct layout, including alignment padding. The FFM API does not parse C header files at runtime; it requires you (or jextract) to pre-compute the layout. An ABI mismatch produces silent data corruption inside the native boundary, similar to JNI.

3. --enable-native-access is mandatory. Since Java 22, using FFM without explicitly allowing native access on the module in question causes a warning; in future versions it will be an error. This is by design — it is the "unsafe perimeter" marker. In library code, document this requirement clearly for your consumers.

4. Arena.ofConfined() is single-threaded by design. Accessing a confined segment from any thread other than the creating thread throws WrongThreadException. If you need to pass MemorySegment objects across thread boundaries, either use Arena.ofAuto() or use shared-arena semantics, accepting that deallocation becomes non-deterministic.

5. Callbacks (upcalls) add complexity. The above walkthrough covers downcalls only. If the C library expects a function pointer callback back into Java, you need an upcall stub via Linker.upcallStub(). Upcalls are more expensive and require the callback's MemorySegment to stay alive for as long as the native side holds the pointer.

6. jextract bindings are regenerated per JDK version. jextract output is tied to the Java version it was run with. After a Java upgrade, regenerate bindings before assuming ABI compatibility.

Key Takeaways

  1. FFM replaces JNI entirely The complete native interop workflow (memory allocation, function calls) is expressed in pure Java, removing the C/C++ glue code layer and the class of maintenance problems it created.
  2. MemorySegment + Arena replaces malloc/free MemorySegment is the typed handle to a memory region; Arena is its lifecycle owner. Arena.ofConfined() inside a try-with-resources block gives you Rust-scope-like deterministic deallocation.
  3. Spatial and temporal safety are runtime, not compile-time Out-of-bounds access raises IndexOutOfBoundsException; use-after-free raises IllegalStateException. These guarantees only cover memory FFM itself allocated — externally allocated pointers require explicit size annotation.
  4. Downcall handles are MethodHandles, which the JIT can optimize This is the structural reason FFM outperforms JNI on call frequency, and why FFM is a better fit for tight loops calling native code.
  5. jextract is the practical entry point for large C APIs For anything beyond a handful of functions, let jextract generate the descriptor boilerplate from the C headers and focus your code on the business logic.

Further Exploration

Official Documentation

Tutorials & Articles

Real-World & Advanced