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 evaluateexp(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 functionJavaMathExp
. 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 theloadLibrary
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
fromMathLib.java
- It’s generated a JNI header file
jni/math_lib_MathLib.h
for us injni
- 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.
- We specified with the
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 likeJNIEnv
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 aJNIEnv
object, which is the JNI environment and provides an interface into the Java virtual machine from the C side of things. The second is ajclass
object, which is a reference to the class object in which the native function was declared, here theMathLib
class. The third is the actual argument we declared in the function signature, namely thejdouble
(Java double) object which is the argument to our Java functionMathLib.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 thejni.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 themath_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.