The Principle of JDK Serialization for Deep Analysis of Long Worded Text and the Ultimate Performance Implementation of Fury's High Compatibility
Fury は、JIT 動的コンパイルに基づく高性能な多言語ネイティブシリアライゼーションフレームワークです。Java/Python/Golang/C++/JavaScript などの言語をサポートし、オブジェクトの完全自動な多言語・クロス言語シリアライゼーションを提供し、他のフレームワークと比較して 20 倍から 200 倍高いパフォーマンスを実現します。
はじめに
Java オブジェクトシリアライゼーションにおいて、JDK のシリアライゼーションパフォーマンスが低いため、業界では Hessian/Kryo などのフレームワークが導入され、シリアライゼーションの高速化が図られてきました。これらのフレームワークは大半の Java オブジェクトをシリアライズできますが、オブジェクトが writeObject/readObject/writeReplace/readResolve などの JDK カスタムシリアライゼーションメソッドを実装している場合、これらのフレームワークでは対応できません。ユーザーはこれらのメソッド内で任意のロジックを実行できるため、シリアライゼーションの正確性を保証するには、これらのメソッドを JDK シリアライゼーションと一貫性のある方法で実行する必要があります。この場合、ユーザーは JDK 付属のシリアライゼーションフレームワークを選択するしかなく、極めて遅いパフォーマンスに耐えなければなりません。
業務システムにおけるデータオブジェクトのカスタム JDK シリアライゼーションはよく見られるケースです。たとえば、以下は Fury でテストした複雑なシナリオのシリアライゼーションのフレイムグラフですが、コストのかなりの部分が JDK シリアライゼーションに費やされています(Fury の初期バージョンでは、カスタム JDK シリアライゼーション型に遭遇すると、JDK に処理を転送してシリアライズしていました)。
シリアライゼーションパフォーマンスを向上させ、いかなるシナリオでもフォールバックしないようにするため、Fury はバージョン 0.9.2 から JDK シリアライゼーションプロトコル全体を完全に実装しました。これにより、すべての JDK カスタムシリアライゼーション動作との互換性を保ち、いかなるシナリオでも JDK シリアライゼーションの使用を回避し、効率的なシリアライゼーションパフォーマンスを確保しています。
本稿では、まず JDK シリアライゼーションの原理を分析し、その原理に基づいて Hessian/Kryo などのフレームワークの課題を解説した上で、Fury の効率的かつ互換性のある実装を紹介し、最後にパフォーマンス比較のデータを提示します。
JDK シリアライゼーションの原理分析
JDK シリアライゼーションフレームワークは、ObjectOutputStream と ObjectInputStream を使用してシリアライゼーションとデシリアライゼーションを行います。このフレームワークでは、Externalization/writeObject/readObject/readObjectNoData/writeReplace/readResolve などのメソッドを通じて、ユーザーがシリアライゼーション動作をカスタマイズできます。シリアライズ対象のオブジェクトにこれらのメソッドが含まれていない場合、ObjectOutputStream は内部の defaultWriteObject を呼び出して、型階層のすべてのフィールドと型情報をシリアライズします。デシリアライゼーション時には、ObjectInputStream を使用して各型の情報と型階層の対応するフィールド値を読み取り、オブジェクト全体に設定します。カスタムシリアライゼーションメソッドが含まれている場合は、別の実行プロセスに移行します。
シリアライゼーションの全体プロセス
オブジェクトに writeReplace メソッドが定義されている場合、シリアライゼーションはまずそのメソッドを呼び出し、メソッドが返すオブジェクト参照を使用して参照テーブルに記録された参照を置き換えます。返されたオブジェクトの型が変わらない場合、つまり返された型にまだ writeReplace メソッドが存在する場合は、そのメソッドは無視され、通常の writeObject/writeExternal プロセスが開始されます。返された型が変更された場合は、writeReplace メソッドを繰り返し呼び出して上記のプロセスを繰り返します。
返されたオブジェクトに writeReplace メソッドが含まれなくなると、フィールドデータのシリアライゼーションプロセスが開始されます。オブジェクトが Externalizable インターフェイスを実装している場合は、writeExternal を呼び出してシリアライズします。それ以外の場合は、オブジェクト階層で Serializable を定義する最初の親クラスから始めて、各型および現在の型に属するすべてのフィールドデータを順次シリアライズします。
オブジェクト階層内の型に writeObject メソッドが定義されている場合、その型に対応するフィールドのシリアライゼーションには、その型で定義された writeObject メソッドが呼び出されます。writeObject メソッドは、内部で ObjectOutputStream の defaultWriteObject を呼び出してデフォルトフィールドをシリアライズすることも、完全に独自のシリアライゼーションロジックを記述することもできます。
異なる JDK バージョン間でフィールドに不整合があり、互換性を保つ必要がある場合は、putFields メソッドを呼び出して PutField オブジェクトを取得します。このオブジェクトを使用して、一部の JDK バージョンにのみ存在し、現在の JDK バージョンには存在しないフィールドデータを設定してから、writeFields を呼び出してフィールドデータの書き込みを完了します。
デシリアライゼーションの全体プロセス
デシリアライゼーションはまずオブジェクト型を読み取り、次にその型の引数なしコンストラクターを検索してオブジェクトを作成します。引数なしコンストラクターが存在しない場合は、ReflectionFactory#newConstructorForSerialization(java.lang.Class) を通じて型階層構造を上方に走査し、最初の非 Serializable 親クラスの引数なしコンストラクターを取得します(このプロセスは繰り返し検索を避けるためにキャッシュされます)。
次に、コンストラクターに従ってオブジェクトを作成し、オブジェクトが見つからない循環参照を避けるために、オブジェクトを参照テーブルに登録します。
次に、最初の Serializable 親クラスから各型と対応するフィールドデータを順次デシリアライズし、コンストラクターで作成されたオブジェクトに設定します。デシリアライズされた型が存在しない場合、オブジェクト階層が変更され、デシリアライズ対象のオブジェクトに新しい親クラスが追加されたことを意味します。その型に readObjectNoData メソッドが定義されていれば、そのメソッドが呼び出されてフィールド状態が初期化されます。定義されていない場合、これらのフィールドはデフォルト状態のままです。
親型に readObject が定義されていない場合、defaultReadObject が呼び出され、各非 transient 非 static フィールドの値を順次読み取ってオブジェクトに設定します。readObject メソッドが定義されている場合、そのメソッドが呼び出されてその型のデータのデシリアライゼーションが完了します。
readObject メソッドは、defaultReadObject を呼び出してデフォルトフィールド値のデシリアライゼーションを完了してから他のカスタムロジックを実行することも、完全に独自のデシリアライゼーションロジックを記述することもできます。
異なる JDK バージョン間でフィールドに不整合があり、互換性を保つ必要がある場合は、readFields メソッドを呼び出して GetField オブジェクトを取得します。このオブジェクトには現在のクラスバージョンに存在しないフィールドデータが含まれている場合がありますが、その場合は直接無視できます。他のフィールドは GetField から照会してオブジェクトに設定できます。なお、defaultReadObject と readFields のどちらか一方のみを呼び出せる点に注意してください。
場合によっては、親クラスフィールドのデシリアライゼーションが子クラスフィールドのデシリアライゼーション後の状態に依存することがあります。親クラスフィールドが先にデシリアライズされるため、その時点ではデシリアライズ後の子クラスの状態を取得できません。そのため、JDK は registerValidation コールバックを提供し、オブジェクト全体のデシリアライゼーション完了後に実行できるようにしています。この時点で、追加の操作を実行してオブジェクト状態を復元できます。
オブジェクトのデシリアライゼーション後、そのオブジェクトの型に readResolve メソッドが定義されているかどうかを確認します。定義されていれば、そのメソッドを呼び出して代替オブジェクトを返します。返された型が変更された場合、readResolve メソッドを繰り返し呼び出して上記のプロセスを繰り返します。
readResolve の実行後、オブジェクト全体のデシリアライゼーションが完了します。
Hessian/Kryo などのフレームワークの問題点
Hessian の問題点
Hessian は現在、writeReplace/readResolve カスタムメソッドをサポートしています。オブジェクトに writeReplace メソッドが定義されている場合、com.caucho.hissian.io.WriteReplaceSerializer を通じてシリアライズされます。このシリアライザーは一部のシナリオの要件を満たすことができますが、writeReplace メソッドが同じ型の新しいオブジェクトを返す場合、Hessian ではスタックオーバーフローが発生します。
Hessian は現在、writeObject/readObject メソッドをサポートしていません。シリアライズ対象のオブジェクトにこれらのメソッドが定義されている場合、Hessian は直接無視します。実際のシナリオでは、多くのオブジェクトがこれらの 2 つのメソッドを定義しており、大半の JDK 型もこれらの 2 つのメソッドを定義しています。そのため、Hessian がこれらの型をシリアライズする際に状態の不整合エラーが発生します。
これらの型では、データフィールドは一般的に transient としてマークされているため、これらの 2 つのメソッドを無視してすべての非 transient フィールドを直接シリアライズすると、データ損失が発生します。たとえば、LinkedBlockingQueue の主要なデータフィールドはすべて transient であり、writeObject で特別な処理が実行されています。
同時に、これらの 2 つのメソッド内のカスタムロジックが実装されていないため、最終的なデシリアライゼーション時のオブジェクト状態が不正になります。たとえば、Java
一般的な型であれば、組み込みシリアライザーを通じてシリアライズできる可能性がありますが、すべての既知および未知の型を列挙することはできません。マルチスレッドの Lock 状態エラーなどのシリアライゼーションエラーが発生すると、トラブルシューティングは非常に困難になります。
同時に、Hessian は親クラスと子クラスの重複名フィールドをサポートしておらず、特定の条件下では使用上の制約となる可能性があります。
そのため、RPC フレームワークでは、多くのシナリオでユーザーが直接 JDK シリアライゼーションを選択しますが、これらのシナリオは現在 FURY に切り替えて高速化できます。
Kryo の問題点
シリアライゼーションの正確性を保証するため、Kryo は writeObject/readObject/readObjectNoData/writeReplace/readResolve が定義されたオブジェクトに遭遇すると、JDK の ObjectOutputStream と ObjectInputStream を呼び出してシリアライズします。この方法には 3 つの問題があります。
*JDK のシリアライゼーションパフォーマンスが低いため、Kryo のシリアライゼーションパフォーマンスが著しく低下する
*JDK のシリアライゼーション結果が非常に大きいため、Kryo でシリアライズされたデータが肥大化する
*JDK に転送されたオブジェクトサブグラフは Kryo と同じ参照テーブルを共有しない。サブグラフが他のオブジェクトを共有・循環参照している場合、重複シリアライゼーションや再帰によるスタックオーバーフローが発生する
Kryo は親クラスと子クラスの重複名フィールドをサポートしておらず、特定の条件下では使用上の制約となる可能性があります。
その他のフレームワークの問題点
Jsonb は JDK のカスタムシリアライゼーションメソッドを一切サポートしていません。デシリアライゼーション時にエラーが発生します。
Fst は型の互換性をサポートしておらず、サービスシナリオでは使用できません。
Fury の互換実装の原理
初期バージョンのシリアライゼーションプロセス
Fury の初期バージョンのシリアライゼーションプロセスは Kryo と同様の方式を採用していました。writeObject/readObject/readObjectNoData/writeReplace/readResolve を持つオブジェクトに遭遇すると、JDK の ObjectOutputStream と ObjectInputStream を呼び出してシリアライズしていました。
新しいシリアライゼーションプロセス
Fury 0.9.2 では、JIT 動的コンパイルに基づく JDK カスタムシリアライゼーションと 100% 互換の実装を提供し、パフォーマンスを 1 桁向上させています。
全体的な実装プロセスは JDK のシリアライゼーションプロセスを模倣していますが、Fury の組み込み JIT シリアライザーを使用して高速化とシリアライゼーション結果のサイズ削減を実現しています。同時に、オブジェクト階層内の各 Serializable クラスについて、クラスのメタデータではなくクラス名のみをシリアライズすることで、オーバーヘッドを削減しています。
全体的な実装は、io.fury.serializers.ReplaceResolveSerializer と io.fury.serializers.ObjectStreamSerializer の 2 つのシリアライザーに分かれており、それぞれ writeReplace/readResolve カスタムシリアライゼーションと writeObject/readObject/readObjectNoData カスタムシリアライゼーションを担当しています。
ReplaceResolveSerializer
ReplaceResolveSerializer は、JDK と同じ replace/resolve の動作を完全に実装しています。writeReplace メソッドが同じ型で異なる参照のオブジェクトを返す場合でも、Hessian のようなスタックオーバーフローの問題なく正常にシリアライズできます。同時に、返されたオブジェクトの型が元のオブジェクトの型と異なる場合、Fury は元のオブジェクトのクラス名の書き込みを回避し、シリアライゼーション結果のサイズを削減できます。
オブジェクトに writeObject/readObject/readObjectNoData/writeReplace/readResolve メソッドが同時に定義されている場合、Fury はまず ReplaceResolveSerializer に処理を振り分けて参照の replace/resolve を処理し、その後、処理されたオブジェクトを ObjectStreamSerializer に渡して JDK カスタムシリアライゼーションプロセスを実行します。
ObjectStreamSerializer
ObjectStreamSerializer は、JDK の writeObject/readObject/readObjectNoData/registerValidation の動作を完全に実装し、JDK との動作の一貫性を保証しています。これにより、いかなる状況でもシリアライゼーションでエラーは発生しません。ユーザーは writeObject/readObject/readObjectNoData/registerValidation 内で JDK の ObjectOutputStream/ObjectInputStream/PutField/GetField インターフェイスを呼び出すため、Fury も ObjectOutputStream/ObjectInputStream/PutField/GetField のサブクラスを実装し、実際のシリアライゼーションロジックを Fury に転送できるようにしています。
型の互換性と defaultWriteObject/defaultReadObject と putFields/readFields の互換性を保証するため、フィールドデータのシリアライゼーションには Fury の CompatibleSerializer を使用します。読み取り側と書き込み側の型が不一致の場合でもデシリアライゼーションが可能です。高いパフォーマンスを確保するため、JIT モードが有効な場合は io.fury.serializers.CodegenSerializer#loadCompatibleCodegenSerializer を通じて JITCompatibleSerializer が作成され、シリアライゼーションに使用されます。
全体的な実装は、シリアライザーの初期化部分と実行部分に分けられます。
シリアライザー初期化部分
引数なしコンストラクター、または最初の非 Serializable 親クラスの引数なしコンストラクターを取得します。JDK 17 以降のリフレクションアクセス権限の問題を回避するため、JDK 17 以降では ObjectStreamClass.lookup(type) から抽出したコンストラクターを Unsafe を通じて直接取得します。
シリアライザー実行部分
シリアライゼーション実行部分
すべての Serializable クラスの数を書き込む。
オブジェクトのクラス階層を走査し、各型のフィールドデータを順次シリアライズします。各型のデータのシリアライゼーションは以下の部分に分けられます。
現在のオブジェクトの型に writeObject メソッドが定義されていない場合、slotsSerializer (JITCompatibleSerializer) を直接呼び出して現在の型のすべてのフィールドをシリアライズします。
前述のオブジェクトの型に writeObject メソッドが定義されている場合、前述のシリアライゼーションのコンテキストをキャッシュしてから、writeObject メソッドを呼び出して Fury が実装した FuryObjectOutputStream を渡します。
FuryObjectOutputStream では、putFields/writeFields/defaultWriteObject に対しても特別な処理が実行されます。putFields/writeFields はオブジェクトを CompatibleSerializer が認識できる配列形式に変換し、defaultWriteObject は slotsSerializer (JITCompatibleSerializer) を直接呼び出して現在の型のすべてのフィールドをシリアライズします。
デシリアライゼーション実行部分:
コンストラクターに基づいてオブジェクトインスタンスを作成する。
オブジェクトインスタンスを参照テーブルに書き込む。
オブジェクト階層内のすべての Serializable クラスの数を読み取る。
データから順次クラスを読み取り、現在の型階層のクラスと比較します。不一致の場合、現在の型階層が変更され、新しい親クラスが導入されたことを意味します。その型に readObjectNoData が定義されていれば、このメソッドを呼び出して初期化し、その後、型階層を上方に走査して同じ型が見つかるまで続けます。
その型のすべてのフィールド値をデシリアライズし、オブジェクトのフィールドに設定する。
オブジェクトに readObject メソッドが定義されていない場合、slotsSerializer (JITCompatibleSerializer) を直接呼び出してデシリアライズします。
readObject メソッドが定義されている場合、オブジェクトの readObject メソッドを呼び出し、Fury が実装した FuryObjectInputStream を渡します。
FuryObjectInputStream では、readFields/defaultReadObject に対しても特別な処理が実行されます。readFields は CompatibleSerializer を使用してオブジェクトを認識可能な GetField 形式に変換し、defaultReadObject は slotsSerializer (JITCompatibleSerializer) を直接呼び出して現在の型のすべてのフィールドをデシリアライズします。
ユーザーが readObject 内で registerValidation を通じて ObjectInputValidation コールバックを登録している場合、そのコールバックはオブジェクトを返す前に優先度順に実行されます。
これでデシリアライゼーションが完了します。コアコードは以下のようになります。
パフォーマンス比較
JDK カスタムシリアライゼーションの完全な実装により、Fury はいかなるシナリオでも JDK シリアライゼーションを呼び出さなくなりました。同じデータテスト条件下で、Fury は Kryo 比で 10 倍、Hessian などのフレームワーク比で 3 倍のパフォーマンス向上を、設定なしで実現しています(データの大部分は文字列とハッシュマップであり、文字列のシリアライゼーションとハッシュマップのイテレーション・リバランスが JIT の優位性を相殺するため、設定なしでもこの結果が得られます)。
image.pngimage.png
以下は Fury シリアライゼーションのフレイムグラフです。このグラフから、JDK シリアライゼーションのスタックが含まれていないことが明確に確認できます。
結論
バージョン 0.9.2 以降、Fury はいかなるシナリオでも JDK/Kryo/Hessian に対して顕著なパフォーマンス優位性を持っています。また、JDK シリアライゼーションとの 100% の互換性と正確性を維持できる、業界で唯一のフレームワークでもあります。現在、正確性を重視する多くのビジネスは、JDK シリアライゼーションを直接使用し、その遅いパフォーマンスに耐えています。Fury が提供するシリアライゼーション機能を通じて、JDK シリアライゼーションに完全に別れを告げ、ビジネスをこの苦痛から解放し、より高い生産性を提供できることを期待しています。
はじめに
Java オブジェクトシリアライゼーションにおいて、JDK のシリアライゼーションパフォーマンスが低いため、業界では Hessian/Kryo などのフレームワークが導入され、シリアライゼーションの高速化が図られてきました。これらのフレームワークは大半の Java オブジェクトをシリアライズできますが、オブジェクトが writeObject/readObject/writeReplace/readResolve などの JDK カスタムシリアライゼーションメソッドを実装している場合、これらのフレームワークでは対応できません。ユーザーはこれらのメソッド内で任意のロジックを実行できるため、シリアライゼーションの正確性を保証するには、これらのメソッドを JDK シリアライゼーションと一貫性のある方法で実行する必要があります。この場合、ユーザーは JDK 付属のシリアライゼーションフレームワークを選択するしかなく、極めて遅いパフォーマンスに耐えなければなりません。
業務システムにおけるデータオブジェクトのカスタム JDK シリアライゼーションはよく見られるケースです。たとえば、以下は Fury でテストした複雑なシナリオのシリアライゼーションのフレイムグラフですが、コストのかなりの部分が JDK シリアライゼーションに費やされています(Fury の初期バージョンでは、カスタム JDK シリアライゼーション型に遭遇すると、JDK に処理を転送してシリアライズしていました)。
シリアライゼーションパフォーマンスを向上させ、いかなるシナリオでもフォールバックしないようにするため、Fury はバージョン 0.9.2 から JDK シリアライゼーションプロトコル全体を完全に実装しました。これにより、すべての JDK カスタムシリアライゼーション動作との互換性を保ち、いかなるシナリオでも JDK シリアライゼーションの使用を回避し、効率的なシリアライゼーションパフォーマンスを確保しています。
本稿では、まず JDK シリアライゼーションの原理を分析し、その原理に基づいて Hessian/Kryo などのフレームワークの課題を解説した上で、Fury の効率的かつ互換性のある実装を紹介し、最後にパフォーマンス比較のデータを提示します。
JDK シリアライゼーションの原理分析
JDK シリアライゼーションフレームワークは、ObjectOutputStream と ObjectInputStream を使用してシリアライゼーションとデシリアライゼーションを行います。このフレームワークでは、Externalization/writeObject/readObject/readObjectNoData/writeReplace/readResolve などのメソッドを通じて、ユーザーがシリアライゼーション動作をカスタマイズできます。シリアライズ対象のオブジェクトにこれらのメソッドが含まれていない場合、ObjectOutputStream は内部の defaultWriteObject を呼び出して、型階層のすべてのフィールドと型情報をシリアライズします。デシリアライゼーション時には、ObjectInputStream を使用して各型の情報と型階層の対応するフィールド値を読み取り、オブジェクト全体に設定します。カスタムシリアライゼーションメソッドが含まれている場合は、別の実行プロセスに移行します。
シリアライゼーションの全体プロセス
オブジェクトに writeReplace メソッドが定義されている場合、シリアライゼーションはまずそのメソッドを呼び出し、メソッドが返すオブジェクト参照を使用して参照テーブルに記録された参照を置き換えます。返されたオブジェクトの型が変わらない場合、つまり返された型にまだ writeReplace メソッドが存在する場合は、そのメソッドは無視され、通常の writeObject/writeExternal プロセスが開始されます。返された型が変更された場合は、writeReplace メソッドを繰り返し呼び出して上記のプロセスを繰り返します。
返されたオブジェクトに writeReplace メソッドが含まれなくなると、フィールドデータのシリアライゼーションプロセスが開始されます。オブジェクトが Externalizable インターフェイスを実装している場合は、writeExternal を呼び出してシリアライズします。それ以外の場合は、オブジェクト階層で Serializable を定義する最初の親クラスから始めて、各型および現在の型に属するすべてのフィールドデータを順次シリアライズします。
オブジェクト階層内の型に writeObject メソッドが定義されている場合、その型に対応するフィールドのシリアライゼーションには、その型で定義された writeObject メソッドが呼び出されます。writeObject メソッドは、内部で ObjectOutputStream の defaultWriteObject を呼び出してデフォルトフィールドをシリアライズすることも、完全に独自のシリアライゼーションロジックを記述することもできます。
異なる JDK バージョン間でフィールドに不整合があり、互換性を保つ必要がある場合は、putFields メソッドを呼び出して PutField オブジェクトを取得します。このオブジェクトを使用して、一部の JDK バージョンにのみ存在し、現在の JDK バージョンには存在しないフィールドデータを設定してから、writeFields を呼び出してフィールドデータの書き込みを完了します。
デシリアライゼーションの全体プロセス
デシリアライゼーションはまずオブジェクト型を読み取り、次にその型の引数なしコンストラクターを検索してオブジェクトを作成します。引数なしコンストラクターが存在しない場合は、ReflectionFactory#newConstructorForSerialization(java.lang.Class) を通じて型階層構造を上方に走査し、最初の非 Serializable 親クラスの引数なしコンストラクターを取得します(このプロセスは繰り返し検索を避けるためにキャッシュされます)。
次に、コンストラクターに従ってオブジェクトを作成し、オブジェクトが見つからない循環参照を避けるために、オブジェクトを参照テーブルに登録します。
次に、最初の Serializable 親クラスから各型と対応するフィールドデータを順次デシリアライズし、コンストラクターで作成されたオブジェクトに設定します。デシリアライズされた型が存在しない場合、オブジェクト階層が変更され、デシリアライズ対象のオブジェクトに新しい親クラスが追加されたことを意味します。その型に readObjectNoData メソッドが定義されていれば、そのメソッドが呼び出されてフィールド状態が初期化されます。定義されていない場合、これらのフィールドはデフォルト状態のままです。
親型に readObject が定義されていない場合、defaultReadObject が呼び出され、各非 transient 非 static フィールドの値を順次読み取ってオブジェクトに設定します。readObject メソッドが定義されている場合、そのメソッドが呼び出されてその型のデータのデシリアライゼーションが完了します。
readObject メソッドは、defaultReadObject を呼び出してデフォルトフィールド値のデシリアライゼーションを完了してから他のカスタムロジックを実行することも、完全に独自のデシリアライゼーションロジックを記述することもできます。
異なる JDK バージョン間でフィールドに不整合があり、互換性を保つ必要がある場合は、readFields メソッドを呼び出して GetField オブジェクトを取得します。このオブジェクトには現在のクラスバージョンに存在しないフィールドデータが含まれている場合がありますが、その場合は直接無視できます。他のフィールドは GetField から照会してオブジェクトに設定できます。なお、defaultReadObject と readFields のどちらか一方のみを呼び出せる点に注意してください。
場合によっては、親クラスフィールドのデシリアライゼーションが子クラスフィールドのデシリアライゼーション後の状態に依存することがあります。親クラスフィールドが先にデシリアライズされるため、その時点ではデシリアライズ後の子クラスの状態を取得できません。そのため、JDK は registerValidation コールバックを提供し、オブジェクト全体のデシリアライゼーション完了後に実行できるようにしています。この時点で、追加の操作を実行してオブジェクト状態を復元できます。
オブジェクトのデシリアライゼーション後、そのオブジェクトの型に readResolve メソッドが定義されているかどうかを確認します。定義されていれば、そのメソッドを呼び出して代替オブジェクトを返します。返された型が変更された場合、readResolve メソッドを繰り返し呼び出して上記のプロセスを繰り返します。
readResolve の実行後、オブジェクト全体のデシリアライゼーションが完了します。
Hessian/Kryo などのフレームワークの問題点
Hessian の問題点
Hessian は現在、writeReplace/readResolve カスタムメソッドをサポートしています。オブジェクトに writeReplace メソッドが定義されている場合、com.caucho.hissian.io.WriteReplaceSerializer を通じてシリアライズされます。このシリアライザーは一部のシナリオの要件を満たすことができますが、writeReplace メソッドが同じ型の新しいオブジェクトを返す場合、Hessian ではスタックオーバーフローが発生します。
Hessian は現在、writeObject/readObject メソッドをサポートしていません。シリアライズ対象のオブジェクトにこれらのメソッドが定義されている場合、Hessian は直接無視します。実際のシナリオでは、多くのオブジェクトがこれらの 2 つのメソッドを定義しており、大半の JDK 型もこれらの 2 つのメソッドを定義しています。そのため、Hessian がこれらの型をシリアライズする際に状態の不整合エラーが発生します。
これらの型では、データフィールドは一般的に transient としてマークされているため、これらの 2 つのメソッドを無視してすべての非 transient フィールドを直接シリアライズすると、データ損失が発生します。たとえば、LinkedBlockingQueue の主要なデータフィールドはすべて transient であり、writeObject で特別な処理が実行されています。
同時に、これらの 2 つのメソッド内のカスタムロジックが実装されていないため、最終的なデシリアライゼーション時のオブジェクト状態が不正になります。たとえば、Java
一般的な型であれば、組み込みシリアライザーを通じてシリアライズできる可能性がありますが、すべての既知および未知の型を列挙することはできません。マルチスレッドの Lock 状態エラーなどのシリアライゼーションエラーが発生すると、トラブルシューティングは非常に困難になります。
同時に、Hessian は親クラスと子クラスの重複名フィールドをサポートしておらず、特定の条件下では使用上の制約となる可能性があります。
そのため、RPC フレームワークでは、多くのシナリオでユーザーが直接 JDK シリアライゼーションを選択しますが、これらのシナリオは現在 FURY に切り替えて高速化できます。
Kryo の問題点
シリアライゼーションの正確性を保証するため、Kryo は writeObject/readObject/readObjectNoData/writeReplace/readResolve が定義されたオブジェクトに遭遇すると、JDK の ObjectOutputStream と ObjectInputStream を呼び出してシリアライズします。この方法には 3 つの問題があります。
*JDK のシリアライゼーションパフォーマンスが低いため、Kryo のシリアライゼーションパフォーマンスが著しく低下する
*JDK のシリアライゼーション結果が非常に大きいため、Kryo でシリアライズされたデータが肥大化する
*JDK に転送されたオブジェクトサブグラフは Kryo と同じ参照テーブルを共有しない。サブグラフが他のオブジェクトを共有・循環参照している場合、重複シリアライゼーションや再帰によるスタックオーバーフローが発生する
Kryo は親クラスと子クラスの重複名フィールドをサポートしておらず、特定の条件下では使用上の制約となる可能性があります。
その他のフレームワークの問題点
Jsonb は JDK のカスタムシリアライゼーションメソッドを一切サポートしていません。デシリアライゼーション時にエラーが発生します。
Fst は型の互換性をサポートしておらず、サービスシナリオでは使用できません。
Fury の互換実装の原理
初期バージョンのシリアライゼーションプロセス
Fury の初期バージョンのシリアライゼーションプロセスは Kryo と同様の方式を採用していました。writeObject/readObject/readObjectNoData/writeReplace/readResolve を持つオブジェクトに遭遇すると、JDK の ObjectOutputStream と ObjectInputStream を呼び出してシリアライズしていました。
新しいシリアライゼーションプロセス
Fury 0.9.2 では、JIT 動的コンパイルに基づく JDK カスタムシリアライゼーションと 100% 互換の実装を提供し、パフォーマンスを 1 桁向上させています。
全体的な実装プロセスは JDK のシリアライゼーションプロセスを模倣していますが、Fury の組み込み JIT シリアライザーを使用して高速化とシリアライゼーション結果のサイズ削減を実現しています。同時に、オブジェクト階層内の各 Serializable クラスについて、クラスのメタデータではなくクラス名のみをシリアライズすることで、オーバーヘッドを削減しています。
全体的な実装は、io.fury.serializers.ReplaceResolveSerializer と io.fury.serializers.ObjectStreamSerializer の 2 つのシリアライザーに分かれており、それぞれ writeReplace/readResolve カスタムシリアライゼーションと writeObject/readObject/readObjectNoData カスタムシリアライゼーションを担当しています。
ReplaceResolveSerializer
ReplaceResolveSerializer は、JDK と同じ replace/resolve の動作を完全に実装しています。writeReplace メソッドが同じ型で異なる参照のオブジェクトを返す場合でも、Hessian のようなスタックオーバーフローの問題なく正常にシリアライズできます。同時に、返されたオブジェクトの型が元のオブジェクトの型と異なる場合、Fury は元のオブジェクトのクラス名の書き込みを回避し、シリアライゼーション結果のサイズを削減できます。
オブジェクトに writeObject/readObject/readObjectNoData/writeReplace/readResolve メソッドが同時に定義されている場合、Fury はまず ReplaceResolveSerializer に処理を振り分けて参照の replace/resolve を処理し、その後、処理されたオブジェクトを ObjectStreamSerializer に渡して JDK カスタムシリアライゼーションプロセスを実行します。
ObjectStreamSerializer
ObjectStreamSerializer は、JDK の writeObject/readObject/readObjectNoData/registerValidation の動作を完全に実装し、JDK との動作の一貫性を保証しています。これにより、いかなる状況でもシリアライゼーションでエラーは発生しません。ユーザーは writeObject/readObject/readObjectNoData/registerValidation 内で JDK の ObjectOutputStream/ObjectInputStream/PutField/GetField インターフェイスを呼び出すため、Fury も ObjectOutputStream/ObjectInputStream/PutField/GetField のサブクラスを実装し、実際のシリアライゼーションロジックを Fury に転送できるようにしています。
型の互換性と defaultWriteObject/defaultReadObject と putFields/readFields の互換性を保証するため、フィールドデータのシリアライゼーションには Fury の CompatibleSerializer を使用します。読み取り側と書き込み側の型が不一致の場合でもデシリアライゼーションが可能です。高いパフォーマンスを確保するため、JIT モードが有効な場合は io.fury.serializers.CodegenSerializer#loadCompatibleCodegenSerializer を通じて JITCompatibleSerializer が作成され、シリアライゼーションに使用されます。
全体的な実装は、シリアライザーの初期化部分と実行部分に分けられます。
シリアライザー初期化部分
引数なしコンストラクター、または最初の非 Serializable 親クラスの引数なしコンストラクターを取得します。JDK 17 以降のリフレクションアクセス権限の問題を回避するため、JDK 17 以降では ObjectStreamClass.lookup(type) から抽出したコンストラクターを Unsafe を通じて直接取得します。
シリアライザー実行部分
シリアライゼーション実行部分
すべての Serializable クラスの数を書き込む。
オブジェクトのクラス階層を走査し、各型のフィールドデータを順次シリアライズします。各型のデータのシリアライゼーションは以下の部分に分けられます。
現在のオブジェクトの型に writeObject メソッドが定義されていない場合、slotsSerializer (JITCompatibleSerializer) を直接呼び出して現在の型のすべてのフィールドをシリアライズします。
前述のオブジェクトの型に writeObject メソッドが定義されている場合、前述のシリアライゼーションのコンテキストをキャッシュしてから、writeObject メソッドを呼び出して Fury が実装した FuryObjectOutputStream を渡します。
FuryObjectOutputStream では、putFields/writeFields/defaultWriteObject に対しても特別な処理が実行されます。putFields/writeFields はオブジェクトを CompatibleSerializer が認識できる配列形式に変換し、defaultWriteObject は slotsSerializer (JITCompatibleSerializer) を直接呼び出して現在の型のすべてのフィールドをシリアライズします。
デシリアライゼーション実行部分:
コンストラクターに基づいてオブジェクトインスタンスを作成する。
オブジェクトインスタンスを参照テーブルに書き込む。
オブジェクト階層内のすべての Serializable クラスの数を読み取る。
データから順次クラスを読み取り、現在の型階層のクラスと比較します。不一致の場合、現在の型階層が変更され、新しい親クラスが導入されたことを意味します。その型に readObjectNoData が定義されていれば、このメソッドを呼び出して初期化し、その後、型階層を上方に走査して同じ型が見つかるまで続けます。
その型のすべてのフィールド値をデシリアライズし、オブジェクトのフィールドに設定する。
オブジェクトに readObject メソッドが定義されていない場合、slotsSerializer (JITCompatibleSerializer) を直接呼び出してデシリアライズします。
readObject メソッドが定義されている場合、オブジェクトの readObject メソッドを呼び出し、Fury が実装した FuryObjectInputStream を渡します。
FuryObjectInputStream では、readFields/defaultReadObject に対しても特別な処理が実行されます。readFields は CompatibleSerializer を使用してオブジェクトを認識可能な GetField 形式に変換し、defaultReadObject は slotsSerializer (JITCompatibleSerializer) を直接呼び出して現在の型のすべてのフィールドをデシリアライズします。
ユーザーが readObject 内で registerValidation を通じて ObjectInputValidation コールバックを登録している場合、そのコールバックはオブジェクトを返す前に優先度順に実行されます。
これでデシリアライゼーションが完了します。コアコードは以下のようになります。
パフォーマンス比較
JDK カスタムシリアライゼーションの完全な実装により、Fury はいかなるシナリオでも JDK シリアライゼーションを呼び出さなくなりました。同じデータテスト条件下で、Fury は Kryo 比で 10 倍、Hessian などのフレームワーク比で 3 倍のパフォーマンス向上を、設定なしで実現しています(データの大部分は文字列とハッシュマップであり、文字列のシリアライゼーションとハッシュマップのイテレーション・リバランスが JIT の優位性を相殺するため、設定なしでもこの結果が得られます)。
image.pngimage.png
以下は Fury シリアライゼーションのフレイムグラフです。このグラフから、JDK シリアライゼーションのスタックが含まれていないことが明確に確認できます。
結論
バージョン 0.9.2 以降、Fury はいかなるシナリオでも JDK/Kryo/Hessian に対して顕著なパフォーマンス優位性を持っています。また、JDK シリアライゼーションとの 100% の互換性と正確性を維持できる、業界で唯一のフレームワークでもあります。現在、正確性を重視する多くのビジネスは、JDK シリアライゼーションを直接使用し、その遅いパフォーマンスに耐えています。Fury が提供するシリアライゼーション機能を通じて、JDK シリアライゼーションに完全に別れを告げ、ビジネスをこの苦痛から解放し、より高い生産性を提供できることを期待しています。
Related Articles
-
A detailed explanation of Hadoop core architecture HDFS
Knowledge Base Team
-
What Does IOT Mean
Knowledge Base Team
-
6 Optional Technologies for Data Storage
Knowledge Base Team
-
What Is Blockchain Technology
Knowledge Base Team
Explore More Special Offers
-
Short Message Service(SMS) & Mail Service
50,000 email package starts as low as USD 1.99, 120 short messages start at only USD 1.00
