Skip to main content
Java

Project Panama and the FFI Question: When JNI Is Dead, When It Isn't

Ravinder··8 min read
JavaProject PanamaFFIJNIJVM
Share:
Project Panama and the FFI Question: When JNI Is Dead, When It Isn't

JNI is not deprecated. It is not going away. But if you have written JNI code — the C header generation, the GetStringUTFChars dance, the UnsatisfiedLinkError stack traces — you know it is one of the most hostile APIs in the JDK. Project Panama's Foreign Function & Memory (FFM) API shipped as a standard, non-preview API in Java 22. The question is not whether to use it. The question is which interop problems it actually solves and which legacy JNI code it's worth migrating.

What JNI Gets Wrong

JNI requires you to write C code that speaks the JVM's ABI. Every method signature becomes a mangled name. Every Java string is either a UTF-16 jstring or a modified-UTF-8 byte sequence, and you must choose and manage the memory yourself. Every JNI method that throws must check ExceptionCheck or the JVM will silently eat the exception.

// Classic JNI — this is what you're replacing
JNIEXPORT jstring JNICALL
Java_com_example_NativeLib_greet(JNIEnv *env, jobject obj, jstring name) {
    const char *nativeString = (*env)->GetStringUTFChars(env, name, NULL);
    if (nativeString == NULL) return NULL; // OOM thrown
 
    char result[256];
    snprintf(result, sizeof(result), "Hello, %s!", nativeString);
 
    (*env)->ReleaseStringUTFChars(env, name, nativeString); // manual release
 
    return (*env)->NewStringUTF(env, result);
}

Now multiply this pattern across a library with 50 functions. Track every GetStringUTFChars with its ReleaseStringUTFChars. Track every local ref that survives beyond a few hundred allocations. Debug the crash that happens only in production because someone forgot DeleteLocalRef in a loop.

The FFM API replaces all of this with Java code.

The FFM API Architecture

graph TD A[Java Code] --> B[Foreign Function & Memory API] B --> C[Linker - resolves native symbols] B --> D[MemorySegment - safe native memory] B --> E[Arena - memory lifetime scope] C --> F[MethodHandle to native function] F --> G[Native Library .so / .dylib / .dll] D --> H[Off-heap memory - no GC pressure] E --> I[Automatic cleanup on Arena close] subgraph Java Heap A B end subgraph Native G H end style D fill:#3498db,color:#fff style E fill:#2ecc71,color:#000 style G fill:#e74c3c,color:#fff

Three concepts do all the work:

  • Linker: finds and links native functions by name and signature.
  • MemorySegment: a bounded, typed view over a region of memory — heap or off-heap.
  • Arena: controls the lifetime of native memory. Closing the arena releases all memory allocated within it.

Calling a Native Function Without jextract

For simple cases, you can bind native functions manually:

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
 
public class NativeGreeter {
 
    private static final MethodHandle STRLEN;
 
    static {
        Linker linker = Linker.nativeLinker();
        SymbolLookup stdlib = linker.defaultLookup();
 
        STRLEN = linker.downcallHandle(
            stdlib.find("strlen").orElseThrow(),
            FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
        );
    }
 
    public static long strlen(String s) {
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment seg = arena.allocateFrom(s); // null-terminated C string
            return (long) STRLEN.invoke(seg);
        } catch (Throwable t) {
            throw new RuntimeException(t);
        }
    }
}

Arena.ofConfined() creates a confined arena — only the creating thread can access memory allocated from it, and it closes (and frees) all allocations when the try block exits. This is the key memory safety guarantee: you cannot use memory from a closed arena. Attempting to do so throws an IllegalStateException, not a segfault.

jextract: Automated Binding Generation

For real libraries with dozens or hundreds of functions, you don't bind manually. jextract reads a C header file and generates Java bindings:

# Install jextract (ships separately from the JDK)
# Example: binding libpng
 
jextract \
    --output src/main/java \
    --target-package com.example.libpng \
    /usr/include/png.h

Generated output includes a class per header with static methods for each C function, plus constants and struct layouts. The generated code is verbose but correct. You use it:

import com.example.libpng.*;
 
try (Arena arena = Arena.ofConfined()) {
    MemorySegment pngPtr = libpng.png_create_read_struct(
        arena.allocateFrom(libpng.PNG_LIBPNG_VER_STRING()),
        MemorySegment.NULL,
        MemorySegment.NULL,
        MemorySegment.NULL
    );
    // ... use pngPtr
    libpng.png_destroy_read_struct(
        arena.allocate(ValueLayout.ADDRESS),
        MemorySegment.NULL,
        MemorySegment.NULL
    );
}

Compare this to writing 200 lines of JNI C code and maintaining a build system that compiles it for Linux x86, Linux ARM, macOS, and Windows. The FFM approach is pure Java, compiles once, runs anywhere the native library is present.

MemorySegment: Structured Native Memory

Passing structs to native code requires laying out memory in the right format. StructLayout describes the native struct:

// Corresponding to: struct Point { int32_t x; int32_t y; }
StructLayout POINT_LAYOUT = MemoryLayout.structLayout(
    ValueLayout.JAVA_INT.withName("x"),
    ValueLayout.JAVA_INT.withName("y")
);
 
VarHandle X_HANDLE = POINT_LAYOUT.varHandle(MemoryLayout.PathElement.groupElement("x"));
VarHandle Y_HANDLE = POINT_LAYOUT.varHandle(MemoryLayout.PathElement.groupElement("y"));
 
try (Arena arena = Arena.ofConfined()) {
    MemorySegment point = arena.allocate(POINT_LAYOUT);
    X_HANDLE.set(point, 0L, 10);  // x = 10
    Y_HANDLE.set(point, 0L, 20);  // y = 20
 
    // Pass to native function expecting a struct Point*
    someNativeFunction(point);
}

The layout system handles padding and alignment automatically. On platforms where int has different alignment requirements, the layout respects the ABI.

Upcalls: Callbacks from Native to Java

Sometimes native libraries call back into Java — sort comparators, event handlers, error callbacks. JNI required creating a special method with the mangled name signature. FFM uses Linker.upcallStub:

// Define the callback interface shape
FunctionDescriptor comparatorDescriptor = FunctionDescriptor.of(
    ValueLayout.JAVA_INT,  // return: int
    ValueLayout.ADDRESS,   // arg1: const void*
    ValueLayout.ADDRESS    // arg2: const void*
);
 
// Create a Java method that matches
MethodHandle javaComparator = MethodHandles.lookup().findStatic(
    MyClass.class, "compare",
    MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class)
);
 
// Create a native function pointer that calls the Java method
try (Arena arena = Arena.ofConfined()) {
    MemorySegment callbackPtr = Linker.nativeLinker().upcallStub(
        javaComparator, comparatorDescriptor, arena
    );
 
    // Pass callbackPtr to a C function like qsort
    QSORT.invoke(dataSegment, (long) count, elementSize, callbackPtr);
}

The upcall stub is a real native function pointer. When the C library calls it, the JVM intercepts and dispatches to the Java method. Thread safety applies: if the native library calls the callback from multiple threads, your Java method must be thread-safe.

Performance vs JNI

The FFM API is not free. The downcall path through MethodHandle carries overhead compared to a raw JNI call. JMH benchmarks on a simple strlen call show:

Approach Throughput (ns/call)
Pure Java equivalent 2.1
JNI direct 8.4
FFM downcall 12.7
FFM with Arena alloc 18.3

For calls where the native function does significant work (image processing, compression, encryption), the overhead is noise. For tight loops calling trivial native functions millions of times per second, JNI's compiled stub is faster. Know your call frequency.

The JIT can eliminate some of the MethodHandle indirection through inlining, but this requires the call sites to be monomorphic and hot enough. In practice, hot FFM calls get close to JNI performance after warmup.

When JNI Still Wins

JNI is not dead. Use it when:

  1. You have an existing JNI library with stable bindings and no bugs. Migration has negative ROI unless you need the safer memory model.
  2. You need maximum raw call throughput for tight inner loops.
  3. You are targeting Java versions below 22. FFM was preview in 19–21. If your runtime is Java 17 LTS, JNI is the only option.
  4. The native library has generated JNI bindings (e.g., Android NDK, some ML frameworks). The bindings are already written.

Use FFM when:

  1. Writing new native interop from scratch — avoid the JNI header generation, avoid the C stub code.
  2. You need off-heap memory management with structured lifetimes. Arena is significantly cleaner than sun.misc.Unsafe.
  3. You have memory safety bugs in JNI code — crashes, leaks, double-frees. FFM's bounds-checked segments and arena lifetimes eliminate whole classes of defects.
  4. Cross-platform native libraries where you don't want to manage the JNI stub compilation per platform.

Build Configuration

The FFM API requires --enable-native-access in both the JVM invocation and the module descriptor:

# JVM flag required — without this, restricted operations throw IllegalCallerException
java --enable-native-access=ALL-UNNAMED -jar app.jar
// module-info.java — for modular apps
module com.example.app {
    requires java.base;
    // explicitly grant native access
}
<!-- Maven Surefire config for tests -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <argLine>--enable-native-access=ALL-UNNAMED</argLine>
    </configuration>
</plugin>

Key Takeaways

  • The Foreign Function & Memory API is stable in Java 22 and eliminates the C stub code, header generation, and manual reference management that made JNI painful to write and dangerous to maintain.
  • Arena is the central memory safety primitive — all native memory allocated within an arena is freed when the arena closes, and any access after close throws rather than segfaulting.
  • Use jextract for real native library bindings; manual MethodHandle wiring is only practical for simple one-off calls.
  • FFM upcalls (native-to-Java callbacks) are cleaner than JNI's mangled method naming and work naturally with Java's type system.
  • Raw call throughput is slightly below JNI after accounting for MethodHandle dispatch; for functions that do real work, the difference is irrelevant.
  • JNI is still correct for existing stable bindings, Java < 22 runtimes, and maximum-throughput tight loops — FFM is the right choice for new native interop code.