原文はこちら。
The original article was written by Maurizio Cimadamore (Software Architect, Oracle).
https://cr.openjdk.java.net/~mcimadamore/panama/ffi.html
この文書では、Panamaのforeign functionサポートの背後にある主要なコンセプトを探ります。ご覧の通り、foreign function(外部関数)サポートの中心的な抽象化はいわゆるforeign linker(外部リンカ)で、クライアントがネイティブ・メソッド・ハンドルを構築できるようにする抽象化です。つまり、ネイティブライブラリで定義されたネイティブ関数が呼び出されるメソッド・ハンドルのことです。ご覧の通り、Panamaのforeign functionサポートは完全にJavaコードで表現されているため、中間ネイティブコードは不要です。
Native addresses
foreign functionサポートの詳細に飛び込む前に、外部メモリアクセスサポートを探索する際に学んだ主な概念のいくつかを簡単にまとめておきましょう。
State of foreign memory support
http://cr.openjdk.java.net/~mcimadamore/panama/foreign-memaccess.html
https://logico-jp.io/2020/08/08/state-of-foreign-memory-support/
外部メモリアクセスAPIを使うと、クライアントがメモリセグメントを作成して操作することができます。メモリセグメントとは、空間的、時間的、スレッド的に制約された(オンヒープまたはオフヒープの)メモリソース上のビューのことです。この保証により、Javaコードが作成したセグメントのデリファレンスは常に安全であり、VMのクラッシュや、さらにひどい場合は何もエラーメッセージもなくメモリ破損を引き起こすことはありません。
メモリ・セグメントの場合、上記のプロパティ(空間的境界、時間的境界、閉じ込め)は、セグメントが作成された時点で完全に知ることができます。しかし、ネイティブライブラリと対話する場合、RAWポインタを受け取ることが多々あります。このようなポインタには空間的な境界(C言語のchar*は1つのcharを指すのか、それとも指定されたサイズのchar配列を指すのか)、時間的な境界の概念、スレッドの制約がありません。相互運用サポートにおけるRAWアドレスは、MemoryAddress抽象化を使ってモデル化されています。
MemoryAddress
はその名の通り、(オンヒープまたはオフヒープの)メモリアドレスをカプセル化したものです。メモリアクセスVarHandle(memory access var handle)を使ってメモリのデリファレンスをするにはセグメントが必要なので、メモリアドレスを直接参照することはできません。デリファレンスするためにはまずセグメントが必要です。そのため、クライアントはここで3つの異なる方法で処理を進めることができます。
- メモリアドレスが、クライアントが既に所有しているセグメントに属することがわかっている場合、リベース操作を実行できます。つまり、クライアントは与えられたセグメントに対する相対的なオフセットをアドレスに尋ね、それに応じて元のセグメントをデリファレンスできます。
MemorySegment segment = MemorySegment.allocateNative(100);
...
MemoryAddress addr = ... //obtain address from native code
int x = MemoryAccess.getIntAtOffset(segment, addr.segmentOffset(segment));
- クライアントが指定されたメモリアドレスを含むセグメントを持っていない場合、MemorySegment::ofNativeRestrictedを使用して安全でないセグメントを作成できます。これは、クライアントが対話しているネイティブライブラリで利用できる可能性がある、空間境界に関する知識を注入するのにも便利です。
MemoryAddress addr = ... //obtain address from native code
MemorySegment segment = MemorySegment.ofNativeRestricted(addr, 100, Thread.currentThread(), null, null);
int x = MemoryAccess.getInt(segment);
- あるいは、クライアントは、いわゆる everything セグメント、つまりネイティブ・ヒープ全体をカバーする原始的なセグメントを使用するようにフォールバックすることもできます。このセグメントは定数として利用可能なので、追加のセグメントインスタンスを作成しなくてもデリファレンスできます。
MemoryAddress addr = ... //obtain address from native code
int x = MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(), addr.toRawLongValue());
もちろん、ネイティブヒープ全体へのアクセスは本質的に安全ではないので、everything セグメントへのアクセスは制限された操作とみなされ、foreign.restricted=permitランタイムフラグを設定することで明示的にopt inした後にのみ許可されます。
MemoryAddress は、MemorySegment のように Addressable インターフェースを実装しています。これは、メソッドがエンティティを MemoryAddress インスタンスに投影する関数型インターフェースです。MemoryAddressの場合,このような投影は同一性関数であり,メモリセグメントの場合,投影はセグメントのベースアドレスに対するMemoryAddresインスタンスを返します.この抽象化により、メモリアドレスまたはアドレスが期待されるメモリセグメントのいずれかを渡すことができます (これは特にネイティブバインディングを生成する際に便利です)。
Symbol lookups
foreign functionサポートの最初の要素は、ネイティブ・ライブラリ内のシンボルを検索するメカニズムです。伝統的なJava/JNIでは、System::loadLibraryとSystem::loadメソッドを使って実施していて、内部的にdlopenの呼び出しにマップされます。Panamaでは、ライブラリ・ルックアップはより直接的に、(メソッド・ハンドル・ルックアップと類似した)LibraryLookupクラスを使ってモデル化されています。このクラスは与えられたネイティブライブラリ内の名前付きシンボルの検索が可能です。3つの方法でライブラリ・ルックアップを取得できます。
LibraryLookup::ofDefault | VM で読み込まれたすべてのシンボルを見ることができるライブラリ・ルックアップを返します。 |
LibraryLookup::ofPath | 指定した絶対パスにあるライブラリに関連付けられたライブラリ・ルックアップを作成します。 |
LibraryLookup::ofLibrary | 指定した絶対パスにあるライブラリに関連付けられたライブラリ・ルックアップを作成します。 |
ルックアップを取得すると、クライアントはそれを使用し、新しいLibraryLookup.Symbolを返すlookup(String)メソッドを使って(グローバル変数または関数)のライブラリシンボルへのハンドルを取得できます。ルックアップシンボルは、メモリアドレス(実際にはAddressableを実装しています)と名前のプロキシにすぎません。
例えば、以下のコードを使ってclangライブラリが提供するclang_getClangVersion関数を検索できます。
LibraryLookup libclang = LibraryLookup.ofLibrary("clang");
LibraryLookup.Symbol clangVersion = libclang.lookup("clang_getClangVersion");
ライブラリ・ローディング・サポートに関して、Foreign Linker APIとJNIの決定的な違いは、JNIライブラリがクラス・ローダにロードされることです。さらに、クラスローダの整合性を維持するために、同じJNIライブラリを複数のクラスローダにロードすることができません。
JNI Enhancements Introduced in version 1.2 of the JavaTM 2 SDK – Library and Version Management
https://docs.oracle.com/javase/7/docs/technotes/guides/jni/jni-12.html#libmanage
JavaTM 2 SDK のバージョン 1.2 で導入されたJNI の拡張機能 – ライブラリおよびバージョン管理
https://docs.oracle.com/javase/jp/7/technotes/guides/jni/jni-12.html#libmanage
ここで説明されているforeign functionサポートはより原始的です。つまりForeign Linker APIを使うと、ネイティブ・ライブラリを直接ターゲットにすることができます。クライアントがJNIコードを介在させる必要はありません。重要なことに、Foreign Linker APIがネイティブコードとの間でJavaオブジェクトを渡すことはありません。このため、LibraryLookupフックを使ってロードされたライブラリは、どのクラスローダーにも縛られず、必要に応じて何度でも(再)ロードできます。
Foreign linker
Panamaのforeign functionサポートの中核には、ForeignLinkerの抽象化があります。この抽象化は以下の両方の役目があります。
- ダウンコール(downcall、高レベルサブシステムから低レベルサブシステムの呼び出し。つまりJavaからC)の場合、ネイティブ関数の呼び出しをプレーンな MethodHandle 呼び出しとしてモデル化できます(ForeignLinker::downcallHandleを参照)。
- アップコール(upcall、低レベルサブシステムから高レベルサブシステムの呼び出し。つまりCからJava)の場合、既存の MethodHandle(Java メソッドを指す可能性があります)を、関数ポインタとしてネイティブ関数に渡すことができる MemorySegment に変換できます(ForeignLinker::upcallStub を参照)。
interface ForeignLinker {
MethodHandle downcallHandle(Addressable func, MethodType type, FunctionDescriptor function);
MemorySegment upcallStub(MethodHandle target, FunctionDescriptor function);
}
次のセクションでは、ダウンコール・ハンドルとアップコール・スタブがどのようにして作成されるのかを深く掘り下げていきます。まず、どちらの場合もFunctionDescriptorのインスタンスを取ります。基本的には、foreign functionのシグネチャを完全に記述するために使用されるメモリレイアウトの集合体です。Cといえば、CSupportクラスは多くのレイアウト定数(主要なCのプリミティブ型に1つずつ)を定義しています。これらのレイアウトを、C関数のシグネチャを記述するためにFunctionDescriptorを使用して組み合わせることができます。例えば、char*を受け取りlongを返すC関数がある場合、以下のような記述子でそのような関数をモデル化できます。
FunctionDescriptor func = FunctionDescriptor.of(CSupport.C_LONG, CSupport.C_POINTER);
上記で使用したレイアウトは、実行しているプラットフォームに応じて適切なレイアウトにマッピングされます。これはまた、これらのレイアウトがプラットフォームに依存することを意味します。例えば、C_LONG は Windows では 32 ビット値のレイアウトになりますが、Linux では 64 ビット値になります。ユーザーが特定のプラットフォームをターゲットにしたい場合は、プラットフォーム依存のレイアウト定数の特定のセットを利用することも可能です(例: CSupport.Win64.C_LONG)。
CSupportクラスで定義されたレイアウトは、扱いたいCの型を既にモデル化しているので便利なだけでなく、(レイアウト属性を使って)外部リンカのサポートが与えられた関数記述子に関連付けられた呼び出しシーケンスを計算するために使用する隠れた情報をも含んでいます。例えば、2つのC言語の型であるintとfloatは、似たようなメモリレイアウトを共有しているかもしれませんが(どちらも32ビット値として表現されます)、通常は異なるマシンレジスタを使用して渡されます。CSupport クラスの C固有のレイアウトに添付されたレイアウト属性が、引数と戻り値を正しい方法で解釈することを保証します。
downcallHandleとupcallStubの間のもう一つの類似性は、両方ともMethodTypeインスタンスを(直接または間接的に)受け付ける点です。メソッドタイプは、クライアントがダウンコール・ハンドルやアップコール・スタブと対話する際に使用する Java シグネチャを記述します。(CSupport::getSystemLinkerが返すものなどの)外部リンカ実装は、通常、どのレイアウトがどのJavaキャリアで使用できるかという制約を追加します。例えば、Javaキャリアのサイズが対応するレイアウトと同じであることを強制したり、特定のレイアウトが特定のキャリアに関連付けられていることを確認したりします。下表は、Linux/macOSの外部リンカ実装によって強制されるJavaキャリアとレイアウトのマッピングです。
C layout | Java carrier |
---|---|
C_BOOL | byte |
C_CHAR | byte |
C_SHORT | short |
C_INT | int |
C_LONG | long |
C_LONGLONG | long |
C_FLOAT | float |
C_DOUBLE | double |
C_POINTER | MemoryAddress |
GroupLayout | MemorySegment |
プリミティブ・レイアウトとプリミティブ・Javaキャリア(プラットフォームによって異なる可能性があります)の間のマッピングは別として、すべてのポインタ・レイアウトがMemoryAddressキャリアに対応する必要があるのに対し、構造体(レイアウトがGroupLayoutによって定義されている)はMemorySegmentキャリアに関連付けられている必要があることに注意することが重要です。
Downcalls
ここでは、外部リンカの抽象化を使用して、Javaからforeign functionを呼び出す方法を見ていきます。標準のCライブラリから以下の関数を呼び出す場合を考えます。
size_t strlen(const char *s);
呼び出すためには、以下のことが必要です。
- strlenのシンボルを検索
- CSupportクラスのレイアウトを使い、C関数のシグネチャを記述
- ネイティブ関数にオーバーレイしたいJavaシグネチャを選択(これはネイティブ・メソッド・ハンドルのクライアントが対話するシグネチャになります)
- 標準的なC言語の外部リンカを使い、ダウンコールのネイティブ・メソッド・ハンドルを上記情報を使って作成
以下で実現方法の例を示します(この章以後のすべての例の完全なコードはappendixを参照)。
MethodHandle strlen = CSupport.getSystemLinker().downcallHandle(
LibraryLookup.ofDefault().lookup("strlen"),
MethodType.methodType(long.class, MemoryAddress.class),
FunctionDescriptor.of(C_LONG, C_POINTER)
);
strlen関数は標準Cライブラリの一部であり、VMと一緒にロードされるので、デフォルトのルックアップを使用して検索できる点にご注意ください。唯一トリッキーな点は、size_tをどのようにモデル化するかということです。通常、この型はポインタのサイズであり、LinuxではC_LONGを使用できますが、WindowsではC_LONGLONGを使用しなければなりません。Java側では、longを使ってsize_tをモデル化し、ポインタはMemoryAddressパラメータを使ってモデル化します。
ダウンコールのネイティブ・メソッド・ハンドルを取得したら、それを使って他のメソッド・ハンドルと同じように利用できます。
long len = strlen.invokeExact(CSupport.toCString("Hello").address()) // 5
ここでは、CSupport のヘルパーメソッドを使用して、Java 文字列を NULL 終端の C 文字列を含むオフヒープメモリセグメントに変換しています。その後、そのセグメントをメソッド・ハンドルに渡して、結果を Javaのlongで取得します。ネイティブコードを介在させることなく、これらすべてのことが可能になっている、つまり全ての相互運用コードを(低レベルの)Javaで表現できることに注目してください。
Panamaにおけるforeign function呼び出しサポートの基本を見てきましたが、さらにいくつかの考慮事項を追加してみましょう。まず、相互運用コードはJavaで書かれているとはいえ、上記のコードが100%安全であるとは言えないことに注意してください。上記のようなダウンコールメソッドのハンドルを設定する際には、多くの任意の決定がありますが、そのうちのいくつかは(例えば、その関数が取るパラメータの数など)、私たちには明らかなものもあり得ますが、Panamaランタイムでは最終的に検証できません。結局のところ、ダイナミックライブラリのシンボルは、ほとんどの場合、数値のオフセットであり、デバッグ情報のある共有ライブラリを使用しなければ、ライブラリのシンボルには型の情報はありません。つまり、この場合、Panamaランタイムは、我々が記述したstrlen関数を信頼しなければなりません。この理由から、外部リンカへのアクセスは制限された操作であり、Javaランチャーのコマンドラインでランタイムフラグ
foreign.restricted=permit
が渡された場合にのみ実行できます[1]。
最後に、ここで関係するいくつかのエンティティのライフサイクルについて説明します。まず、ダウンコールのネイティブ・ハンドルがルックアップシンボルをラップしているので、そのシンボルがロードされたライブラリは、そのシンボルの一つを参照する到達可能なダウンコール・ハンドルが存在するまでロードされたままになります。上の例では、デフォルトのルックアップオブジェクトを使用しているため、この考慮事項はあまり重要ではありません。というのも、デフォルトのルックアップオブジェクトは、アプリケーションの全期間にわたって生存すすると仮定できるからです。
関数の中にはポインタや構造体を返すものもありますが、もし関数がポインタ(またはMemoryAddress)を返す場合、そのポインタには何のライフサイクルもないことを理解しておくことが重要です。ポインタに関連付けられたメモリを解放するか、何もしないか(ライブラリがポインタのライフサイクルを担当している場合)はクライアント次第です。ライブラリが構造体を値で返す場合は、新たに制限されたメモリ・セグメントがオフヒープに割り当てられ、呼び出された側に返されるため、状況が異なります。構造体のセグメントをクリーンアップするのは呼び出される側の責任です(MemorySegment::closeを使用)[2]。
性能面では、ネイティブ・メソッド・ハンドルを使用するforeign functionの呼び出しがどれほど効率的か疑問に思われるかもしれません。JVMはネイティブ・メソッド・ハンドルを特別にサポートしているので、与えられたメソッド・ハンドルが何度も(例えば、ホット・ループ内で)呼び出された場合、JITコンパイラはネイティブ関数を呼び出すために必要なアセンブリ・コードのスニペットを生成して、それを直接実行することを決定する可能性があります。ほとんどの場合、この方法でネイティブ関数を呼び出すことは、JNIで呼び出すのと同じくらい効率的です[3][4]。
Upcalls
時として、Javaコードを関数ポインタとしてネイティブ関数に渡すことが有用な場合があります。アップコールに対する外部リンカサポートを使ってこれを実現できます。説明のため、Cの標準ライブラリの以下の関数を考えます。
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
これは配列の中身をソートするために利用できる関数で、関数ポインタとして渡されるカスタムコンパレータ関数(compar)を使います。Javaコードからqsort関数の呼び出しを可能にするためには、まずそのためのダウンコールのネイティブ・メソッド・ハンドルを作成する必要があります。
MethodHandle qsort = CSupport.getSystemLinker().downcallHandle(
LibraryLookup.ofDefault().lookup("qsort"),
MethodType.methodType(void.class, MemoryAddress.class, long.class, long.class, MemoryAddress.class),
FunctionDescriptor.ofVoid(C_POINTER, C_LONG, C_LONG, C_POINTER)
);
前と同じく、C_LONGとlong.classを使ってC size_t型をマッピングし、最初のポインタパラメータ(配列ポインタ)と最後のパラメータ(関数ポインタ)の両方に対しMemoryAddess.classを使っています。
今回は、qsortのダウンコール・ハンドルを呼び出すために、最後のパラメータとして関数ポインタを渡す必要があります。既存のメソッド・ハンドルから関数ポインタを作成することができるので、ここで外部リンカのアップコールサポートが便利です。まず、(ポインタとして渡される)2つのint要素を比較する関数を書いてみましょう。
class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
return MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(), addr1.toRawLongValue()) -
MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(), addr2.toRawLongValue());
}
}
ここで、この関数が everything セグメントを使用して、ポインタの内容の安全でないデリファレンスをしていることがわかります。では、上のコンパレータ関数を指すメソッド・ハンドルを作成してみましょう。
MethodHandle comparHandle = MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class, MemoryAddress.class, MemoryAddress.class));
これでJavaのコンパレータ関数のメソッド・ハンドルができたので、外部リンカのアップコールサポートを使って関数ポインタを作成できます。ダウンコールについては、CSupportクラスのレイアウトを使ってforeign functionポインタのシグネチャを記述しなければなりません。
MemorySegment comparFunc = CSupport.getSystemLinker().upcallStub(
comparHandle,
FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER)
);
これで、Javaコンパレータ関数を呼び出すために利用可能なスタブを含むメモリセグメント(comparFunc)を最終的に手に入れました。つまり、qsortダウンコール・ハンドルを呼び出すために必要なものがすべて揃ったことになります。
MemorySegment array = MemorySegment.allocateNative(4 * 10);
array.copyFrom(MemorySegment.ofArray(new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 }));
qsort.invokeExact(array.address(), 10L, 4L, comparFunc.address());
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
上記のコードでは、オフヒープの配列を作成し、そこにJava配列の内容をコピーし(これをより簡潔に行う方法については次章で説明します)、その配列を外部リンカから取得したコンパレータ関数と共にqsortハンドルに渡しています。副作用として、呼び出し後、(Java で書かれたコンパレータ関数によって指示される通りに)オフヒープの配列の内容がソートされます。そして、そのセグメントから、ソートされた要素を含む新しいJava配列を抽出できます。これはより高度な例ですが、外部リンカの抽象化がもたらすネイティブの相互運用サポートがいかに強力であるかを示す例で、Javaとネイティブの間で完全な双方向の相互運用サポートを可能にしています。
前述のように、ライフサイクルについての簡単にまとめておきます。最初に、アップコール・スタブのライフサイクルは、外部リンカが返すセグメントのライフサイクルと結びついています。セグメントが閉じられると、アップコールはVMからアンインストールされ、有効な関数ポインタではなくなります。第二に、Javaアップコール関数に値渡しされる構造体(もしあれば)のライフサイクルは、アップコールのライフサイクルとは無関係[5]なので、ユーザは再び、メモリをリークしないように注意を払い、アップコールで得たセグメントに対してMemorySegment::closeを呼び出す必要があります。
Native scope
慣習的にCのコードでは、簡潔な変数宣言を可能にするために、暗黙のうちにスタックの割り当てに依存しています。以下の例を考えてみましょう。
void foo(int i, int *size);
int size = 5;
foo(42, &size);
ここで、関数fooはint変数へのポインタの出力パラメータを取ります。残念ながら(上記のqsortの例でも見られましたが)Panamaにおけるこのイディオムの実装は簡単ではありません。
MethodHandle foo = ...
MemorySegment size = MemorySegment.allocateNative(C_INT);
MemoryAccess.setInt(size, 5);
foo.invokeExact(42, size);
上記のコードスニペットには数多くの問題があります。
- Cのコードに比べて非常に冗長である。
- Cに比べて割り当てが非常に遅い。size変数の割り当てには完全な malloc が必要なのに対し、C では変数は単なるスタックに割り当てられた変数だった。
- 結果的に、独自の時間的境界を持つ独立したセグメントになってしまっている。リークを避けるため、これは後でクローズする必要がある。
これらの問題を解決するために、PanamaではNativeScopeの抽象化を提供しています。これを使って、優れた割り当て性能を達成するために割り当てをグループ化できますが、それだけでなく、論理的に関連するセグメントのグループが同じ時間的境界を共有することができるため、したがって、(例えば1個のtry-with-resources文を使用して)一度にクローズできます。ネイティブスコープの助けを借りて、上記のコードは次のように書き換えることができます。
try (NativeScope scope = NativeScope.unboundedScope()) {
MemorySegment size = scope.allocate(C_INT, 5);
foo.invokeExact(42, size);
}
このコードの前者に比べての改善点は簡単にわかります。
- ネイティブスコープにはセグメントの内容を割り当てたり初期化したりするためのプリミティブがあります。
- ネイティブスコープは配下でより効率的な割り当てを行うため、すべての割り当て要求がmallocになるわけではありません。実際には、利用予定のメモリサイズが事前に分かっている場合、クライアントはNativeScope::boundedScope(long size)ファクトリを使ってネイティブスコープの境界つきバリアントを利用することも可能です[6]。
- ネイティブスコープはその中に割り当てられた全てのセグメントに対する単一の時間的制約として利用できます。つまり、コードが他の変数をインスタンス化する必要がある場合、同じスコープを使用してインスタンス化を継続できます。try-with-resource文が完了すると、スコープに関連付けられたすべてのリソースが解放されます。
ネイティブ リソースの割り当てがネイティブ スコープ外で発生するケースは、少なくとも 2 つあります。
- ネイティブコールが返した構造体セグメント (または アップコールJavaメソッドに渡されたもの)
- 外部リンカから返されたアップコール・スタブ・セグメント
これらのケースでは、API は独自の時間的境界を特徴とするセグメントを提供してくれますが、これは、セグメントを使用して外部リンカサポートによって割り当てられたリソースのライフサイクルを制御できるので便利なのですが、いささか残念な面もあります。それは、周囲のコードがすでにネイティブスコープを持っている場合、これらの新しいセグメントはそれと相互運用できないため、別の try-with-resource セグメントを使用しなければならない、という点です。
この問題を緩和し、すべてのセグメントを同じネイティブスコープを使って管理できるようにするために、ネイティブスコープAPIは新しいセグメントを割り当てる機能をサポートしているだけでなく、既存のセグメントの所有権を取得することも可能にしています。この例を確認するために、先ほどのqsortの例に戻り、ネイティブスコープを使用してどのように改善できるかを見てみましょう。
try (NativeScope scope = NativeScope.unboundedScope()) {
comparFunc = scope.register(comparFunc);
MemorySegment array = scope.allocateArray(C_INT, new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 });
qsort.invokeExact(array, 10L, 4L, comparFunc);
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
}
ネイティブスコープを使うと、ネイティブ配列の割り当てが簡単になるだけでなく(ヒープセグメントを作成して、その内容をオフヒープ配列にダンプする必要がなくなります)、ネイティブスコープを使って既存のアップコール・スタブ・セグメントを登録することもできます。その際、ネイティブスコープと同じ時間的境界を持つ新しいセグメントを取得します(古いセグメントは破棄されて使えなくなります)。ネイティブスコープから返されるすべてのセグメントと同様に、登録済みセグメントはクローズできません。クローズするためには、セグメントが属するネイティブスコープをクローズするしかありません。
つまり、ネイティブスコープは複数の論理的に関連したセグメントのライフサイクルを管理するのに適した方法であり、そのシンプルさにもかかわらず、ユーザビリティとパフォーマンスをかなり向上させることができます。
Varargs
Cの関数の中には、任意の個数の引数を取ることができる可変長引数の関数があります。おそらく最も一般的な例は、C標準ライブラリで定義されている printf 関数でしょう。
int printf(const char *format, ...);
この関数は、0個以上の穴を特長とするformat文字列を受け取り、format文字列の穴の個数に等しい多くの追加の引数を受け取ることができます。
foreign functionサポートにより、可変長引数の呼び出しがサポートされますが、注意点があります。クライアントは特殊なJavaシグネチャと、Cシグネチャの特殊な記述を提供する必要があります。例えば、以下のCの呼び出しをモデリングする場合を考えます。
printf("%d plus %d equals %d", 2, 2, 4);
Panamaが提供するforeign functionサポートを使ってこれを実現するには、その呼び出し形態のための特殊なダウンコール・ハンドルを構築しなければなりません[7]。
MethodHandle printf = CSupport.getSystemLinker().downcallHandle(
LibraryLookup.ofDefault().lookup("printf"),
MethodType.methodType(int.class, MemoryAddress.class, int.class, int.class, int.class),
FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_INT, C_INT)
);
そのあとは、通常通り特殊なダウンコール・ハンドルを呼び出すことができます。
printf.invoke(CSupport.toCString("%d plus %d equals %d").address(), 2, 2, 4); //prints "2 plus 2 equals 4"
これは動作はするものの、このようなアプローチが好ましくないのは明らかです。
- 可変長引数関数を多くの異なる形状で呼び出す必要がある場合、多くの異なるダウンコール・ハンドルを作成しなければなりません。
- このアプローチは(Javaコードはどの引数をどれだけ渡すべきかを決定する役割を担っているので)ダウンコールでは機能しますが、アップコールに拡張できません。その場合、ネイティブコードからの呼び出しのため、作成したアップコール・スタブの形状がネイティブ関数が必要とするものと一致することを保証する方法がありません。
これらの問題を軽減するために、標準の C 言語の外部リンカは、C言語の可変引数リスト(va_list)をサポートしています。 可変長引数関数が呼び出されると、Cのコードは va_list構造体を作成して可変長引数を展開し、(va_arg マクロを使用して)va_listを通して可変長引数に一つずつアクセスしなければなりません 。標準の可変長引数関数とva_listとの相互運用を容易にするために、実際には多くのCのライブラリ関数は、同じ関数の 2 つのフレーバーを定義しています。1つは標準の可変長シグネチャを使用し、もう1つは追加のva_listパラメータを使用しています。例えばprintfの場合、同じタスクを実行する va_listを受け付ける関数も定義されていることがわかります。
int vprintf(const char *format, va_list ap);
この関数の振る舞いはこれまでと同様です。唯一の違いは省略記号 … が1個のva_listパラメータに置き換えられている点です。言い換えると、この関数はもう可変長引数関数ではありません。
確かにvprintfのダウンコールを作成するのはかなり簡単です。
MethodHandle vprintf = CSupport.getSystemLinker().downcallHandle(
LibraryLookup.ofDefault().lookup("vprintf"),
MethodType.methodType(int.class, MemoryAddress.class, VaList.class),
FunctionDescriptor.of(C_INT, C_POINTER, C_VA_LIST));
ここで、CSupport には特別なC_VA_LISTレイアウト(va_list パラメータのレイアウト)と VaList キャリアが備わっていることに注目してください。これを使ってJavaコードから可変引数リストを作成、表現できます。
vprintfハンドルを呼び出すためには、vprintf関数に渡したい引数が含まれるVaListインスタンスを作成する必要があります。以下はその例です。
vprintf.invoke(
CSupport.toCString("%d plus %d equals %d").address(),
VaList.make(builder ->
builder.vargFromInt(C_INT, 2)
.vargFromInt(C_INT, 2)
.vargFromInt(C_INT, 4))
); //prints "2 plus 2 equals 4"
呼び出される側は vprintf ハンドルを呼び出すためにさらに多くの作業をしなければなりませんが、以前のようにダウンコール・ハンドル vprintf が複数で共有できるようになったことに注意してください。このようにして作成されたVaListオブジェクトは独自のライフタイムを持っているので(VaListはクローズ操作もサポートしています)、リークを避けるためにVaListインスタンスがクローズされている(または既存のネイティブスコープにアタッチされている)ことを確認するのは呼び出される側に任されています。
VaList を使用するもう 1 つの利点は、このアプローチがアップコール・スタブにも拡張できる点です。したがって、クライアントが VaList を取るアップコール・スタブを作成し、Java アップコールから、(Cのva_argマクロの動作に倣い)VaList API によって提供されるメソッド (VaList::vargAsInt(MemoryLayout)) を使用して、VaList 内に詰められた引数を 1 つずつ読み取ることが可能です。
Appendix: full source code
この文書内で紹介したスニペットを含む完全なコードは以下の通りです。
import jdk.incubator.foreign.Addressable;
import jdk.incubator.foreign.CSupport;
import jdk.incubator.foreign.FunctionDescriptor;
import jdk.incubator.foreign.LibraryLookup;
import jdk.incubator.foreign.MemoryAccess;
import jdk.incubator.foreign.MemoryAddress;
import jdk.incubator.foreign.MemorySegment;
import jdk.incubator.foreign.NativeScope;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Arrays;
import static jdk.incubator.foreign.CSupport.*;
public class Examples {
public static void main(String[] args) throws Throwable {
strlen();
qsort();
printf();
vprintf();
}
public static void strlen() throws Throwable {
MethodHandle strlen = CSupport.getSystemLinker().downcallHandle(
LibraryLookup.ofDefault().lookup("strlen"),
MethodType.methodType(long.class, MemoryAddress.class),
FunctionDescriptor.of(C_LONG, C_POINTER)
);
try (MemorySegment hello = CSupport.toCString("Hello")) {
long len = (long) strlen.invokeExact(hello.address()); // 5
System.out.println(len);
}
}
static class Qsort {
static int qsortCompare(MemoryAddress addr1, MemoryAddress addr2) {
int v1 = MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(), addr1.toRawLongValue());
int v2 = MemoryAccess.getIntAtOffset(MemorySegment.ofNativeRestricted(), addr2.toRawLongValue());
return v1 - v2;
}
}
public static void qsort() throws Throwable {
MethodHandle qsort = CSupport.getSystemLinker().downcallHandle(
LibraryLookup.ofDefault().lookup("qsort"),
MethodType.methodType(void.class, MemoryAddress.class, long.class, long.class, MemoryAddress.class),
FunctionDescriptor.ofVoid(C_POINTER, C_LONG, C_LONG, C_POINTER)
);
MethodHandle comparHandle = MethodHandles.lookup()
.findStatic(Qsort.class, "qsortCompare",
MethodType.methodType(int.class, MemoryAddress.class, MemoryAddress.class));
MemorySegment comparFunc = CSupport.getSystemLinker().upcallStub(
comparHandle,
FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER)
);
try (NativeScope scope = NativeScope.unboundedScope()) {
comparFunc = scope.register(comparFunc);
MemorySegment array = scope.allocateArray(C_INT, new int[] { 0, 9, 3, 4, 6, 5, 1, 8, 2, 7 });
qsort.invokeExact(array.address(), 10L, 4L, comparFunc.address());
int[] sorted = array.toIntArray(); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
System.out.println(Arrays.toString(sorted));
}
}
public static void printf() throws Throwable {
MethodHandle printf = CSupport.getSystemLinker().downcallHandle(
LibraryLookup.ofDefault().lookup("printf"),
MethodType.methodType(int.class, MemoryAddress.class, int.class, int.class, int.class),
FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_INT, C_INT)
);
try (MemorySegment s = CSupport.toCString("%d plus %d equals %d\n")) {
printf.invoke(s.address(), 2, 2, 4);
}
}
public static void vprintf() throws Throwable {
MethodHandle vprintf = CSupport.getSystemLinker().downcallHandle(
LibraryLookup.ofDefault().lookup("vprintf"),
MethodType.methodType(int.class, MemoryAddress.class, CSupport.VaList.class),
FunctionDescriptor.of(C_INT, C_POINTER, C_VA_LIST));
try (NativeScope scope = NativeScope.unboundedScope()) {
MemorySegment s = CSupport.toCString("%d plus %d equals %d\n", scope);
CSupport.VaList vlist = CSupport.VaList.make(builder ->
builder.vargFromInt(C_INT, 2)
.vargFromInt(C_INT, 2)
.vargFromInt(C_INT, 4), scope);
vprintf.invoke(s.address(), vlist);
}
}
}
- 実際には、これは全く新しいことではありません。JNIでも、ネイティブメソッドを呼び出すとき、VMは対応するC言語の実装関数が互換性のあるパラメータ型と戻り値を持っていることを信頼しています。そうでなければ、クラッシュが発生する可能性があります。↩
- 将来的には、値渡しの構造体をオフヒープではなくオンヒープに割り当てることができるようにするためのノブを検討する可能性があります。これらの構造体が常に不透明な方法で受け渡しされていれば、オフヒープ割り当てを避けることができ、パフォーマンス上の大きな利点があるかもしれません。↩
- 執筆時点では、ネイティブメソッドの intrinsics のサポートはデフォルトでは無効になっています。これは intrinsics サポートを有効にして jextract を実行したときに VM がクラッシュしてしまうためです。この状況を改善するために努力しています。なお、-Djdk.internal.foreign.ProgrammableInvoker.USE_INTRINSICS=trueフラグを使用して、intrinsicsサポートを有効にできます。↩
- 高度なオプションとして、PanamaではJavaからネイティブへのスレッド遷移を削除することができます。一般的なケースではこの削除は安全ではありませんが(スレッド遷移の削除は、長時間実行されているネイティブ関数のGCに悪影響を与える可能性があり、ダウンコールが例えばアップコールなどでJavaでポップアップバックする必要がある場合、VMをクラッシュさせる可能性があります)、より高い効率性を実現できます。パフォーマンスに敏感なユーザーは、これらの関数がリーフ関数(例えば、アップコールを使ってJavaに戻らない)であり、比較的短命であって、頻繁に呼び出される関数であれば、少なくともこのオプションを検討すべきでしょう。↩
- アップコールのために作成された構造体のライフサイクルをアップコール自体のライフサイクルに結びつけて、例えば Javaアップコールを呼び出す前に作成されたセグメントをアップコールが返された後すぐに解放されるようにしたいので、これは将来的には変更の可能性があります。↩
- 現在、ネイティブスコープ内でのアロケーションをより高速化するための別のアロケーション戦略を検討しています。↩
- Windowsでは、可変引数のレイアウトはCSupport.Win64.asVarArg(ValueLayout)を使用して調整しなければなりません。これは、Windows ABIが通常の引数に使用されるものとは異なるルールを使用して可変引数を渡すために必要だからです。↩