![[Java] Project Valhalla の value classで「==」の動作が変わるという話](https://devio2024-media.developers.io/image/upload/f_auto,q_auto,w_3840/v1784167818/user-gen-eyecatch/kfoe4ynqlttip71ewujg.png)
[Java] Project Valhalla の value classで「==」の動作が変わるという話
Introduction
先日、「Project Valhalla の JEP 401 が、JDK 28 への Preview 搭載を目指して
mainline 統合に向けたコードレビューが進んでいる」というニュースを見かけました。
私は10数年前までJavaで開発していたのですが、最近はまったく使っていません。
なので、「Project Valhalla って何?」というのが第一印象でした。
調べてみると、2014年から10年以上も続いているプロジェクトのようです。
あまりに長期化していたため、
「Project Valhalla がリリースされる前に、我々のほうが(死んで)Valhalla に行ってしまう」
というエンジニアのジョークまで生まれたほど。
それがここへ来て、最初のプレビューが見えてきた、というのが今のタイミングのようです。
さらに内容を見てみると、「== でオブジェクトを比較すると true になる」という例が出ていました。
Javaの入門書に必ず載っていた「== で比較するな、equals を使え」という鉄則、
その前提のほうが変わろうとしている、という話です。
ということで、実際にプレビュービルドを動かしてみました。
About Project Valhalla
簡単にいうと、Project Valhalla は
「クラス(オブジェクト)を、プリミティブ並みに軽く・速く扱えるようにする」
ための改修です。
Project Valhalla は 2014 年から続く OpenJDK のプロジェクトで、
スローガンは以下。
Javaには昔から言われている非対称性(プリミティブ型と参照型)があります。
int 、 long 、 double などのプリミティブ型は値そのものとして格納されて高速ですが、
それ以外のすべて(Integer、LocalDate、自作クラス)は参照型です。
参照型は典型的な実装ではヒープ上に確保され、オブジェクトヘッダを持ち、
参照経由の間接アクセスになります。
// p に入るのは (x,y) の値そのものではなく、ヒープ上の Point への参照
Point p = new Point(1, 2);
CPUとメモリの性能差が広がった現代では、メモリフェッチのほうがはるかに遅く、
キャッシュライン(多くのCPUで64バイト)にデータを効率よく収めて局所性を高めることが重要です。
(参照だらけでデータが散らばっているとキャッシュミスが多発する)
Valhalla はこの「便利なクラスか、高速なプリミティブか」という二択を解消しようとしています。
「== でオブジェクトを比較するな」— Javaの入門書に必ず書いてあったやつ
Valhalla の話に入る前に、まず昔ながらのJavaの挙動を振り返っておきます。
私がJavaを使っていた頃、入門書には必ずといっていいほど
「オブジェクトを == で比較してはいけない、equals() を使え」
と書いてありました。
String a = new String("hello");
String b = new String("hello");
a == b; // false ← 参照(アドレス)が違うので中身が同じでも false
a.equals(b); // true ← equals は中身を比較する
↑の== は文字列を比較しているのではなく、
「同じオブジェクト(同じアドレス)か」を見るので、
中身が同じでも別インスタンスなら falseになります。
これがJava初心者定番のトピックでした。
また、Integer(intのラッパーオブジェクト) のキャッシュ問題もあります。
Integer x = 127, y = 127;
x == y; // true ← -128〜127 はキャッシュされていて同じインスタンス
Integer p = 128, q = 128;
// 今回の環境では false. -128〜127 の共有だけが仕様で保証され、それ以外は実装依存
p == q;
「Integer の == は 127 まではなぜか通るのに 128 で急に落ちる」という、あの話。
正確には JLS §5.1.7(Boxing Conversion) が同一インスタンスの共有を保証するのは、定数式をボクシングした結果が true/false、\u0000〜\u007f の char、または -128〜127 の整数のときだけで、それ以外の値をキャッシュするかは実装次第です。手元の環境(後述の Valhalla ビルドを preview なしで実行)でも、当時と同じ挙動をそのまま確認できました。
a == b : false
a.equals(b) : true
127 == 127 : true
128 == 128 : false
この「オブジェクトは中身が同じでも == では別物」という前提こそが、Valhalla で変わろうとしている部分です。
value class — identity を捨てる
まずvalue class 自体について簡単に解説します。
value class は、Valhalla によって Java に新しく導入される「クラスの一種」です
("identity を持たない値" という発想は C# の struct や Rust の値型を連想させますが、JEP 401 は「Java に struct を入れる」ことは明確に否定していて、あくまで参照型のまま identity だけを外す別モデルです)。
普通のクラスと同じようにフィールドやメソッドを持てますが、
宣言時に class(や record)の前に value を付けます。
// value を付けるだけ。見た目はほぼ普通の record
value record Point(int x, int y) {}
見た目は普通のクラス/record とほとんど変わりません。
違いは、「value class のインスタンス(= value object)は identity(同一性)を持たない」
という点です。どのインスタンスかを区別しません。(int の 46 のような“ただの値”として扱われる)
この性質が、== の意味や後述する性能特性の起点になっています。
なので、従来のオブジェクトと比べて == の意味が変わります。
従来のオブジェクトだと、生成時に一意な identity を持ち、
== は「同じオブジェクトか(同一性)」を比較します。
一方、value objectの場合は identity を持たず、
== は statewise-equivalent(同じクラスで全フィールドが等価か)を比較します。
※int の 4 が常に 4 と等しいのと同じ扱い
なお、== の挙動が変わるのは value を付けた value class(と JDK が value class 化した30クラス)だけです。
value を付けない普通のクラスの == は従来どおり参照比較のままで、既存コードの == がいきなり変わるわけではありません。
また、注意点として、value object の == は equals() と同じではありません。
比較はあくまで「フィールド値の statewise equivalence」で、
プリミティブフィールドはビット単位、
参照型フィールドは(equals() ではなく)再帰的に == で比較されます。
実際、String フィールドを持つ value class では次のようになります(EA build で確認済み)。
value record Box(String value) {}
// false(Stringフィールドを == 比較)
new Box(new String("hello")) == new Box(new String("hello"));
// true(record の equals)
new Box(new String("hello")).equals(new Box(new String("hello")));
JEP 401 も「equals() の代わりに == を使えるようにすること」は
明確に非目標(non-goal)としています。value class になっても
「オブジェクトの内容比較は equals()」という原則は有効です。
なお、先ほどは record で書きましたが、普通のクラスも value class として書けます。
※immutableなデータ管理に向く record は value class 候補
value class USDCurrency {
private int cents; // 注意:フィールドは暗黙的に final
public USDCurrency(int dollars, int cents) {
this.cents = dollars * 100 + cents;
}
}
主な制約は、全インスタンスフィールドが暗黙的に finalになること、
synchronized インスタンスメソッドを持てないこと(this=value object をロックできないため)、
そして具体的な value class は暗黙的に final(=直接は継承させられない)、などです。
※継承は完全に不可というわけではない。value class は java.lang.Object か「abstract value class」を継承(extends)できる。
また abstract value class は、value class・identity class のどちらからも継承されうる(=スーパークラスになれる)。インタフェース実装も可能
identity が無くなると、JVMは value object を自由に複製・再エンコードできるようになります。
これが性能につながる仕組みで、以下2つの最適化があります。
(いずれも今回の EA build にすでに実装されていますが、適用できるかは JIT コンパイルや静的型・レイアウトなどの条件次第です。なお本記事では性能測定はしていません)
Scalarization
JITコンパイル時(C2)に value object への参照を構成フィールド群へ分解する最適化です。
うまく効けば、その値のヒープ割り当てや GC 負荷を避けられます。
ただし C2 コンパイル後に限られ、静的型や実行状況によっては適用されません。
Heap Flattening
value object の中身をコンパクトなビット列にエンコードする最適化です。
参照なしでフィールドや配列セルに直接格納できる場合があり、
うまくいけば Point[] をフラットに格納してキャッシュ効率を上げられます。
Environment
今回の検証環境です。
- macOS (Apple Silicon / arm64)
- OpenJDK 27-jep401ea3+1-1(Project Valhalla Early-Access Build / JEP 401 実装済み)
通常の JDK 27 や LTS の JDK 25 には value class は入っていないので、
検証には専用の EA ビルドを使います。
今回は執筆時点(2026年7月)で最新の EA ビルド 27-jep401ea3を使用。
# 展開した JDK 28 EA の bin ディレクトリ
% export JDK28EA="/path/to/jdk-28.jdk/Contents/Home/bin"
# 検証した mainline JDK 28 EA は build 6(2026-07-09 版)
% "$JDK28EA/java" --version
openjdk 28-ea 2027-03-23
OpenJDK Runtime Environment (build 28-ea+6-306)
# この build ではまだ value class は入っていない
% "$JDK28EA/java" --enable-preview Valhalla.java
Valhalla.java:5: エラー: クラス、インタフェース、注釈型、列挙、レコード、メソッドまたはフィールドが必要です
value record Point(int x, int y) {}
# API 側(Objects.hasIdentity)もまだ存在せず
% "$JDK28EA/jshell"
jshell> java.util.Objects.hasIdentity("x")
| エラー:
| シンボルを見つけられません
| シンボル: メソッド hasIdentity(java.lang.String)
というわけで、いま value class を試すなら通常の JDK ではなく、
Valhalla プロジェクトの専用 EA ビルドを使う必要があります。
※ここで確認するのはあくまでJEP 401 EA 時点の挙動
Valhalla の EA ビルドは jdk.java.net/valhalla でのみ配布のため、
今回は公式ページから直接ダウンロードしています。
(sdkmanとかではインストールできない)
Setup
jdk.java.net/valhalla から
macOS aarch64 向けのビルドをダウンロードしてインストール。
% mkdir -p sandbox/project-valhalla && cd sandbox/project-valhalla
# 公式 EA ビルドを取得
% curl -sL -o valhalla.tar.gz \
https://download.java.net/java/early_access/valhalla/27/1/openjdk-27-jep401ea3+1-1_macos-aarch64_bin.tar.gz
% tar xzf valhalla.tar.gz
% xattr -dr com.apple.quarantine jdk-27.jdk
# 展開した JDK の binを使用
% export JH="$PWD/jdk-27.jdk/Contents/Home/bin"
% "$JH/java" --version
openjdk 27-jep401ea3 2026-09-15
OpenJDK Runtime Environment (build 27-jep401ea3+1-1)
OpenJDK 64-Bit Server VM (build 27-jep401ea3+1-1, mixed mode, sharing)
Valhalla の機能を有効にするには、コンパイル・実行の両方で
--enable-preview が必須です。
Try
ここからは実際に触っていきます(公式の試し方は Inside Java の Try Out JEP 401 Value Classes and Objects にもまとまっています)。
jshell で確認してみる
まずは jshell(JDK 9 で入ったREPL)で手軽に確認します。
--enable-preview を付けて起動します。
% "$JH/jshell" --enable-preview
jshell> value record Point(int x, int y) {}
| 次を作成しました: レコード Point
jshell> Point p = new Point(17, 3)
p ==> Point[x=17, y=3]
// 別々に構築した値なのに trueとなる
jshell> new Point(17, 3) == p
$3 ==> true
jshell> import java.util.Objects
// Point は identity を持たない
jshell> Objects.hasIdentity(p)
$5 ==> false
// preview 下では Integer も value class 扱い
jshell> Objects.hasIdentity(Integer.valueOf(999))
$6 ==> false
new Point(17, 3) == p が true になりました。
昔の常識なら「別々に構築したから falseになる」部分が、
value class では「全フィールドが statewise-equivalent なら同じ値」と判定されます。
Objects.hasIdentity() という新しいAPIで identity の有無も確認できて、
Point も Integer も false(identity なし)と出ています。
javaファイルで実行
javaファイルに書いて実行してみます。
// Valhalla.java
import java.time.LocalDate;
import java.util.Objects;
// identity を持たない value class(record 版)
value record Point(int x, int y) {}
public class Valhalla {
public static void main(String[] args) {
Point p1 = new Point(17, 3);
Point p2 = new Point(17, 3);
// 別々に構築した値でも全フィールドが等価なら == が true
System.out.println("new Point == new Point : " + (p1 == p2));
// identity の有無
System.out.println("Point has identity? : " + Objects.hasIdentity(p1));
System.out.println("String has identity? : " + Objects.hasIdentity(new String("x")));
// JDK 標準クラスも preview 下では value class 扱いになる
System.out.println("Integer has identity? : " + Objects.hasIdentity(Integer.valueOf(128)));
Integer a = 128, b = 128;
System.out.println("Integer 128 == 128 : " + (a == b));
System.out.println("LocalDate has identity?: " + Objects.hasIdentity(LocalDate.now()));
LocalDate d1 = LocalDate.of(2026, 7, 14);
LocalDate d2 = LocalDate.of(2026, 7, 14).plusDays(10).minusDays(10);
System.out.println("LocalDate == LocalDate : " + (d1 == d2));
}
}
まず --enable-preview を付けずに実行すると、そもそもコンパイルが通りません。
% "$JH/java" Valhalla.java
Valhalla.java:5: エラー: value classesはプレビュー機能であり、デフォルトで無効になっています。
value record Point(int x, int y) {}
^
(value classesを有効にするには--enable-previewを使用します)
--enable-preview を付けて実行するとこうなります。
% "$JH/java" --enable-preview Valhalla.java
new Point == new Point : true
Point has identity? : false
String has identity? : true
Integer has identity? : false
Integer 128 == 128 : true
LocalDate has identity?: false
LocalDate == LocalDate : true
Integer 128 == 128 : trueです。
先ほど「Setup 前」の昔ながらの挙動では、今回の環境で 128 == 128 は false でした。
それが Valhalla の下では Integer が value class になり、
== が(identity ではなく)フィールド値の比較になるので true になります。
また、さきほど出たIntegerキャッシュの件が、
そもそも == の意味が変わることで消えています。
String だけは has identity? : true のままです。String は移行対象ではないので、従来どおり identity を持ち続けます。
なお、既存クラスが自動的に value class 化されるわけではありません。JEP 401 では、Java Platform API のうち Integer などのラッパーや LocalDate を含む 30 クラスが value class として宣言される、と選定されています(String はこの対象外)。
エラーになるケース
== の意味が変わるということは、既存コードへの影響もあります。
value object は identity を持たないので、synchronized でロック対象にできません。
ただしこれがコンパイルエラーになるか実行時例外になるかは、静的な型で変わります。
まず、静的型が具体的な value class 型(Point)のままなら、コンパイルエラーになります。
value record Point(int x, int y) {}
Point p = new Point(1, 2);
synchronized (p) { ... } // ← p の静的型は Point
エラー: 予期しない型
synchronized (p) {
^
期待値: a type with identity
検出値: Point
一方、静的型が Object などだとコンパイル可能で、
実行時に IdentityException になります。
Object object = new Point(1, 2); // 静的型は Object
synchronized (object) { ... }
java.lang.IdentityException:
Cannot synchronize on an instance of value class Point
※どちらも EA build で確認
今回の EA build では Integer などもすでに value class 扱いなので、
synchronized(someInteger) のような(そもそも良くない)コードも、
上と同じ理由で、書き方に応じてコンパイル時か実行時のどちらかで弾かれることになります。
Summary
ひさしぶりにJavaを触ってみました。
Project Valhalla の value class は、簡単に言うと
「identity(同一性)を捨てて、オブジェクトを int のような“ただの値”として扱えるようにする」
仕組みです。
その結果、new Point(17,3) == new Point(17,3) が true になったり、
Integer 128 == 128 が true に変わったりと、
私の常識だった「== で比較するな」という前提が変わるという話でした。
References
- Project Valhalla, Explained: How a Decade of Work Arrives in JDK 28
- Java's Project Valhalla finally lands a preview in JDK 28(The Register, 2026-06-15)
- jdk-dev: JEP 401 Value Classes and Objects (Preview) JDK 28 July integration
- openjdk/jdk PR #31120(JEP 401 統合PR)
- OpenJDK: Project Valhalla
- JEP 401: Value Classes and Objects
- Project Valhalla Early-Access Builds
- Value Objects (openjdk.org)
- Inside Java: Try Out JEP 401 Value Classes and Objects
- JLS SE 26 §5.1.7 Boxing Conversion(Integer キャッシュの仕様)









