Embedding native C/C++ in Java without an IDE (Part II)

Embedding native C/C++ in Java without an IDE (Part II)

In the previous post, we discussed at length how to cross-compile and run C/C++ code natively on an Android operating system “natively” using the Android Studio NDK. We also alluded to the fact that while the Android operating system is built on an LTS Linux kernel and it’s possible to treat your mobile device like a linux box, interacting with the Android UI/UX is typically done through Java or Kotlin code. Therefore, this post will break down the process of binding our cross-compiled C/C++ code to Java programs—and in the spirit of the last post, we will do so without any sort of integrated development environment (that is, no Android Studio IDE, Eclipse, NetBeans or so on) in order to make the build process and interactions between programming languages completely transparent.

Java without an IDE

It seems that nearly all Java development is typically done using some sort of IDE to manage the project. In fact, as a university student, I completed a year’s worth of courses about software development and design taught entirely in Java, and every assignment, project, and code snippet was done in Eclipse—never once was I asked to open a terminal. In my opinion, for engineers looking to understand or modify the build process, this IDE-based workflow is not ideal, as it obscures the details of how Java projects are actually built and run.

Languages like Python are interpreted; the Python interpreter runs each line of code without any knowledge of what comes next. Loosely speaking, in contrast, languages like C and C++ are compiled—a compiler must turn the entire program into architecture-dependent machine code, which requires introspection of the entire program without running it in order to generate a binary executable. The Java language uses a different paradigm—Java programs are run using the Java Virtual Machine (JVM) regardless of the hardware architecture. This means that there’s a Java compiler that typically turns java code into executable bytecode which can be run by the JVM. Therefore, as long as the JVM is installed on your device of choice, it doesn’t matter whether the Java program was compiled on a different machine, the JVM knows how to execute the program instructions. This language feature can be advantageous for mobile deployment, as it supports a “write once, run anywhere” workflow.

Let’s compile and run some Java code. If we write a simple Java program:

// Test1.java
package example; 

public class Test1 {
  public Test1() { }

  public static void main(String[] args) {
    Test1 obj = new Test1();
    System.out.println("Java is named after Indonesian coffee");
  }
}

We can run it by itself using java in a linux terminal—this will attempt to compile and run the file in a way that can be thought of as running a standalone script:

workstation >> java Test1.java
Java is named after Indonesian coffee

However, we can also compile it to a class file Test1.class without running anything by using the javac command (think “Java compiler”) and use the compiled file instead to run the main() method of the class (a quirk of Java is that we invoke the functionality in the Test1.class file using the class name rather than the file, i.e., Test1)

workstation >> javac Test1.java
workstation >> tree .
. 
├── Test1.class
└── Test1.java

workstation >> java Test1
Error: Could not find or load main class Test1.class
Caused by: java.lang.ClassNotFoundException: Test1.class

What happened? We didn’t have any compiler errors, we could run the program as-is using java , but we aren’t able to invoke the Test1.main() method using the class file.

We’ve hit the first aspect of Java project management that IDEs hide from us. Java packages require a directory structure; the package example; declaration at the top of the file tells the Java virtual machine that it expects the Test1.class file to be located in a directory called example, relative to where we invoke the java command. Therefore, let’s put the class file in the directory where Java expects it:

workstation >> mkdir example; mv Test1.class example/
workstation >> tree .

├── example/
│   └── Test1.class
└── Test1.java

workstation >> java example/Test1
Java is named after Indonesian coffee

Details: if we cd into example and try to run java Test1, we will encounter the same error—the directory structure and run location are both important to resolve the package locations.

Let’s add a dependency with another simple piece of Java code at the top level of the directory. Notice that we don’t declare a package for this file, indicating to Java that it exists in the top-level directory. Similarly, we import Test1.class functionality from the example package:

# Test2.java
import example.Test1;

public class Test2 {
  public Test2() { }

  public static void main(String[] args) {
    Test1 obj = new Test1();
    System.out.println("Imported Test1 class");
    
    String[] dummy_argument = {};
    Test1.main(dummy_argument);
  }
}

and then we can compile and run the code:

workstation >> javac Test2.java
workstation >> tree .

├── example/
│   └── Test1.class
├── Test1.java
├── Test2.class
└── Test2.java

workstation >> java Test2
Imported Test1 class
Java is named after the Indonesian coffee

Great—we’ve set up our own Java project workflow from the terminal without an IDE. Now we’re in a position to appreciate what an IDE like Eclipse handles for us. It checks to make sure that all the import statements can be resolved, and automatically compiles and stores our *.class files in predictable locations that the entire project can access at runtime when you use the IDE’s graphical user interface to click a “run” button.

However, if we need to build a complicated piece of software that requires use of Java files, we now have the tools to build everything we need from a shell script and javac.

The Java Native Interface (JNI)

Now we’re ready to embed native C/C++ code within Java code. The Java Native Interface (JNI) is an interface that allows the Java virtual machine to interact with compiled executables, shared libraries, or static libraries. This is necessary because the Java language has its own data types and ways to interpret data structures in memory; it needs some type of contract to interact with data that exists externally to the JVM. The JNI is exactly this bridge: it specifies an interface that allows us to call C/C++ code and interpret C/C++ data structures from Java directly—otherwise we’d have to reimplement all the functionality in our C/C++ libraries in Java itself. Imagine how much work it would be, for example, to implement Tensorflow in Java!

Let’s get started with a simple example. We’re going to evaluate the exponential function both from Java and from native C++. We’ll walk through the creation of the project as a build system would.

The Java driver

Since the C++ will be called from Java, let’s first build the Java library that will call the function. In an empty new project directory, we set up the following structure:

workstation >> tree . 
├── jni/
└── src/
    └── math_lib/
        └── MathLib.java

The top-level prgram will be called from Java, so we will define its behavior from the MathLib driver class, so we’ll give it a main() method and break down what’s happening piece-by-piece:

// src/math_lib/Mathlib.java
package math_lib;
import java.lang.Math;

public class MathLib {

  // static -> we don't need any class data to return the answer
  public static double JavaMathExp(double x) {
    return Math.exp(x);
  }

  // native keyword tells Java that this method will be native,
  // i.e., supplied by a C++ implementation in a *.so file
  public native static double CMathExp(double x);

  // when this class is initialized, load the external *.so library too
  // --> will look for libjnitests.so in the java path
  static {
    System.loadLibrary("jnitests");
  }

  public static void main(String[] args) {
    System.out.println("MathLib.main() called");

    double x = 1.0;
    System.out.printf("x: %f%n", x);
    System.out.printf("Java: exp(x): %f%n", MathLib.JavaMathExp(x)); 
    System.out.printf("C++ : exp(x): %f%n", MathLib.CMathExp(x)); 
  }
}

As we explained in the previous section, we are defining this function in the math_lib package. This tells us that at the end of the build process, we will call this driver using java from the src directory. Let’s walk through the pieces

  • The main() method is what we will run to test the library. All it does is to evaluate exp(1.0) using the Java and C++ implementations and print the result.

  • To call the exponential function from Java, we need to import java.lang.Math at the top of the file, and write the function JavaMathExp . This function doesn’t have to be static, but the idea is that the MathLib is a library class, and therefore will export individual functions in a stateless way.

  • We declare the native C++ implementation with the line

    public native static double CMathExp(double x);

    This is the function that we will need to call from the C++ shared object library; it’s important to realize that as this entire file is Java code, the double objects are Java double objects, not C++ double objects. The conversion will be defined when we implement the JNI header.

  • The static { } static block defines the static initialization of the class, in short, this is done once for all class instances, not upon the instantiation of each class object. It means that when the class is initialized, it will look for the shared object [libjnitests.so](http://libjnitests.so) on the Java path and attempt to load it


    Details: notice that this is not the filename. Since libraries are usually compiled with the prefix lib , this prefix is removed from the loadLibrary call. If you provide the actual name of the *.so file, it will not be loaded.

This file is all we need from the Java side!

Generating the JNI header

Now we need to write a header file for the JNI in which we will declare static native C++ function that we declared using public native static double CMathExp(double x) in the Java class file. While we could technically write this ourselves, the Java compiler can actually generate the header for us based on our declaration in MathLib.java! This process of generating a JNI header used to have its own command, javah , which was deprecated after Java 9 in favor of calling the compiler directly with javac -h. We do this with the following command, which we run from the top-level of the directory:

workstation >> javac -h jni/ src/math_lib/MathLib.java 
workstation >> tree .

├── jni/
│   └── math_lib_MathLib.h
└── src/
    └── math_lib/
        ├── MathLib.class
        └── MathLib.java

We can see that javac has done two things for us:

  • It’s compiled a class file MathLib.class from MathLib.java
  • It’s generated a JNI header file jni/math_lib_MathLib.h for us in jni
    • We specified with the jni/ argument that that’s where we’d like the header file to be generated. This follows Java / android convention, but we could have put it anywhere.
    • Notice that javac chose the name of the header file for us. JNI provides a namespace mapping between the Java package structure (math_lib.MathLib.java) and a C-compliant namespace.

If we open the C++ header file jni/math_lib_MathLib.h that the Java compiler generated, we see that it contains the following:

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

#ifndef _Included_math_lib_MathLib
#define _Included_math_lib_MathLib
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     math_lib_MathLib
 * Method:    CMathExp
 * Signature: (D)D
 */
JNIEXPORT jdouble JNICALL Java_math_1lib_MathLib_CMathExp
  (JNIEnv *, jclass, jdouble);

#ifdef __cplusplus
}
#endif
#endif

We see that this header is C/C++ code that does a few things:

  • It includes the system jni.h header file, which contains all the C definitions necessary for the JNI. So objects like JNIEnv are defined in that file.
  • It’s declared a function Java_math_1lib_MathLib_CMathExp which is the C/C++ native function that we will need to implement—the Java namespace has been flattened out to a single function name to avoid namespace clashes in C. The function takes three arguments. First is a pointer to a JNIEnv object, which is the JNI environment and provides an interface into the Java virtual machine from the C side of things. The second is a jclass object, which is a reference to the class object in which the native function was declared, here the MathLib class. The third is the actual argument we declared in the function signature, namely the jdouble (Java double) object which is the argument to our Java function MathLib.CMathExp().

Now we will need to implement this function.

The C++ implementation

In order to provide the implementation for the JNI header generated for us, we create a new file in the jni/ directory, the matching math_lib_MathLib.cc implementation. We are free to choose whatever name we’d like for this file—as long as the compiler can resolve the function definition, we’re in the clear. However, to keep things clear, we choose a matching name.

// jni/math_lib_MathLib.cc

// include the header we generated using the JNI
#include "math_lib_MathLib.h"
#include <cmath>

// here we include the implementation
// we've also added names to the args
JNIEXPORT jdouble JNICALL Java_math_1lib_MathLib_CMathExp
  (JNIEnv *env, jclass clazz, jdouble x) 
{
  // my impulse is to explicitly cast the datatype but it turns out JNI is
  // pretty good implicit casting, so we *don't* need to do the following
  //     double arg = (double)x;
  //     return std::exp( arg )
  return std::exp( x );
}

Our implementation is simple. We need to import the JNI header so that we have access to the function header definition, and since we’re going to be using std::exp we include cmath as well. I’ve copied the function definition from the JNI header and provided an implementation. I also annotated the arguments (gave explicit names to the JNIEnv , the jclass , and the jdouble as env , clazz, and x , respectively)—we need to do this to use the arguments in the function body. For this implementation, I need only the argument x , but if I wanted to interact with the JVM, e.g., casting a C const char * to a Java understandable UTF string, I would need to use this object as well.

And that’s it! Now with the Java compiler-generated JNI header and the C++ file, we’ve provided the complete implementation of the native function to be loaded into the JVM runtime.

Using CMake to build the C++ shared library

Now we need to actually compile the C++ shared object library for the target architecture we will deploy on. In the previous part of this blog series, I showed how to use the NDK cross-compilers to build this for Android. But for this part, I’ll keep things simple and just run everything on the workstation to keep the ideas clear.

So to that end, we write a simple CMakeLists.txt file to handle the compilation with CMake:

cmake_minimum_required(VERSION 3.10)
project(jni_example)

set(CMAKE_BUILD_TYPE Release)
find_package(JNI REQUIRED)

include_directories(${JNI_INCLUDE_DIRS})
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17")

set(SOURCE_FILES jni/math_lib_MathLib.cc)
add_library(jnitests SHARED ${SOURCE_FILES})

There isn’t too much to explain here.

  • We’re asking cmake to find our system install of JNI, which allows the build system to locate the jni.h header.
  • We build the library with the C++17 standard, and we can see that the only target we’re actually building is the jnitests target, which depends on the math_lib_Mathlib.cc C++ file we wrote above.

Let’s build the library. To ground us to what we’ve done up to this point, our directory structure now looks like the following:

workstation >> tree .

├── CMakeLists.txt                [      written by us       ]
├── jni
│   ├── math_lib_MathLib.cc       [      written by us       ]
│   └── math_lib_MathLib.h        [    generated by javac    ] 
└── src
    ├── libjnitests.so            [    built by cmake/make   ]
    └── math_lib
        ├── MathLib.class         [    generated by javac    ]
        └── MathLib.java          [      written by us       ]

as usual, we’ll build the C/C++ in a separate build/ directory to keep things clean.

workstation >> mkdir build; cd build;
workstation >> cmake ..

-- The C compiler identification is GNU 11.4.0
-- The CXX compiler identification is GNU 11.4.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Found JNI: /usr/lib/jvm/java-21-openjdk-amd64/lib/libjawt.so  
-- Configuring done
-- Generating done
-- Build files have been written to:build

workstation >> make 

[ 50%] Building CXX object CMakeFiles/jnitests.dir/jni/math_lib_MathLib.cc.o
[100%] Linking CXX shared library libjnitests.so
[100%] Built target jnitests

workstation >> cd ..
workstation >> tree .

├── build
│   ├── CMakeCache.txt
│   ├── CMakeFiles/ 
│   ├── cmake_install.cmake
│   ├── libjnitests.so            [   < ----------- here it is   ]
│   └── Makefile
├── CMakeLists.txt
├── jni
│   ├── math_lib_MathLib.cc
│   └── math_lib_MathLib.h
├── make_jni_header
└── src
    └── math_lib
        ├── MathLib.class
        └── MathLib.java

Running our program

As I mentioned above, we set our project up so that java expects to be run from the src/ directory. As long as libjnitests.so is in our Java path, we will be able to use it. For simplicity, I’ll just copy it to the directory I’m going to run in. Then we’re ready to run our program.

workstation >> cd src/
workstation >> cp ../build/libjnitests.so .
workstation >> java math_lib/MathLib
MathLib.main() called
x: 1.000000
Java: exp(x): 2.718282
C++ : exp(x): 2.718282

Discussion

That’s all there is to it. We’ve embedded C++ into Java, from scratch. We can add more complicated functionality, but the ideas herein constitute the heart of the complicated build systems that use the JNI.

A brief note on performance: the JNI acts as a bridge between the operating system memory and instructions executed by C++ and those of the Java virtual machine. Therefore, communication between these two layers can be expensive. As a the JNI design tips documentation of the Android NDK explains, marshalling resources at the JNI layer or performing asynchronous communication between Java and C++ comes with a cost. Therefore it’s best to keep everything as separated as possible.

In subsequent posts, we’ll combine what we’ve developed in part I and part II to build an Android app which can call native C++ as a computational backend to an app run in Java/Kotlin.