10月27日にJJUG CCC 2024 Fallが開催されました。
久しぶりのベルサール新宿グランド。前回までの野村コンファレンスプラザ新宿に比べると、部屋も増えて、参加者も大幅に増えたようです。
で、さくらばはProject Valhallaで策定されているValue Classについてプレゼンしてきました。資料はこちら。
なお、Value Classはまだ策定中の仕様なので、今後変化する可能性があります。本エントリーで説明していることも変わる可能性が高いので、その点はご了承ください。
Value Classを説明する前に、そもそもProject Valhallaとは何なのか?
Project ValhallaはJavaの型システムを見直して、整理するためのOpenJDKのサブプロジェクトです。
Valhallaとは北欧神話に出てくる主神オーディンの宮殿のことです。戦士の魂が最終的に集められるのがValhallaで、日本人的な感覚だとあまり縁起のいい場所ではないような気がするんですけど、どうなんでしょう。
ちなみに、上の資料の表紙の背景にある道は、スゥエーデンのストックホルムにあるヴァルハラ通りです。今年の2月にストックホルムで開催されたJfokusに参加したので、ついでにValhallaにも行ってみたわけですw
そんなこんなで、資料の背景の写真はすべてストックホルムで撮った写真を使ってます。
さて、Project Valhallaです。
Valhallaでは型の再整理を行っているのですが、主な論点としては以下の4つがあります。
Value ClassがValhallaのメインとなる論点で、この後説明していきます。
Value Classの導入過程で必要となったのが、Null-Restricted/Nullable Typeです。
Specialized Genericsというのは、ジェネリクスの型パラメータにプリミティブ型も使用できるようにしようというものです。
Primitive拡張とSpecialized Genericsは、Value ClassとNull-Restricted/Nullable Typeに比べると、仕様策定にまだまだ時間がかかりそうなので、ここでは触れません。
Value Class導入の背景
Value Classの解説をする前に、まずValue Classの導入の背景について説明しましょう。
ご存じの通り、Javaには2種類の型があります。一方がプリミティブ型、もう一方が参照型です。
クラスでオブジェクトを作ってというのは、すべて参照型ですね。また、Javaでは配列も参照型となります。
Javaの言語仕様的にはプリミティブ型と参照型には以下のような違いがあります。
最後の初期化はプリミティブ型ではデフォルト値(たとえば数値型であれば0)があり、初期化しなくてもデフォルト値で使用できます。一方の参照型では必ずnewをしてオブジェクトを生成して初期化しなくてはならないということです。参照型変数のデフォルト値としてnullがありますが、これは変数のデフォルト値であってオブジェクトのデフォルト値ではないです。
言語仕様的なこのような違いはありますが、Valhallaで着目しているのは2つの型がヒープでどのように扱われているかということです。
たとえば、doubleの配列を考えてみます。
配列も参照型のオブジェクトなので、ヒープに生成する場合、オブジェクトヘッダーとフィールド用の領域を確保します。
プリミティブ型の値を配列に格納する場合、フィールド用の領域に直接値が書きこまれます。
一方、参照型の配列の場合、フィールド用の領域には要素のオブジェクトへの参照が格納されます(これが参照型と呼ばれる理由です)。
たとえば、2つのdoubleの要素を持つPointレコードの場合を示したのが、以下の図です。
Pointオブジェクトがヒープ上のどこに配置されるのかについては、JVMまかせでユーザーは指定できません。このため、Pointオブジェクトが離れた位置に配置されることもあります。
CPUのメモリアクセス
ここで、CPUがどのようにメモリにアクセスするかを紹介しておきましょう。
CPUの内部ではALU (Arithmetic Logic Unit、演算ユニット)が演算を行うのですが、そのためのデータはレジスターに格納します。
レジスターは高速ですが小容量なので、他のデータはメインメモリーに配置されます。必要に応じて、メインメモリーからレジスターにデータをロードします(もちろん、その逆方向もあります)。
しかし、メインメモリーは速度が遅いため、メインメモリーに直接アクセスするとCPUがアイドル状態になってしまいます。このため、現代のCPUではレジスターとメインメモリーの間にはキャッシュを配置しています。
その構成を表したのが以下の図です。
キャッシュはL1, L2, L3の3レベルあり、数字が少ないほど高速ですが、容量は少なくなります。
L1にデータがあれば数クロックでアクセスできますが、キャッシュにないデータをロードする場合桁違いに遅くなるわけです。
これをキャッシュミスと呼びます。
データにアクセスする時に、なるべくキャッシュミスが起こらないようにするのが、パフォーマンスを向上させる秘訣になります。
メインメモリーからキャッシュにデータをロードする時は、1つ1つのデータではなく、ある程度まとまった単位(チャンク)でロードします。これは、あるデータを使用する時、その近くにあるデータにもアクセスする傾向があるからです。たとえば、ループで配列をイテレートする場合などですね。
参照型配列のヒープ使用効率
前節で一緒に使うデータをなるべく近くに配置すれば、キャッシュミスが発生しないことを説明します。ところが、参照型の配列だとどうでしょう。
Pointオブジェクトはヒープ上で固まって配置されるとは保証されません。つまり、キャッシュミスを引き起こす可能性が高いということです。
デフォルトで使用されるG1GCの場合、メモリを領域で区分し、Young領域とOld領域に分けられます。そして、新しいオブジェクトは基本的にYoung領域に配置されます。
このため、上記のPointオブジェクトがヒープ上で遠く離れた位置に作られる可能性は少ないのですが、複数のYoung領域に分かれて生成させることはあるかもしれません。このような場合に、キャッシュミスを引き起こしてしまうわけです。
では、どうすればよいでしょう?
プリミティブ型の値と同じようにデータを直接フィールド領域に格納してしまえばいいということです。つまり、下図のようになります
しかし、参照型を使用する限り、このようなデータ格納を行うことができません。
そこで、プリミティブ型に近い新たな型の導入が望まれたわけです。
キーとなるのは、"Codes like a class, works like an int"です。
クラスのように書けるけども、intのようにふるまうということです。
Project Valhallaでは、これを実現させるために10年に渡って議論を続けてきました。数年前までは、新たに様々な型を導入するという複雑な実現方法が提案されていました。しかし、あまりにも複雑すぎました。
そこで、去年ぐらいから、もっとシンプルな方法が検討され、やっと議論が収束してきたのです。
そして、新たに提案されたのがValue Classです。
Value Typeではなく、Value Classだというのがポイントです。
つまり、新たな型を導入するのではなく、既存の参照型の枠組みの中で特殊なクラスを導入することでCode like a class, works like an intが実現できるということです。
では、そのValue Classというのは、どのようなクラスなのでしょう?
Value Class
Value Classの仕様はJEP 401: Value Classes and Objectsに記述されています。
端的にいうと、Value Classをインスタンス化したオブジェクト(Value Object)にはIdentityがありません。といわれても、「Identityって何?」と思いますよね。私もそうでした。
このIdentity、Java Language SpecificationにもJVM Specificationにも明確な定義はありません。
Identityはオブジェクトを区別するために使われるオブジェクトの名前もしくはアドレスのようなものです。
具体的な値としてはSystem.identityHashCodeメソッドが返す値になります(もしくはオーバーライドしていない場合のObject.hashCodeメソッド)。
この値は、==でオブジェクト同士を比較する場合に使用されます。
また、Identityでオブジェクトを区別することが、オブジェクトの状態変更を可能にします。また、synchronizedを使用したモニタロックもIdentityを利用して実現しています。
しかし、なぜIdentityなのでしょうか?それはオブジェクトのヘッダーに関係があります。
オブジェクトヘッダー
オブジェクトヘッダーは、ヒープ上に存在するオブジェクト領域の先頭にあります。実をいうと、オブジェクトヘッダーは、JVMの実装依存でJVM Specificationには定義されていません。ここでは、OpenJDKの64bitのHotSpot VMでのオブジェクトヘッダーについて紹介します。
HotSpot VMのオブジェクトヘッダーはマークワードとクラスワードの2つのパートから構成されます。マークワードはハッシュ値、GC Age、Tagからなります。GC AgeはGCを何度経てきたかを表す回数を示します。また、Tagはマークワードがポインターで上書きされてしまうことがあるので、それを区別するために使われます。
一方のクラスワードはクラスへのポインターが格納されます。
こう見てみると、GC AgeとTagを除けば、ヘッダーによってクラスとオブジェクトを区別するための情報が格納されていることが分かります。クラスはともかく、Identityがないということはオブジェクトヘッダーがなくても大丈夫ということです。
このことから、オブジェクトヘッダーを省略してしまって、そのオブジェクトをフィールドに持つクラスに直接データを埋め込むことが可能であることを示しています。
ちなみに、オブジェクトヘッダーはそれなりにサイズが大きいので、小さいオブジェクトだとヘッダーの方が大きいということが起こります。そこで、Project Lilliputでオブジェクトヘッダーを小さくする仕様を策定しています。
ちょうど、次のJava 24で、LilliputのJEP 450: Compact Object HeadersがExperimentalとして導入予定です。
あらためてValue Class
identityが分かったところで、あらためてValue Classの定義について説明しましょう。
Value ClassはIdentityがないクラスですが、もう1つの特徴としてイミュータブルであることがあります。
Value Classではフィールドを定義すると、そのフィールドはすべて暗黙的にfinalになります。
また、Identityがないことにより、Identityを使用していた操作はできません。
たとえば、オブジェクトの比較を行う==演算は、オブジェクトの同一性ではなく、フィールドの等価性の結果を返します。つまり、equalsメソッドで比較する場合と同様になるということです。
他にも、synchronizedを使用したモニターロックや、参照を使用する弱参照(WeakReference)、ファントム参照(PhantomReference)なども使用できません。
Value Classの書き方
では、Value Classをどのように定義すればよいのでしょう。
これはとても簡単でclassもしくはrecordの前にvalueをつければよいだけです。
value record Point(double x, double y) {} value class Rectangle { Point topleft; Point bottomright; Rectangle(Point tl, Point br) { topleft = tl; bottomright = br; } }
Record Classはもともとイミュータブルなので、valueを付加しても特に問題なくコンパイルできます。
通常のクラスの場合、状態を変更するようなコードがあるとコンパイルエラーになります。
たとえば、Rectangleクラスを以下のようにセッターを追加してコンパイルしてみます。
value class Rectangle { Point topleft; Point bottomright; Rectangle(Point tl, Point br) { topleft = tl; bottomright = br; } public void setTopLeft(Point tl) { topleft = tl; } public void setBottomRight(Point br) { bottomright = br; } }
> javac --release 23 --enable-preview Rectangle.java Rectangle.java:13: エラー: final変数topleftに値を割り当てることはできません topleft = tl; ^ Rectangle.java:17: エラー: final変数bottomrightに値を割り当てることはできません bottomright = br; ^ ノート: Test.javaはJava SE 23のプレビュー機能を使用します。 ノート: 詳細は、-Xlint:previewオプションを指定して再コンパイルしてください。 エラー2個
前述したように、Value Classのフィールドは暗黙的にfinalになるため、そこに再代入しているためコンパイルエラーになっています。
なお、ここでコンパイルに使用しているJDKはjdk.java.netで公開されているValhallaのEarly Access版です。
Vlaue Classが作成できるようになったので、Value Classの特徴の1つでもある==での比較を行ってみましょう。
通常のクラス(Value Classに対応してIdentity Classと呼びます)とValue Classで、JShellを使用して比較してみました。
jshell> record IDPoint(double x, double y) {} | 次を作成しました: レコード IDPoint jshell> var idp1 = new IDPoint(1, 2) idp1 ==> IDPoint[x=1.0, y=2.0] jshell> var idp2 = new IDPoint(1, 2) idp2 ==> IDPoint[x=1.0, y=2.0] jshell> idp1 == idp2 $4 ==> false jshell> idp1.equals(idp2) $5 ==> true jshell> value record VPoint(double x, double y) {} | 次を作成しました: レコード VPoint jshell> var vp1 = new VPoint(1, 2) vp1 ==> VPoint[x=1.0, y=2.0] jshell> var vp2 = new VPoint(1, 2) vp2 ==> VPoint[x=1.0, y=2.0] jshell> vp1 == vp2 $9 ==> true jshell> vp1.equals(vp2) $10 ==> true jshell>
Identity Classだと、フィールドの値が同一のオブジェクトであっても、==はIdentityが同じかどうかを調べるので、falseになります。その一方、Value ClassではIndentityがなく、フィールドの同値性を調べるので、==の結果はtrueになっています。もちろん、equalsメソッドで比較してもtrueです。
では、継承についてはどうでしょう。
Value Classは、コンクリートクラスの場合、finalクラスになるためサブクラスを作ることはできません。ただし、Value Classの抽象クラスであればサブクラスを作ることができます。
jshell> value class A {} | 次を作成しました: クラス A jshell> value class B extends A {} | エラー: | final Aからは継承できません | value class B extends A {} | ^ | エラー: | The concrete class A is not allowed to be a super class of the value class B either directly or indirectly | value class B extends A {} | ^------------------------^ jshell> class C extends A {} | エラー: | final Aからは継承できません | class C extends A {} | ^ jshell> abstract value class X {} | 次を作成しました: クラス X jshell> value class Y extends X {} | 次を作成しました: クラス Y jshell> class Z extends X {} | 次を作成しました: クラス Z jshell>
最後のコードは抽象クラスのValue Classを継承してIdentity Classを作れるということです。まぁ、作れたとしても使うことはないでしょうけど。
もちろん、インタフェースを実装したValue Classを作ることは可能です。
jshell> interface I {} | 次を作成しました: インタフェース I jshell> value class J implements I {} | 次を作成しました: クラス J jshell>
ここで一度Value Classについてまとめておきましょう。
Value Classは以下のような特徴を持つクラスです。
- identityのないクラス
- イミュータブル
- finalクラス
- ==はフィールドの状態の比較
- identityに依存した操作は不可
- synchronizedを使用したモニタロック
- 弱参照、ファントム参照などの参照
書き方に関しては以下のようになります。
- classもしくはrecortdの前にvalueを付加して宣言
- 抽象クラスでもValue Classにすることが可能
- 抽象Value Classのサブクラスを定義可能
- インタフェースの実装も可能
長くなってしまったので、最適化やNull-Restricted Typeについては次のエントリーで紹介していきます。