Java22 で追加される FFM API (JEP 454 Foreign Function & Memory API)

blog1.mammb.com


はじめに

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 は、スコープ付きの mallocfree の抽象と捉えると分かりやすいでしょう(プレビュー公開版における MemorySession に該当します)。 Arena によりネイティブメモリセグメントのライフサイクルを制御します。

Arena で確保されたメモリの連続した領域は、MemorySegment として表現され、オフヒープまたはオンヒープのいずれかに位置します。

以下は 100 バイトのネイティブセグメントをグローバル アリーナ(Arena.global())で割り当てる例です(第2引数 byteAlignment1 を設定しており、これはメモリレイアウトが1バイトの倍数のメモリアドレスに格納されることを意味します)。

MemorySegment segment = Arena.global().allocate(100, 1);

グローバル アリーナ(Arena.global())は、寿命が無制限です。そのため、グローバル アリーナに割り当てられたネイティブ セグメントには常にアクセスでき、メモリのバッキング領域の割り当てが解除されることはありません。さらに、グローバル アリーナで割り当てられたメモリ セグメントには、どのスレッドからもアクセスできます。


ArenaSegmentAllocator を継承し、以下のように定義されています。

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

より複雑なレイアウトとなる場合には、SequenceLayoutGroupLayout などにより構造化したレイアウトとして扱うこともできます。 以下のような 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 を得ます。MethodHandleinvoke を呼び出すことで外部関数を実行します。

アップコールでは、メソッドハンドルを渡し、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();
    }
}