Java から Go でビルドした shared library を JNI と JNA で呼び出した際の速度を比較する

golangjava

JNI (Java Native Interface) は Java から JVM 外で実行される C/C++ のネイティブコードを呼び出したり、ネイティブコードから Java のコードを呼び出すためのインタフェースで、 これにより重い処理を高速化したり複数プラットフォームで処理を共通化することができる。 Java からネイティブコードを呼ぶのは JNA (Java Native Access) というライブラリを用いることでもでき、 実装は簡単だが、速度についてはなるべく問題にならないよう配慮はされているものの JNI と比べると差があるようだ。 そこで実際に JNI と JNA で簡単な shared library を呼び出した際の速度を比較してみる。

全体のコードは GitHub にある。

Go での shared library のビルド

Go には cgo という C のコードを呼ぶための仕組みがあり、import “C” して、そのすぐ上にコメントで必要なライブラリを #include すると、 C.strcpy() のようにして C の関数などを参照したり、C.GoString() や C.CString() などによって型を変換することができる。 C.CString() は malloc() するのでどこかで free() する必要がある。

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() {}

公開する関数に “export 関数名” のコメントを書き –buildmode=c-shared でビルドすると、C の shared library と header ファイルを出力できる。 ちなみにクロスコンパイルする場合は cgo がデフォルトで無効になるので明示的に CGO_ENABLED=1 を明示的に設定する必要がある。

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

glibc の UnsatisfiedLinkError

Shared library を Lambda Layer の /lib に置くと LD_LIBRARY_PATH が通るので、 GitHub Actions の ubuntu-latest でビルドして Layer を作成し、現状 Amazon Linux 2 上で動く Lambda から読もうとしたところ、C の標準ライブラリが含まれている glibc のバージョン違いでエラーになってしまったので 同環境でビルドしたところ動くようになった。

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)]

JNI での呼び出し

まずは引数を取得したりオブジェクトを作成するため cgo では行えない JNIEnv に含まれている JNI の関数のポインタを呼ぶ処理を C で実装する。 jstring や jint は ${JAVA_HOME}/include/jni.h で定義されているJavaの型に対応する型。 Java から null が渡されたときに GetStringUTFChars() するとエラーになってしまうのでチェックする必要がある。

// 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);
}

ちなみにプリミティブ型でない配列は jobjectArray で受け取り、GetObjectArrayElement() で取得する。 配列のポインタは unsafe.Slice() で Go の 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)
  
  ...
}

ビルドして shared library を生成する。

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

これを Go から呼び出す。 export する関数名は Java_package_class_function の形式で、 第一引数と第二引数で *JNIEnv と jclass を受け取る必要がある。

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() {}

これをビルドし System.loadLibrary() すると native の関数を通して呼ぶことができるようになる。 loadLibrary() に渡すのはファイル名の先頭の lib と OS ごとに異なる拡張子 (.dll/.dylib/.so) を除いた文字列。 パスは -Djava.library.path で通すことができ、linux では LD_LIBRARY_PATH を用いることもできる。

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);
}

Go も含めると 3 言語の型を意識したり、引数を取得するのに C のコードが必要だったりすることもあるが、とにかくトラブルシューティングが大変だった。 LDFLAGS を書き忘れるなど何か間違えるたびに JVM が crash し、関数名を typo すると UnsatisfiedLinkError になってしまう。

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

JNA での呼び出し

dependency に追加する。

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

JNI と異なり JNA は C の関数をそのまま Java のメソッドにマッピングして呼び出すことができる。 メソッドへのマッピングの方法として Interface mappingDirect mapping がある。

Interface mapping では Library を継承したクラスを Native.load() に渡すとインスタンスが返り、そのメソッドを呼ぶと 汎用の invoke 関数を介して型変換を行い対応するネイティブの関数が呼び出される。

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

一方、Direct Mapping はプロキシを介さず直接呼び出すので高速だが String の配列など対応していない型がある。

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

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

ライブラリのパスは -Djna.library.path で通すことができる。

Go で shared library を生成すると Go の型が対応する C の型に typedef されているため、C の型を参照しなくても良さそうだが、 string が char* ではなく次のような struct に対応しているために (string, int) のような複数の引数を取ると後方の引数が正しく渡らず crash してしまうことがあったので、 export する関数の型は C のものに統一した。

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

速度の比較

JMH でベンチマークを取る。

JMH で Java のコードのベンチマークを取る - 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);
    }
}

結果は次の通り。今回の例では DirectMapping は ほぼ JNI と同じ時間で、Interface Mapping でも JNI の 2 倍程度の時間に収まっている。

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 

1500ns というと想像しづらいが、Go の crypto/cipher の OFB モードで 128 bytes を暗号化したときと同程度の時間と考えるとややオーバーヘッドが大きく感じる。 Android NDK のドキュメントでもマーシャリングのコストについて言及されており、 呼び出し頻度や渡すデータ量を最小限に抑えることが推奨されている。

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

参考

CALL GO CODE FROM JAVA THROUGH JNI