Skip to content

필사 모드: Java の Value Objects(JEP 401)が変えるもの — アイデンティティを手放して得るものと失うもの

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 7月31日 00時45分 UTC、22万行

2026年7月31日00時45分UTC、OpenJDKのメインラインにひとつのコミットが入りました。タイトルは二行です。

8389219: Implement JEP 401: Value Objects (Preview)
8389220: Implement JEP 539: Strict Field Initialization in the JVM (Preview)

該当コミットの統計は、1,888ファイルに208,011行の追加、13,161行の削除です。元のプルリクエスト31120は5月11日にDavid Simmsが開き、OpenJDKのSkaraボットが慣例どおりひとつのコミットにスクワッシュして取り込みました。数時間後、ハッカーニュースのスレッドは177ポイントまで上がりました。

JEP 401自体は2020年8月13日に作成され、その源流であるProject Valhallaは2014年に始まりました。EffortとDurationの両方がXLと記されているJEPは珍しいものです。この記事を書いている時点での状態は Targeted, Release 28 であり、文書の更新日は2026年7月30日です。同時に入ったJEP 539は、値オブジェクトのフィールドが初期構築段階で必ず初期化されることをバイトコード検証レベルで強制する依存JEPであり、401なしには意味をなさず、401もこれなしには成立しません。

ここで少し興奮を一段階下げておくのがよさそうです。これは プレビュー機能 であり、デフォルトでは無効になっていて、JDK 28のGAはまだ随分先です。それでも詳しく見る価値があるのは、これがJavaのオブジェクトモデル自体に触れるからです。==演算子とsynchronizedキーワードの意味が変わる変更は、Javaの歴史でも数えるほどしかありません。

アイデンティティがないことの正確な意味

JEPが挙げる例は明快です。LocalDate.of(1996, 1, 23)で作ったオブジェクトと、そこに30年を足してからまた引いたオブジェクトは、同じ年・月・日を保持しています。equalsはtrueを返します。ところが==はfalseです。二つのオブジェクトが互いに異なる アイデンティティ を持つからです。

可変オブジェクトにとってアイデンティティは欠かせません。今は状態が同じでも、後で変わりうる二つのオブジェクトを区別しなければならないからです。テキストエディタの二行がたまたま同じ文字列を保持していたとして、片方を直したときにもう片方まで変わってしまってはいけません。しかし不変データでは事情が逆になります。1996-01-23を表す二つのLocalDateのあいだに実質的な違いはありません。区別する理由がないのに、区別を強いられている状態です。

その強制の代償は二つあります。ひとつは毎回新しいメモリを割り当てなければならないこと、もうひとつはそのメモリを使うたびにポインタをたどらなければならないことです。JEPはLocalDate配列のメモリ配置を図で示していますが、実質的には48ビットのデータ五つを収めるために、ポインタ五つと五つのヒープオブジェクト(それぞれ最低でも64ビットのヘッダー付き)を使っています。しかもそれらのオブジェクトが互いに遠く離れていれば、たどるたびにキャッシュラインが分断されます。

JDK 28プレビューでは、プラットフォームAPIの 30個のクラスが値クラスとして宣言されます。ボックス型全部(IntegerLongFloatDoubleByteShortCharacterBoolean)に加えてNumberRecordOptional系列四つ、java.timeの12個、java.time.chronoの暦日付四つです。そのためプレビューを有効にするとこうなります。

// jshell --enable-preview
Integer x = 1996, y = 1996;
x == y                       // true   (プレビューなしでは false — Integer キャッシュ範囲外のため)

LocalDate d1 = LocalDate.of(1996, 1, 23);
LocalDate d3 = d1.plusYears(30).minusYears(30);
d1 == d3                     // true

Objects.hasIdentity(d1)      // false  (新規メソッド)

Stringは値クラスでは ありません。APIと実装がアイデンティティに依存する部分がまだ残っているため、文字列は引き続きアイデンティティオブジェクトです。

==の新しい定義はこうです。二つの値オブジェクトは、(1)同じ値クラスのインスタンスであり、(2)プリミティブ型フィールドのビットパターンが一致し、(3)参照型フィールドに==を再帰的に適用したときに区別できないなら、==です。アイデンティティオブジェクトに対する==の挙動はJava 1.0以来変わっていません。

注意すべき点は、==equalsが値オブジェクトでも 異なりうる ということです。JEPの例は、文字列の部分列を元の文字列と二つの座標で表す値クラスです。同じ文字シーケンスを表しますが内部状態が異なるため、equalsはtrueで==はfalseです。浮動小数点も同様です — 異なるビットパターンのNaNを持つ二つの値オブジェクトは、equalsでは等しく、==では異なります。「値オブジェクトだから==を使ってよい」という結論に飛びついてはいけません。依然としてequalsが基本です。

性能はどこから来るのか — フラット化とスカラー化

JEPは最適化を二つに分けて説明します。この区別が実務上かなり重要です。

参照フラット化 はヒープ上のフィールドや配列要素に適用されます。あるオブジェクトのフィールドが値オブジェクトを指すとき、ポインタの代わりにその値オブジェクトのフィールドを参照の中に直接エンコードします。Integer配列なら、各要素はnullフラグ1ビットとint32ビットを収めた64ビットワードになります。ポインタ配列より確実に小さく、何より 追加のメモリロードがありません

参照スカラー化 はメソッドのパラメータとローカル変数に適用されます。JITはLocalDate参照ひとつをnullフラグ、int、byte、byteの四つのローカル値に分解して扱います。JEPの疑似コードがこの部分をよく示していて、plusYearsメソッドがコンパイルされたあとはLocalDateポインタにまったく触れません。

二つの最適化の決定的な違いは サイズ制限 です。フラット化された参照は常に原子的に読み書きしなければなりません。そうしないと引き裂かれる(tearing)おそれがあります — あるスレッドが書いた前半と別のスレッドが書いた後半が混ざり、存在したことのない日付が観測されることがあります。一般的なハードウェアで原子的アクセスが保証されるサイズが64ビットなので、 可変フィールドに収まるフラット化された参照は事実上64ビットに制限されます

そのためLocalDateTimeは可変フィールドにフラット化できません。内部のLocalDateLocalTimeフィールド、それぞれのnullフラグ、さらに自身のnullフラグまで合わせると64ビットを超えるからです。JVMはこの場合、黙ってポインタ配置を選びます。一方 値クラスのフィールドにはこの制限がありません — 値オブジェクトのフィールドは変更が観測されえないので、引き裂かれる余地自体がないからです。同じLocalDateTimeフィールドでも、収めるクラスがアイデンティティクラスならポインタになり、値クラスならフラット化されえます。

スカラー化にはこの制限がありません。スタックとレジスタにある値はデータレースにさらされないからです。

フラット化とスカラー化が いつ起きるか もJEPが明示しています。これは言語機能ではなく最適化であり直接制御することはできませんが、可能性を高める方法はあります。

Integer[] ints = { 1996, 2006, 1996, null, null };  // フラット化可能
Object[]  objs = { 1996, 2006, 1996, null, null };  // 不可能

record Box<T>(T field) { }      // field は消去されて Object になる — フラット化不可
var b = new Box<Integer>(1996); // ヒープポインタを格納する

変数が 特定の値クラス型として宣言されている必要があります。上位型やジェネリック型パラメータとして宣言された場所は消去のため実質Objectであり、最適化の対象になりません。ここが残るValhallaの断片(JEP 218、プリミティブ型に対するジェネリクス)が必要な理由です。

もうひとつあります。クラスをコンパイルするとき、フィールド・メソッドシグネチャに現れる値クラス名はLoadableDescriptorsという新しいクラスファイル属性に記録され、JVMはこれを見て該当の値クラスを十分に早くロードし、フラット化されたフィールドとスカラー化されたパラメータを準備します。つまり 既存のクラスが値クラスに移行すると、それを参照するコードは再コンパイルしなければ最適な性能が出ません。再コンパイルしなくても動作はしますが、ヒープ割り当てが残ります。

records、そしてprimitivesとの関係

レコードはすでにfinalで、フィールドも全部finalなので、値クラスの有力な候補です。構文はそのまま重なります。

value record Point(int x, int y) { }

Point p = new Point(17, 3);
Objects.hasIdentity(p);      // false
new Point(17, 3) == p;       // true

ただし、すべての値クラスがレコードになれるわけではありません。レコードはコンストラクタ引数とフィールドが正確に対応する 透明な クラスでなければなりませんが、内部表現を別に取ってメモリを節約するクラスも多いからです。JEPの例はユーロとセントをlongひとつに詰めて保存する通貨クラスです — 値レコードにはなれませんが値クラスにはなれます。

プリミティブ型との関係はJEPのNon-Goalsに明記されています。 プリミティブ型の扱いを変えることは目標ではありません。 値オブジェクトは多くの点でプリミティブ型のように振る舞いますが、別の概念です。Java言語は引き続き二種類のデータしか扱いません — プリミティブ型とオブジェクト参照です。CやC#のstructを導入することも明示的な非目標です。C#の値型はインスタンスがアイデンティティを持ちフィールドを変更できるため、代入や呼び出し時のコピー意味論を細かく規定する必要があり、その分ユーザーモデルが複雑になります。Javaはその低レベルの決定をJVM実装者に委ねる道を選びました。

階層構造も整理しておくとこうです。値クラスはインタフェースを実装でき、sealedにでき、abstractとして宣言すると拡張可能な「値互換」上位クラスになります。値クラスはObjectか抽象値クラスしか拡張できず、アイデンティティクラスは拡張できません。java.lang.Valueのような共通の上位クラスはありません。

ハッカーニュースの議論でよく言及された「四つのバケツ」(通常のオブジェクト/アイデンティティのないオブジェクト/原子的な値/引き裂き許容の値)というフレーミングはValhallaの設計ノートに由来しますが、 今回入ったのは前の二つまでです。引き裂きを明示的に許容して原子性の制約から外れるオプトアウト構文はこのPRには含まれておらず、JEPも「将来の拡張」として先送りしています。

何が壊れるのか

値クラスに変わると、アイデンティティに依存していた操作は挙動を変えるか失敗します。JEP原文に基づいて整理するとこうです。

操作値オブジェクトでの結果
==アイデンティティではなくフィールド値を再帰的に比較。Integer の 1996 が二つあれば、いまや ==
synchronized静的型が値クラスならコンパイルエラー、Object 型を経由すると実行時に IdentityException
wait / notifyロックを取得できないため常に IllegalMonitorStateException
System.identityHashCodeアイデンティティではなくフィールド値からハッシュを計算(名前だけがレガシー)
finalizeGC が絶対に呼び出さない。オーバーライドすると javac が identity 警告を出す
java.lang.ref 系列、WeakHashMapReference 生成時に IdentityException
シリアライズ値レコードは自動的に動作する。それ以外の値クラスは writeReplace/readResolve がないと InvalidClassException
深いリフレクションによる final フィールドの変更サポートされない。--enable-final-field-mutation を与えても不可
clone元と区別できない値オブジェクトを返す。x.clone() != x という期待は意味をなさなくなる

この表で実務上もっとも痛い箇所は三つです。

第一に、 弱参照系 です — java.lang.refReferenceサブクラス群とWeakHashMap。値オブジェクトをキーに使う弱いキャッシュがあれば、そのまま例外になります。ただしJDK 25からvalue-basedクラスに対してjavacがidentity警告を出してきており、JDK 28からは値クラスに対しても同じ警告が出ます。警告を見ていたなら、すでにリストを持っているも同然です。

第二に、 シリアライズ です。値クラスは厳格初期化(strictly-initialized)フィールドとしてコンパイルされ、逆シリアライズはコンストラクタを経由せずフィールドを埋める方式なので安全に初期化できません。そのためSerializableを実装する非レコード値クラスは、代替オブジェクトを使うようwriteReplacereadResolveを自前で実装する必要があります。やらなければ実行時InvalidClassExceptionになり、javacがserial警告を出します。

第三に、 深いリフレクションでfinalフィールドを触るライブラリ です。シリアライズフレームワーク、ORM、モックライブラリ、DIコンテナの一部がこの方式を使います。値オブジェクトに対しては例外なく塞がれます — コンストラクタを通すしかありません。finalフィールド変更の経路自体が狭まる流れはJDK 26のfinalフィールド変更警告 — JEP 500の回ですでに始まっていた話であり、JEP 401はその行き着く先を見せています。

そしてJEPのRisks and Assumptionsに静かに書かれていることのうち二つは、ライブラリ設計者が覚えておく価値があります。==が値オブジェクトの木を再帰的に比較するため、 深い入れ子では時間がかかったりStackOverflowErrorが起きたりすることがあります。そして==identityHashCodeが非公開フィールドの値を間接的に露出しうるため、 機微なデータを保持するクラスは値クラスにしないほうが安全です

ライブラリ作者が今すべきこと

JDK 28はまだ先ですが、今やっておけば後で無料で手に入るものがあります。

1. 候補を選んでおきます。 判断基準は二つです — インスタンスが不変か(すべてのインスタンスフィールドがfinalで、表す値が時間とともに変わらないか)、そして互いに交換可能か(同じ値を表す二つのインスタンスを区別する必要がないか)です。フィールドのない抽象クラスもよい候補です — アイデンティティ要件をサブクラスに強制する理由がないからです。Numberがまさにそうして抽象値クラスになりました。

2. equalshashCodeを先にオーバーライドします。 移行の前にこの二つがアイデンティティに依存しないようにしておけば、値クラスに変わっても挙動は変わりません。オーバーライドしないまま移行すると、継承された実装の意味が変わってしまいます。

3. publicコンストラクタを整理します。 クライアントがnewで「他のすべてのオブジェクトと区別されるインスタンス」を作り、ロックとして使ったり==比較の目印として使ったりしているかもしれません。JEPが勧める道筋はJava 9でIntegerのコンストラクタに対して行ったのと同じです — コンストラクタをdeprecateしてファクトリメソッドへ誘導します。

4. 同期対象を監査します。 自分のコードが値候補クラスのインスタンスをロックとして使っていないか、そしてドキュメントでクライアントにそうするよう誘導していないかを確認します。Java 16からvalue-basedクラスに対する同期警告があったので、ビルド警告を有効にしていたならリストはすでに出ています。

5. バイナリ互換性は心配ありません。 クラスがfinalまたはabstractで、フィールドがすべてfinalなら、valueキーワードを付けたり外したりするのは バイナリ互換の変更 です。ソース・挙動互換性のリスクは上のリストがすべてです。

実際に今手を動かしてみるには、JDK 28 EAビルドを取得してプレビューを有効にすればよいです。

javac --release 28 --enable-preview Main.java
java  --enable-preview Main

# 単一ファイルソースコード起動
java --enable-preview Main.java

# jshell
jshell --enable-preview

プレビューの規則をひとつ押さえておく必要があります。プラットフォームAPIのその30個のクラスは プレビューを有効にしたときだけ値クラスです。プレビューなしでコンパイルすると、コンパイラは既存のアイデンティティ版のLocalDateを使い、プレビューを有効にすると値版を使います。プレビューを有効にした状態でアイデンティティ版を使う方法はありません。つまりこの機能は「一部だけ有効化する」ことができません。

「プレビュー」という言葉の重み — スケジュールと残された断片

日付を正確にしておきましょう。JDK 28プロジェクトページで、JEP 401のtargetingレビューは2026年7月30日に終わり、JEP文書の状態はその日付で Targeted / Release 28 です。マージはその翌日の未明でした。同じページにJEP 539(レビュー終了7月30日)とJEP 535(Shenandoah世代別モードのデフォルト化、レビュー終了8月3日)が並んで掲載されています。

一方、JDK 27はすでにRampdown Phase Twoに入っており、GAは2026年9月15日と定められています。Javaの6か月周期を踏まえると、 JDK 28 GAは2027年3月です。そしてプレビュー機能は慣例的に少なくとも一回以上のリリースをさらにプレビューとして送り出すので、--enable-previewなしで値クラスを使える時点はそれより後になります。何回のプレビューを経るかはまだ決まっていません — この部分は未確認の事項です。

そして今回入ったものがValhallaのすべてではありません。JEPがFuture Workとして明示しているものだけでも二つあります。

  • JEP 402, Enhanced Primitive Boxing — ボクシングが値オブジェクトによって軽くなったことを言語レベルで活用します。
  • JEP 218, Generics over Primitive Types(改訂版) — ジェネリッククラスが値クラスでパラメータ化されたとき、フィールド・配列・ローカル変数のレイアウトを特殊化できるようにします。前節で見た「ジェネリックフィールドはフラット化されない」という制約を解く断片です。

ここに先述の引き裂きオプトアウト、nullを排除するレイアウト、値クラスの自動シリアライズまでが残っています。だから「Valhallaが終わった」という表現は正確ではありません。12年目にして 最初の断片がメインラインに入りました

おわりに — オブジェクトモデルを変える変更は静かには来ない

まとめるとこうです。

  • 値クラスはアイデンティティを放棄する代償として、JVMにメモリレイアウトの自由を与えます。==はフィールド値の比較になり、synchronizedは不可能になります。
  • 性能はフラット化(ヒープのフィールド・配列)とスカラー化(スタックのローカル変数・パラメータ)から来ます。前者は原子性のために事実上64ビットという壁がありますが、後者にはありません。
  • 最適化を受けるには、変数を具体的な値クラス型として宣言しなければなりません。Objectやジェネリック型パラメータの場所は対象外です。そして移行済みの値クラスを使うコードは再コンパイルが必要です。
  • 壊れるのはjava.lang.ref系、WeakHashMap、非レコード値クラスのシリアライズ、深いリフレクションによるfinalフィールド変更、そしてクライアントの同期です。Java 16以降に積み重なったidentity警告がそのリストです。
  • 状態はTargeted / JDK 28プレビューで、JDK 28 GAは2027年3月予定です。デフォルトで有効になる時点はそれより後です。

今すぐプロダクションへの影響はありません。しかしWeakHashMapLocalDateをキーとして入れているコードがどこかにあるなら、そのコードはすでに警告を出しているはずです。今がその警告を読むときです。

参考資料

현재 단락 (1/94)

2026年7月31日00時45分UTC、OpenJDKのメインラインにひとつのコミットが入りました。タイトルは二行です。

작성 글자: 0원문 글자: 10,772작성 단락: 0/94