
Going Native - Java Native Interface (JNI)
Why do humans like old things?
Dr. Noonien Soong, Star Trek the Next Generation
Although I haven't actually done it very much, I've always been fascinated by the idea of calling into old code from modern applications. Who knows what value is locked away in those old libraries? Graphics, matrix calculations, statistics, quantum mechanical calculations, etc. I want to be able to do it all!
In reality, old code is probably dusty, unmaintained, and harder to use than modern code. I'm still interested in being able to access it.
As discussed in the introduction to this series, I wrote a small library to do matrix calculations on rational numbers so that I could focus on the calculations without worrying about data loss or errors due to rounding. This post is about calling into it from Java via the Java Native Interface (JNI).
Starting point
To get a basic education I started with Baeldung's Tutorial on JNI. This gave a few basic examples of how to write the Java code, compile it, generate the C header file, write and compile the implementation of that, and call it all together. In particular, the Using Objects and Calling Java Methods From Native Code section was a good introduction to generating Java objects from the native side of the world.
I did most of this work in an Ubuntu image in a WSL on a Windows machine. I used Java 11 SDK for the Java compilation steps.
Calling RMatrix from Java, creating Java objects from the results
I wrote some simple Java classes as counterparts of the C structs. Then I wrote a simple driver to create small matrix, call the Gauss Factorization method, and display the U matrix. To call the native method I decided to pass a three-dimensional integer array. The first two dimensions represent the height and width of the matrix. The third dimension is either a one- or two-element array representing the numerator and denominator of a rational number, with a one-dimensional array representing a denominator of 1. This matches well with the behavior of String.split("/")
.
1package org.jtodd.jni;
2
3public class JRashunal {
4 private int numerator;
5 private int denominator;
6
7 public JRashunal(int numerator, int denominator) {
8 this.numerator = numerator;
9 this.denominator = denominator;
10 }
11
12 @Override
13 public String toString() {
14 if (denominator == 1) {
15 return String.format("{%d}", numerator);
16 } else {
17 return String.format("{%d/%d}", numerator, denominator);
18 }
19 }
20}
21
22package org.jtodd.jni;
23
24public class JRashunalMatrix {
25 private int height;
26 private int width;
27 private JRashunal[] data;
28
29 public JRashunalMatrix(int height, int width, JRashunal[] data) {
30 this.height = height;
31 this.width = width;
32 this.data = data;
33 }
34
35 @Override
36 public String toString() {
37 StringBuilder builder = new StringBuilder();
38 for (int i = 0; i < height; ++i) {
39 builder.append("[ ");
40 for (int j = 0; j < width; ++j) {
41 builder.append(data[i * width + j]);
42 builder.append(" ");
43 }
44 builder.append("]\n");
45 }
46 return builder.toString();
47 }
48}
49
50package org.jtodd.jni;
51
52public class RMatrixJNI {
53
54 static {
55 System.loadLibrary("jnirmatrix");
56 }
57
58 public static void main(String[] args) {
59 RMatrixJNI app = new RMatrixJNI();
60 int data[][][] = {
61 { { 1 }, { 2 }, { 3, 2 }, },
62 { { 4, 3 }, { 5 }, { 6 }, },
63 };
64 JRashunalMatrix u = app.factor(data);
65 System.out.println(u);
66 }
67
68 private native JRashunalMatrix factor(int data[][][]);
69}
The Java code is compiled and the C header file is generated in the same step:
1$ javac -cp build -h build -d build RMatrixJNI.java JRashunal.java JRashunalMatrix.java
Other blogs, including Baeldung's, show you what a JNI header file looks like, so I won't copy it all here. The most important line is the declaration of the method defined in the Java class:
1JNIEXPORT jobject JNICALL Java_org_jtodd_jni_RMatrixJNI_factor
2 (JNIEnv *, jobject, jobjectArray);
This is the method that you have to implement in the code you write. Include the header file generated by the Java compiler, as well as the Rashunal and RMatrix libraries. I named the C file the same as the header file generated by the compiler.
1#include "rashunal.h"
2#include "rmatrix.h"
3#include "org_jtodd_jni_RMatrixJNI.h"
4
5JNIEXPORT jobject JNICALL Java_org_jtodd_jni_RMatrixJNI_factor (JNIEnv *env, jobject thisObject, jobjectArray jdata)
6{
7 ...
8}
After I got this far, Baeldung couldn't help me much anymore. I turned to the full list of functions defined in the JNI specification. This let me get the dimensions of the Java array and allocate the array of C Rashunals:
1 long height = (long)(*env)->GetArrayLength(env, jdata);
2 jarray first_row = (*env)->GetObjectArrayElement(env, jdata, 0);
3 long width = (long)(*env)->GetArrayLength(env, first_row);
4
5 size_t total = height * width;
6 Rashunal **data = malloc(sizeof(Rashunal *) * total);
It took some fiddling, but then I figured out how to get data from the elements of the 2D array, create C Rashunals, create the C RMatrix, and factor it:
1 for (size_t i = 0; i < total; ++i) {
2 size_t row_index = i / width;
3 size_t col_index = i % width;
4 jarray row = (*env)->GetObjectArrayElement(env, jdata, row_index);
5 jarray jel = (*env)->GetObjectArrayElement(env, row, col_index);
6 long el_count = (long)(*env)->GetArrayLength(env, jel);
7 jint *el = (*env)->GetIntArrayElements(env, jel, JNI_FALSE);
8 int numerator = (int)el[0];
9 int denominator = el_count == 1 ? 1 : (int)el[1];
10 data[i] = n_Rashunal(numerator, denominator);
11 }
12 RMatrix *m = new_RMatrix(height, width, data);
13 Gauss_Factorization *f = RMatrix_gelim(m);
14
15 const RMatrix *u = f->u;
16 size_t u_height = RMatrix_height(u);
17 size_t u_width = RMatrix_width(u);
The really tricky part was finding the Java class and constructor definitions from within the native code. The JNI uses something called descriptors to refer to primitives and objects:
- The descriptors for primitives are single letters:
I
for integer,Z
for boolean, etc. - The descriptor for a class is the fully-qualified class name, preceded by an L and trailed by a semicolon:
Lorg/jtodd/jni/JRashunal;
- The descriptor for an array is the primitive/class descriptor preceded by an opening bracket:
[I
,[Lorg/jtodd/jni/JRashunal;
. Multidimensional arrays add an opening bracket for each dimension of the array. - The descriptor for a method is the argument descriptors in parentheses, followed by the descriptor of the return value.
- You can see an example of this in the header file generated by the Java compiler for the Java class: it accepts a three-dimensional array of integers and returns a JRashunalMatrix, so the signature is
([[[I)Lorg/jtodd/jni/JRashunalMatrix;
. - If a method has multiple arguments, the descriptors are concatenated with no delimiter. This caused me a lot of grief because I couldn't find any documentation about it. ChatGPT finally gave me the clue to this. It also told me a handy tool to find the method signature of a compiled class:
javap -s -p fully.qualified.ClassName
.
- You can see an example of this in the header file generated by the Java compiler for the Java class: it accepts a three-dimensional array of integers and returns a JRashunalMatrix, so the signature is
So in our native code we first need to find the class descriptions, then we need to find the constructors for those classes. The documentation for the GetMethodID says the name of the constructor is <init>
, and the return type is void (V
):
1 jclass j_rashunal_class = (*env)->FindClass(env, "org/jtodd/jni/JRashunal");
2 jclass j_rmatrix_class = (*env)->FindClass(env, "org/jtodd/jni/JRashunalMatrix");
3 jmethodID j_rashunal_constructor = (*env)->GetMethodID(env, j_rashunal_class, "<init>", "(II)V");
4 jmethodID j_rmatrix_constructor = (*env)->GetMethodID(env, j_rmatrix_class, "<init>", "(II[Lorg/jtodd/jni/JRashunal;)V");
(Those II's look like the Roman numeral 2!)
That was the hard part. Although the syntax is ugly, allocating and populating an array of JRashunals and creating a JRashunalMatrix was pretty straightforward:
1 jobjectArray j_rashunal_data = (*env)->NewObjectArray(env, u_height * u_width, j_rashunal_class, NULL);
2 for (size_t i = 0; i < total; ++i) {
3 const Rashunal *r = RMatrix_get(u, i / width + 1, i % width + 1);
4 jobject j_rashunal = (*env)->NewObject(env, j_rashunal_class, j_rashunal_constructor, r->numerator, r->denominator);
5 (*env)->SetObjectArrayElement(env, j_rashunal_data, i, j_rashunal);
6 free((Rashunal *)r);
7 }
8 jobject j_rmatrix = (*env)->NewObject(env, j_rmatrix_class, j_rmatrix_constructor, RMatrix_height(u), RMatrix_width(u), j_rashunal_data);
Compiling, linking, and running
Up to now I've assumed you understand the basics of C syntax, compiling, linking, and running. I won't assume that for the rest of this because it got pretty tricky and took me a while to figure it out.
I've laid out my project like this:
1$ tree .
2.
3├── JRashunal.java
4├── JRashunalMatrix.java
5├── RMatrixJNI.java
6├── build
7│ ├── all generated and compiled code
8└── org_jtodd_jni_RMatrixJNI.c
9
104 directories, 31 files
I set JAVA_HOME
to the root of the Java 11 SDK I'm using. To compile the C file:
1$ echo $JAVA_HOME
2/usr/lib/jvm/java-11-openjdk-amd64
3$ cc -c -fPIC \
4 -Ibuild \
5 -I${JAVA_HOME}/include \
6 -I${JAVA_HOME}/include/linux \
7 org_jtodd_jni_RMatrixJNI.c \
8 -o build/org_jtodd_jni_RMatrixJNI.o
Adjust the includes to find the JNI header files for your platform. If you installed Rashunal and RMatrix to a recognized location (/usr/local/include
for me) the compiler should find them on its own. If not, add includes to them as well.
1$ cc -shared -fPIC -o build/libjnirmatrix.so build/org_jtodd_jni_RMatrixJNI.o -L/usr/local/lib -lrashunal -lrmatrix -lc
To create the shared library you have to link in the Rashunal and RMatrix libraries, hence the additional link location and link switches.
1$ LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH java -cp build \
2 -Djava.library.path=/home/john/workspace/JavaJNI/build org.jtodd.jni.RMatrixJNI
3[ {1} {2} {3/2} ]
4[ {0} {1} {12/7} ]
This is the tricky one. Since we created a shared library (libjnirmatrix.so), we need to provide the runtime the path to the linked Rashunal and RMatrix libraries. This isn't done through the java.library.path
variable; that tells the JVM where to find the JNI header file. You need the system-specific load path to tell the system runtime (not the JVM runtime) where to find the linked libraries. On Linux that's the LD_LIBRARY_PATH
variable. Thanks again, ChatGPT!
Whew, that's a lot of work! And who wants to type those absurdly long CLI commands?
Doing it in a modern build system: Gradle
I'm continuing in my Ubuntu WSL shell with Java 11 and Gradle 8.3.
1$ mkdir jrmatrix
2$ gradle init
3# I chose a basic project with Groovy as the DSL and the new APIs
Apparently Gradle's Java and native plugins don't play nicely in the same project, so the first thing I did was separate the project into app (Java) and native (C) subprojects. All of the automatically-generated files could stay the way they were. I just needed to make a small change to settings.gradle:
1rootProject.name = 'jrmatrix'
2
3include('app', 'native')
Then I made folders for each subproject.
In the app subproject I created the typical Java folder structure:
1$ tree .
2.
3├── build.gradle
4└── src
5 └── main
6 └── java
7 └── org
8 └── jtodd
9 └── jni
10 ├── JRashunal.java
11 ├── JRashunalMatrix.java
12 ├── RMatrixJNI.java
13 └── jrmatrix
14 └── App.java
15
1626 directories, 11 files
The build.gradle file in app needed switches to tell the Java compiler to generate the JNI header file and where to put it. This is no longer a separate command (javah
), but is an additional switch on the javac
command. In addition, I wanted the run task to depend on the native compilation and linking steps I'll describe later in the native subproject.
1plugins {
2 id 'application'
3 id 'java'
4}
5
6tasks.named("compileJava") {
7 def headerDir = file("${buildDir}/generated/jni")
8 options.compilerArgs += ["-h", headerDir.absolutePath]
9 outputs.dir(headerDir)
10}
11
12application {
13 mainClass = 'org.jtodd.jni.jrmatrix.App'
14 applicationDefaultJvmArgs = [
15 "-Djava.library.path=" + project(":native").layout.buildDirectory.dir("libs/shared").get().asFile.absolutePath
16 ]
17}
18
19tasks.named("run") {
20 dependsOn project(":native").tasks.named("linkJni")
21}
Most of the fun was in the native project. I set it up with a similar folder structure to Java projects:
1$ tree .
2.
3├── build.gradle
4└── src
5 └── main
6 └── c
7 └── org_jtodd_jni_RMatrixJNI.c
8
96 directories, 4 files
Gradle has a cpp-library
plugin, but it seems tailored just to C++, not to C. There is also a c-library
plugin, but that seems not to be bundled with Gradle 8.3, so I decided to skip it. The other alternative was a bare-bones c
plugin, which apparently is pretty basic. Much of the code will look similar to what I did earlier at the command line.
Like I did there, I had to write separate steps to compile and link the implementation of the JNI header file with the Rashunal and RMatrix libraries. After a couple of refactorings to pull out common definitions of the includes and not hardcoding the names of C source files I wound up with this:
1apply plugin: 'c'
2
3def jniHeaders = { project(":app").layout.buildDirectory.dir("generated/jni").get().asFile }
4def jvmHome = { file(System.getenv("JAVA_HOME")) }
5def outputDir = { file("$buildDir/libs/shared") }
6
7def osSettings = {
8 def os = org.gradle.nativeplatform.platform.internal.DefaultNativePlatform.currentOperatingSystem
9 def baseInclude = new File(jvmHome(), "/include")
10 def includeOS
11 def libName
12 if (os.isLinux()) {
13 includeOS = new File(jvmHome(), "/include/linux")
14 libName = "libjnirmatrix.so"
15 } else if (os.isMacOsX()) {
16 includeOS = new File(jvmHome(), "/include/darwin")
17 libName = "libjnirmatrix.dylib"
18 } else if (os.isWindows()) {
19 includeOS = new File(jvmHome(), "/include/win32")
20 libName = "jnirmatrix.dll"
21 } else if (os.isFreeBSD()) {
22 includeOS = new File(jvmHome(), "/include/freebsd")
23 libName = "libjnirmatrix.so"
24 } else {
25 throw new GradleException("Unsupported OS: $os")
26 }
27 [baseInclude, includeOS, libName]
28}
29
30def sourceDir = file("src/main/c")
31def cSources = fileTree(dir: sourceDir, include: "**/*.c")
32def objectFiles = cSources.files.collect { file ->
33 new File(outputDir(), file.name.replaceAll(/\.c$/, ".o")).absolutePath
34}
35
36tasks.register('compileJni', Exec) {
37 dependsOn project(":app").tasks.named("compileJava")
38 outputs.dir outputDir()
39 doFirst { outputDir().mkdirs() }
40
41 def (baseInclude, includeOS, _) = osSettings()
42
43 def compileArgs = cSources.files.collect { file ->
44 [
45 '-c',
46 '-fPIC',
47 '-I', jniHeaders().absolutePath,
48 '-I', baseInclude.absolutePath,
49 '-I', includeOS.absolutePath,
50 file.absolutePath,
51 '-o', new File(outputDir(), file.name.replaceAll(/\.c$/, ".o")).absolutePath
52 ]
53 }.flatten()
54
55 commandLine 'gcc', *compileArgs
56}
57
58tasks.register('linkJni', Exec) {
59 dependsOn tasks.named("compileJni")
60 outputs.dir outputDir()
61 doFirst { outputDir().mkdirs() }
62
63 def (baseInclude, includeOS, libName) = osSettings()
64
65 commandLine 'gcc',
66 '-shared',
67 '-fPIC',
68 '-o', new File(outputDir(), libName).absolutePath,
69 *objectFiles,
70 '-I', jniHeaders().absolutePath,
71 '-I', baseInclude.absolutePath,
72 '-I', includeOS.absolutePath,
73 '-L', '/usr/local/lib',
74 '-l', 'rashunal',
75 '-l', 'rmatrix',
76 '-Wl,-rpath,/usr/local/lib'
77}
78
79tasks.named('build') {
80 dependsOn tasks.named('compileJni')
81 dependsOn tasks.named('linkJni')
82}
Gradle subprojects have references to each other, so this library can get references to app's output directory to reference the JNI header file. The compileJni task is set to depend on app's compileJava task, and native's build task is set to depend on the compileJni and linkJni tasks defined in this file.
This worked if I explicitly called app's compileJava task and native's build task, but it failed after a clean task. It turned out Java's compile task wouldn't detect the deletion of the JNI header file as a change that required rebuilding, so I added the build directory as an output to the task (outputs.dir(headerDir)
). Thus deleting that file (or cleaning the project) caused recompilation and rebuilding.
The nice thing is that this runs with a single command now (./gradlew run
). Much nicer than entering all the command line commands by hand!
Reflection
As expected, this works but is very fragile. Particularly calling Java code from the native code depends on knowledge of the class and method signatures. If those change in the Java code, the project will compile and start just fine, but blow up pretty explosively and nastily with unclear explanations at runtime.
I was surprised by Gradle's basic tooling for C projects. I thought there would be more help than paralleling the command line so closely. I'll have to look into the c-library
plugin to see if it offers any more help. I'm also surprised by how few blogs and Stack Overflow posts I found about this: apparently this isn't something very many people do (or live to tell the tale!).