こんにちは、かつコーチです。
オブジェクトをファイルに保存したり、ネットワーク越しに送ったりする場面で、なぜSerializableを実装しないとNotSerializableExceptionが飛ぶのか、腹落ちしないまま使っていませんか。
今回はJavaの標準シリアライズの仕組みと、実務で踏みやすい落とし穴を掘り下げます。前提としてequals・hashCode・オブジェクトの参照関係は理解している読者を想定します。
シリアライズの基本
ObjectOutputStream・ObjectInputStreamによる直列化
Javaのオブジェクトはヒープ上の複雑な参照構造を持つため、そのままではファイルに書き出せません。Serializableインターフェースを実装すると、JVMがオブジェクトのフィールド値を再帰的にたどり、バイト列に変換(シリアライズ)できるようになります。
import java.io.*;
class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return name + "(" + age + ")";
}
}
public class SerializeSample {
public static void main(String[] args) throws IOException, ClassNotFoundException {
User user = new User("かつコーチ", 35);
// シリアライズ(オブジェクト → バイト列 → ファイル)
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.ser"))) {
oos.writeObject(user);
}
// デシリアライズ(ファイル → バイト列 → オブジェクト)
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.ser"))) {
User restored = (User) ois.readObject();
System.out.println(restored); // かつコーチ(35)
}
}
}
Serializableにはメソッドが1つもないマーカーインターフェース(目印だけを目的としたインターフェース)で、実装を強制するのではなくJVMに「このクラスはシリアライズ対象」と伝える役割しか持ちません。
serialVersionUIDの意味
serialVersionUIDはクラスのバージョンを識別するIDです。
明示的に宣言しないとコンパイラがクラス構造からハッシュ値を自動生成しますが、フィールドを1つ追加しただけで値が変わってしまい、過去にシリアライズしたデータを読めなくなる(InvalidClassException)ことがあります。
実務では必ずprivate static final long serialVersionUID = 1L;のように固定値で明示するのが定石です。
よくあるつまずきポイント・エラー対処
transientを付け忘れて機密情報や非シリアライズ対象が流出する
実際に私が業務でハマった経験ですが、Userクラスに一時的なDBコネクションオブジェクトを持たせたままSerializableを実装し、実行時にNotSerializableExceptionで落ちたことがあります。
// ❌Before:シリアライズ不可能なフィールドをそのまま持たせる
class Session implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private Connection dbConnection; // java.sql.ConnectionはSerializableではない
public Session(String userId, Connection dbConnection) {
this.userId = userId;
this.dbConnection = dbConnection;
}
}
// oos.writeObject(session) 実行時に NotSerializableException: java.sql.Connection...
このコードはConnectionがシリアライズ不可能な型のため、writeObject実行時にjava.io.NotSerializableException: <ドライバの実装クラス名>が発生します。
JVMは全フィールドを再帰的にシリアライズしようとするため、1つでも非対応の型が混ざると失敗します。
// ✅After:transientでシリアライズ対象から除外する
class Session implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private transient Connection dbConnection; // シリアライズ対象から除外
public Session(String userId, Connection dbConnection) {
this.userId = userId;
this.dbConnection = dbConnection;
}
}
transient修飾子を付けたフィールドはシリアライズ対象から除外され、デシリアライズ後は型のデフォルト値(オブジェクト型ならnull)になります。
パスワードやDB接続、Threadオブジェクトのような「その場限りの状態」を持つフィールドにはtransientを付けるのが基本方針です。
応用・一歩先の使い方
標準シリアライズを避ける現代的な選択肢
Javaの標準シリアライズは歴史的に多くの脆弱性が報告されており、外部から受け取ったバイト列をそのままreadObjectで復元する設計はデシリアライズ脆弱性(悪意あるバイト列から任意コードが実行されるリスク)の温床になります。
実務では次のような代替手段が広く使われています。
| 方式 | 特徴 | 向いている場面 |
|---|---|---|
| Java標準シリアライズ | JVM専用、バージョン管理が脆い、脆弱性リスクあり | ローカルキャッシュなど閉じた環境限定 |
| JSON(Jackson・Gson) | 言語非依存、可読性が高い、脆弱性リスクが低い | Web API、外部連携全般 |
| Protocol Buffers | バイナリで軽量、スキーマ定義が必須 | マイクロサービス間の高速通信 |
外部からの入力を復元する用途では、標準シリアライズよりJSONベースのライブラリを選ぶのが現在の主流です。
readObjectのカスタマイズ
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
if (this.age < 0) {
throw new InvalidObjectException("ageが不正な値です: " + age);
}
}
readObjectをprivateメソッドとしてオーバーライドすると、デシリアライズ時にバリデーションを差し込めます。
外部由来のバイト列を扱う可能性がある場合は、この防御的なチェックを入れておくとリスクを軽減できます。
まとめ
この記事のポイント
Serializableはマーカーインターフェースで、JVMがフィールドを再帰的にバイト列化するserialVersionUIDは明示的に固定し、クラス変更による互換性崩れを防ぐ- シリアライズ不可能なフィールドや機密情報には
transientを付ける - 外部連携ではデシリアライズ脆弱性のリスクからJSON等の代替手段を優先する
次に読むべき記事
- JDBCでデータベースに接続する基本
- ファイル入出力(java.nio.file)
タグ: Java, 上級者向け, 標準ライブラリ