Part 13 of 16

Foreign Function & Memory API (JEP 442): Calling Native Code Without JNI

Preview Feature in Java 21 — Finalized in Java 22 (JEP 454). The API shown here is the Java 21 preview version; it is nearly identical to the final API.

Why Replace JNI?

Java Native Interface (JNI) has been the standard way to call native code since Java 1.1. It works but is notoriously painful:

  • Requires writing C wrapper code for every native call
  • Native method signatures must exactly match Java declarations (or you get silent crashes)
  • Memory management is manual — leak native memory once and you have a slow memory leak
  • JNI calls disable JIT optimizations around the call site
  • Debugging native crashes through JNI is extremely difficult

The Foreign Function & Memory (FFM) API replaces all of this: call native functions directly from Java, manage native memory safely with automatic lifetime management, and do it without writing a single line of C.


Core Concepts

flowchart TD
    subgraph FFM["Foreign Function & Memory API"]
        Linker["Linker\nBridge between Java and native ABI"]
        SL["SymbolLookup\nFind native functions by name"]
        FD["FunctionDescriptor\nDescribe C function signature in Java types"]
        MH["MethodHandle\nInvoke the native function"]
        Arena["Arena\nAllocate and manage native memory lifetime"]
        MS["MemorySegment\nSafe reference to native memory region"]
    end

    Linker --> SL & FD & MH
    Arena --> MS

Setting Up

No additional dependencies — FFM is part of the JDK. Enable preview in Java 21:

<!-- pom.xml -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <release>21</release>
        <compilerArgs><arg>--enable-preview</arg></compilerArgs>
    </configuration>
</plugin>

Also add --enable-native-access=ALL-UNNAMED to JVM args to suppress the native access warning:

java --enable-preview --enable-native-access=ALL-UNNAMED MyApp

Calling a C Standard Library Function

Example 1: strlen

C signature: size_t strlen(const char *s);

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

public class StrlenExample {
    public static void main(String[] args) throws Throwable {
        // 1. Get the native linker for this platform
        Linker linker = Linker.nativeLinker();

        // 2. Look up the symbol in libc
        SymbolLookup stdlib = linker.defaultLookup();
        MemorySegment strlenAddr = stdlib.find("strlen")
            .orElseThrow(() -> new RuntimeException("strlen not found"));

        // 3. Describe the C function signature
        //    size_t strlen(const char *s)
        //    → returns JAVA_LONG, takes ADDRESS
        FunctionDescriptor descriptor = FunctionDescriptor.of(
            ValueLayout.JAVA_LONG,   // return type: size_t (64-bit)
            ValueLayout.ADDRESS      // parameter: const char*
        );

        // 4. Create a MethodHandle — the callable bridge
        MethodHandle strlen = linker.downcallHandle(strlenAddr, descriptor);

        // 5. Allocate native memory and call the function
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateUtf8String("Hello, FFM!");
            long length = (long) strlen.invoke(cString);
            System.out.println("strlen = " + length);  // 11
        }
        // Arena closed — native memory freed automatically
    }
}

Example 2: printf

C signature: int printf(const char *format, ...); (variadic)

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

MemorySegment printfAddr = stdlib.find("printf").orElseThrow();

// Describe concrete call (non-variadic portion)
FunctionDescriptor descriptor = FunctionDescriptor.of(
    ValueLayout.JAVA_INT,   // int return
    ValueLayout.ADDRESS,    // const char* format
    ValueLayout.JAVA_INT    // int argument
);

MethodHandle printf = linker.downcallHandle(printfAddr, descriptor);

try (Arena arena = Arena.ofConfined()) {
    MemorySegment format = arena.allocateUtf8String("Value: %d\n");
    printf.invoke(format, 42);  // prints: Value: 42
}

Memory Management with Arena

Arena controls the lifetime of native memory allocations. When the arena closes, all memory allocated through it is freed — no leaks, even if an exception is thrown.

Arena Types

// Confined arena — thread-restricted, deterministic close
try (Arena arena = Arena.ofConfined()) {
    MemorySegment seg = arena.allocate(1024);
    // Only the creating thread can access seg
    // Freed when try block exits
}

// Shared arena — accessible from multiple threads
try (Arena arena = Arena.ofShared()) {
    MemorySegment seg = arena.allocate(1024);
    // Any thread can read/write seg
    // Freed when try block exits
}

// Auto arena — GC-managed lifetime (no explicit close)
Arena arena = Arena.ofAuto();
MemorySegment seg = arena.allocate(1024);
// Freed when arena object is GC'd (not deterministic)

// Global arena — lives for the JVM lifetime
MemorySegment seg = Arena.global().allocate(1024);
// Never freed — use for truly permanent native structures

MemorySegment — Working with Native Memory

Reading and Writing Primitives

try (Arena arena = Arena.ofConfined()) {
    // Allocate 16 bytes
    MemorySegment segment = arena.allocate(16);

    // Write values at specific byte offsets
    segment.set(ValueLayout.JAVA_INT,  0, 42);      // write int at offset 0
    segment.set(ValueLayout.JAVA_LONG, 4, 1000L);   // write long at offset 4
    segment.set(ValueLayout.JAVA_DOUBLE, 12, 3.14); // write double at offset 12

    // Read them back
    int    i = segment.get(ValueLayout.JAVA_INT,    0);
    long   l = segment.get(ValueLayout.JAVA_LONG,   4);
    double d = segment.get(ValueLayout.JAVA_DOUBLE, 12);

    System.out.printf("int=%d, long=%d, double=%.2f%n", i, l, d);
}

Working with Arrays

try (Arena arena = Arena.ofConfined()) {
    // Allocate a native int array of 10 elements
    MemorySegment intArray = arena.allocateArray(ValueLayout.JAVA_INT, 10);

    // Write elements
    for (int i = 0; i < 10; i++) {
        intArray.setAtIndex(ValueLayout.JAVA_INT, i, i * i);
    }

    // Read elements
    for (int i = 0; i < 10; i++) {
        System.out.print(intArray.getAtIndex(ValueLayout.JAVA_INT, i) + " ");
    }
    // 0 1 4 9 16 25 36 49 64 81
}

Copying Between Java and Native Memory

try (Arena arena = Arena.ofConfined()) {
    byte[] javaBytes = "Hello, native!".getBytes(StandardCharsets.UTF_8);

    // Copy Java array to native memory
    MemorySegment nativeBuffer = arena.allocate(javaBytes.length);
    MemorySegment.copy(javaBytes, 0, nativeBuffer, ValueLayout.JAVA_BYTE, 0, javaBytes.length);

    // Copy native memory back to Java array
    byte[] result = new byte[javaBytes.length];
    MemorySegment.copy(nativeBuffer, ValueLayout.JAVA_BYTE, 0, result, 0, result.length);

    System.out.println(new String(result));  // Hello, native!
}

Mapping C Structs

Use MemoryLayout to describe C struct layouts:

// C struct: struct Point { int x; int y; };
StructLayout pointLayout = MemoryLayout.structLayout(
    ValueLayout.JAVA_INT.withName("x"),
    ValueLayout.JAVA_INT.withName("y")
);

// VarHandles for field access
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);  // set x = 10
    yHandle.set(point, 0L, 20);  // set y = 20

    int x = (int) xHandle.get(point, 0L);
    int y = (int) yHandle.get(point, 0L);
    System.out.printf("Point(%d, %d)%n", x, y);  // Point(10, 20)
}

Loading a Native Library

// Load a custom shared library (e.g., libmylib.so on Linux, mylib.dll on Windows)
SymbolLookup myLib = SymbolLookup.libraryLookup("mylib", Arena.ofAuto());

// Or load by full path
SymbolLookup myLib = SymbolLookup.libraryLookup(Path.of("/usr/local/lib/libmylib.so"), Arena.ofAuto());

// Find and call a function from the library
MemorySegment computeAddr = myLib.find("compute")
    .orElseThrow(() -> new RuntimeException("compute not found in mylib"));

FunctionDescriptor desc = FunctionDescriptor.of(
    ValueLayout.JAVA_DOUBLE,
    ValueLayout.JAVA_DOUBLE,
    ValueLayout.JAVA_INT
);

MethodHandle compute = Linker.nativeLinker().downcallHandle(computeAddr, desc);

try (Arena arena = Arena.ofConfined()) {
    double result = (double) compute.invoke(3.14, 2);
    System.out.println("Result: " + result);
}

Upcalls — Calling Java from Native Code

FFM supports upcalls: pass a Java method as a C function pointer to a native function:

// Define a Java method that matches the C callback signature
// C: typedef int (*comparator)(const void* a, const void* b);
MethodHandle javaComparator = MethodHandles.lookup().findStatic(
    MyClass.class, "compareInts",
    MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class)
);

// Wrap as a native function pointer
try (Arena arena = Arena.ofConfined()) {
    FunctionDescriptor callbackDesc = FunctionDescriptor.of(
        ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS
    );

    MemorySegment callbackPointer = Linker.nativeLinker()
        .upcallStub(javaComparator, callbackDesc, arena);

    // Pass callbackPointer to qsort or any native function that takes a callback
}

// The Java callback method
static int compareInts(MemorySegment a, MemorySegment b) {
    int ia = a.get(ValueLayout.JAVA_INT, 0);
    int ib = b.get(ValueLayout.JAVA_INT, 0);
    return Integer.compare(ia, ib);
}

FFM vs JNI Comparison

AspectJNIFFM API
C wrapper codeRequiredNot required
Type safetyRuntime crashes on mismatchDescriptor checked at call time
Memory lifetimeManual free()Arena-scoped, automatic
PerformanceJIT-inhibited around callsFull JIT optimization
DebuggingCrash dumps, hard to traceNormal Java stack traces
Platform native libRequiredNot required
Learning curveVery high (C + JNI conventions)High (Java only)

Key Takeaways

  • Linker.nativeLinker() provides the bridge to the platform’s C ABI
  • SymbolLookup finds native functions by name in the standard library or a loaded library
  • FunctionDescriptor describes the C function signature using Java ValueLayout types
  • Linker.downcallHandle() creates a MethodHandle that invokes the native function
  • Arena manages native memory lifetime — always use try-with-resources for confined/shared arenas
  • MemorySegment is a safe, bounds-checked reference to a native memory region
  • Upcalls let you pass Java methods as native function pointers (callbacks)
  • Finalized in Java 22 — Java 21 preview is nearly identical to the final API

Next: Vector API (JEP 448) — express data-parallel SIMD computations in Java that the JIT compiles to hardware vector instructions.