ネイティブ・イメージを使用したJavaでのネイティブ・メソッドの実装
ネイティブ・イメージを使用すると、Javaで低レベルのシステム操作を実装し、標準JVMで実行されるJavaコードでJNIを介して使用できるようにすることができます。そのため、同じ言語を使用してアプリケーション・ロジックとシステム・コールを記述できます。
このドキュメントでは、JNIを介して一般的に実行される処理とは反対のことについて説明します。通常、低レベルのシステム操作はCで実装され、JNIを使用してJavaから呼び出されます。ネイティブ・イメージにおける一般的なユース・ケースのサポートの詳細は、かわりにネイティブ・イメージJNIサポート・ガイドを参照してください。
共有ライブラリの作成
まず、native-image
ビルダーを使用して、JNI互換のエントリ・ポイントを持つ共有ライブラリを生成する必要があります。次のJavaコードから始めます:
package org.pkg.implnative;
import org.graalvm.nativeimage.c.function.CEntryPoint;
import org.graalvm.word.Pointer;
public final class NativeImpl {
@CEntryPoint(name = "Java_org_pkg_apinative_Native_add")
public static int add(Pointer jniEnv, Pointer clazz, @CEntryPoint.IsolateThreadContext long isolateId, int a, int b) {
return a + b;
}
}
native-image
ビルダーによるコードの処理後、Java_org_pkg_apinative_Native_add
(名前は後で便利なようにJNIの命名規則に準拠) C関数が公開され、JNIメソッドの標準的なネイティブ・イメージ・シグネチャが公開されます。1つ目のパラメータは、JNIEnv*
値の参照です。2つ目のパラメータは、メソッドを宣言するクラスのjclass
値の参照です。3つ目のパラメータは、ネイティブ・イメージ分離スレッドの移植可能な(long
など)識別子です。残りのパラメータは、次の項で説明するJava Native.add
メソッドの実際のパラメータです。--shared
オプションを使用してコードをコンパイルします:
$GRAALVM/bin/native-image --shared -H:Name=libnativeimpl -cp nativeimpl
libnativeimpl.so
が生成されます。標準Javaコードから使用できるようになりました。
Javaネイティブ・メソッドのバインド
前のステップで生成されたネイティブ・ライブラリを使用するには、別のJavaクラスが必要です:
package org.pkg.apinative;
public final class Native {
private static native int add(long isolateThreadId, int a, int b);
}
クラスのパッケージ名とメソッド名は、前に導入された@CEntryPoint
の名前に対応している必要があります(JNIマングリングの後)。1つ目の引数は、ネイティブ・イメージ分離スレッドの移植可能な(long
など)識別子です。残りの引数は、エントリ・ポイントのパラメータと一致します。
ネイティブ・ライブラリのロード
次のステップでは、生成された.so
ライブラリにJDKをバインドします。たとえば、ネイティブのNative.add
メソッドの実装がロードされていることを確認します。単純なload
またはloadLibrary
コールでは、次の処理が実行されます:
public static void main(String[] args) {
System.loadLibrary("nativeimpl");
// ...
}
これは、LD_LIBRARY_PATH
環境変数が指定されているか、java.library.path
Javaプロパティが正しく設定されていることを前提としています。
ネイティブ・イメージ分離の初期化
Native.add
メソッドをコールする前に、ネイティブ・イメージ分離を作成する必要があります。ネイティブ・イメージには、そのための特別な組込み機能(CEntryPoint.Builtin.CREATE_ISOLATE
)が用意されています。他の既存の@CEntryPoint
メソッドに沿って別のメソッドを定義します。パラメータを渡さずにIsolateThread
が返されるようにします:
public final class NativeImpl {
@CEntryPoint(name = "Java_org_pkg_apinative_Native_createIsolate", builtin=CEntryPoint.Builtin.CREATE_ISOLATE)
public static native IsolateThread createIsolate();
}
その後、ネイティブ・イメージにより、メソッドのデフォルトのネイティブ実装が最終的な.so
ライブラリに生成されます。このメソッドにより、ネイティブ・イメージ・ランタイムが初期化され、ネイティブ・イメージ分離スレッドのインスタンスを保持する移植可能なID (long
など)が返されます。分離スレッドは、コードのネイティブ部分の複数の呼出しに使用できます:
package org.pkg.apinative;
public final class Native {
public static void main(String[] args) {
System.loadLibrary("nativeimpl");
long isolateThread = createIsolate();
System.out.println("2 + 40 = " + add(isolateThread, 2, 40));
System.out.println("12 + 30 = " + add(isolateThread, 12, 30));
System.out.println("20 + 22 = " + add(isolateThread, 20, 22));
}
private static native int add(long isolateThread, int a, int b);
private static native long createIsolate();
}
標準JVMが起動されます。ネイティブ・イメージ分離が初期化され、現在のスレッドが分離にアタッチされて、ユニバーサル・アンサー42
が分離内で3回計算されます。
ネイティブJavaからのJVMのコール
ネイティブ・イメージのCインタフェースに関する詳細なチュートリアルが用意されています。次の例は、JVMへのコールバックを行う方法を示しています。
従来の設定では、CでJVMをコールする必要がある場合、jni.hヘッダー・ファイルが使用されます。このファイルでは、必須のJVM構造(JNIEnv
など)に加え、JVM内のクラスの検査、フィールドへのアクセスおよびメソッドのコールを行うために呼び出すことができる関数を定義します。前述の例のNativeImpl
クラスからこれらの関数をコールするには、jni.h
概念の適切なJava APIラッパーを定義する必要があります:
@CContext(JNIHeaderDirectives.class)
@CStruct(value = "JNIEnv_", addStructKeyword = true)
interface JNIEnvironment extends PointerBase {
@CField("functions")
JNINativeInterface getFunctions();
}
@CPointerTo(JNIEnvironment.class)
interface JNIEnvironmentPointer extends PointerBase {
JNIEnvironment read();
void write(JNIEnvironment value);
}
@CContext(JNIHeaderDirectives.class)
@CStruct(value = "JNINativeInterface_", addStructKeyword = true)
interface JNINativeInterface extends PointerBase {
@CField
GetMethodId getGetStaticMethodID();
@CField
CallStaticVoidMethod getCallStaticVoidMethodA();
}
interface GetMethodId extends CFunctionPointer {
@InvokeCFunctionPointer
JMethodID find(JNIEnvironment env, JClass clazz, CCharPointer name, CCharPointer sig);
}
interface JObject extends PointerBase {
}
interface CallStaticVoidMethod extends CFunctionPointer {
@InvokeCFunctionPointer
void call(JNIEnvironment env, JClass cls, JMethodID methodid, JValue args);
}
interface JClass extends PointerBase {
}
interface JMethodID extends PointerBase {
}
現時点では、JNIHeaderDirectives
の意味を除けば、残りのインタフェースはjni.h
ファイルにあるCポインタの型安全な表現です。JClass
、JMethodID
およびJObject
はすべてポインタです。前述の定義により、これらのオブジェクトのインスタンスをネイティブJavaコードで型安全な方法で表現するためのJavaインタフェースが作成されました。
JNI APIのコア部分は、JVMと通信するときにコールできる関数のセットです。それらは多数ありますが、JNINativeInterface
定義では、単に、この例で必要なものに対してラッパーを定義します。この場合も、適切な型を指定して、ネイティブJavaコードでGetMethodId.find(...)
、CallStaticVoidMethod.call(...)
などを使用できるようにします。欠落するパズルのピースとなるもう1つの重要な部分は、使用可能なすべてのJavaプリミティブ型およびオブジェクト型をラップするjvalue
共用体型です。getterおよびsetterの定義を次に示します:
@CContext(JNIHeaderDirectives.class)
@CStruct("jvalue")
interface JValue extends PointerBase {
@CField boolean z();
@CField byte b();
@CField char c();
@CField short s();
@CField int i();
@CField long j();
@CField float f();
@CField double d();
@CField JObject l();
@CField void z(boolean b);
@CField void b(byte b);
@CField void c(char ch);
@CField void s(short s);
@CField void i(int i);
@CField void j(long l);
@CField void f(float f);
@CField void d(double d);
@CField void l(JObject obj);
JValue addressOf(int index);
}
addressOf
メソッドは、Cポインタ演算を実行するために使用される特殊なネイティブ・イメージ構造体です。ポインタを指定すると、それを配列の初期要素として扱い、たとえば、addressOf(1)
を使用して後続の要素にアクセスできます。これにより、コールバックの実行に必要なすべてのAPIが得られます。前に導入したNativeImpl.add
メソッドを再定義して適切に型指定されたポインタが受け入れられるようにし、これらのポインタを使用してJVMメソッドを呼び出してから、a + b
の合計を計算します:
@CEntryPoint(name = "Java_org_pkg_apinative_Native_add")
static int add(JNIEnvironment env, JClass clazz, @CEntryPoint.IsolateThreadContext long isolateThreadId, int a, int b) {
JNINativeInterface fn = env.getFunctions();
try (
CTypeConversion.CCharPointerHolder name = CTypeConversion.toCString("hello");
CTypeConversion.CCharPointerHolder sig = CTypeConversion.toCString("(ZCBSIJFD)V");
) {
JMethodID helloId = fn.getGetStaticMethodID().find(env, clazz, name.get(), sig.get());
JValue args = StackValue.get(8, JValue.class);
args.addressOf(0).z(false);
args.addressOf(1).c('A');
args.addressOf(2).b((byte)22);
args.addressOf(3).s((short)33);
args.addressOf(4).i(39);
args.addressOf(5).j(Long.MAX_VALUE / 2l);
args.addressOf(6).f((float) Math.PI);
args.addressOf(7).d(Math.PI);
fn.getCallStaticVoidMethodA().call(env, clazz, helloId, args);
}
return a + b;
}
前述の例では、静的メソッドhello
を検索し、スタック上のStackValue.get
によって予約されている配列内の8つのJValue
パラメータを使用して呼び出します。個々のパラメータはaddressOf
演算子を使用してアクセスされ、コールが発生する前に適切なプリミティブ値が入力されます。メソッドhello
はクラスNative
に定義されています。このメソッドにより、すべてのパラメータの値が出力されるため、NativeImpl.add
のコール元から正しく伝播されたことを確認できます:
public class Native {
public static void hello(boolean z, char c, byte b, short s, int i, long j, float f, double d) {
System.err.println("Hi, I have just been called back!");
System.err.print("With: " + z + " " + c + " " + b + " " + s);
System.err.println(" and: " + i + " " + j + " " + f + " " + d);
}
説明が必要な最後のピースはJNIHeaderDirectives
です。ネイティブ・イメージのCインタフェースでは、C構造体のレイアウトを認識できる必要があります。JNINativeInterface
構造体のどのオフセットでGetMethodId
関数へのポインタを検出できるかを認識する必要があります。そのためには、コンパイル時にjni.h
および追加ファイルが必要になります。これらは、@CContext
注釈とそのDirectives
の実装によって指定できます:
final class JNIHeaderDirectives implements CContext.Directives {
@Override
public List<String> getOptions() {
File[] jnis = findJNIHeaders();
return Arrays.asList("-I" + jnis[0].getParent(), "-I" + jnis[1].getParent());
}
@Override
public List<String> getHeaderFiles() {
File[] jnis = findJNIHeaders();
return Arrays.asList("<" + jnis[0] + ">", "<" + jnis[1] + ">");
}
private static File[] findJNIHeaders() throws IllegalStateException {
final File jreHome = new File(System.getProperty("java.home"));
final File include = new File(jreHome.getParentFile(), "include");
final File[] jnis = {
new File(include, "jni.h"),
new File(new File(include, "linux"), "jni_md.h"),
};
return jnis;
}
}
利点は、すべてのJDK内にjni.h
が含まれていることから、java.home
プロパティを使用して必要なヘッダー・ファイルを検索できることです。もちろん、実際のロジックは、より堅牢でOS非依存にすることもできます。
JavaでのJVMネイティブ・メソッドの実装またはネイティブ・イメージを使用したJVMへのコールバック(あるいはその両方)は、提示された例を発展させてnative-image
を呼び出すことで簡単に実行できるようになりました。