原文はこちら。
The original article was written by Julia Boes (OpenJDK developer on Java Platform Group, Oracle) and Chris Hegarty (Networking Lead of the Java Platform Group, Oracle).
https://inside.java/2021/03/12/simpler-serilization-with-records/
JavaのRecordsの設計を活用してJavaのシリアライゼーションを改善するには
Record Classes
Recordクラスは、Javaの機能を強化し、「単純なデータ」の集合体を少ない儀式でモデル化できるようにします。
Java 14 Feature Spotlight: Records
https://www.infoq.com/articles/java-14-feature-spotlight/
Java 14の新機能 – Record
https://www.infoq.com/jp/articles/java-14-feature-spotlight/
Recordクラスは、immutableな状態を宣言し、その状態に一致するAPIにコミットします。つまり、Recordクラスは、クラスが通常享受している自由(APIを内部表現から切り離す能力)を放棄する代わりに、大幅に簡潔になります。Recordクラスは、Java 14および15ではプレビュー機能でしたが、Java 16で最終化され、一般提供されました。
JEP 12: Preview Features
https://openjdk.java.net/jeps/12
JEP 395: Records
https://openjdk.java.net/jeps/395
以下は、JDKのjshellツールで宣言されたRecordクラスです。
JShell
https://docs.oracle.com/en/java/javase/16/jshell/introduction-jshell.html
jshell> record Point (int x, int y) { }
| created record Point
Point
の状態はx
とy
の 2 つのコンポーネントで構成されています。これらのコンポーネントはimmutableで、コンパイル時にPoint
クラスに自動的に追加されるアクセサメソッドx()
とy()
を使ってのみアクセスできます。また、コンポーネントを初期化するための標準的なコンストラクタもコンパイル時に追加されます。RecordクラスPoint
の場合は以下の通りです。
public Point(int x, int y) {
this.x = x;
this.y = y;
}
通常のクラスに追加される、引数なしのデフォルトコンストラクタとは異なり、Recordクラスの正規のコンストラクタは状態と同じシグネチャを持っています(オブジェクトが可変型の状態を必要とする場合、またはオブジェクトの作成時に不明な状態を必要とする場合は、Recordクラスは適切な選択ではなく、代わりに通常のクラスを宣言する必要があります)。
8.8.9. Default Constructor
https://docs.oracle.com/javase/specs/jls/se16/html/jls-8.html#jls-8.8.9
ここでは、Point
がインスタンス化されて使用されています。Point
のインスタンスであるpは、Recordであると言います。
jshell> Point p = new Point(5, 10)
p ==> Point[x=5, y=10]
jshell> System.out.println("value of x: " + p.x())
value of x: 5
まとめると、Recordクラスの要素が開発者が信頼できる簡潔なプロトコルを形成します。つまり、状態の簡潔な説明、状態を初期化するための正規のコンストラクタ、状態への制御されたアクセスです。この設計には多くの利点があり、特にシリアライゼーションに適しています。
Serialization
シリアライゼーションとは、オブジェクトを、ディスクに保存したりネットワークで送信したりできる形式に変換し(シリアライズとかマーシャリングとか)、後にオブジェクトを再構成できるようにする(デシリアライズとかアンマーシャリングとか)プロセスです。オブジェクトの状態を抽出し、それを永続フォーマットに変換する仕組みと、そのフォーマットから同等の状態を持つオブジェクトを再構築する手段を提供します。単純なデータキャリアとしての性質を持つRecordは、この使用例に適しています。
シリアライゼーションは強力なアイデアであり、多くのフレームワークがこれを実装していますが、その1つがJDKのJava Object Serialization(以下、JavaSerialization)です。
Java Object Serialization Specification
https://docs.oracle.com/en/java/javase/16/docs/specs/serialization/index.html
Java Serializationでは、java.io.Serializable
インターフェイスを実装しているクラスはすべてシリアライズ可能と、疑わしいほどシンプルです。このインターフェイスはメンバーを持たず、クラスがシリアライズ可能であることを示すだけの役割を果たします。シリアライズ時には、(プライベートフィールドも含め)すべての一時的ではないフィールドの状態が消去され、シリアルバイトストリームに書き込まれます。デシリアライズ時には、スーパークラスの引数なしコンストラクタが呼び出されてオブジェクトが作成され、そのフィールドにシリアルバイトストリームから読み込まれた状態が入力されます。カスタム・フォーマットを指定するために特別なメソッド writeObject
および readObject
が実装されていない限り、Java Serializationはシリアルバイトストリームのフォーマット(serialized form)を選択します。
Java Serializationに欠陥があることはニュースではありませんし、Brian GoetzのTowards Better Serializationには問題領域の概要が記載されています。
Towards Better Serialization
https://cr.openjdk.java.net/~briangoetz/amber/serialization.html
問題の核心は、Java SerializationがJavaのオブジェクトモデルの一部として設計されていないことです。つまり、Java Serializationは、オブジェクトのクラスが提供するAPIに依存せず、リフレクションなどの裏技を使ってオブジェクトを扱います。例えば、コンストラクタを呼び出さずに新しいデシリアライズされたオブジェクトを作成できます。そして、シリアルバイトストリームから読み取られたデータは、コンストラクタの不変性(constructor invariants)に対する検証の対象ではありません。
Record Serialization
Java Serializationでは、Recordクラスはjava.io.Serializable
を実装することにより、通常のクラスのようにシリアライズ可能です。
jshell> record Point (int x, int y) implements Serializable { }
| created record Point
しかし内部では、Java SerializationはRecord(つまりRecordクラスのインスタンス)を通常のクラスのインスタンスとは全く異なる方法で扱います(以下の記事では良い比較をしています)。
Record Serialization
https://inside.java/2020/07/20/record-serialization/
この設計は、可能な限り物事をシンプルに保つことを目的としており、次の2つの特性に基づいています。
- Recordのシリアライゼーションが状態コンポーネントのみをベースとする
- Recordのデシリアライゼーションが正規のコンストラクタ(canonical constructor)のみ利用する
Recordのシリアライゼーションプロセスはカスタマイズできません。このアプローチのシンプルさは、Recordに課せられた意味上の制約を論理的に継続することで実現しています。immutableなデータキャリアであるRecordは、1つの状態(コンポーネントの値)しか持つことができないため、シリアライズされたフォームのカスタマイズを許可する必要はありません。同様に、デシリアライズ側では、Recordを作成する唯一の方法は、そのRecordクラスの正規のコンストラクタであり、そのパラメータは状態の表現と同じであるため既知です。
サンプルのRecordクラスPoint
に話を戻すと、Java Serialization はPoint
オブジェクトのシリアライズを次のように扱います。
jshell> var out = new ObjectOutputStream(new FileOutputStream("serial.data"));
out ==> java.io.ObjectOutputStream@5f184fc6
jshell> out.writeObject(new Point(5, 10));
jshell> var in = new ObjectInputStream(new FileInputStream("serial.data"));
in ==> java.io.ObjectInputStream@504bae78
jshell> in.readObject();
$5 ==> Point[x=5, y=10]
シリアライズ中に、Point
の x()
および y()
アクセサが呼び出してpのコンポーネントの状態を抽出し、シリアルバイトストリームに書き込みます。デシリアライズの際には、 serial.data
からバイト列を読み取り、その状態をPoint
の正規のコンストラクタに渡して新しいRecordを取得します。
全体として、Recordの設計はシリアライゼーションの要求に自然に適合しています。状態とAPIを緊密に結合することで、より安全で保守しやすい実装になっています。さらに、Recordのデシリアライゼーションにおいても、興味深い効率化が可能になります。
Optimizing Record Deserialization
通常のクラスの場合、Java Serializationは、リフレクションを多用しながら新たにデシリアライズされたオブジェクトのプライベートな状態を設定します。しかしRecordクラスの場合、その状態と再構築の手段を、明記されたパブリックAPIを通じて公開しており、Java Serializationはこれを利用します。
Recordクラスの制約を持つ性質により、Java Serializationのリフレクションの戦略を再評価できます。上記のように、RecordクラスのAPIがRecordの状態を記述し、この状態がimmutableである場合、シリアルバイトストリームはもはやsingle source of truthである必要はなく、シリアライズ・フレームワークはその真実の唯一のインタープリタである必要があります。代わりに、Recordクラスは、コンポーネントから派生したシリアライズされたフォームを制御することができます。シリアライズされたフォームが派生されると、事前にそのフォームに基づいてマッチするinstantiatorを生成し、Recordクラスのクラスファイルに保存できます。このようにして、Java Serialization(またはその他のシリアライゼーションフレームワーク)からRecordクラスへと制御が反転します。Recordクラスは、必要に応じて最適化、保存、利用可能にできる独自のシリアライズされたフォームを決定します。
これにより、Recordのデシリアライゼーションをいくつかの方法で強化できます。特に興味深いのは、クラスの進化とスループットです。
More Freedom to Evolve Records
この可能性は、レコード・デシリアライゼーションの既存の機能(欠けているストリーム・フィールドに対するデフォルト値の注入)から生まれます。特定のRecordコンポーネントのシリアルバイトストリームに値が存在しない場合、そのデフォルト値が正規のコンストラクタに渡されます。次の例は、RecordクラスPoint
の進化したバージョンを使っています。
jshell> record Point (int x, int y, int z) implements Serializable { }
| created record Point
前述の例でRecordクラスのPoint
オブジェクトをシリアライズ後、x
と y
のみの値を持ち、z
の値を持たないPoint
の表現がserial.data
ファイルに含まれていました。互換性の理由から、オリジナルのシリアライズされたオブジェクトを、新しい Point
宣言のコンテキストでデシリアライズできるようにしたいと思います。フィールド値が存在しない場合のデフォルト値の注入により、これが可能になり、デシリアライズは正常に完了します。
jshell> var in = new ObjectInputStream(new FileInputStream("serial.data"));
in ==> java.io.ObjectInputStream@421faab1
jshell> in.readObject();
$3 ==> Point[x=5, y=10, z=0]
Recordのシリアライズの観点でこの機能を活用できます。デシリアライズ時にデフォルト値が注入された場合、シリアル化されたフォームでそれらを表現する必要があるでしょうか?この場合、よりコンパクトなシリアライズされたフォームでも、Recordオブジェクトの状態を完全に把握できます。
より一般的には、この機能はRecordクラスのバージョニングをサポートし、シリアライズおよびデシリアライズ全体で、バージョン間でのRecordの状態の変化に対する耐性を高めます。通常のクラスと比較して、Recordクラスはデータを保存するのに適していると言えます。
More Throughput When Processing Records
別の強化点は、デシリアライズ時のスループットです。デシリアライズ時にオブジェクトを生成するには、通常、リフレクティブ(reflective)なAPI呼出しが必要になります。この2つの問題は、リフレクティブ呼出しをより効率的にして、インスタンス化の仕組みをRecordクラス自体にカプセル化することで解決できます。
そのために、メソッドハンドルと動的に計算される定数(dynamically-computed constant)の組み合わせを活用できます。java.lang.invoke
のメソッドハンドルAPIはJava 7で導入されたもので、メソッドや設定フィールドを見つけたり、適応させたり、組み合わせたり、呼び出したりするための一連の低レベルの操作を提供します。メソッドハンドルは型付きの参照で、引数や戻り値の型の変換が可能であり、賢く使えばJava 1.1からの伝統的なリフレクションよりも高速になります。今回のケースでは、いくつかのメソッドハンドルを連鎖させ、Recordクラスのシリアライズされたフォームに基づいて、Recordの作成を調整できます。
このメソッドハンドルチェーンは、Recordクラスのクラスファイルに動的に計算される定数(dynamically-computed constant)として保存することができ、最初の呼び出し時に遅延計算されます。動的に計算される定数は、JVMのダイナミックコンパイラによる最適化が可能なため、インスタンス化コードはRecordクラスのフットプリントにわずかなオーバーヘッドを加えるだけです。これにより、Recordクラスはシリアライズされたフォームとそのインスタンス化コードの両方を担当することになり、他の仲介者やフレームワークに依存することはなくなりました。この戦略により、パフォーマンスとコードの再利用がさらに向上します。また、シリアライズフレームワークの負担も軽減され、複雑で安全でない可能性のあるマッピングメカニズムを記述することなく、レコードクラスが提供するデシリアライズ戦略を単純に使用することができます。
In Conclusion
Java言語の設計によりRecordに課せられた意味的な制約を利用して、シリアライズを行う方法を見てきました。ここからさらに多くの潜在的な最適化を検討できるでしょう。Recordクラスがそれ自身のシリアライズされたフォームを担当することで、Recordのシリアライズをさらに進めることができることは明らかです。