原文はこちら。
The original article was written by Marizio Cimadamore (Compiler architect, Oracle) and Per-Ake Minborg (Consulting Member of Technical Staff, Oracle).
https://inside.java/2025/06/12/ffm-vs-unsafe/
Background
Foreign Function & Memory API(FFM API)は Java 22 で最終化され、Javaからネイティブメモリおよびネイティブ関数と直接やり取りができます。ネイティブメモリとのやり取りにおいて、FFM APIは Unsafe経由のアクセスとは対照的に、安全な API を提供します。
JEP 454: Foreign Function & Memory API
https://openjdk.org/jeps/454
Safety
FFM APIでは、ネイティブ(およびヒープ)メモリは、64 ビットのアドレス指定とオフセット、およびさまざまなMemoryLayoutクラスによる構造化メモリアクセスを提供するMemorySegmentクラスによってモデル化されています。MemorySegment経由のアクセスでは、いくつかの安全メカニズムが提供されます。そのチェックには以下も含まれています。
- Bounds(境界)
- Liveness(生存)
- Alignment(アラインメント)
- Read-only state(読み取り専用状態)
これらのメカニズムはUnsafeメモリアクセスでは提供されていません。したがって、Unsafeを使用すると、次のようなことが可能です。
- 境界外のアドレス指定、任意のメモリの読み取り/書き込み、および/または VM のクラッシュ
- 既に解放されたメモリまたは再割り当てされたメモリへのアクセス
- 他に問題の無いメモリ領域内で不正なオフセットの使用
さらに、Unsafeはメモリ領域の読み取り専用ビューを提供していません。
What is the Performance Cost of Safety
MemorySegmentへの不適切なアクセスについては、Unsafeと同じパフォーマンスを期待することはできません。それは上記の必須チェックを実行する必要があるためです。ただし、セグメントが(ループ内などで)複数回アクセスされる場合、アクセスパターンが予測可能であれば、これらのチェックのコストは分散されるという仮定があります。実際、JIT コンパイラはこれらのチェックをループや類似の構文の外側に移動できます。
Loop-invariant code motion
https://en.wikipedia.org/wiki/Loop-invariant_code_motion
これにより、例えば、ループを100回や1,000回繰り返しても、チェックは1回だけ行えば済みます。
以下のベンチマークは、外出しのメリットを説明する例です。ここでは、foreign memory access(FMA)とUnsafeを使用して、ループ内でint型要素に1回、10回、1,000回アクセスし、読み取りと書き込みの両方を使用しています。
Benchmark Mode Cnt Score Error Units
FMASerDeOffHeap.fmaReadSingle avgt 10 1.482 ± 0.020 ns/op
FMASerDeOffHeap.fmaReadLoop_10 avgt 10 2.978 ± 0.051 ns/op
FMASerDeOffHeap.fmaReadLoop_100 avgt 10 15.385 ± 0.218 ns/op
FMASerDeOffHeap.fmaReadLoop_1000 avgt 10 116.588 ± 3.114 ns/op
FMASerDeOffHeap.fmaWriteSingle avgt 10 1.646 ± 0.024 ns/op
FMASerDeOffHeap.fmaWriteLoop_10 avgt 10 3.289 ± 0.024 ns/op
FMASerDeOffHeap.fmaWriteLoop_100 avgt 10 10.085 ± 0.561 ns/op
FMASerDeOffHeap.fmaWriteLoop_1000 avgt 10 32.705 ± 0.448 ns/op
FMASerDeOffHeap.unsafeReadSingle avgt 10 0.569 ± 0.012 ns/op
FMASerDeOffHeap.unsafeReadLoop_10 avgt 10 1.747 ± 0.023 ns/op
FMASerDeOffHeap.unsafeReadLoop_100 avgt 10 13.087 ± 0.099 ns/op
FMASerDeOffHeap.unsafeReadLoop_1000 avgt 10 117.363 ± 0.081 ns/op
FMASerDeOffHeap.unsafeWriteSingle avgt 10 0.563 ± 0.016 ns/op
FMASerDeOffHeap.unsafeWriteLoop_10 avgt 10 1.169 ± 0.027 ns/op
FMASerDeOffHeap.unsafeWriteLoop_100 avgt 10 6.148 ± 0.528 ns/op
FMASerDeOffHeap.unsafeWriteLoop_1000 avgt 10 30.940 ± 0.147 ns/op
一見すると、書き込みが読み取りよりも一般的に高速であることは不思議に思えるかもしれませんが、これはベンチマークの設定によるものです。JIT コンパイラは書き込みには自動ベクトル化を使用できますが、読み取りには使用できないためです。ただし、FMAとUnsafeの相対的な差は依然として有意です。
Automatic vectorization
https://en.wikipedia.org/wiki/Automatic_vectorization
続いて、FFMを使用した単一の読み込み/書き込みは、Unsafeに比べてほぼ3倍低速です。ループのバリエーションを進めていくと、状況は改善され、読み込みの場合には10から100回の反復で均衡に達するのに対し、書き込みの場合には100から1,000回の反復で均衡に達することがわかります。この差は再び自動ベクトル化によるものです:書き込みコードがベクトル化されることで(複数の要素が単一のSIMD命令で書き込まれるため)、CPUが実行するコード量が減少します、これによりFFMが導入する「固定」コストが償却されるまでに時間がかかります。
より良い方法はあるでしょうか?ベンチマークでの問題は、メモリセグメントをフィールドmemSegmentから読み込んでいる点にあります(以下のコードを参照)。そのため、JITコンパイラはセグメントサイズを「認識」できず、コンパイルされたコードから境界チェックを削除できません。
@Benchmark
public void fmaReadLoop_1000(Blackhole blackhole) {
for (int i = 0 ; i < 4000 ; i+=4) {
blackhole.consume(memSegment.get(ValueLayout.JAVA_INT_UNALIGNED, i));
}
}
しかし、unsafe(または元のセグメント)アドレスに基づいて「実行中に」メモリセグメントを作成したらどうなるでしょうか?このトリックは過去にも議論されていて、例えば次のようなものです。
@Benchmark
public void fmaReadLoop_1000(Blackhole blackhole) {
MemorySegment memSegment = MemorySegment.ofAddress(bufferUnsafe).reinterpret(4000);
for (int i = 0 ; i < 4000 ; i+=4) {
blackhole.consume(memSegment.get(ValueLayout.JAVA_INT_UNALIGNED, i));
}
}
表面上は以前と変わらないように見えます(すべてのチェックを実行する必要があります!)。しかし、重要な違いがあります。JITコンパイラは以下の2点を認識できます。
- (
MemorySegment::ofAddressにより)memSegmentが常にglobal arenaにマッピングされていること - (
MemorySegment::reinterpretにより)そのサイズが常に 4,000であること
この情報をさらに活用して、一部のチェックのコストを削減できます。以下のベンチマークで示します。
Benchmark Mode Cnt Score Error Units
FMASerDeOffHeapReinterpret.fmaReadSingle avgt 10 0.588 ± 0.016 ns/op
FMASerDeOffHeapReinterpret.fmaReadLoop_10 avgt 10 1.762 ± 0.025 ns/op
FMASerDeOffHeapReinterpret.fmaReadLoop_100 avgt 10 13.370 ± 0.028 ns/op
FMASerDeOffHeapReinterpret.fmaReadLoop_1000 avgt 10 124.499 ± 1.051 ns/op
FMASerDeOffHeapReinterpret.fmaWriteSingle avgt 10 0.548 ± 0.002 ns/op
FMASerDeOffHeapReinterpret.fmaWriteLoop_10 avgt 10 1.180 ± 0.010 ns/op
FMASerDeOffHeapReinterpret.fmaWriteLoop_100 avgt 10 6.278 ± 0.301 ns/op
FMASerDeOffHeapReinterpret.fmaWriteLoop_1000 avgt 10 38.298 ± 0.792 ns/op
FMASerDeOffHeapReinterpret.unsafeReadSingle avgt 10 0.564 ± 0.005 ns/op
FMASerDeOffHeapReinterpret.unsafeReadLoop_10 avgt 10 1.661 ± 0.013 ns/op
FMASerDeOffHeapReinterpret.unsafeReadLoop_100 avgt 10 12.514 ± 0.023 ns/op
FMASerDeOffHeapReinterpret.unsafeReadLoop_1000 avgt 10 115.906 ± 4.542 ns/op
FMASerDeOffHeapReinterpret.unsafeWriteSingle avgt 10 0.577 ± 0.005 ns/op
FMASerDeOffHeapReinterpret.unsafeWriteLoop_10 avgt 10 1.114 ± 0.003 ns/op
FMASerDeOffHeapReinterpret.unsafeWriteLoop_100 avgt 10 6.028 ± 0.140 ns/op
FMASerDeOffHeapReinterpret.unsafeWriteLoop_1000 avgt 10 30.631 ± 0.928 ns/op
ご覧のとおり、FFMバージョンとUnsafeバージョンは互いに非常に近い結果になりました(ただし、読み取りと書き込みの差による若干のばらつきは依然として存在します)。
Is It Worth It?
レースカーから安全ベルトとロールケージを取り外せば、確かに少しは速くなるでしょう。しかし、これはほぼ絶対に実施されず、主要なレースクラスでは規則で禁止されています。ソフトウェア開発についても同じように考えるべきです。ほとんどの場合、安全性を犠牲にして最後の数パーセントのパフォーマンスを引き出すためにUnsafeを使うことは、絶対に避けるべきです。
では、FFMとUnsafeの比較はどうなるのでしょうか?残念ながら、ここでは直接比較するのは難しいです。なぜなら、ある意味では梨と林檎を比較しているようなものだからです。(その名の通り「安全でない」)Unsafeはメモリに直接アクセスし、追加のチェックを行いません。一方、FFMは安全なメモリアクセスを前提に設計されたAPIです。この安全性はコストを伴い、特に不正なメモリアクセスの場合に顕著です。
OpenJDKコミュニティはFFMのパフォーマンス向上を継続的に追求していますが、不正メモリアクセスのケースでUnsafeと同等になると期待するのは現実的ではありません。ただし、現実的なユースケースでは、この問題はほとんど発生しません。実際のコードにおけるオフヒープメモリアクセスは、通常、2種類の異なるパターンに分類されます。
- 他のコードに囲まれた不正メモリアクセスが発生する場合
- Apache Luceneのように、ループ内で繰り返し同じセグメントにアクセスする場合(場合によってはvector APIを使用する場合もある)
Apache Lucene
https://lucene.apache.org/
1つ目のケースの最適化はそれほど興味深いものではありません。このようなケースでは、単一のメモリアクセスのパフォーマンスは通常、無関係です。一方、2つ目のケースの最適化は非常に重要です。上記のベンチマークでも示されているように、同じセグメントを繰り返しループするにつれ、FFMはUnsafeとほぼ同等のパフォーマンスに達します(もちろん、将来的には「収支均衡点」を下げたいと考えています)。
もちろん、アクセスパターンが予測不能で推測できない特異なケースも存在します(オフヒープの二分探索などを考えてみてください)。このような場合、チェックの追加コストが蓄積し始める可能性があります。このような状況では、上記で示した(reinterpretを使用する)ようなトリックが、パフォーマンスプロファイルをUnsafeに近づけるために非常に有用かもしれません。ただし、これらのテクニックは慎重に使うべきです。ほとんどのケースでは、そのようなテクニックは必要ない可能性が高いからです。なぜなら、メモリアクセス性能がそれほど重要でないか、JITコンパイラが既に十分最適化可能なループ内でアクセスが発生しているからです。
(二分探索のような)予測不可能なアクセスパターン下でのアクセスを自動ベクトル化機能は、UnsafeよりもFFM APIを使用するコストを十分に回収できる可能性があります。それゆえ、このようなアルゴリズムは、Unsafeを使用する単純なアルゴリズムよりも高速かつ安全であることを慎重に設計し、評価する必要があります。安全なアクセスを維持しつつパフォーマンスを大幅に向上させる手法も存在します。例えば、「分岐なし二分探索アルゴリズム」を使用する方法です。ただし、これについては別の投稿で詳しく説明します。
What’s the Next Step?
メモリアクセスにUnsafeを使用しており、JDK 22以前のリリースで実行している場合は、JDK 25をダウンロードし、FFM API へ移行することで現在のアプリケーションの安全性がどのように向上するかを確認してください。
OpenJDK JDK 25 Early-Access Builds
https://jdk.java.net/25/