Compare the speed of calling a shared library built in Go from Java with JNI and JNA

golangjava

JNI (Java Native Interface) is an interface to enable Java to call native codes in C/C++ executed outside JVM and enable native codes to call codes in Java. This makes it possible to speed up heavy processing and share processing across multiple platforms. JNA (Java Native Access), a library, is also available to call native codes from Java, and it is easier to use and makes effort to minimize the overhead, but it seems to be slower than JNI. So, I tried comparing the speed with actually calling a simple shared library with JNI and JNA.

There are whole codes in GitHub.

Build a shared library in Go

cgo is a mechanism to call C codes from Go. Once import “C” and #include necessary libraries with comments just above it, you can refer to C functions like C.strcpy(), and convert types with like C.GoString() and C.CString(). C.CString() calls malloc(), so you need to call free() after that.

package main

/*
#include <stdlib.h>
#include <string.h>
*/
import "C"
import (
	"strings"
	"unsafe"
)

//export repeat
func repeat(buf *C.char, n C.int) {

	cRetStr := C.CString(strings.Repeat(C.GoString(buf), int(n)))
	defer C.free(unsafe.Pointer(cRetStr))

	C.strcpy(buf, cRetStr)
}

func main() {}

Write “export function_name” comments to functions to be exported and build them with –buildmode=c-shared, and then a shared library in C and a header file are outputted. By the way, when cross-compiling, cgo is disabled by default, so you need to explicitly set CGO_ENABLED=1.

$ go build -o libtestjna.so --buildmode=c-shared testjna.go
$ cat libtestjna.h
...
extern char* repeat(char* str, int n);

UnsatisfiedLinkError of glibc

Once shared libraries are placed in /lib of Lambda Layer, they are added to LD_LIBRARY_PATH, so I built them in ubuntu-latest of GitHub Actions and created the layer, and then tried to call them from Lambda running on Amazon Linux 2, but the error occurred due to glibc version difference. When I built them in the same environment, they succeeded to be called.

GENERIC_USER_ERROR: Encountered an exception[java.lang.UnsatisfiedLinkError] from your LambdaFunction[test-function] executed in context[ping] with message[Error loading class net.sambaiz.TestHandler: /opt/lib/libtest.so: /lib64/libc.so.6: version `GLIBC_2.32' not found (required by /opt/lib/libtest.so)]

Call with JNI

First, in order to get the arguments and construct objects, implement the process of calling the JNI function pointers of JNIEnv in C, which cannot be implemented in cgo. jstring and jinit defined in ${JAVA_HOME}/include/jni.h is types that correspond to Java types. If null is passed from Java, GetStringUTFChars() occurs an error, so you need to check it.

// calljnifunc.h
#include "jni.h"

const char* CallJNIGetStringUTFChars(JNIEnv* env, jstring str, jboolean* isCopy);
void CallJNIReleaseStringUTFChars(JNIEnv* env, jstring str, const char* utf);
jstring CallJNINewStringUTF(JNIEnv *env, const char *bytes); 

// calljnifunc.c
#include "calljnifunc.h"

const char* CallJNIGetStringUTFChars(JNIEnv *env, jstring str, jboolean *isCopy) {
  if (str == NULL) {
    return NULL;
  }
  return (*env)->GetStringUTFChars(env, str, isCopy);
}

void CallJNIReleaseStringUTFChars(JNIEnv *env, jstring str, const char *utf) {
  if (str == NULL) {
    return;
  }
  (*env)->ReleaseStringUTFChars(env, str, utf);
}

jstring CallJNINewStringUTF(JNIEnv *env, const char *bytes) {
  return (*env)->NewStringUTF(env, bytes);
}

Besides, non-primitive type arrays can be received as a jobjectArray and obtained by GetObjectArrayElement(). An array pointer can be converted to a Go Slice with unsafe.Slice().

// C
int GetJStringsFromObjectArray(JNIEnv *env, jobjectArray stringArray, jstring** retStrings) {
  int len = (*env)->GetArrayLength(env, stringArray);
  jstring* jStrs = malloc(sizeof(jstring) * len);
  for (int i = 0; i < len; i++) {
      jStrs[i] = (jstring) ((*env)->GetObjectArrayElement(env, stringArray, i));
  }
  *retStrings = jStrs;
  return len;
}

// Go
var jStrsPointer *C.jstring
size := C.GetJStringsFromObjectArray(env, stringArray, &jStrsPointer)
defer C.free(unsafe.Pointer(jStrsPointer))

var jStrsSlice []C.jstring = unsafe.Slice(jStrsPointer, int(size))
for _, jStr := range jStrsSlice {
  cStr := C.CallJNIGetStringUTFChars(env, jStr, (*C.jboolean)(nil))
  defer C.CallJNIReleaseStringUTFChars(env, jStr, cStr)
  
  ...
}

Build the shared library.

$ gcc -shared -fPIC -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux calljnifunc.c -o libcalljnifunc.so

Call it from Go. The name of functions to be exported have to be the format of Java_package_class_function, and it takes *JNIEnv and jclass as the first and second argument.

package main

/*
#cgo CFLAGS: -I<%= ENV['JAVA_HOME'] %>/include/
#cgo CFLAGS: -I<%= ENV['JAVA_HOME'] %>/include/<%= ENV['OS_NAME'] %>
#cgo LDFLAGS: -L${SRCDIR} -lcalljnifunc
#include "calljnifunc.h"
#include <stdlib.h>
*/
import "C"
import (
	"strings"
	"unsafe"
)

//export Java_net_sambaiz_TestLibJNI_repeat
func Java_net_sambaiz_TestLibJNI_repeat(env *C.JNIEnv, clazz C.jclass, jArgStr C.jstring, jArgN C.jint) C.jstring {
	cArgStr := C.CallJNIGetStringUTFChars(env, jArgStr, (*C.jboolean)(nil))
	defer C.CallJNIReleaseStringUTFChars(env, jArgStr, cArgStr)

	cRetStr := C.CString(strings.Repeat(C.GoString(cArgStr), int(jArgN)))
	defer C.free(unsafe.Pointer(cRetStr))

	return C.CallJNINewStringUTF(env, cRetStr)
}

func main() {}

Build it and load it with System.loadLibrary(), and then you can call it through the native function. The argument of loadLibrary() is a string excluding the lib prefix of the file name and the extension (.dll/.dylib/.so) depending on the OS. The path can be passed with -Djava.library.path, and you can also use LD_LIBRARY_PATH on linux.

package net.sambaiz;

import com.sun.jna.Native;

import java.io.File;
import java.io.IOException;

public class TestLibJNI {
    static {
        System.loadLibrary("testjni");
    }

    public native String repeat(String str, int n);
}

Types of the three languages including Go are needed to be handled and C codes are needed to get arguments, but anyway, troubleshooting was hard. JVM crashed many times due to mistakes such as forgetting LDFLAGS, and UnsatisfiedLinkError occurred due to typo the function name.

[ERROR] org.apache.maven.surefire.booter.SurefireBooterForkException: The forked VM terminated without properly saying goodbye. VM crash or System.exit called?

Call with JNA

Add the library to dependencies.

<!-- pom.xml -->
<dependency>
    <groupId>net.java.dev.jna</groupId>
    <artifactId>jna</artifactId>
    <version>5.13.0</version>
</dependency>

Unlike JNI, JNA can call C functions directly by mapping them to Java methods. There are two mapping ways, Interface mapping and Direct mapping.

In interface mapping, if you pass a class that extends Library to Native.load(), an instance will be returned. Once the methods are called, native functions are called with converted types through generic invoke functions.

public interface TestLibJNAInterfaceMapping extends Library {
    TestLibJNAInterfaceMapping INSTANCE = Native.load("testjna", TestLibJNAInterfaceMapping.class);
    
    void repeat(byte[] buf, int n);
}

On the other hand, Direct Mapping is faster because it calls directly without going through the proxy, but there are unsupported types such as array of Strings.

public class TestLibJNADirectMapping {
    static {
        Native.register("testjna");
    }

    public static native void repeat(byte[] buf, int n);
}

The library path can be passed with -Djna.library.path.

When you build a shared library in Go, the primitive Go types are defined as typedef of C types, so I thought it could work without referring to C types directly. However, string corresponds to the following struct instead of char*, so when multiple arguments such as (string, int) are taken, the second argument couldn’t be passed correctly and crashed. Therefore, I am using C types to define functions to be exported.

typedef int GoInt32;
typedef struct { const char *p; ptrdiff_t n; } _GoString_;

Compare the speed

Run benchmarks with JMH.

Benchmark Java codes with JMH - sambaiz-net

package net.sambaiz;

import com.sun.jna.Native;
import org.openjdk.jmh.annotations.*;

import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Benchmarks {
    @State(Scope.Thread)
    public static class Instances {
        public TestLibJNI testLibJNI = new TestLibJNI();
        public TestLibJNAInterfaceMapping testLibJNAInterfaceMapping = TestLibJNAInterfaceMapping.INSTANCE;
        public TestLibJNADirectMapping testLibJNADirectMapping = new TestLibJNADirectMapping();
    }

    @Benchmark
    public String repeatWithJNI(Instances in) {
        return in.testLibJNI.repeat("abc", 3);
    }

    @Benchmark
    public String repeatWithJNAInterfaceMapping(Instances in) {
        byte[] buf = new byte[256];
        byte[] data = "abc".getBytes();
        System.arraycopy(data, 0, buf, 0, data.length);

        in.testLibJNAInterfaceMapping.repeat(buf, 3);
        return Native.toString(buf);
    }

    @Benchmark
    public String repeatWithJNADirectMapping(Instances in) {
        byte[] buf = new byte[256];
        byte[] data = "abc".getBytes();
        System.arraycopy(data, 0, buf, 0, data.length);

        in.testLibJNADirectMapping.repeat(buf, 3);
        return Native.toString(buf);
    }
}

The results are as follows. In this case, DirectMapping takes about the same time as JNI, and Interface Mapping takes about twice as long as JNI.

Benchmark                                 Mode  Cnt     Score    Error  Units
Benchmarks.repeatWithJNADirectMapping     avgt   25  1690.029 ± 19.361  ns/op
Benchmarks.repeatWithJNAInterfaceMapping  avgt   25  3178.877 ± 29.591  ns/op
Benchmarks.repeatWithJNI                  avgt   25  1718.787 ±  9.206  ns/op 

The scale of 1500ns is hard to be imagined, but considering it is almost the same time as encrypting 128 bytes with OFB mode of crypto/cipher in Go, I feel the overhead is a little big. Android NDK docs mention the cost of marshaling and recommend minimizing the frequency of calls and the amount of data passed.

Goのcipher packageに実装されている暗号利用モードのベンチマーク - sambaiz-net

References

CALL GO CODE FROM JAVA THROUGH JNI