Calling C from Java the easy way — Project Panama

Atri Sarkar
7 min readOct 31, 2023

--

Introduction

In this article we’ll be looking at how we can interact with C code from Java using OpenJDK’s new Project Panama. By the end of the article you will know how to effectively use the Foreign Function Interface (FFI) to call into C code.

This article is aimed at people who might be new to low level programming. I will assume that you have a basic understanding of Java and C and will be following along on a Unix based system. But don’t worry I’ve put code explanations for all code blocks where necessary.

The Why

You might wonder why we even might want to call native/C code from Java? Well there are a number of reasons:

Interacting with System APIs

Most operating systems are written in a systems programming language like C, hence all of the system apis also written in C. So if you want to for e.g. play a sound through your speakers, draw something with your GPU, you have to eventually invoke a native system API.

Performance

Java executes bytecode that is compiled from the source on the JVM. This means that there’s a whole runtime on top of which your own code gets executed. This can add a undesirable amount of performance overhead for certain CPU intensive tasks. Furthermore, Java’s memory model doesn’t allow for low level memory access/management, which can prevent certain types of optimizations you might want to do as a programmer. We can alleviate this by delegating these performance intensive operations to native code.

Existing Libraries

C has a vast ecosystem of libraries across various domains and some of these codebases are very mature and very well tested. For e.g. look at openssl, its been the gold standard for encryption for years. It is well-vetted and has undergone extensive security audits and testing over the years. So there is no point in reimplementing this functionality ourselves when we can generate wrappers for this in Java.

The What

Project Panama is the new way to interface with code written in systems programming languages like C. It is supposed to replace the Java Native Interface (JNI) by being both more performant and easy to use. It is an experimental project as of now, but is likely to become stable and fully available in Java 22.

The How

Project Panama exposes three main APIs namely Foreign Function, Foreign Memory and a Foreign Linker API. We’ll mostly be using the Foreign Function API to make a call to C code for this demo.

Since Java is a managed language, and C is a low level language, we cannot directly call C code. To pass data to C we need to convert it to data types that it understands(marshalling). There is also some context switching involved in the moving the control flow back and forth to and from the JVM.

Fortunately there’s a tool called jextract that will help us generate the code to do all of this! Running jextract on our C code will automagically generate Java bindings(wrapper) that utilize panama’s ffi api, which we can then use directly.

Setup JDK

First we need to install a version of java that supports Project Panama features. Project Panama’s features have been supported on non early access builds since JDK 20. I am going to be using OpenJDK 21 for this demo. If you already have a suitable JDK version installed you can skip this step.

I am using sdk man for managing multiple JDK versions, feel free to use your favorite JDK management script.

Install the JDK:

sdk install java 21-open

To set the default version to the one we just installed:

sdk default java 21-open

or

you can set it only for the current shell using:

sdk use java 21-open

Setup Project

We’ll be using a standard gradle application, so generate one using:

mkdir panama_demo
gradle init

Here’s the configuration I used:

Now lets create a c folder under src/app/main for our C sources.

We’ll also create two new files native.c and native.h.

The final directory structure should look similar to this:

Lets write some C !

Now that we are setup let’s write a simple C function that we can call from Java later.

native.c

#include <stdio.h>

void print_hello() {
printf("Hello from C\n");
}

This print_hello function will simply print a hello message to stdout. Notice how we don’t have a main function because we are not going to be executing this C code directly.

You might have noticed that we have also created a native.h file. This is a header file and is there to provide a declaration of the functions that we want to use from Java and its what will be used to generate the Java binding. This file doesn’t contain the implementation of the function only the attributes of the function like return type and arguments.

native.h

void print_hello(void);

Now let’s try to generate java bindings. We’ll be using jextract as mentioned before, although not directly. We are going to use the jextract gradle plugin by Filip Krakowski which is a wrapper around the cli tool that will generate the bindings for us as part of the build process.

Add the following to the plugins section of your build.gradle file:

id "io.github.krakowski.jextract" version "0.4.1"

and add the following jextract config as well:

jextract {
// The header file from which we want to generate the bindings
header("${project.projectDir}/src/main/c/native.h") {
// The library name (don't worry about this for now)
libraries = [ 'nativeLib' ]
// The package under which all source files will be generated
targetPackage = 'org.nativelib'
// The generated class name
className = 'NativeLib'
}
}

Now if we build our project:

./gradlew build

jextract will use the native.h header that we created to generate a Java class called NativeLib in the package org.nativelib with a print_hello() function we can call.

and if you go to the path above you’ll see the generated class as well!

Now lets try to call this function from Java:

App.java


package panama_demo;

import org.nativelib.NativeLib;

public class App {

public static void main(String[] args) {
NativeLib.print_hello();
}
}

and we should see “Hello from C” printed if we do:

./gradlew run

Hmmm…

Why did that not work?

Remember that header files (native.h) only contain declarations and not implementations. So far we’ve only generated Java bindings which are just wrappers that call the actual C implementation of the function. But this implementation has to be made available to the JVM somehow for it be called. For this we need to compile our native.c into a shared library (called DLL on Windows) and load this into the JVM. That link error is thrown because the JVM looks for a library named “nativeLib” which we specified in the jextract config, but can’t find the library in its library path.

Setting up a Shared Library

To compile our C code as part of the build process we’ll use the c gradle plugin:

build.gradle

plugins {
id 'c'
}
model {
components {
nativeLib(NativeLibrarySpec) {
sources {
c {
source {
srcDir 'src/main/c' // Path to your C source files
}
}
}
}
}
}
// Tells gradle to wait for our native library to compile before we can run our java application
run.dependsOn 'nativeLibSharedLibrary'

The above configuration will generate a shared library called “nativeLib” from all of the c source files under src/main/c put it under build/libs/nativeLib/shared. You can read more about the specifics of building native code with gradle here.

Adding to Java Library Path

Now that our shared library is being built, we need to add it to the library path, so that the JVM can load it. We can do that by specifying the Djava.library.path argument to the java command. We also need to add a — enable-preview argument to enable the project panama runtime, as its still in preview:

build.gradle

application {
// Define the main class for the application.
mainClass = 'panama_demo.App'
applicationDefaultJvmArgs = ["-Djava.library.path=" + file("${buildDir}/libs/nativeLib/shared").absolutePath, "--enable-preview"]
}

Success!

And it works now! We have successfully called our C function:

Now let’s try to add a second function factorial like below:

native.c

#include <stdio.h>

void print_hello() {
printf("Hello from C\n");
}

int factorial(int n) {
int result = 1;
for (int i = 1; i <= n; i++) {
result *= i;
}
return result;
}

native.h

void print_hello(void);
int factorial(int);

App.java

package panama_demo;

import org.nativelib.NativeLib;

public class App {

public static void main(String[] args) {
System.out.println(NativeLib.factorial(5));
NativeLib.print_hello();
}
}

which also works as expected!

Conclusion

I hope you can see how easy it is to use the Panama apis to call into native code. There is almost no boilerplate that you have to write and achieve an almost effortless interoperability between Java and C. The jextract tool automatically generates bindings and marshalling boilerplate. Overall Project Panama offers a much simpler and performant way to use native libraries from Java.

If you had any problems following along you can reference my project here on github.

References

  1. https://foojay.io/today/project-panama-for-newbies-part-1/
  2. https://www.baeldung.com/java-project-panama
  3. https://openjdk.org/projects/panama/

--

--