1. 導入: なぜシリアライゼーションを最適化するのか?
現代のアプリケーションでは、シリアライゼーションはネットワークプロトコルから分散キャッシュ、サービス間のデータ交換に至るまで、あらゆる場面で登場します。
ここでは、シリアライゼーションの速度とサイズが重要です。シリアライゼーションが遅いと、データの保存や読み込み時にアプリがもたつき、ネットワークやディスクが無駄に待たされます。オブジェクトが大きすぎると、ディスク容量を消費し、ネットワーク転送に時間がかかり、メモリと帯域に余計な負荷を与えます。
典型的な課題としては、大きなオブジェクトグラフをファイルやキャッシュに保存する、遅延を最小化してネットワーク経由でオブジェクトを送る、マルチスレッド環境でデータを高速にシリアライズ/デシリアライズする、などがあります。
要点は簡単です。シリアライゼーションの最適化は「プレミアム機能」ではなく、高性能でスケーラブルなアプリケーションに不可欠の実践です。
2. シリアライズ済みデータのサイズ最適化
不要なデータの除外: キーワード transient
デフォルトでは、transient としてマークされたものを除き、オブジェクトのすべてのフィールドがシリアライズされます。保存する必要のないフィールド(例: キャッシュ、一時データ、サービス参照)は transient を付けましょう:
public class User implements Serializable {
private String name;
private transient String sessionToken; // シリアライズされない
}
利点:
- シリアライズ済みオブジェクトのサイズが小さくなる。
- ファイルやネットワークに余計/危険なデータを含めない。
手動シリアライゼーション: インターフェース Externalizable
何をどのようにシリアライズするかを完全に制御したい場合は、インターフェース Externalizable を実装し、シリアライゼーションを明示的に記述します(メソッド writeExternal/readExternal):
public class Person implements Externalizable {
private String name;
private int age;
private transient String secret;
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(name);
out.writeInt(age);
// secret はシリアライズしない
}
@Override
public void readExternal(ObjectInput in) throws IOException {
name = in.readUTF();
age = in.readInt();
}
}
利点:
- 必要なフィールドだけをシリアライズできる。
- 互換性を損なわずにシリアライゼーション形式を変更できる。
圧縮: シリアライズ済みデータの圧縮
シリアライズ済みオブジェクトは、特に繰り返しの多い文字列や大きなコレクションを含む場合、サイズが大きくなりがちです。圧縮を使ってサイズを小さくできます。
GZIPOutputStream の例:
try (ObjectOutputStream out = new ObjectOutputStream(
new GZIPOutputStream(new FileOutputStream("data.gz")))) {
out.writeObject(bigObject);
}
ZipOutputStream の例:
try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream("data.zip"))) {
zip.putNextEntry(new ZipEntry("object"));
ObjectOutputStream out = new ObjectOutputStream(zip);
out.writeObject(bigObject);
out.flush();
zip.closeEntry();
}
利点:
- ファイルサイズが大幅に小さくなる(特に大きなオブジェクトグラフで効果的)。
- ネットワーク転送量が減る。
欠点:
- 圧縮/展開に追加の時間(CPU)がかかる。
3. シリアライゼーション速度の最適化
バッファリング: BufferedOutputStream と BufferedInputStream が必要な理由
問題:
バッファリングがないと、write() や read() のたびにディスクやネットワークへのシステムコールが発生し、非常に遅いです!
解決策:
バッファ付きストリームを使いましょう:
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
try (ObjectInputStream in = new ObjectInputStream(
new BufferedInputStream(new FileInputStream("data.bin")))) {
Object obj = in.readObject();
}
利点:
- 大きなオブジェクトの読み書きを大幅に高速化。
- ディスク/ネットワークへのアクセス回数を削減。
仕組み:
バッファがメモリにデータを貯め込み、1 バイトずつではなく塊で書き込みます。
高速コピー: FileChannel.transferTo
大きなシリアライズ済みファイルを素早くコピーする必要がある場合は、NIO とメソッド transferTo を使いましょう:
try (FileChannel src = new FileInputStream("data.bin").getChannel();
FileChannel dest = new FileOutputStream("copy.bin").getChannel()) {
src.transferTo(0, src.size(), dest);
}
利点:
- コピーが OS レベルで行われ、Java 側の余分なバッファリングを回避できるため、大きなファイルで非常に高速。
4. シリアライゼーションのプロファイリング
簡易な時間計測: System.nanoTime()
シリアライゼーションの性能を手早く見積もるには、System.nanoTime() を使えます:
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
long end = System.nanoTime();
System.out.println("シリアライズ時間: " + (end - start) / 1_000_000 + " ミリ秒");
利点:
- シンプルで高速。
- バッファあり/なし、圧縮あり/なしなど、さまざまな構成を比較できる。
欠点:
- GC やバックグラウンド処理の影響で結果がぶれることがある。
- ごく小さな差の厳密比較には向かない。
厳密なプロファイリング: JMH (Java Microbenchmark Harness)
より正確な計測には、マイクロベンチマーク専用ライブラリ JMH を使います。
シンプルなベンチマーク例:
@Benchmark
public void serializeWithBuffer() throws Exception {
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
}
利点:
- JVM のウォームアップ、GC の影響、OS ノイズを考慮する。
- 信頼性が高く再現可能な結果が得られる。
欠点:
- JMH の設定と方法論の理解が必要。
- 「ざっくり比較」にはやや大げさ。
5. 実践: シリアライズの時間とサイズの比較
ミニ実験をしてみましょう。大きなオブジェクトグラフ(たとえば、入れ子のコレクションを持つ 100_000 個のオブジェクトのリスト)を複数の方法でシリアライズし、時間とファイルサイズを比較します。
バッファ・圧縮なしのシリアライズ
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data1.bin"))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("バッファなし: " + (end - start) / 1_000_000 + " ミリ秒, サイズ: " +
new File("data1.bin").length() + " バイト");
バッファありのシリアライズ
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data2.bin")))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("バッファあり: " + (end - start) / 1_000_000 + " ミリ秒, サイズ: " +
new File("data2.bin").length() + " バイト");
圧縮ありのシリアライズ (GZIP)
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new GZIPOutputStream(new FileOutputStream("data3.gz")))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("圧縮あり: " + (end - start) / 1_000_000 + " ミリ秒, サイズ: " +
new File("data3.gz").length() + " バイト");
結果の分析
シリアライゼーションをテストすると、バッファリングや圧縮の影響がどれほど大きいかがよく分かります。圧縮されたファイルは通常 2〜10 倍小さくなります(正確な係数はデータ構造に依存)。バッファありならシリアライズは明らかに速く、圧縮は処理をやや遅くしますが、サイズ削減の価値があることが多いです。
結論: 大量データでは必ずバッファリングを使い、サイズが重要であれば圧縮も併用しましょう。
6. シリアライゼーション最適化での典型的なミス
エラー No.1: バッファリングを使わない — 大きなオブジェクトのシリアライズが何倍も遅くなる。
エラー No.2: 不要または機微なデータ(例: パスワード、一時トークン)をシリアライズしてしまう — そのようなフィールドには必ず transient を使う。
エラー No.3: 圧縮すれば常に速くなると期待する — 実際にはサイズは小さくなるが、処理は少し遅くなることがある(非力な CPU で特に)。
エラー No.4: JVM のウォームアップや GC の影響を考慮せずに時間を測る — 正確なベンチマークには JMH を使う。
エラー No.5: 時間かサイズのどちらか一方だけを比較する — どちらも見て、自分の用途に最適なバランスを選ぶ。
GO TO FULL VERSION