Project Valhalla を用いた Java のドメインプリミティブの再考

長年にわたり、Java 開発者は型安全性とパフォーマンスの間で苛立たしいトレードオフに直面してきました。一方で、ドメインプリミティブ(ビジネス制約をエンコードした型、例: PositiveInt は生の int の代わり)を使用すると、コンパイラが無効な状態を拒否し、バグを防止できます。もう一方で、プリミティブをクラスでラップすると、ヘッダーとポインタを持つヒープオブジェクトが生成され、メモリオーバーヘッドが大きくなり、パフォーマンスが重要な「ホットループ」でキャッシュミスが発生します。

歴史的に、経験則としてはシステム境界で型を洗練し、コアロジックでは生のプリミティブに戻すことで速度を維持してきました。Project Valhalla はバリュークラスを導入することでこの方程式を変え、JVM がラッパーをレジスタや包含オブジェクト、配列スロットに直接フラット化できるようにします。

ラッパークラスのパフォーマンス税

Valhalla が必要な理由を理解するためには、標準的な Java ラッパーのメモリレイアウトを見る必要があります。シンプルな class PositiveInt { final int v; } は、HotSpot では通常 16 バイト(12 バイトのヘッダーと 4 バイトの整数)を消費します。さらに、これらのオブジェクトの配列は実際の値を格納せず、ヒープ上に散在するオブジェクトへの 4 バイト参照を格納します。

数百万件のイベントを処理するストリームプロセッサにとって、このアーキテクチャは壊滅的です。ラッパーは生の int の 4 倍のメモリを消費し、すべてのアクセスは「ポインタチェイス」を必要とし、しばしばキャッシュラインのロードとキャッシュミスを引き起こします。このオーバーヘッドが、開発者が従来ホットパスで洗練型を避けてきた理由です。

洗練型の実装

Scala のような言語では、洗練型はコンパイル時に述語で絞り込むことができます。Java にはこのネイティブな仕組みがなく、制約は構築時にチェックする必要があります。典型的な実装は、基底の Refined<T> クラスと具体的な拡張を伴います。

private static class PositiveInt extends Refined<Integer> {
    public PositiveInt(int value) {
        super(i -> i > 0, value);
    }
}

これにより任意の PositiveInt インスタンスが有効であるという静的保証が得られますが、プリミティブのボクシングは依然としてボトルネックです。ここで Project Valhalla の value キーワードが登場します。

Project Valhalla とバリュークラスの登場

バリュークラスは同一性を持たないオブジェクトです。振る舞いと不変条件を保持しますが、プリミティブのように格納されます。value キーワードを使用することで、開発者は JVM にその型に同一性がないことを伝え、オブジェクトが現れる場所でフィールドをインライン化する許可を与えます。

以下は Valhalla プレビュー(JEP 401)を使用したドメインプリミティブの実装例です。

public value class PositiveInt implements RefinedInt<PositiveInt> {
    private final int value;

    public PositiveInt(int value) {
        if (value <= 0) {
            throw new IllegalArgumentException("must be positive: " + value);
        }
        this.value = value;
    }

    @Override public int value() { return value; }
}

Refined<T> のような汎用インターフェースではなく、RefinedInt のような特化インターフェースを使用することで、ボクシングが回避されます。その結果、クラスと同等の安全性を提供しつつ、プリミティブと同等のパフォーマンスを持つ型が得られます。

複数フィールド型への拡張

このパターンは単一値ラッパーを超えて拡張できます。LatitudeLongitude(どちらも double バックのバリュークラス)を含む Coordinate バリュークラスは、JVM がスロットごとに連続した 16 バイトの double を格納できるようにします。対照的に、同等の同一性クラスは各々がヘッダーを持つ散在するヒープオブジェクトへの参照を格納します。

数値で見るインパクト

圧縮 oops を使用した 64 ビット HotSpot のベンチマークは、10 要素の配列におけるメモリフットプリントの劇的な違いを示しています。

  • int[10]: 56 バイト(純粋なプリミティブ)
  • PositiveInt[10] (Identity Class): 216 バイト(ドメイン認識だが高コスト)
  • PositiveInt[10] (Value Class): 56 バイト(安価でドメイン認識)

バリュークラスは純粋なプリミティブと全く同じメモリレイアウトとキャッシュ動作を実現し、ドメインモデリングに対する「パフォーマンス税」を実質的に除去します。

採用時の重要な考慮点

バリュークラスは強力なツールですが、開発者が考慮すべきいくつかのセマンティックな変化をもたらします。

1. 等価性のセマンティクス

バリュークラスはポインタではなく値で比較します。バリュークラスに対する == 演算子はフィールド単位の代替可能性をテストし、適切に実装された equals() メソッドと同等です。既存の同一性クラスを value クラスに移行すると、参照等価性に依存しているコードの挙動が黙って変わります。

2. null 許容性

バリュークラスは null になることはできません。Project Valhalla は null 制限付き参照(例: 非 null 用の PositiveInt!、nullable 用の PositiveInt?)を導入しており、コンパイラはホットパスでの null チェックを除去できます。

3. ジェネリクスのギャップ

現在、List<PositiveInt>Optional<PositiveInt> は型消去のため依然としてボクシングが発生します。ジェネリックの特殊化はまだ完全に提供されていないため、クリティカルパスで最大のパフォーマンスを得るには、コレクションではなく型付き配列(PositiveInt[])を使用すべきです。

4. フレームワーク統合

ほとんどの Java フレームワーク(Jackson、JPA、Bean Validation)はプリミティブまたは String を期待します。バリュー型は薄いアダプタが必要です。例えば、PositiveInt 用の Jackson デシリアライザは、p.getIntValue() の呼び出しを新しい PositiveInt コンストラクタでラップするだけです。

結論

Project Valhalla は高レベルのドメインモデリングと低レベルのパフォーマンスのギャップを埋めています。型が「クラスのようにコードでき、int のように動作する」ことを可能にすることで、Java はアプリケーション全体で強力なドメインプリミティブを使用する最後の障壁を取り除いています。まだプレビュー段階ですが、バリュークラスは型システムにエンコードされたセキュリティと正確性がパフォーマンスペナルティなしで実現できる未来を約束します。

Sources