2024/12/12

Null-Restricted Typeとオブジェクト初期化の変更
 で、ValhallaのValue Classってどうなったの? その3

このエントリーをはてなブックマークに追加

本エントリーはJava Advent Calendarの12日目です。昨日はmackey0225さんのイレイジャってなんじゃ?でした。

 

Project Valhallaを紹介するエントリーも3回目になりました。

本エントリーではNull-Restricted Type(Null非許容型)について紹介していきます。いわゆるNon-Nullです。

Null-Restricted Typeに関するJEPは2つありますが、現状はドラフトなので番号がついていません。

 

Null非許容型にまつわる小史

Javaの開発者であれば誰もが1度は遭遇したことがあるNull Pointer Exception例外ですが、これを防ぐための取り組みが行われてきました。

ここではOpenJDKおよびJCPによる標準APIでの取り組みについて簡単に紹介します。

 

型アノテーションを使ったDefect Detection

変数にNullを許容するかしないかをアノテーションで修飾する取り組みは、多くライブラリやフレームワークでも導入されてきました。

たとえば、IntelliJ IDEAの設定で[Compiler]の項の1つに[Add runtime assertions for notnull-annotatedmethods and parameters]があり、その[Configure annotations...]をクリックすると、どのライブラリ/フレームワークのアノテーションを使用するか選択できます(下図参照)。

 

この設定ダイアログを見ると、AndroidやJakarta EEなどが@NonNullアノテーションもしくは@NotNullアノテーションを導入していることが分かります。

たとえば、メソッド引数にnullを禁止したいのであれば、次のように書けます。

    String readContext(@NonNull String filename) throws IOException {
        ...
    }

 

ところが、このアノテーションだと書けないことがあります。たとえば、リストの要素にnullを許さない場合はどうでしょう。これを解決するためにJava 8で導入されたのが、型アノテーション(JSR 308 Annottations on Java Type)です。

型アノテーションは型に対してアノテーションで修飾します。たとえば、リストの要素にnullを許さないという場合は次のように記述できます。

    List<@NonNull String> texts = ...;

 

そして、この型アノテーションを使用してNull非許容性を表そうとしたのが、JSR 305 Annotations for Software Defect Detecctionです。

このJSRのスペックリードはFindBugsの作者のBill Pughだったのですが、Bill Pughに連絡がとれなくなり、JSRも中断してしまいました。ご存じの方もいらっしゃると思いますが、FindBugsの開発が停滞してしまったのもこの頃です。

同様にJSR 308の型アノテーションを使って@NonNullを表そうとしたのが静的解析ツールのChecker Frameworkなのですが、こちらもそこまで流行らず...

うまく活用すればよかったのですが、標準にならなかったのが痛かったのが型アノテーションを使ったNull非許容性でした。

 

Optional

OptionalもJava 8で導入されました。

Optional自体はNull許容性を表すというよりは、値の有無を扱うために使われるクラスです。

しかし、値がないことをnullで表す場合が多かったため、Optionalを使うことでnullの使用を避けることができました。

ところが、Optional型を使ったとしても次のように書けてしまうのが...

    Optional<String> option = null;

 

つまり、値の有無を扱うことはできても、自分自身のNull非許容性は表せないのです。

 

ということで、Null非許容性を表すための取り組みはあったものの、成功したとはいえないのがJavaの現状でした。

 

Null-Restricted Type/Nullable Type

さて、Project ValhallaのNull-Restricted Typeです。

今までのNullに対する取り組みは、ソフトウェアの堅牢性を高めるためのものでした。これに対しValhallaのNull-Restricted Typeはパフォーマンス向上のためという大きな違いがあります。

前回、説明したValueクラスの平坦化やスカラー化は、行われるためのいくつかの条件があります。そのうちの1つが、値にnullが入らないことです。

Valueオブジェクトはプリミティブ型のようにふるまいますが、プリミティブ型の変数にはnullが値として入ることがありません。もし、平坦化やスカラー化で値を埋め込む時にnullが入るかもしれないのであれば、それを示すためのフラグなどが必要になります。しかし、それではせっかくの最適化の効果が低くなってしまいます。

なので、nullを許さないというのが最適化の条件になっているわけです。

 

ただし、Null非許容性はValueクラスでなくても有用です。そこで、Value Classとは独立して仕様を策定しようというのがJEPのNull-Restricted and Nullable Typesです。

そして、ValueクラスのNull非許容性はNull-Restricted Value Class Typesで仕様策定されます。

 

Null-Restricted Type/Nullable Typeの書き方

Null非許容/Null許容型の変数は次のように記述します。

    // null非許容
    String! nonnullText = "...";

    // null許容
    String? nullableText = "...";

 

他の言語でもNon-NullとNullableに!と?を使うことが多いので、理解しやすいですね。!と?はnullnessマーカーと呼ばれます。

nullnessマーカーはジェネリクスの型パラメータでも使用することができます。

    // null非許容
    class Foo<T!> { ... }

    // null許容
    class Bar<T?> { ... }

    // null非許容
    Foo<String!> foo = ...;

    // null許容
    Bar<String?> bar = ...;

 

!や?を指定していない型は未指定(Unspecified)です。未指定、つまり従来の型については仕様を変更していないので、nullが入ることもあります。実質的には?と未指定は同じような動作になりますが、型としては異なります。

また、nullnessの型を変換することもできます。Foo!をFoo?に代入するようなwide変換はOKです。しかし、Foo?をFoo!に代入するようなnarrow変換の場合、コンパイル時に警告が出るようです。

ただし、このJEPに対応するEalry Accessがないので、実際にどのような警告が出るのか、実行させるとどうなるのかなどは、よく分かりません。キャストすればいいのか、nullチェックをした後でないと代入できないのかなどは、Early Accessが出たら確かめてみたいと思います。

 

配列の初期化

Null-Restrictedな変数は、変数の宣言時に初期化を行う必要があります。ただし、クラスのフィールドであれば、コンストラクターやイニシャライザーでも初期化できます。

ここで困るのが配列です。要素も含めて初期化する必要があるからです。

たとえば、"a", "b", "c"を要素に持つString!の配列であれば、次のように書けます。

    String![] texts = new String![] { "a", "b", "c" };

では、初期値として""で埋めた、長さ10の配列はどうでしょう。また、配列のインデックスを使った初期化はどうでしょう? もちろん、Stream APIを使えば書けますが、それではちょっとおおげさですね。

現状のJEPのドラフトでは以下の書き方が提案されていますが、あくまでも現状であり、文法については変わる可能性も高いのですが、とりあえずこういうことが書けるようなことが考えられています。

    String![] texts1 = new String![10] { "" };
    String![] texts2 = new String![10] { i -> "s" + i };

 

この他にもメソッドをオーバーロードする場合、nullnessの違いだけではオーバーロードできないなど、いろいろとルールがありますが、実際にやってみないと具体的にどのようになるのかがJEPだけではよく分からないことが多々あります。

Early Accessが出て、JEPもドラフトではなく正式なものになったら、再度取り上げてみたいと思います。

 

オブジェクト初期化の変更

クラスのフィールドがNull-Restrictedな型の場合、宣言時に初期化するか、コンストラクターもしくはイニシャライザーで初期化する必要があります。

では、次に示すコードは実行したらどのようにふるまうでしょう。

フィールドの初期化ははまりどころが多いので、よくクイズになるところですね。Javaのクイズといえば、JavaOneの名物セッションだったJoshua BlochとNeal GafterによるJava Puzzlersです。

短いコードを提示して実行したらどうなるかを4択で選ぶというセッションなのですが、彼らのウィットに富んだセッションはさくらばもとても影響を受けています。

ということで、ここでもJava Puzzlersをまねて、実行したらどうなるかを4択で選んでみてください。

class Cat {
    String meow = "Meow";

    Cat() {
        meow = ((Lion)this).roar;
    }
}

class Lion extends Cat {
    final String roar;

    Lion() {
        roar = "Roar";
    }
}

public class DoLionMeow {
    public static void main(String... args) {
        System.out.println(new Lion().meow);
    }
}

選択肢は以下の4つ

  1. Meow
  2. Roar
  3. null
  4. 例外発生

 

ちなみに、Meowはネコの鳴き声(ニャーオ)で、Roarはライオンの鳴き声(ガオー)です。

このDoLionMeowクラスでは、Lionオブジェクトを生成して、そのスーパークラスであるCatクラスのフィールドのmeowを表示させています。

Catクラスのコンストラクターでは、サブクラスのLionのroarをmeowに代入しています。roarはLionのコンストラクターで"Roar"を代入しています。

 

さて、どうでしょう。

答えは 3. の null です。

 

それほど難しくはないですよね。

roar変数はfinalなので一度しか初期化できません。しかし、実際には初期化する前の状態があり、その時の値はnullになります。

そして、Lionクラスのコンストラクターでは省略されていますが、super()をコールしているということです。つまり、Lionクラスのコンストラクターは省略しないで記述すると、次のようになります。

    Lion() {
        super();
        roar = "Roar";
    }

 

このため、roarを初期化する前にCatクラスのコンストラクターがコールされてしまい、初期化されていないroarにアクセスしてしまっているということです。

初期化していないのでroarの値はnullになり、meowに代入するので、結果的にnullが表示されてしまいます。

 

ここで重要なのはfinal変数でも、初期化前の状態にアクセスできてしまうということです。もし、meowの型がString!だったらどうでしょう。nullはとらないはずなのに、実際はnullになってしまうのは問題です。

これを解決するために、オブジェクトの初期化を変更するというのがJEP 492: Flexible Constructor Bodiesです。

JEP 492はProject Valhallaではなく、Javaの言語仕様をアップデートするProject Amberで策定されています。Value ClassやNull-Restricted Typeとは独立に仕様策定できるので、Value Classにさきがけてアップデートしてしまおうということなのかもしれません。

JEP 492はJava 24で3rd Previewになっているので、次のLTSのJava 25に標準で取り込まれる可能性が高いです。

 

Lionクラスのように今までのコンストラクターは、必ず先頭でスーパークラスのコンストラクターを呼び出していました。デフォルトコンストラクター以外のコンストラクターをコールするのであれば、明示的にsuper(...)をコールする必要がありますが、デフォルトコンストラクターであれば記述を省略できます。

このため、スーパークラスからはサブクラスの初期化していないフィールドにアクセスできてしまいます。

そこで、JEP 492ではsuper(...)をコールする前にフィールドの初期化を行えるように言語仕様を変更しています。

 

とはいえ、super(...)をコールする前にどういう処理でもできるわけではありません。たとえば、thisは使うことはできません。他にもルールはありますが、要するにフィールドの初期化以外の処理をsuper(...)の前に記述しないということが重要です。

また、デフォルトコンストラクターのsuper()を省略した場合は、現在の使用と同じくコンストラクターの先頭でコールされます。

 

さて、DoLionMeowクラスで"Roar"を出力させるためには、Lionクラスのコンストラクターを次のように記述すればよいことが分かります。

    Lion() {
        roar = "Roar";
        super();
    }

 

ただし、JEP 492はPreview JEPなので、コンパイルや実行する時にはオプションの--enable-previewが必要です。

 

ところで、前述したJava Puzzlersのセッションではフィールドの初期化に関するパズルは必ず1問は出題される頻出分野だったのですが、JEP 492が導入されるとそれらのパズルは通用しなくなってしまいますね。まぁ、どちらにしろずっと昔の話なので、どうでもいいといえばどうでもいいのですけど。

 

最後に

3回に渡ってValue Classに関連したトピックを紹介してきました。

Value Classを作成するのは簡単ですが、最適化されることを考慮して使う必要があります。

サイズが小さいことと、フィールドがNull-Restrictedであることが最適化の条件です。このように考えると、Recordクラスで記述していたデータで、サイズが小さければValue Classにするというのがいいと思います。

とはいうものの、いつからValue Classが使えるようになるのかはまだまだ分かりません。Project Valhallaが発足して10年。ここまで待ったのですから、もうちょっとだとは思いますが気長に待ちましょう!

2024/12/02

Valueクラスによる最適化
 で、ValhallaのValue Classってどうなったの? その2

このエントリーをはてなブックマークに追加

本エントリーはJava Advent Calendarの2日目です。昨日はHatanoさんのこんにちは、世界でした。

 

さて、前回のエントリーに続き、Project ValhallaのValue Classについて紹介していきます。

本エントリーでは、Value Classを使用した場合に可能になる最適化について説明します。

 

前回のエントリーで紹介したようにValue Classの導入の背景にあったのが、ヒープ使用効率の最適化にあります。

そこで説明したのが、配列の領域に参照ではなく、直接データを埋め込む手法です。この最適化を平坦化(Flattering)と呼ぶようです。まずは、この平坦化から紹介していきましょう。

 

なお、Value Classはまだ策定中の仕様なので、今後変化する可能性があります。本エントリーで説明していることも変わる可能性が高いので、その点はご了承ください。

 

平坦化

Value ClassのオブジェクトをIdentityオブジェクトと同じようにヒープに配置するのではなく、Valueオブジェクトをフィールドとして持つオブジェクトのフィールド領域に埋め込んでしまうのが平坦化です。

前回は配列で説明しましたが、value record Point(double x, double y){}のような小さなValue Classであれば、その配列であるPoint[]にPointオブジェクトが保持すべきxとyを直接埋め込むこと最適化が可能です。

とはいっても、配列なんか使わないからなぁ... と思いますよね。

配列ではなくてリストで平坦化してくれればと思いますよね。でも、ほとんどの場合、リストといえばArrayListクラスを使っているはず。

ArrayListクラスのArrayは配列のこと。ArrayListクラスが内部で保持している配列が平坦化できれば恩恵は大きいはず。

ただ、ArrayListクラスが内部で保持しているのはObjectクラスの配列なのが気になります。今のジェネリクスは、型パラメータで指定された型の配列を作成することができません。

これができれば、平坦化することも可能なはず。というようなことをProject Valhallaの人たちが考えていないわけがないので、今後何らかの進展があると予想しているのですが、どうなんでしょうね。

また、平坦化は配列以外にも適用されます。たとえば、以下のようなレコードはどうでしょう。

value record Point(double x, double y) {}
    
record Rectangle(Point topLeft, Point bottomRight) {}

 

RectangleレコードクラスはValueクラスのPointオブジェクトを2つフィールドに保持します。Valueクラスであれば、フィールドに参照を保持させるのではなく、直接値を保持できるようになります。

さらにRectangleレコードクラスがValueクラスであれば... というように考えていくこともできるはずです。

ただし、平坦化が常に行われるとは限りません。Value ClassがPreview機能で提供されたとしても、当初は最適化される部分は少ないはずです。リリースが進むにつれ、徐々に最適化の範囲が増えていくことが予想されます。

 

スカラー化

もう1つの最適化がスカラー化(Scalarized)です。スカラー化というと多目的計画法で使う言葉だと思っていたのですが、JVMの最適化でも使うんですね。

それはそうとして、以下のようなコードを考えてみます。

    record Score(int score) {}
    
    record Adder(int sum) {
	Adder() { this(0); }

	Adder add(int v) {
	    return new Adder(sum + v);
	}
    }

    int calcTotal(List<Score> scores) {
	Adder adder = new Adder();

	for (var s: scores) {
	    adder = adder.add(s.score());
	}

	return adder.sum();
    }

 

通常は意識しないとは思いますが、Javaのコードはjavacコンパイラでバイトコードに変換され、JVMはバイトコードを実行します。

バイトコードの実行にはスレッドごとにJava Stackという特殊なスタックが作成されます。スタックにはメソッドごとにフレームが積まれます。このフレームにはオペランドスタックというスタックとローカル変数用の領域を持っており、これらを利用してバイトコードを実行します。

オペランドスタックは実行中の状態を保持させるスタックで、演算やメソッドコールはこのオペランドスタックに積まれた値に対して行われます。

ローカル変数領域も実際に使用する時には、オペランドスタックにロードし、処理の結果は再びローカル変数領域にストアされます。

ローカル変数領域もプリミティブ型の値であれば直接保持されますが、参照型の値の場合はヒープに存在するオブジェクトへの参照が保持されます。

つまり、上記のcalTotalメソッドの場合、scores変数はListオブジェクトへの参照、adder変数はAdderオブジェクトへの参照が保持されるわけです。

このcalcTotalメソッドのバイトコードは以下のようになります。

  int calcTotal(java.util.List<Score>);
    descriptor: (Ljava/util/List;)I
    flags: (0x0000)
    Code:
      stack=2, locals=5, args_size=2
         0: new           #7                  // class Adder
         3: dup
         4: invokespecial #9                  // Method Adder."<init>":()V
         7: astore_2
         8: aload_1
         9: invokeinterface #10,  1           // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
        14: astore_3
        15: aload_3
        16: invokeinterface #16,  1           // InterfaceMethod java/util/Iterator.hasNext:()Z
        21: ifeq          48
        24: aload_3
        25: invokeinterface #22,  1           // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
        30: checkcast     #26                 // class Score
        33: astore        4
        35: aload_2
        36: aload         4
        38: invokevirtual #28                 // Method Score.score:()I
        41: invokevirtual #32                 // Method Adder.add:(I)LAdder;
        44: astore_2
        45: goto          15
        48: aload_2
        49: invokevirtual #36                 // Method Adder.sum:()I
        52: ireturn

全体を解説することはしませんが、注目していただきたいところは色付きにしました。

オレンジの0から7の行はAdderオブジェクトを生成して、ローカル変数の[2]に保存しているバイトコードになります。

インデックス0にはthis、インデックス1には引数のListオブジェクトの参照が保持されており、その後にAdderオブジェクトの参照が保持されるわけです。

赤で示した35から44がJavaのコードでいうところのforループの内部の処理に当たります。

aload_2でローカル変数[2]からAdderオブジェクトの参照をオペランドスタックに積み、次のaloadでローカル変数[4]をスタックに積んでいます。このインデックス4には、Scoreオブジェクトの参照が保持されています。

その後の38のinvokevirtualがコメントにあるようにScoreクラスのscoreメソッドをコールしています。その結果はそのままスタックに積まれます。この時点でスタックにはAdderオブジェクトとscoreメソッドの戻り値のint値が積まれています。

そして、41のinvokevirtualでAdderクラスのaddメソッドをスタックに積まれたint値を引数にコールします。addメソッドの戻り値は新たに生成されたAdderオブジェクトで、スタックに積まれるので、44のastore_2でローカル変数[2]に保存されます。

 

 

このように、forループの内部では毎回Adderオブジェクトを生成し、ローカル変数のオブジェクト参照を更新するということを繰り返します。また、そのオブジェクトが保持している値はやはり毎回アクセスする必要があります。

毎回のオブジェクト生成や、値の取得処理が省略できるのであれば、パフォーマンが向上します。

もし、メソッド内で使用していたオブジェクトが、戻り値などでメソッドの外に逃げ出さないのであれば、この最適化をすることができます。

メソッドから逃げ出さないというのは、当該メソッド以外の部分でオブジェクト参照するということです。オブジェクトにどこから参照されるか分からないので、たとえValueオブジェクトであっても通常のIdentityオブジェクトと同じようにヒープにオブジェクトを配置しなければなりません。

逆に、オブジェクトがメソッド内だけで使われるのであれば、Valueオブジェクトをヒープに作るのではなく、Valueオブジェクトが保持する値をローカル変数領域に直接保持させてしまえばいいわけです。

 

 

ローカル変数領域に値を直接保持させることで、オブジェクト生成や参照の張替えが不要になります。

繰り返しになりますが、この最適化はValueオブジェクトがメソッド内にとどまっていることが条件になります。このため、オブジェクトがメソッド内だけで使用されているかどうかを調べる必要があります。

これをエスケープ解析(Escape Analysis)と呼びます。オブジェクトがメソッドの外に逃げ出さないかどうかを解析するということですね。エスケープ解析でオブジェクトが逃げ出さないと分かれば、スカラー化以外にも最適化が可能になります。

 

ここでは2種類の最適化を紹介しましたが、Valueクラスが提供された時にはじめから両方の最適化が行われるとは限りません。まずはValueクラスを使えるようになり、そこから徐々に最適化が導入されていくことが予想されます。

また、最適化を行うにはエスケープ解析でオブジェクトが逃げ出さないことが条件になりますが、他にもいくつか条件があります。その1つにnullの扱いがあります。

たとえば、Valueオブジェクトが保持する値にnullが紛れてしまうと、平坦化もスカラー化もできなくなってしまいます。

このため、Project Valhallaでは、null非許容性とnull許容性、つまりNon-NullとNullableを導入することになりました。

Non-NullとNullableは以前から要望がありましたが、まさかProject Valhallaによって仕様策定されることになるとは思いもよりませんでした。

そこで、次のエントリーではNon-Null/Nullableと、それに関連してオブジェクト初期化処理の変更について紹介する予定です。

2024/11/10

で、ValhallaのValue Classってどうなったの? その1
(JJUG CCC 2024 Fall)

このエントリーをはてなブックマークに追加

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については次のエントリーで紹介していきます。