Java 22 Foreign Function & Memory API Explained | JNI Alternative Faster & Safer?

A New Way to Call C from Java: How Fast Is It?

No matter which programming language you prefer, calling C functions is often unavoidable. Operating systems, high-performance libraries, compression engines, cryptography toolkits, and specialized data structures are frequently written in C. If you are working in Java and need to tap into that ecosystem, you must cross the boundary between managed and native code.

For decades, Java developers had only one standard tool for that job: JNI (Java Native Interface). JNI is powerfulโ€”but it has a reputation. It is verbose, complex, easy to misuse, and unforgiving when you make mistakes. Memory management errors can crash the JVM. Debugging can be painful. Many developers avoided it unless absolutely necessary.

That situation has changed.

Since Java 22, we have a modern alternative: the Foreign Function & Memory (FFM) API, available in java.lang.foreign. It replaces much of JNIโ€™s ceremony with a safer, more expressive, and more maintainable model.

But the big question remains:

Is it fast enough?

Letโ€™s walk through how it worksโ€”and then measure its performance.


The Core Idea

At a high level, calling a C function with the FFM API involves three main components:

  1. Linker โ€“ connects Java to native code.
  2. SymbolLookup โ€“ finds the address of a native function.
  3. MethodHandle โ€“ a callable representation of that function in Java.

Letโ€™s go step by step.


Step 1: Getting a Linker

The linker is simple to obtain:

Linker linker = Linker.nativeLinker();

This object understands the platformโ€™s calling conventions and knows how to bridge Java and native code.


Step 2: Loading the Native Library

Before calling a C function, the JVM must load the shared library.

System.loadLibrary("mylibrary");
SymbolLookup lookup = SymbolLookup.loaderLookup();

The native library must be available on your java.library.path, or in a default system library location. If necessary, you can specify it explicitly:

java -Djava.library.path=target -cp target/classes MyApp

There are alternative lookup methods such as SymbolLookup.libraryLookup, but System.loadLibrary works well for most use cases.


Step 3: Finding the Function

Once the library is loaded, you can find a function symbol:

MemorySegment mem = lookup.find("myfunction").orElseThrow();

This gives you a MemorySegment representing the address of the C function.


Step 4: Creating a Callable MethodHandle

Now you create a MethodHandle using linker.downcallHandle().

You must describe the functionโ€™s signature using a FunctionDescriptor.

For example, if the function returns a long and accepts a pointer:

MethodHandle myfunc = linker.downcallHandle(
    mem,
    FunctionDescriptor.of(
        ValueLayout.JAVA_LONG,
        ValueLayout.ADDRESS
    )
);

If the function returns nothing:

FunctionDescriptor.ofVoid(...)

You can now invoke it like this:

long result = (long) myfunc.invokeExact(parameters);

It behaves much like a normal Java functionโ€”though it returns Object, so casting is usually required.


Automating the Boilerplate with jextract

Manually defining function descriptors and layouts can be tedious.

Thankfully, Java provides jextract, a tool that generates Java bindings directly from C header files.

Instead of writing descriptors manually, you can:

  • Point jextract at a header file
  • Generate Java classes
  • Call native functions almost like regular Java methods

This dramatically improves productivity for larger native libraries.


Managing Native Memory with Arenas

Memory management is where JNI historically became dangerous.

The FFM API introduces Arena, which provides structured lifetime management.

Example:

try (Arena arena = Arena.ofConfined()) {
    // allocate and use memory
}

When the block exits, memory is automatically released.

There are multiple arena types:

  • Confined โ€“ single-threaded, manually managed
  • Shared โ€“ multi-thread accessible
  • Global / Automatic โ€“ GC-managed

This design prevents many classes of memory leaks and use-after-free bugs.


Allocating C Structures from Java

Suppose we define a C-like structure:

MemoryLayout mystruct = MemoryLayout.structLayout(
    ValueLayout.JAVA_LONG.withName("age"),
    ValueLayout.JAVA_INT.withName("friends")
);

You can allocate it like this:

MemorySegment myseg = arena.allocate(mystruct);

This segment can be passed directly to C as a pointer.

This approach makes native memory explicit, safe, and structured.


The Real Question: Is It Fast?

All of this sounds goodโ€”but performance matters.

To measure overhead, I conducted a benchmark using a C library implementing binary fuse filters, which are fast probabilistic data structures similar to Bloom filters.

I created:

  • A pure Java implementation
  • A Java wrapper calling the C implementation via FFM

Then I benchmarked lookups on a filter containing one million keys.

Important note:

Even if native calls had zero overhead, pure Java might still win because:

  • Small Java methods can be inlined
  • The JIT can perform constant folding
  • The JVM can optimize aggressively

Native calls generally cannot be inlined.

So this benchmark slightly overestimates overheadโ€”but thatโ€™s fine. We want a realistic upper bound.


Benchmark Results

Here are the results on a MacBook with an M4 processor:

MethodTime per Query
Java-to-C22.7 ns
Pure Java2.5 ns

That means the overhead of crossing the Javaโ€“C boundary is roughly:

~20 nanoseconds per call

In throughput terms:

  • Java-to-C: ~44 million calls per second
  • Pure Java: ~400 million calls per second

In this specific case, pure Java is dramatically faster because the algorithm is extremely lightweight and benefits from JIT inlining.

But 20 ns is not outrageous.

For native calls that perform meaningful workโ€”compression, encryption, parsingโ€”that overhead may be negligible.


Optimizing with Critical Calls

The FFM API allows you to mark functions as critical, reducing some safety checks:

MethodHandle handle = linker.downcallHandle(
    mem,
    descriptor,
    Linker.Option.critical(false)
);

In my benchmark, this reduced cost by about 15%.

Updated results:

MethodTime per Query
Java-to-C22.7 ns
Java-to-C (critical)19.5 ns
Pure Java2.5 ns

So critical mode saves a few nanoseconds.

Again, whether that matters depends on workload.


How Does It Compare to JNI?

I did not benchmark JNI directly in this experiment.

However, published comparisons suggest the FFM API can be measurably faster than JNI, sometimes by as much as 50%.

More importantly, it is:

  • Safer
  • Cleaner
  • Easier to maintain
  • Less error-prone

The ergonomics improvement alone is significant.


Passing Java Heap Arrays to C (Without Copies)

One of the most impressive features of the new API is the ability to pass Java heap arrays directly to native code.

Consider this C function:

int sum_array(int* data, int count) {
    int sum = 0;
    for(int i = 0; i < count; i++) {
        sum += data[i];
    }
    return sum;
}

Now suppose we have:

int[] javaArray = {10, 20, 30, 40, 50};

Calling it from Java:

System.loadLibrary("sum");

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

MemorySegment sumAddress = lookup.find("sum_array").orElseThrow();

MethodHandle sumArray = linker.downcallHandle(
    sumAddress,
    FunctionDescriptor.of(
        ValueLayout.JAVA_INT,
        ValueLayout.ADDRESS,
        ValueLayout.JAVA_INT
    ),
    Linker.Option.critical(true)
);

int[] javaArray = {10, 20, 30, 40, 50};

try (Arena arena = Arena.ofConfined()) {
    MemorySegment heapSegment = MemorySegment.ofArray(javaArray);
    int result = (int) sumArray.invoke(heapSegment, javaArray.length);
    System.out.println("The sum from C is: " + result);
}

No manual copying. No unsafe pointer tricks.

Just structured memory access.


Practical Takeaways

Hereโ€™s what we learn from this exploration:

1. The API Is Modern and Structured

No raw pointer gymnastics. No manual memory cleanup mistakes. Clear ownership rules.

2. The Overhead Is Real but Predictable

Expect roughly 20 ns per call in simple scenarios.

3. Pure Java Still Wins for Tiny Functions

If your logic is small and JIT-friendly, stay in Java.

4. Native Code Makes Sense for Heavy Work

Compression, cryptography, large batch operationsโ€”these easily amortize the call overhead.

5. Developer Experience Is Dramatically Better Than JNI

The code is cleaner, safer, and easier to reason about.


Final Thoughts

The Foreign Function & Memory API represents one of the most important JVM evolutions in years.

JNI was powerful but intimidating. The new API makes native interop:

  • Safer
  • More expressive
  • Often faster
  • Far more maintainable

Yes, crossing the Javaโ€“C boundary costs around 20 nanoseconds per call in microbenchmarks.

But in real systems, that overhead is often negligible compared to the work done inside native code.

For the first time in Javaโ€™s history, calling C feels like a modern, first-class capabilityโ€”not an escape hatch.

And thatโ€™s a big deal.

Post Comment