Contents

Contents

Welcome to Panama

Welcome to Panama featured image

JEP-454 introduced the Foreign Function & Memory (FFM) API as a stable feature. Since JDK 22, developers can leverage this API without additional configuration or "hassle." In this article, I will examine how the FFM API simplifies working with native libraries and enhances performance.

Environment

The complete source code used for this article is available on GitHub. Although the code is written in Java, I used Scala-CLI to run it for its simplicity. Benchmarks were performed on an Intel(R) Xeon(R) @ 2.20GHz system with 4 vCPUs (2 cores), 16GB RAM, running Debian GNU/Linux 12 (bookworm) and the OpenJDK Runtime Environment Temurin-25.0.1+8 (build 25.0.1+8-LTS).

Example

To demonstrate these benefits, I will use the pow function from the libm.so library. To provide a clear comparison, we will first look at how this function is invoked using the traditional JNI API.

JNI

To invoke a native function using the JNI API, we must follow several manual steps.

First, we define a Java method using the native keyword. It serves as a placeholder for the native implementation:


public static native double calculate(double base, double exponent);

While this example uses a static method, it is not strictly required. However, choosing between a static and a non-static method changes the parameters passed to the native function (more on that in a moment).

Next, we use the Java compiler to generate a C/C++ header file. This file acts as the "bridge" between Java and C:


javac -h native/ src/main/java/methods/JNIPowerCalculator.java

This command instructs the Java Compiler to scan JNIPowerCalculator.java for native methods and output a header file in the native/ directory. For our function, the generated declaration looks like this:


JNIEXPORT jdouble JNICALL Java_methods_JNIPowerCalculator_calculate
  (JNIEnv *, jclass, jdouble, jdouble);
  • JNIEXPORT - a macro that ensures the function is visible by the JVM.
  • jdouble - the JNI-mapped return type (equivalent to Java’s double).
  • JNICALL - a macro that ensures the correct calling convention (this varies between operating systems, such as Windows vs. Linux).
  • Java_methods_JNIPowerCalculator_calculate is a method name. It has to follow a strict naming convention:

a) Java_

b) package_

c) class_

d) method name

  • JNIEnv * - a pointer to the JNI environment, used to interact with the JVM (e.g., creating Java objects or throwing exceptions).
  • jclass - a reference to the class containing the method. If this were a non-static method, this parameter would be a jobject (a reference to the specific instance).
  • At the end we have two jdouble arguments which are function arguments.

Now we provide the actual C implementation. In this case, it is a simple delegation to the standard math library:


JNIEXPORT jdouble JNICALL Java_methods_JNIPowerCalculator_calculate
  (JNIEnv *env, jclass clazz, jdouble base, jdouble exponent) {
    return pow(base, exponent);
}

The final step is to compile the C code into a shared library that the JVM can load:


gcc -shared -fPIC \
 -I"$JAVA_HOME/include" \
 -I"$JAVA_HOME/include/linux" \
 native/PowerCalculator.c -o lib/libnative-power-calculator.so -lm
  • -shared - creates a shared library (.so) instead of an executable.
  • -fPIC - generates Position Independent Code, which is required for shared libraries on Linux to ensure they can be loaded at any memory address without conflicts.
  • -I - tells the compiler where to find the necessary header files.
  • -o - specifies the output path and filename.
  • -lm - links the standard math library (m) so we can use the pow function.

As you can see, calling even a simple native function requires significant "boilerplate" code and external tooling. You cannot call the native function directly. Instead, you must write and compile a wrapper that matches JNI's strict naming and parameter requirements.

Now, let’s take a look at how FFM API has changed the landscape.

Project Panama

With the Foreign Function & Memory (FFM) API, we can call the same pow function entirely from Java without writing a single line of C "glue" code. The process starts by obtaining a MethodHandle. Example code may look as follows:


public class PanamaPowerCalculator {
    private static final MethodHandle METHOD_HANDLE;

    static {
        (1) Linker linker = Linker.nativeLinker();
        Map<String, MemoryLayout> layouts = (3) linker.canonicalLayouts();
        (5) MemorySegment symbol = linker.defaultLookup().find("pow").get();
        (2) ValueLayout cDouble = (ValueLayout) layouts.get("double");
        (4) FunctionDescriptor fd = FunctionDescriptor.of(cDouble, cDouble, cDouble);
        (6) METHOD_HANDLE = linker.downcallHandle(symbol, fd);
    }
    ...
}
  1. Linker - provides access to foreign functions.
  2. ValueLayout - represents size and alignment of basic data types, e.g., Integer, Long, ….
  3. canonicalLayouts - provides standard layouts of C data types for the underlying operating system and processor. This is important because it may vary between operating systems e.g.,: on Windows long usually has size of 32 bits, while on Linux 64 bits.
  4. FunctionDescriptor - represents the signature of foreign function. The first argument is the returned type layout and two consecutive are arguments layouts.
  5. MemorySegment - provides access to contiguous regions of memory - in our case to the pow function.
  6. MethodHandle - reference to a directly executable foreign function.

In the example above, we are performing a downcall (calling a native function from Java). It is important to note that the FFM API also supports the reverse: upcalls.

Using an upcallStub, you can pass a Java method handle to native code as a function pointer, allowing C code to "call back" into your Java application. However, in this article, I will cover just the downcall case.

Once your MethodHandle is initialized for a downcall, you can execute it using either the invoke or invokeExact method.

The invoke method is lenient. If the arguments you pass don't match the FunctionDescriptor exactly, the JVM will attempt to perform automatic type conversions before the native call.


public static double calculateInvokeWithConversion(int base, int exponent) {
   try {
      return (double) METHOD_HANDLE.invoke(base, exponent);
   } catch (Throwable t) {
      throw new RuntimeException(t);
   }
}

The invokeExact method requires the types to match the FunctionDescriptor perfectly. If you pass an inaccurate argument, it will immediately throw a WrongMethodTypeException.


public static double calculateInvokeExact(double base, double exponent) {
   try {
      return (double) METHOD_HANDLE.invokeExact(base, exponent);
   } catch (Throwable t) {
      throw new RuntimeException(t);
   }
}

This FFM approach is significantly cleaner and more maintainable than JNI. It removed the need for C headers, external compilers, and "glue" code, keeping the entire logic within the Java ecosystem.

But can we do even better? In the next section, we will look at how to automate this entire process using jextract.

Jextract

Writing MethodHandle and FunctionDescriptor logic by hand is manageable for a single function like pow, but it becomes tedious and error-prone for larger libraries. This is where jextract comes in.

The jextract tool mechanically generates Java bindings from native library headers. It’s worth noting that while it was originally developed within the OpenJDK, it is now maintained as a separate tool to allow it to evolve independently of the main JDK release cycle.

To use it, you need the jextract binary. The following command generates a Java wrapper for the pow function:


jextract --output src/main/java \
-t math \
/usr/include/math.h \
--header-class-name NativeMath \
--include-function pow
  • --output - the destination directory for the generated source files.
  • -t - the target Java package name.
  • /usr/include/math.h - the header file to be parsed.
  • --header-class-name - the name of the Java class that will host the native methods.
  • --include-function - filters the generation process. Without this, jextract would attempt to generate bindings for every single function in math.h.

This command typically generates two primary files:

  • NativeMath.java - provides the high-level Java methods you call directly, acting as the primary interface for the native library.
  • NativeMath$shared.java - contains the background "plumbing", such as memory layouts, that tells the JVM how to handle the native data types.

Now that we have explored JNI, manual Panama, and jextract-generated Panama, let's compare their performance to see if the "boilerplate" actually impacts speed.

Comparison

To evaluate the efficiency of these approaches, I conducted a JMH benchmark comparing five different implementations:

  • JNI API: The traditional native bridge.
  • Panama invokeExact.
  • Panama invoke (With Conversion): Passing int arguments to a double signature.
  • Panama invoke (Matching Types): Passing double arguments directly
  • jextract: Using the auto-generated bindings.

Benchmark results:


Benchmark                                                                              Mode  Cnt   Score   Error  Units
NativeMethodCallBenchmark.benchmark_JNI_Power_Calculator                               avgt   15  34.659 ? 0.198  ns/op
NativeMethodCallBenchmark.benchmark_Panama_Invoke_Exact_Power_Calculator               avgt   15  29.541 ? 0.222  ns/op
NativeMethodCallBenchmark.benchmark_Panama_Invoke_With_Conversion_Power_Calculator     avgt   15  34.310 ? 0.312  ns/op
NativeMethodCallBenchmark.benchmark_Panama_Invoke_Without_Conversion_Power_Calculator  avgt   15  29.554 ? 0.138  ns/op
NativeMethodCallBenchmark.benchmark_Panama_J_Extract_Power_Calculator                  avgt   15  29.413 ? 0.067  ns/op

The results show that invokeExact, invoke (with matching types), and jextract are the top performers, essentially tied at ~29.5 ns. The other options are roughly 5ns (17%) slower.

However, this 29.5 ns includes the time taken by the actual pow calculation in libm.so. To see the true overhead of the Java-to-native transition, I ran the benchmark with the perfasm profiler:


scala-cli . --jmh --power -- -f 1 -prof "perfasm:events=cpu-clock;intel=true"

Even though results were a bit different due to profiler overhead, the characteristic was the same:


Benchmark                                                                                  Mode  Cnt   Score    Error  Units
NativeMethodCallBenchmark.benchmark_JNI_Power_Calculator                                   avgt    5  35.050 ?  3.193  ns/op
NativeMethodCallBenchmark.benchmark_Panama_Invoke_Exact_Power_Calculator                   avgt    5  30.336 ?  7.101  ns/op
NativeMethodCallBenchmark.benchmark_Panama_Invoke_With_Conversion_Power_Calculator         avgt    5  36.807 ? 12.709  ns/op
NativeMethodCallBenchmark.benchmark_Panama_Invoke_Without_Conversion_Power_Calculator      avgt    5  29.660 ?  0.335  ns/op
NativeMethodCallBenchmark.benchmark_Panama_J_Extract_Power_Calculator                      avgt    5  30.168 ?  6.217  ns/op

The profiler output revealed that the actual math operation in libm.so takes approximately 17 ns. If we subtract this "work time" from our totals:

  • Panama Overhead: ~12.5 ns
  • JNI/Conversion Overhead: ~17.5 ns

In this light, the fastest Panama solutions outperform the slower approaches by roughly 30% in pure call overhead.

Of course, this is just for such a simple example. If our native library performed different kinds of tasks, this gain would be slightly higher or lower depending on the ratio of "work" to "call overhead". Nevertheless, let's try to find out why some solutions are faster than others.

Performance analysis

To make debugging with jhsdb easier, I added a Main class to the project. While code compiled by the JIT in a standard application isn't always identical to JMH's highly optimized code, it provides a clear window into how the JVM handles these calls.

For the three fastest solutions (invokeExact, invoke - without conversion, and jextract), the generated assembly looks very similar:


0x00007fa5dfef0166:    movabs $0x7fa5f7a682b0,%rsi
...
0x00007fa5dfef0178:    movabs $0x7157a5328,%rdx
0x00007fa5dfef0182:    nop
0x00007fa5dfef0183:    call   0x00007fa5dfede980
  • rsi register holds the memory address of the pow function.
  • rdx register holds the NativeEntryPoint address.
  • NativeEntryPoint holds the reference to nep_invoker_blob which is a specialized stub that handles the transition into the native function with minimal instruction overhead.

For JNI, the JVM generates a native_wrapper. This acts as the bridge between the JVM and the foreign function, similar to the nep_invoker_blob, but significantly less optimized. Furthermore, our custom C wrapper adds its own layer of overhead that cannot be optimized away by the JIT.

When using invoke with type conversion, the path to reach the native function is much longer. Even though it eventually calls the nep_invoker_blob, the JIT compiler cannot perform the same aggressive optimizations because it must build logic to transform the data types before they reach the native boundary.

For those curious about the low-level details, I have uploaded the JIT-compiled assembly for both JNI and Panama to the GitHub repository.

Conclusion

The Foreign Function & Memory (FFM) API significantly simplifies how we can interact with native libraries. As our benchmarks demonstrate, this simplicity comes with a performance boost. By allowing the JIT compiler to optimize the call path directly, the FFM API reduces call overhead by roughly 30% (as demonstrated) compared to traditional JNI.

Whether you use jextract for automation or MethodHandle for manual control, the result is a faster, safer, and more maintainable way to leverage native code in modern Java.

I hope you find this article useful.

Reviewed by Paweł Stawicki

Blog Comments powered by Disqus.