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:
- Linker โ connects Java to native code.
- SymbolLookup โ finds the address of a native function.
- 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:
| Method | Time per Query |
|---|---|
| Java-to-C | 22.7 ns |
| Pure Java | 2.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:
| Method | Time per Query |
|---|---|
| Java-to-C | 22.7 ns |
| Java-to-C (critical) | 19.5 ns |
| Pure Java | 2.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