使用するプログラミング言語に関係なく、C関数を呼び出すことはしばしば必要になります。長い間、Cを呼び出す唯一の標準的な方法はJava Native Interface (JNI) でした。しかしそれは非常に煩雑で、敢えて行う人はほとんどいませんでした。純粋なJavaをできるだけ使うよう促すために意図的に煩雑にされたと言われることもあります。

Java 22 以降では、java.lang.foreign に Foreign Function & Memory API という新しいアプローチが導入されました。順を追って説明します。

呼び出したいネイティブ関数を捕捉する MethodHandle を構築するには、LinkerSymbolLookup のインスタンスが必要です。

リンカーは簡単に取得できます:

Linker linker = Linker.nativeLinker();

ライブラリ(mylibrary とします)の SymbolLookup インスタンスを読み込むには、次のようにします:

System.loadLibrary("mylibrary");
SymbolLookup lookup = SymbolLookup.loaderLookup();

ネイティブライブラリファイルは java.library.path またはデフォルトのライブラリパス上に存在する必要があります(-Djava.library.path=something を java 実行時に渡すこともできます)。

あるいは SymbolLookup.libraryLookup など他の方法でライブラリを読み込むこともできますが、System.loadLibrary で十分に動作します。

lookup を取得したら、以下のようにして関数のアドレスを取得できます:

lookup.find("myfunction")

これは Optional<MemorySegment> を返します。次のようにして MemorySegment を取得できます:

MemorySegment mem = lookup.find("myfunction").orElseThrow()

MemorySegment を取得したら、それを linker に渡して、呼び出し可能な関数に近い MethodHandle を取得できます:

 MethodHandle myfunc = linker.downcallHandle(
     mem,
     functiondescr
 );

functiondescr は、関数が返す値と受け取るパラメータを記述する必要があります。ポインタを渡して long 値を受け取る場合は、次のようにします:

 MethodHandle myfunc = linker.downcallHandle(
     mem,
     FunctionDescriptor.of(
        ValueLayout.JAVA_LONG,
        ValueLayout.ADDRESS
    )
 );

最初の引数が返り値になります。

何も返さない関数の場合は FunctionDescriptor.ofVoid を使用します。

MethodHandle は通常の Java 関数とほぼ同じように呼び出せます:
myfunc.invokeExact(parameters)。常に Object を返すため、long を返すはずの場合は Long が返されます。キャストが必要になることがあります。

少し煩雑ですが、jextract というツールを使うことでこの作業を自動化できます。ネイティブライブラリのヘッダーから Java バインディングを生成します。

Arena を使用して、ネイティブコードに渡す C のデータ構造を Java から割り当てることができます。次のようなインスタンスを作成したいとします:

MemoryLayout mystruct = MemoryLayout.structLayout(
        ValueLayout.JAVA_LONG.withName("age"),
        ValueLayout.JAVA_INT.withName("friends"));

次のようにして作成できます:

MemorySegment myseg = arena.allocate(mystruct);

その後、myseg を C のデータ構造へのポインタとして渡せます。

配列は try 句で次のように取得することがよくあります:

try (Arena arena = Arena.ofConfined()) {
       //
}

アリーナには confined、global、automatic、shared など多くの種類があります。confined アリーナは単一のスレッドからのみアクセス可能です。shared または global アリーナは複数のスレッドからアクセス可能です。global と automatic アリーナは Java のガベージコレクタによって管理され、confined と shared アリーナは明示的に管理され、特定のライフタイムを持ちます。

かなり複雑ですが、管理可能です。高速でしょうか? 確認するため、Java から binary fuse filters をサポートする自作の C ライブラリを呼び出してみます。binary fuse filters は Bloom filters の高速な代替です。

これらの意味を知る必要はありませんが、C ライブラリを呼び出す Java ライブラリ jfusebin を作成しました。また純粋な Java 実装 もあり、速度を比較できます。

最初に指摘しておきたいのは、C 関数の呼び出しにオーバーヘッドが一切なくても、Java コンパイラがネイティブ関数をインライン化しにくいため、依然として遅くなる可能性があることです。一方、純粋な Java 関数で比較的小さい場合はインライン化され、定数畳み込みなどのさまざまな最適化が適用されます。

したがって、オーバーヘッドのコストを過大評価することができます。ただし、それで問題ありません。大まかな目安が欲しいだけです。

ベンチマークでは、集合内のキーの存在をチェックします。フィルタには 100 万個のキーを入れています。キーがフィルタに存在しないかどうかを問い合わせることができます。

8 ビットの binary fuse filter を使用した C を呼び出すライブラリでは、毎秒 4400 万回の呼び出しが可能でした。純粋な Java 実装では毎秒約 4 億回の呼び出しに達しました。

method time per query in nanoseconds
Java-to-C 22.7 ns
Pure Java 2.5 ns

したがって、macBook(M4 プロセッサ)を使用して Java から C 関数を呼び出す場合、1 回の呼び出しあたり約 20 ns のオーバーヘッドを測定しました。

短時間で終了することが予想される関数を critical とマークすることで、少し改善できます。これは linker.downcallHandle の呼び出し時にオプションを渡すことで実現します。

binary_fuse8_contain = linker.downcallHandle(
    lookup.find("xfuse_binary_fuse8_contain").orElseThrow(),
    binary_fuse8_contain_desc,
    Linker.Option.critical(false)
);

今回のケースでは実行時間を約 15% 短縮できました。

method time per query in nanoseconds
Java-to-C 22.7 ns
Java-to-C (critical) 19.5 ns
Pure Java 2.5 ns

当然ながら、今回のケースでは Java ライブラリが非常に高速であるため、20 ns は大きすぎます。しかし、それ以外では妥当なオーバーヘッドです。

古いアプローチ(JNI)との比較は行いませんでしたが、他の人々が行っており、新しい foreign function アプローチの方が測定可能な速度向上(例: 50% 高速)であることがわかっています。特に、C から Java 関数を呼び出す場合も比較的高速になったと報告されています。私はこの機能自体はテストしていません。

新しいインターフェースの優れた機能のひとつは、Java ヒープから C 関数へ直接データを比較的容易に渡せることです。

次のような C 関数があるとします:

int sum_array(int* data, int count) {
    int sum = 0;
    for(int i = 0; i < count; i++) {
        sum += data[i];
    }
    return sum;
}

そして、次の Java 配列をコピーせずに C に渡したいとします:

int[] javaArray = {10, 20, 30, 40, 50};

次のコードのように簡単に実現できます。

System.loadLibrary("sum");
Linker linker = Linker.nativeLinker();
SymbolLookup lookup = SymbolLookup.loaderLookup();
MemorySegment sumAddress = lookup.find("sum_array").orElseThrow();

// C Signature: int sum_array(int* data, int count)
MethodHandle sumArray = linker.downcallHandle(
    sumAddress,
    FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT),
    Linker.Option.critical(true)
);

int[] javaArray = {10, 20, 30, 40, 50};

try (Arena arena = Arena.ofConfined()) {
    MemorySegment heapSegment = MemorySegment.ofArray(javaArray);
    int result = (int) sumArray.invoke(heapSegment, javaArray.length);
    System.out.println("The sum from C is: " + result);
}

数分で完全な例を作成しました。ネイティブライブラリを java が見つけられるようにすることがコツのひとつです。標準のライブラリパスにない場合は、-Djava.library.path で場所を指定できます:

java -Djava.library.path=target -cp target/classes IntArrayExample

参考文献。When Does Java’s Foreign Function & Memory API Actually Make Sense? by A N M Bazlur Rahman.