JNI Basics: Calling Native Code from Java

Have you ever wondered, how you could call native C/C++ Code from your Java Code? Say you have a library, that does exactly what you want either because it is legacy code that you want to wrap within Java or you have found an obscure problem, where no Java solution exists. Of course you could rewrite it in Java, which is insanely costly, time consuming and you will get it wrong the first few times. Altenatively you might use the Java Native Interface (JNI) for calling the native code, let's say C++ code, directly.

This article tries to explain how to use JNI in a cook book fashion. We will be using CMake to build our C++ code. We will be working on a Linux System, so the paths and linking behaviour of other systems may be different.

Before we start

We are going to link a Java program to native C/C++ code. This is quite a handy way of using old code or maybe run performance critical code natively and have the user interface run in the type safe Java language.

However, this approach also has its shortcomings. If you already know interfaces like COM in Windows then you understand, that COM runs your library in a separate server process. If your COM library crashes it only takes down the server process; your Java (or C# or whatever) client process remains intact, sees an error and can handle it in the safe world appropriately (e.g. by restarting the server and showing an error message). This of course comes with a (heavier) price in performance.

Now, JNI uses the loader/dynamic linker to load your native libraries into the Java process. While the native function is now present in the same process, there might still be a dozen or so steps between Java and the first of our native functions, so it will incur some run-time overhead over going completely native. Even worse if your native code crashes, which is easier done than said in C/C++, it will take your whole Java process with it and whatever lives in there. If you run a Tomcat in the same process it will go as well.

Even more nasty, the native code could ruin your whole memory and you might not even catch it. While Java guards the memory quite well from the Java programmers, native code can do nearly whatever it wants. It can read the whole Java heap, the JVM binary code, etc, and it could also write to it, without the operating system intervening, well at least if Write XOR Execute is not activated.

So keep in mind: you must guard native code from user input. Handling malformed or even malicious input in C++ is no easy task. If you don't get it right you might leak memory or crash the whole process. Use the strength of the Java type system to your advantage.

Prerequisites

We will be using the following tools, so make sure to install them in your distribution: * javac * cmake * gcc

Also make sure, the following libraries are installed on your system. On Arch Linux these are installed into /usr/lib/jvm/java-17-openjdk/lib/server. I'm going to use jdk17-openjdk. * libjni.so * libjvm.so

On Arch Linux it should suffice to install these packages:

$ pacman -Syu jdk17-openjdk jdk17-openjre cmake gcc g++

Starting with the Native Code

We'll be starting with our native code. In our example, we'll have two functions, one that just prints Hello World, takes no arguments and has no return values. And a second one, that sums two integer-parameters and returns the resulting integer. I'll prefix both with native_, which has no significance other than making clear, that this is our native code. So this is our legacy.cpp:

// legacy.cpp
#include <iostream>

extern "C" {
    void native_hello_world() {
        std::cout << "Hello World" << std::endl;
    }

    int native_sum( int a, int b ) {
        return a+b;
    }
}

For easier reading, I've marked these functions as extern-C. This will deactivate C++ name mangling for these functions. In our example it wouldn't make a difference, because we'll create a separate glue-layer between Java and our native code anyways.

Building this code to a library in CMake looks like this. Place this code in the CMakeLists.txt next to your code.

# CMakeLists.txt
cmake_minimum_required (VERSION 3.5)
project (JniTest)

add_library(JniNative SHARED legacy.cpp)

Then configure the project and build it like this:

$ mkdir build
$ cd build
$ cmake ..
$ make

Now we have build our native "legacy" code, that could have been used in some project from C++. Running nm on the resulting libJniNative.so reveals these two exported symbols (the capital T indicates a .text-symbol (code), that is exported):

$ nm libJniNative.so
0000000000001109 T native_hello_world
0000000000001129 T native_sum

We'll also prepare a header file legacy.h to export these function names to other C++ compilation units, so we can use it later:

// legacy.h
extern "C" {
    void native_hello_world();
    int native_sum( int a, int b);
}

Preparing the Java-Code

Now we'll have to tell Java the names and signatures of our functions and that these functions will be native code. We'll start with the Hello-World function. In a file called jnitest.java, we'll create a public class jnitest, with a native static function java_hello_world and one java_sum with two parameters and an int return value. Note int and Integer are two different types in Java, we want to use the int-type here, so we won't need to convert it to C++ ints later on.

But it has a twist. We create a static block in the class, where we tell the Java Virtual Machine to load a library into. We will reference a glue-library, which we'll have to create. This load will fill out the code of both native functions, so calling them later from the Java side will actually land us in our native code.

// jnitest.java
package de.snaums;

public class jnitest {
    static {
        System.loadLibrary("JniGlue");
    }

    public static native void java_hello_world();
    public static native int java_sum( int a, int b );

    public static void main( String[] args ) {
            jnitest.java_hello_world();
            System.out.println("Sum: 1+2: " + jnitest.java_sum(1,2));
    }
}

This code will not run because Java will not understand the link between java_* functions and our native_* functions. We will have to create glue code for this mapping.

Creating a glue library is a bit more work, but the scenario is, that the legacy code comes directly from some other project and we wouldn't want to alter it for this. So we'll have to introduce a small wrapper around the native code. Afterall we'll have to convert our parameters for the sum-function from Java into C/C++ types.

Compile this code with the Java Compiler manually to a class-file, to see that this works. Running it won't do, because we have yet to create the glue.

javac jnitest.java

Sniffing some Glue-Code

For Java, our functions are actually called de.snaums.jnitest.java_hello_world. We've created a package de.snaums, in it a class jnitest and this class has the Java-end of our native functions in them. However the implementation of these functions is still missing, we'll have to call our native legacy code. Implementing these calls will be done in C++ again in our glue-library.

Using the Java Compiler we generate a header file for the native glue functions, with the -h parameter, followed by the output directory and the source file to be analyzed.

javac -h . jnitest.java

This will create a de_snaums_jnitest.h, with both of our functions declared, but we will need to write the wrapper code ourselves.

// de_snaums_jnitest.h/glue.h
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class de_snaums_jnitest */

#ifndef _Included_de_snaums_jnitest
#define _Included_de_snaums_jnitest
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     de_snaums_jnitest
 * Method:    java_hello_world
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_de_snaums_jnitest_java_1hello_1world
  (JNIEnv *, jclass);

/*
 * Class:     de_snaums_jnitest
 * Method:    java_sum
 * Signature: (II)I
 */
JNIEXPORT jint JNICALL Java_de_snaums_jnitest_java_1sum
  (JNIEnv *, jclass, jint, jint);

#ifdef __cplusplus
}
#endif
#endif

Note that the function names are export'ed "C" in C++, so the C++ name mangling is deactivated. If it were to interfere with function names, the Java runtime would be unable to link the native java calls to the symbol names in our library, because it has no concept of the C++ name mangling algorithm in each compiler.

Now we need to implement these two functions in a glue.cpp. We'll start with our Hello-World function, it takes no parameters and no return values, so we can just call the function straight up. The sum-function will need a bit more work, because we would need to convert the types. However Java-int types are essentially C++ ints, if we aren't going too crazy.

// glue.cpp
#include "legacy.h"
#include <jni.h>

extern "C" {
    void Java_de_snaums_jnitest_java_1hello_1world 
        (JNIEnv *, jclass) {
            native_hello_world();
    }

    jint Java_de_snaums_jnitest_java_1sum
        (JNIEnv *env, jclass cls, jint first, jint second) {
            return (jint) native_sum( (int)first, (int)second );
    }
}

We'll compile this code into a library (libJniGlue), in a sense same as before. However, we will need to link it to some Java libraries, which have loads of functionality to convert to and from native/Java data structures. In this example we've essentially just casted the integer types. When using classes in Java as parameters for native functions, have fun converting them into C/C++ native types.

Now we have all the pieces together and could start running our Java program, have it load our glue-library, which in turn loads the "legacy" native code and we have execution.

CMake Compilation

To make it a bit more elegant, let's use CMake to build everything. We've already had a CMakeLists.txt for building our native code, now include making the Java code into a Java Archive (jar) and also building our glue library:

// CMakeLists.txt
cmake_minimum_required (VERSION 3.5)
project (JniTest)

find_package (Java REQUIRED)
find_package (JNI REQUIRED)
include (UseJava)

# Example without JNI. Simple HelloWorld class with an entry point.
add_jar (jnitest
         VERSION 0.0.1
         ENTRY_POINT de.snaums.jnitest
         SOURCES jnitest.java)

add_library(JniNative SHARED legacy.cpp)
add_library(JniGlue SHARED glue.cpp)
target_include_directories(JniGlue PRIVATE ${JNI_INCLUDE_DIRS})
target_link_libraries(JniGlue PRIVATE ${JNI_LIBRARIES} JniNative)

We include the Java-Packages for CMake for the interesting Java-parts: having the Java library and include directories preset. After including Java, we instruct CMake to build a jar jnitest.jar from jnitest.java, with a given entry point.

Then we add both the native library and the glue library and state the dependencies for the glue library, both the include directory of jni.h and the Java libraries. Importantly, we also have to link our glue library to our "legacy" code, we want to call into our "legacy" code afterall.

Running the jar

Now just run the jar, right?

$ java -jar jnitest.jar
Exception in thread "main" java.lang.UnsatisfiedLinkError: no JniGlue in
java.library.path: /usr/java/packages/lib:/usr/lib64:/lib64:/lib:/usr/lib
    at java.base/java.lang.ClassLoader.loadLibrary(ClassLoader.java:2458)
    at java.base/java.lang.Runtime.loadLibrary0(Runtime.java:916)
    at java.base/java.lang.System.loadLibrary(System.java:2059)
    at de.snaums.jnitest.<clinit>(jnitest.java:5)

Ah, now that's not good. Java loads the jar-file correctly, but then hits a bit of a snag, because it can't find our glue library. Let's look at the directories contents:

$ ls
cmake_install.cmake    jnitest-0.0.1.jar    libJniNative.so
CMakeCache.txt         jnitest.jar          Makefile
CMakeFiles             libJniGlue.so   

Ok, so the libraries are clearly there. Well, Linux will only look for libraries in the system paths, and we can see it clearly in the output of Java, because it helpfully lists all the places it looked for our code. We can extend this list, by setting the environment variable LD_LIBRARY_PATH.

This environment variable does not only serve Java as a guide where to find libraries, the Linux loader will look in the given places first. It is quite a handy tool, when you explicitly don't want the libraries of the system, e.g. because you linked your program against a different version of a shared object.

Anyway, let's set the environment variable:

# fish
$ set -xg LD_LIBRARY_PATH (pwd)
# bash
$ export LD_LIBRARY_PATH $(pwd)

Then run the jar again to see the output:

$ java -jar jnitest.jar
Sum: 1+2: 3
Hello World⏎ 

Some fun to be had

Now, what if we'd want to choose at run-time which library was loaded. Say we have several versions of our legacy code, all in the same directory (because we cannot set LD_LIBRARY_PATH in any meaningful way from within Java).

Stunningly this is possible. The static System.loadLibrary-block is not executed when the Java program is started, but when the jnitest.class is first loaded, i.e. some class or function from it is used. After that, we cannot change what was loaded. But until then, wen can have some fun.

So, if we create a new class jnitestConfig, in an own .java file, which only has one member, a String, that sometimes holds the value "JniGlue". You see where this is going? From a third .java file - the new entry point - we can then alter the static config class member as long as we haven't called into jnitest.

So, lets add jnitestConfig.java first:

// jnitestConfig.java
package de.snaums;

public class jnitestConfig {
    public static String library = "JniGlue";
}

Then add a third .java file, which might present our user-interface. In this case I'll just try and load whatever is in the first parameter of our Java-Program:

// fun.java
package de.snaums;

public class fun {
    public static void main( String[] args ) {
        if ( args.length > 0 ) {
            jnitestConfig.library = args[0];
        }

        jnitest.java_hello_world();
    }
}

The System.loadLibrary now looks like this:

// in jnitest.java
    // ..
    static {
        System.loadLibrary( jnitestConfig.library );
    }
    // ...

Now running the program does exactly what we had before, but we could change the library to be loaded at run-time. If that ever might be useful to you, here you go.

$ java -jar jnitest.jar
Hello World
$ java -jar jnitest.jar unhold
Exception in thread "main" java.lang.UnsatisfiedLinkError: no unhold in java.library.path:
/home/snaums/jni-test/build:/usr/java/packages/lib:/usr/lib64:/lib64:/lib:/usr/lib
    at java.base/java.lang.ClassLoader.loadLibrary(ClassLoader.java:2458)
    at java.base/java.lang.Runtime.loadLibrary0(Runtime.java:916)
    at java.base/java.lang.System.loadLibrary(System.java:2059)
    at de.snaums.jnitest.<clinit>(jnitest.java:5)
    at de.snaums.fun.main(fun.java:10)

Please be aware, this is probably not in the spec of the JVM or JNI, so it might break. You probably shouldn't use this in production code.

Conclusion (tl;dr)

What have we done? We've created a Java class jnitest with 3 methods: one main-function as an entry point to the jar. Two of the methods are marked as native calls.

We've then created a header file with javac -h from the Java file and implemented these functions. The implementation is in a separate glue-library; you could also link it directly to your native code, if you know what you are doing and these calls somehow already fulfilled the interfaces.

From this point on, we can use the native functions of our legacy code from anywhere in our Java program. This can be helpful if you can't replace the native code with Java code easily.

While this article only touches on some very basic features, there are great articles and tutorials out there on some more advanced stuff, like allocating Java objects from native code, setting attributes or even throwing Exceptions. A good starting point might be Baeldungs Guide to JNI.

References

Last edit: 19.11.2023 23:00