Strings Just Got Faster

原文はこちら。
The original article was written by Per-Ake Minborg ().
https://inside.java/2025/05/01/strings-just-got-faster/

JDK 25では、Stringクラスのパフォーマンスを改善し、String::hashCode関数がほぼ定数畳み込み可能(constant foldable)になりました。例えば、静的で変更不可能なMapのキーとしてStringを使用する場合、大幅なパフォーマンス向上が期待できます。

8354300: Mark String.hash field @Stable #24625
https://github.com/openjdk/jdk/pull/24625
Constant folding
https://en.wikipedia.org/wiki/Constant_folding
https://ja.wikipedia.org/wiki/定数畳み込み

Example

以下は、ネイティブ呼び出しのImmutableなMapをメンテナンスする比較的高度な例です。このMapのキーはメソッド呼び出しの名前であり、値は関連するシステム呼び出しを実行するために使用できるMethodHandleです。

// Set up an immutable Map of system calls
static final Map<String, MethodHandle> SYSTEM_CALLS = Map.of(
        “malloc”, linker.downcallHandle(mallocSymbol,…),
        “free”, linker.downcallHandle(freeSymbol…),
        ...);

…

// Allocate a memory region of 16 bytes
long address = SYSTEM_CALLS.get(“malloc”).invokeExact(16L);
…
// Free the memory region
SYSTEM_CALLS.get(“free”).invokeExact(address);

linker.downcallHandle(…) というメソッドは、シンボルと追加のパラメータを受け取り、JDK 22で導入されたForeign Function & Memory APIを使い、ネイティブ呼び出しをJava MethodHandleにバインドします。

JEP 454: Foreign Function & Memory API
https://openjdk.org/jeps/454

これは比較的遅いプロセスであり、バイトコードの展開を伴います。ただし、Mapに格納されると、Stringクラスの新しいパフォーマンス改善により、キーの検索と値の両方の定数化が可能になり、パフォーマンスが8倍以上向上します。

--- JDK 24 ---

Benchmark                     Mode  Cnt  Score   Error  Units
StringHashCodeStatic.nonZero  avgt   15  4.632 ± 0.042  ns/op

--- JDK 25 ---

Benchmark                     Mode  Cnt  Score   Error  Units
StringHashCodeStatic.nonZero  avgt   15  0.571 ± 0.012  ns/op

【注意】上のベンチマークでは、malloc() MethodHandleではなく、intのアイデンティティ関数を使用しています。結局のところ、malloc()のパフォーマンスをテストしているのではなく、実際の文字列検索とメソッドハンドルのパフォーマンスをテストしているからです。

この改善は、キーとして文字列を使用し、値(任意の型 V)を定数文字列で検索するImmutableなMap<String, V>に適用されます。

How Does It Work?

Stringが最初に作成されたとき、そのハッシュコードは未知です。String::hashCodeが呼び出されて初めて実際のハッシュコードが計算され、プライベートフィールドString.hashに格納されます。この変換は奇妙に思えるかもしれません。StringがImmutableなら、なぜ状態が変更されるのでしょうか?答えは、この変更は外部から観察できないためです。Stringは、内部のString.hashキャッシュフィールドを使用するか否かにかかわらず、機能的には同じように動作します。唯一の違いは、以降の呼び出しが高速化される点です。

String::hashCodeの動作が分かったところで、パフォーマンス変更の内容(たった1 行のコード)を明らかにします。内部フィールドString.hashに JDK internalの@Stableアノテーションが付けられました。これだけです!

@Stableにより、仮想マシンは以下のように指示されます。

  • そのフィールドを一度だけ読み取る
  • デフォルト値(0)でなくなった場合、そのフィールドが二度と変更されないことを信頼する

それゆえ、String::hashcode操作を定数展開し、既知のhashで関数呼び出しの置換が可能です。実際、ImmutableなMap内のフィールドとMethodHandle内部の値も同様の信頼性で扱われます。これはつまり、仮想マシンは操作の全体チェーンを定数化できる、ということです。

  • Stringの”malloc”のハッシュコードを計算(常に-1081483544)
  • ImmutableのMapを検査(つまり、mallocハッシュコードに対して常に同じ内部配列インデックスを計算)
  • 関連するMethodHandleを取得 (常に前述の計算済みインデックスに存在)
  • 実際のネイティブ呼び出しを解決(常にネイティブのmalloc()呼び出し)

結果として、ネイティブのmalloc()メソッド呼び出しが直接実行可能になることが、大幅なパフォーマンス向上の理由です。換言すれば、操作のチェーンが完全にショートカットされます。

What Are the Ifs and Buts?

新しい改善点は、残念ながら次のケースをカバーしていません。

Stringのハッシュコードがゼロの場合、定数折り畳みが機能しない

上述の通り、定数折り畳みはデフォルト値以外の値(つまり、intフィールドのゼロ以外の値)に対してのみ発生します。ただし、この小さな問題は近い将来に修正できる見込みです。約40億の異なる文字列のうち、ハッシュコードが0であるものは1つだけだと考えるかもしれません。平均的にはその通りかもしれません。しかし、最も一般的な文字列(空文字列 ”” )のハッシュ値は0です。一方、1文字から6文字までの文字列(すべての文字が空白スペースからZまで)でハッシュコードが0の文字列は存在しません。

A Final Note

@Stableアノテーションはinternal JDKコードにのみ適用可能であるため、Javaアプリケーションで直接使用できません。ただし、ユーザーコードが@Stableフィールドから間接的に恩恵を受けることができる新しいJEP(JEP 502: Stable Values (Preview))の開発を進めています。

JEP 502: Stable Values (Preview)
https://openjdk.org/jeps/502

What’s the Next Step?

JDK 25(早期アクセスビルド)は既にダウンロード可能で、現在のアプリケーションにこのパフォーマンス向上がどの程度役立つかを確認できます。

JDK 25 Early Access Builds
https://jdk.java.net/25/

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください