Introduction to Project Panama (Part I): Loading a Native Library

Project Panama is Java's modern alternative for calling native code or “Foreign Functions” implemented in other languages such as C or C++. While previously we had Java Native Interface (JNI) for achieving the same goal by writing glue code (C files translating types between Java and C) to connect the dots between the JVM and shared so or dll libraries, now Project Panama helps us by achieving that all in Java :)
The glue code part managing translation and memory can now be written and managed in Java itself, thanks to the new shiny Foreign Function and Memory API. Additionally, you have great tools like jextract in your arsenal to do the heavy lifting of generating the Java code for you.
Here is a quick comparison between Project Panama and Java Native Interface:
| Feature | Project Panama (FFM API) | JNI |
|---|---|---|
| Efforts | no need to write C "glue" code. Everything is pure Java. You can also use jextract to generate the Java code to access native code. | Need to write and compile C wrappers for JNI. |
| Performance | FFM enables JIT to inline the native call using MethodHandles. MemorySegments allow Java to read and write to native memory directly. | JNI needs to treat the native code like a black box and needs a “bridge” to switch to the proper environment to execute the native code. JNI often requires copying data between environments. |
| Safety | Includes "Arenas" to manage memory which can be managed using java try-resource and prevent common crashes like "use-after-free." | A small pointer mistake in C code crashes the whole program. |
Note: All codes in this part can be found here.
Loading a Native Library
To access any native library, we need a way to lookup for native functions or their `Symbol`s. In simpler terms: load a shared object or compiled library from a “.so” file and try to find functions that we are interested to natively call in that. We basically described what dynamic linking is. Let’s write our very simple native dynamic linking class:
package rs.reza.pub.loading_native_library;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.SymbolLookup;
import java.lang.invoke.MethodHandle;
public class LibraryLookup {
private final Linker linker = Linker.nativeLinker();
private final SymbolLookup libCSymbolLookup;
public LibraryLookup(Arena lookupArena, String sharedLibraryPath) {
this.libCSymbolLookup = SymbolLookup.libraryLookup(sharedLibraryPath, lookupArena);
}
public MethodHandle findDownCallHandle(String functionName, FunctionDescriptor descriptor) {
// more on this later...
}
}
The purpose of our LibraryLookup class is to achieve dynamic linking. This class relies on the JVM’s linker for the Platform that Virtual Machine is running via the Linker::nativeLinker().
The Linker retrieved this way knows how to communicate bits and bytes between JVM (our world) and the underlying OS and CPU architecture (where native library is compiled for) using the Application Binary Interface (ABI) specific to that platform.
It helps us to establish two important concept:
- Downcalls: Bind Java code to native function (what we will explore in Part II)
- Upcalls: Creating a native pointer to a java method which can enable native code to call a Java method (which we will explore in Part III)
The first step with dynamically loading a library is to access it via SymbolLookup::libraryLookup. This method expects a path to the natively compiled shared library and an Arena to hold the Symbol lookup table.
Arena
An Arena represents an interface to off-heap native memory that has a lifetime. Arena manages a portion of memory and safe-guards it in terms of Thread Safety ( e.g. Confined Arena vs. Shared Arena) and Memory Clean-up (e.g. deallocating memory after try-with-resource or freeing up memory with the Arena object itself gets garbage collected).
It is simpler to think of Arena as a safe memory container, and you access parts of this container via MemorySegment. In order to work with actual memory via MemorySegments you need to ask an Arena for allocating memory depending on your byte size requirements or MemoryLayout definition most of the time.
Here is a summary of available Arena types:
| Type | API | Features | Use Case |
|---|---|---|---|
| Global | Arena::global |
The memory region is never deallocated and sticks around for the entire life of the JVM process. Can be accessed by multiple Threads. Any attempt to call Arena::close would result in an UnsupportedOperationException |
Constants, Immutable lookup tables |
| Automatic | Arena::ofAuto |
This Arena is managed by the GC. Meaning when the Arena object is deemed unreachable (from any other object) will be garbage collected and deallocated.Still calling Arena::close would result in an UnsupportedOperationException |
Small, short-lived allocations |
| Confined | Arena::ofConfined |
The memory region stays around and is not deallocated until Arena::close is called on it. The Thread that created the Confined Arena (owner thread) is only allowed to access the memory segments (any other thread attempting to do so will face an exception). |
Local (to owner thread) buffer management or memory assignment. |
| Shared | Arena::ofShared |
The memory segments backed by this Arena can be shared between different threads and any thread can call the Arena::close and it will be safe and atomic. |
Shared buffers or data structures accessed by multiple concurrent threads. |
Back to our code, The lookupArena manages the lifecycle of the dynamically loaded library in the JVM’s memory. While the Arena is open, the SymbolLookup can resolve function names into native memory addresses. However, because these addresses (represented as MemorySegment objects) are bound to that specific arena, closing the lookupArena triggers the immediate unloading of the library.
Using Arena to Load Symbol Library
To use an Arena, one best practice is to initialize that within a try-resource-block so it is deallocated right at the end of the block using it.
from Main.java:
try (var arena = Arena.ofConfined()) {
var libraryPath = applicationArgs.libraryPath().get();
// …
var libCSymbolLookup = new LibraryLookup(arena, libraryPath);
// …
exitAfter(applicationArgs.sleep().get());
}
*For simple demonstration of loading a shared library I opted to use the Arena::ofConfined so that the off-heap memory is deallocated when the application exits.
Downcall of Void
Next in line, after loading a shared library, is looking for specific Symbols in it, to be called from Java. This is called making a “down call” or in other words calling native function from Java.
Here we will present how we can look for a native function that takes no argument and returns no value (void).
Let’s add a utility function in LibraryLookup.java which will help to find a calling function given its name and using a FunctionDescriptor which defines its signature:
package rs.reza.pub.loading_native_library;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.SymbolLookup;
import java.lang.invoke.MethodHandle;
public class LibraryLookup {
private final Linker linker = Linker.nativeLinker();
private final SymbolLookup libCSymbolLookup;
//…
public MethodHandle findDownCallHandle(String functionName, FunctionDescriptor descriptor) {
return linker.downcallHandle(
libCSymbolLookup.findOrThrow(functionName),
descriptor
);
}
}
What is happening is that we try to return a MethodHandle to get a reference to the native function symbol that we try to find in the loaded shared by the help from the Linker.
When we retrieve the MethodHandle we can then treat it like any other MethodHandle in Java Reflection and for us that means we can execute the function using the MethodHandle::invoke.
FunctionDescriptor is the type where we use to describe the "signature" of the method we are interested in. For example to find a C function with following signature:
void hello(void) {//…}
To describe this function signature, We would use FunctionDescriptor::ofVoid to initialize a FunctionDescriptor for a function that returns void as well as passing no argument to that to signal that function takes no argument either.
Here is how it can be done (from Main.java:
try (var arena = Arena.ofConfined()) {
// …
var downCallHandle = libCSymbolLookup.findDownCallHandle(
methodName,
FunctionDescriptor.ofVoid()
);
// …
downCallHandle.invoke();
// …
}
If successful, we got our hands on a MethodHandle on that native function. Additionally by calling MethodHandle::invoke we will execute the native function, from Java!
The Example C Code
We will be using this piece of simple C code to showcase executing the native function call of hello_panama from a shared library that we will build using the Zig compiler (for ease of setup and use).
#include <stdio.h\>
void hello_panama(void) {
printf("Well well, hello to you too Java Panama!.\n");
}
To compile and create the shared library using the Zig compiler for a Linux environment we need to use following command:
zig cc -target x86_64-linux-gnu -shared -o lib/dist/hello_panama.so lib/hello_panama.c
But first thing first, let’s examine what dynamic libraries JVM will load by default.
Note: You don't need to take care of details like this, all these setup is already done in the demo code using Docker. We just expect that Docker is available to run the demo.
Default loaded Dynamic Libraries
If you manage to clone the demo project, executing docker_run.sh with --sleep 10:
./docker_run.sh --sleep 10
will force the JVM process sleep for 10 seconds without loading any specific dynamic library. For any Java Process we can use the jcmd to see which dynamic libraries have already been loaded using following commands:
JAVA_PID=$(jps -l | grep "rs.reza.pub" | awk '{print $1}' | head -n 1)
jcmd "$JAVA_PID" VM.dynlibs
This script is included in the Docker image entry point already and the output is list of shared libraries that is loaded at runtime by the Java Process.
You should get an output like this:
7fa519f1e000-7fa519f1f000 rw-p 000e5000 00:4e 656883 /usr/lib/x86_64-linux-gnu/libm.so.6
7fa519f1f000-7fa519f20000 r--p 00000000 00:4e 656928 /usr/lib/x86_64-linux-gnu/librt.so.1
7fa519f20000-7fa519f21000 r-xp 00001000 00:4e 656928 /usr/lib/x86_64-linux-gnu/librt.so.1
7fa519f21000-7fa519f22000 r--p 00002000 00:4e 656928 /usr/lib/x86_64-linux-gnu/librt.so.1
7fa519f22000-7fa519f23000 r--p 00002000 00:4e 656928 /usr/lib/x86_64-linux-gnu/librt.so.1
7fa519f23000-7fa519f24000 rw-p 00003000 00:4e 656928 /usr/lib/x86_64-linux-gnu/librt.so.1
7fa519f24000-7fa51a206000 r--p 00000000 00:4e 663384 /opt/java/openjdk/lib/server/libjvm.so
7fa51a206000-7fa51b206000 r-xp 002e2000 00:4e 663384 /opt/java/openjdk/lib/server/libjvm.so
7fa51b206000-7fa51b516000 r--p 012e2000 00:4e 663384 /opt/java/openjdk/lib/server/libjvm.so
7fa51b516000-7fa51b601000 r--p 015f2000 00:4e 663384 /opt/java/openjdk/lib/server/libjvm.so
Here is documentation on jcmd if interested.
Lets try to understand how to make sense out of this! On Linux, jcmd effectively reads the output of /proc/$JAVA_PID/maps.
(more). Lets take a line from the output above:
7fa51a206000-7fa51b206000 r-xp 002e2000 00:4e 663384 /opt/java/openjdk/lib/server/libjvm.so
7fa51a206000-7fa51b206000: This is the start and end address in the process's virtual memory space where this specific chunk of the library is mapped
r-xp: This tells you what the JVM is allowed to do with this memory block.r= Readw= Writex= Execute (contains runnable machine code)-= No permissionp= Private (changes are not shared with other processes)s= Shared (changes are shared)
So r-xp means this memory is read-only and executable, but not writable.
002e2000: The offset inside the physical file on disk where this mapped chunk begins.00:4e: The major and minor ID of the storage device (hard drive, SSD) where the file lives.663384: The unique file identifier (inode number) on the filesystem.- /opt/java/openjdk/lib/server/libjvm.so: The absolute path to the dynamic library file that was loaded.
Armed with this knowledge lets execute some native code.
Executing hello_panama()
Finally, running following command:
./docker_run.sh --sleep 10 --lib lib/dist/hello_panama.so --function hello_panama
Will make the program to load the hello_panama.so shared library dynamically at runtime and execute the hello_panama function from it. If we inspect the output carefully we should see:
- The
hello_panam.soto be present in the list of loaded dynamic libraries - The output from
hello_panama()method being executed in console.
If use grep hello like following at command line:
./docker_run.sh --sleep 10 --lib lib/dist/hello_panama.so --function hello_panama | grep hello
We should be able to verify our expectations:
FINE: Successfully loaded method: hello\_panama
FINE: executing hello\_panama()
Well well, hello to you too Java Panama\!.
7ffa27418000-7ffa27419000 r--p 00000000 00:4e 699166 /app/lib/dist/hello\_panama.so
7ffa27419000-7ffa2741a000 r-xp 00000000 00:4e 699166 /app/lib/dist/hello\_panama.so
7ffa2741a000-7ffa2741b000 r--p 00000000 00:4e 699166 /app/lib/dist/hello\_panama.so
Next, More Downcall
In the next section, we try to handle execution of more complicated downcalls (stay tuned for part II).