はじめに
JEP 454 Foreign Function & Memory API 通称 FFM API では、JVM外部のコードとメモリとの相互運用APIを提供します。
- 外部関数(ネイティブ・コード)の呼び出しには JNI(Java Native Interface) が存在するが、これは生産性が低く、型システムの異なるネイティブライブラリとのデータのやり取りが困難
- オフヒープ・メモリへのアクセスは
ByteBuffer.allocDirect()
のように ByteBuffer API を使うかsun.misc.Unsafe
が提供されるが、 ByteBuffer API では2Gまでのメモリ確保しかできずメモリ解放を制御できない問題があり、sun.misc.Unsafe
はきめ細かなメモリ制御が可能な反面、安全に使うことが困難
FFM API では、JNI の不便さなしにネイティブ・ライブラリを簡単に利用できるサポートされたAPI、オンヒープ・メモリと同じ流動性と安全性でオフヒープ・メモリを扱うAPIを提供します。ネイティブ・ライブラリとのオフヒープ・メモリを介したデータのやり取りも強固になります。
JEP 454 は、JDK19 で JEP 424 としてプレビュー公開、JDK20 で JEP 434 として第二プレビュー、JDK 21 で JEP 442 で第三プレビューとなり、JDK22 で正式リリースが予定されています。
FFM API
FFM API は java.lang.foreign
パッケージ(java.base
モジュール)に以下のモデルを提供します。
Arena
SegmentAllocator
MemorySegment
:外部メモリの割り当てと解放を制御するMemoryLayout
VarHandle
:構造化外部メモリの操作とアクセスLinker
SymbolLookup
FunctionDescriptor
MethodHandle
:外部関数の呼び出し
以下に順に説明します。
Arena
Arena
は、スコープ付きの malloc
と free
の抽象と捉えると分かりやすいでしょう(プレビュー公開版における MemorySession
に該当します)。
Arena
によりネイティブメモリセグメントのライフサイクルを制御します。
Arena
で確保されたメモリの連続した領域は、MemorySegment
として表現され、オフヒープまたはオンヒープのいずれかに位置します。
以下は 100 バイトのネイティブセグメントをグローバル アリーナ(Arena.global()
)で割り当てる例です(第2引数 byteAlignment
に 1
を設定しており、これはメモリレイアウトが1バイトの倍数のメモリアドレスに格納されることを意味します)。
MemorySegment segment = Arena.global().allocate(100, 1);
グローバル アリーナ(Arena.global()
)は、寿命が無制限です。そのため、グローバル アリーナに割り当てられたネイティブ セグメントには常にアクセスでき、メモリのバッキング領域の割り当てが解除されることはありません。さらに、グローバル アリーナで割り当てられたメモリ セグメントには、どのスレッドからもアクセスできます。
Arena
は SegmentAllocator
を継承し、以下のように定義されています。
public interface SegmentAllocator { MemorySegment allocate(long byteSize, long byteAlignment); // ... default メソッド多数 } public interface Arena extends SegmentAllocator, AutoCloseable { static Arena ofAuto() { ... } static Arena global() { ... } static Arena ofConfined() { ... } static Arena ofShared() { ... } // ... Scope scope(); @Override void close(); }
上記 Arena
の定義にあるように、global()
のほか、各種スタティック・メソッドが提供されており、それぞれ以下の特徴があります。
種類 | ライフタイムの制限 | 明示的なクローズ | 複数スレッドからのアクセス |
---|---|---|---|
global() |
無し | 不可 | 可能 |
ofAuto() |
有り | 不可 | 可能 |
ofConfined() |
有り | 可能 | 不可 |
ofShared() |
有り | 可能 | 可能 |
Auto Arena(ofAuto()
) はガベージ コレクターによって自動的に管理される、制限された有効期間を持ちます。
自動アリーナ (および自動アリーナによって割り当てられたすべてのセグメント) が到達不能になった後で、不特定の時点(GC実行時)で割り当てが解除されます。
MemorySegment segment = Arena.ofAuto().allocate(100, 1); ... segment = null; // この時点以降、セグメント領域は割り当て解除が可能になる
メモリ領域の割り当て解除のタイミングを制御したいケースには Confined Arena(ofConfined()
) と Shared Arena(ofShared()
) があり、close()
によりアリーナが閉じられると、割り当てが解除されます。
MemorySegment segment = null; try (Arena arena = Arena.ofConfined()) { segment = arena.allocate(100); ... } // ここでセグメント領域が解放される segment.get(ValueLayout.JAVA_BYTE, 0); // throws IllegalStateException
Confined Arena から割り当てられたメモリ・セグメントにアクセスできるスレッドは1つだけです。
一方、Shared Arena から割り当てられたメモリ・セグメントには複数のスレッドがらアクセスでき、どのスレッドからでもArenaを閉じてセグメントを解放することができます(セグメントに対する保留中の同時アクセス操作を検出してキャンセルするために、高価な同期操作が必要になるため、セグメントをバッキングするメモリ領域の割り当て解除はすぐには行われない可能性があります)。
MemoryLayout
Arena
により確保された外部メモリ MemorySegment
を介して、外部関数とやり取りを行うには、メモリの物理的なレイアウト(バイト数, アドレス, エンディアン,対応する型)を考慮する必要があり、これを定義するのが MemoryLayout
です。
MemorySegment
は以下のシールド・クラスとして定義されています。
public sealed interface MemoryLayout permits SequenceLayout, GroupLayout, PaddingLayout, ValueLayout { ... }
Java の値は、すべて ValueLayout
で定義されています。
public sealed interface ValueLayout extends MemoryLayout permits ValueLayout.OfBoolean, ValueLayout.OfByte, ValueLayout.OfChar, ValueLayout.OfShort, ValueLayout.OfInt, ValueLayout.OfFloat, ValueLayout.OfLong, ValueLayout.OfDouble, AddressLayout { ... OfInt JAVA_INT = ValueLayouts.OfIntImpl.of(ByteOrder.nativeOrder()); ... }
例えば、JAVA_INT
値レイアウトは 4バイト幅で、4バイト境界にアラインされ、ネイティブプラットフォームのエンディアンを使用し、Javaの int
型に関連付けられます。
以下の例では、100バイトのメモリを Java の int
用のレイアウト制約で 確保し(100バイト ÷ 4バイトが割り切れる単位で、この場合 25個分のサイズ)、Java の int
用のレイアウトでインデックスを指定して 1
をメモリに書き込んでいます。
MemorySegment segment = Arena.ofAuto().allocate(100, ValueLayout.JAVA_INT.byteAlignment()); for (int i = 0; i < 25; i++) { segment.setAtIndex(ValueLayout.JAVA_INT, i, 1); }
より複雑なレイアウトとなる場合には、SequenceLayout
や GroupLayout
などにより構造化したレイアウトとして扱うこともできます。
以下のような C 構造体の配列を考えます(sizeof(int) == 4
と仮定)。
struct Point { int x; int y; } pts[10];
上記の構造体は、StructLayout
(GroupLayout
の子) でメモリレイアウトを以下のように定義できます。
MemoryLayout.structLayout( ValueLayout.JAVA_INT.withName("x"), ValueLayout.JAVA_INT.withName("y"))
構造体の配列として扱うため、MemoryLayout.sequenceLayout()
で以下のように定義できます。
SequenceLayout ptsLayout = MemoryLayout.sequenceLayout(10, MemoryLayout.structLayout( ValueLayout.JAVA_INT.withName("x"), ValueLayout.JAVA_INT.withName("y")));
レイアウトを定義すれば、メモリセグメント内のデータ要素にアクセスできる変数ハンドルを得ることができます。
VarHandle xHandle = ptsLayout.varHandle(PathElement.sequenceElement(), PathElement.groupElement("x")); VarHandle yHandle = ptsLayout.varHandle(PathElement.sequenceElement(), PathElement.groupElement("y"));
このハンドルを介して、値の取得や設定が可能になります。
MemorySegment segment = Arena.ofAuto().allocate(ptsLayout); for (int i = 0; i < ptsLayout.elementCount(); i++) { xHandle.set(segment, /* base */ 0L, (long) i, 1); // x yHandle.set(segment, /* base */ 0L, (long) i, 2); // y }
外部関数のルックアップ
外部関数を扱うには、ネイティブライブラリからシンボルのアドレスを見つける必要があります。
これは SymbolLookup
により提供され、以下でインスタンスを取得することができます。
SymbolLookup::libraryLookup(String, Arena)
SymbolLookup::loaderLookup()
Linker::defaultLookup()
SymbolLookup.libraryLookup()
は、オペレーティング・システムで既知のライブラリのシンボル・ルックアップを作成します。ライブラリは、名前またはパスで指定します。作成したシンボル・ルックアップのライフサイクルはArena
によって制御されます(ライブラリは Arena
が閉じられたときにアンロードされます)。
try (Arena arena = Arena.ofConfined()) { SymbolLookup libGL = SymbolLookup.libraryLookup("libGL.so", arena); // libGL.so loaded here MemorySegment glGetString = libGL.find("glGetString").orElseThrow(); ... } // libGL.so unloaded here
SymbolLookup::loaderLookup()
は、System.load(String)
または System.loadLibrary(String)
によりロードされたライブラリのシンボル・ルックアップを作成します(呼び出し元のクラス・ローダーに関連付けられたすべてのライブラリのルックアップ)。多言語で作成した自作ライブラリを利用するなどの場合は、この手段を使います。
System.loadLibrary("GL"); // libGL.so loaded here ... SymbolLookup libGL = SymbolLookup.loaderLookup(); MemorySegment glGetString = libGL.find("glGetString").orElseThrow();
Linker::defaultLookup()
は、そのリンカーがサポートプラットフォームで一般的に使用されるライブラリーのシンボル・ルックアップを提供します。標準Cライブラリ libc などへのコンビニエントなアクセス手段として利用できます。
Linker nativeLinker = Linker.nativeLinker();
SymbolLookup stdlib = nativeLinker.defaultLookup();
MemorySegment malloc = stdlib.find("malloc").orElseThrow();
外部関数の呼び出し
Linker
インターフェイスは、Javaコードがネイティブコードと相互運用する手段を提供します。
Javaコードからネイティブコードへの呼び出しを downcalls 、ネイティブコードからJavaコードへの呼び出しを upcalls と呼び、Linker
はこれら双方を可能にします。
public sealed interface Linker permits AbstractLinker { @CallerSensitive @Restricted MethodHandle downcallHandle(MemorySegment address, FunctionDescriptor function, Option... options); @CallerSensitive @Restricted MemorySegment upcallStub(MethodHandle target, FunctionDescriptor function, Arena arena, Option... options); }
ダウンコールでは、SymbolLookup
で検索した外部関数のアドレスを渡し、MethodHandle
を得ます。MethodHandle
の invoke
を呼び出すことで外部関数を実行します。
アップコールでは、メソッドハンドルを渡し、MemorySegment
インスタンスを得ます。この MemorySegment
が関数ポインタの役割を果たします。ダウンコールの呼び出しで、この関数ポインタを渡し、ネイティブコードからJava関数をコールします。
例として、標準Cライブラリの strlen
のダウンコールを考えます。
size_t strlen(const char *s);
strlen
を公開するダウンコールメソッドハンドルは以下のようにして得ることができます。
Linker linker = Linker.nativeLinker();
MemorySegment ms = linker.defaultLookup().find("strlen").orElseThrow();
MethodHandle strlen = linker.downcallHandle(ms, FunctionDescriptor.of(JAVA_LONG, ADDRESS));
downcallHandle()
で取得した MethodHandle
を呼び出すと strlen
が実行され、その結果を Javaコードから利用できるようになります。
try (Arena arena = Arena.ofConfined()) { MemorySegment str = arena.allocateFrom("Hello"); long len = (long)strlen.invoke(str); // 5 }
downcallHandle()
の引数で指定した FunctionDescriptor
はターゲットC関数の Cパラメータ型と C戻り値型を記述します。これは事実上、C 関数の Java レベルのビューと捉えることができます。
プラットフォームに応じて、スカラーC型のレイアウトを考慮する必要があります。Cの int
を受け取り、Cの long
を返すC関数を考えた場合、Linux/x64とmacOS/x64では、FunctionDescriptor.of(JAVA_LONG, JAVA_INT)
と定義できますが、Windows/x64では、C言語の型 long
は定義済みのレイアウト JAVA_INT
に関連付けられるため、FunctionDescriptor.of(JAVA_INT, JAVA_INT)
と定義する必要があります。
外部関数からJavaコード呼び出し
標準Cライブラリのクイックソート関数を考えます。
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
この関数は、ポインタ base を先頭とする配列の内容を、指定された比較関数で昇順に並べ替えます。
Javaコードからqsort
を呼び出すには、ダウンコール・メソッド・ハンドルを作成します。
Linker linker = Linker.nativeLinker();
MethodHandle qsort = linker.downcallHandle(
linker.defaultLookup().find("qsort").get(),
FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);
C の size_t
型をマップするために JAVA_LONG
レイアウトを使用し、最初のポインタパラメータ(配列ポインタ)と最後のパラメータ(関数ポインタ)の両方に ADDRESS
レイアウトを使用しています。
qsort
は、コンパレータ関数 compar
関数ポインタとして渡す必要があります。このコンパレータは以下のようにJavaにより定義できます。
class Qsort { static int qsortCompare(MemorySegment elem1, MemorySegment elem2) { return Integer.compare(elem1.get(JAVA_INT, 0), elem2.get(JAVA_INT, 0)); } }
次に、Javaコンパレーター・メソッドを指すメソッド・ハンドルを作成します。
MethodHandle comparHandle = MethodHandles.lookup() .findStatic(Qsort.class, "qsortCompare", MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class));
Javaコンパレータのメソッドハンドルを使い、Linker::upcallStub
で関数ポインタを作成することができます。
MemorySegment comparFunc = linker.upcallStub(
comparHandle,
/* A Java description of a C functionimplemented by a Java method! */
FunctionDescriptor.of(JAVA_INT,
ADDRESS.withTargetLayout(JAVA_INT),
ADDRESS.withTargetLayout(JAVA_INT)),
Arena.ofAuto());
qsort
ダウンコールハンドルを呼び出すことができます。
try (Arena arena = Arena.ofConfined()) { MemorySegment array = arena.allocateFrom(ValueLayout.JAVA_INT, 0, 9, 3, 4, 6, 5, 1, 8, 2, 7); qsort.invoke(array, 10L, ValueLayout.JAVA_INT.byteSize(), comparFunc); int[] sorted = array.toArray(JAVA_INT); // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] }
このコードでは、オフヒープ配列を作成し、そこにJava配列の内容をコピーしてから、ネイティブ・リンカーから取得したコンパレーター関数とともに配列をqsort
ハンドルに渡しています。
呼び出し後、オフヒープ配列の内容はJavaコードとして書かれたコンパレータ関数に従ってソートされます。
最後に、セグメントから新しいJava配列を取り出し、その中にソートされた要素を格納しています。
jextract によるグルーコード生成
ここまで見たように FFM API の利用には、かなりのグルーコードが必要になります。 通常は、これらのコードは jextract ツール により自動生成したものを使うことになります。
https://github.com/openjdk/jextract
jextract は、ネイティブ・ライブラリのヘッダ・ファイルを受け取り、そのライブラリとの相互運用に必要なダウンコール・メソッド・ハンドルを機械的に生成します。
Hello World をプリントするだけの helloworld.c
が以下のように有り、
#include <stdio.h> #include "helloworld.h" void helloworld(void) { printf("Hello World!\n"); }
helloworld.h
が以下のように有った場合、
#ifndef helloworld_h #define helloworld_h extern void helloworld(void); #endif /* helloworld_h */
jextract により helloworld.h
からグルーコードを自動生成できます(-t
でターゲットパッケージ、-l
でライブラリ名を指定)。
jextract --source -t org.hello -l helloworld helloworld.h
以下のクラスファイルが生成されます。
org.hello.helloworld_h.class
org.hello.constants$0.class
org.hello.RuntimeHelper$VarargsInvoker.class
org.hello.RuntimeHelper.class
FFM API による多くのグルーコードが生成されますが、メソッドの呼び出しは、以下のように生成されたものを呼び出すだけです。
package org.hello; ... public class helloworld_h { public static void helloworld() { ... } }
つまり、Java 側で org.hello.helloworld_h
をインポートして以下のように実行することができます。
import static org.hello.helloworld_h.*; public class HelloWorld { public static void main(String[] args) { helloworld(); } }