Javaの Serializable とは、「このクラスのオブジェクトはバイト列に変換して保存・送信してよい」とJavaに伝えるための、メソッドを持たない目印のインターフェースです。 オブジェクトをバイト列にすることをシリアライズ(直列化)、バイト列からオブジェクトに戻すことをデシリアライズ(復元)と呼びます。serialVersionUID は、保存したときのクラスと読み込むときのクラスが「同じ版かどうか」を見分けるための番号です。

この記事では、保存して読み込む最小コードから始めて、「保存 → フィールドを追加 → 読み込み」で InvalidClassException が出る流れを、エラーメッセージ全文と直し方つきで再現します。さらに、transient・static・継承・record の扱い一覧、受け取ったデータを読み込むときの ObjectInputFilter による許可リスト、JSON との使い分けまでを1ページにまとめました。掲載したコードはすべて OpenJDK 21.0.10 で実行し、OpenJDK 25.0.4.1 でも同じ結果になることを確かめています。

Serializable と serialVersionUID の早見表

知りたいこと 答え
Serializable とは メソッドを持たない目印のインターフェース。実装したクラスだけが ObjectOutputStream で保存できる
保存する new ObjectOutputStream(出力先).writeObject(obj)
読み込む (型) new ObjectInputStream(入力元).readObject()
serialVersionUID とは クラスの版を表す long の番号。保存時と読み込み時で一致しないと InvalidClassException
書き方 private static final long serialVersionUID = 1L;
書かないとどうなる クラスの構造から自動計算される。フィールドを1つ足すだけで値が変わる
保存したくないフィールド transient を付ける(読み込み後は null や 0)
フィールドの型が Serializable でない 保存時に NotSerializableException
外部から受け取ったデータを読む ObjectInputFilter で、読み込んでよいクラスを許可リストにする
ほかのシステムとやり取りする JSON などの形式を選ぶと、言語や版をまたいで扱いやすい
例外 出るタイミング 主な原因
NotSerializableException 保存時 Serializable を実装していないクラスが含まれている
InvalidClassException: ... local class incompatible 読み込み時 serialVersionUID が保存時と違う
InvalidClassException: ... incompatible types for field 読み込み時 フィールドの型を変えた
InvalidClassException: ... no valid constructor 読み込み時 Serializable でない親クラスに引数なしコンストラクタがない
InvalidClassException: filter status: REJECTED 読み込み時 ObjectInputFilter が許可していないクラス
ClassNotFoundException 読み込み時 読み込む側にそのクラスがない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java Serializableとは|serialVersionUIDの意味と手順」

保存して読み込む最小コード(ObjectOutputStream と ObjectInputStream)

まずは、オブジェクトをファイルに保存し、読み込んで元に戻すまでを1ファイルで確かめます。SaveLoad.java として保存し、java SaveLoad.java で実行できます。

import java.io.*;
import java.nio.file.*;
import java.util.List;

public class SaveLoad {
    // Serializable を実装したクラスだけが保存できる
    static class Member implements Serializable {
        private static final long serialVersionUID = 1L;
        String name;
        int age;
        List<String> tags;

        Member(String name, int age, List<String> tags) {
            this.name = name;
            this.age = age;
            this.tags = tags;
        }

        @Override
        public String toString() {
            return "Member{name=" + name + ", age=" + age + ", tags=" + tags + "}";
        }
    }

    public static void main(String[] args) throws Exception {
        Path file = Path.of("member.ser");
        Member before = new Member("佐藤", 28, List.of("Java", "SQL"));

        // 保存(シリアライズ)
        try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) {
            out.writeObject(before);
        }
        System.out.println("保存したサイズ: " + Files.size(file) + " バイト");

        // 読み込み(デシリアライズ)
        Member after;
        try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(file))) {
            after = (Member) in.readObject();
        }
        System.out.println("読み込み結果: " + after);
        System.out.println("同じインスタンスか: " + (before == after));

        // 先頭4バイトは決まった値(AC ED 00 05)
        byte[] bytes = Files.readAllBytes(file);
        System.out.printf("先頭4バイト: %02X %02X %02X %02X%n", bytes[0], bytes[1], bytes[2], bytes[3]);
    }
}

実行結果(Java 21.0.10):

保存したサイズ: 173 バイト
読み込み結果: Member{name=佐藤, age=28, tags=[Java, SQL]}
同じインスタンスか: false
先頭4バイト: AC ED 00 05
  • implements Serializable を付けるだけで、フィールドの中身がまとめて保存されます。 メソッドを実装する必要はありません。String や List.of(...) が返すリストなど、標準ライブラリの多くのクラスも Serializable を実装しています。
  • 読み込んだオブジェクトは、内容が同じ別のインスタンスです(== は false)。
  • 保存されたデータは、Java 専用のバイナリ形式です。先頭は必ず AC ED 00 05 で始まります。

serialVersionUID の意味を InvalidClassException で確かめる

serialVersionUID は、保存時のクラスと読み込み時のクラスを照合するための番号です。書かなかった場合に何が起きるかを、順番に再現します。

手順1: serialVersionUID を書かずに保存する

3つのファイルを用意します。

// Item.java
import java.io.Serializable;

public class Item implements Serializable {
    String name;
    int stock;

    Item(String name, int stock) {
        this.name = name;
        this.stock = stock;
    }

    @Override
    public String toString() {
        return "Item{name=" + name + ", stock=" + stock + "}";
    }
}
// Save.java
import java.io.*;

public class Save {
    public static void main(String[] args) throws Exception {
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("item.ser"))) {
            out.writeObject(new Item("ノート", 12));
        }
        System.out.println("保存しました: item.ser");
    }
}
// Load.java(保存したファイルを読み込んで表示する)
import java.io.*;

public class Load {
    public static void main(String[] args) throws Exception {
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("item.ser"))) {
            System.out.println("読み込み結果: " + in.readObject());
        }
    }
}

javac *.java でコンパイルし、java Save、java Load の順に実行します。

保存しました: item.ser
読み込み結果: Item{name=ノート, stock=12}

この時点では問題なく読み込めます。

手順2: フィールドを1つ追加して読み込む

Item に shelf フィールドを追加し、toString にも加えて、Item.java だけをコンパイルし直します(item.ser は手順1のまま)。

public class Item implements Serializable {
    String name;
    int stock;
    String shelf;   // 追加したフィールド
    // 以下は同じ(toString に shelf を追加)
}

java Load を実行した結果(Java 21.0.10、エラーメッセージ全文):

Exception in thread "main" java.io.InvalidClassException: Item; local class incompatible: stream classdesc serialVersionUID = -1691005804682638239, local class serialVersionUID = -7809510459183972499
    at java.base/java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:598)
    at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2078)
    at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1927)
    at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2252)
    at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1762)
    at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:540)
    at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:498)
    at Load.main(Load.java:6)

メッセージの読み方は次のとおりです。

部分 意味
Item; local class incompatible 読み込む側(local)の Item クラスが、保存されたデータと合わない
stream classdesc serialVersionUID = -1691005804682638239 保存されたデータ(stream)に記録されている番号
local class serialVersionUID = -7809510459183972499 いま読み込もうとしているクラスの番号

serialVersionUID を書かないと、Java がクラス名・フィールド・メソッドなどの構造から番号を自動で計算します。 フィールドを1つ追加しただけで計算結果が変わり、保存済みのデータと合わなくなりました。

手順3: serialVersionUID を明示して直す

最初から番号を明示しておくと、フィールドを追加しても同じ版として扱われます。手順1の Item に1行足して、保存からやり直します。

public class Item implements Serializable {
    private static final long serialVersionUID = 1L;   // 明示する
    String name;
    int stock;
    // 以下は同じ
}

保存した後に shelf フィールドを追加して java Load を実行した結果:

読み込み結果: Item{name=ノート, stock=12, shelf=null}

今度は読み込めました。 保存データに含まれていない shelf は、型の初期値(参照型なら null、int なら 0)になります。

すでに保存済みのデータを読みたいとき

番号を書かずに保存してしまったデータが残っている場合は、エラーメッセージの stream classdesc serialVersionUID の値を、そのままクラスに書きます。

    private static final long serialVersionUID = -1691005804682638239L;

手順2の状態(shelf を追加済み)にこの1行を足して java Load を実行した結果:

読み込み結果: Item{name=ノート, stock=12, shelf=null}

保存済みのデータを活かしたまま、クラスを変更できました。

変更の種類ごとの結果

serialVersionUID = 1L を明示した状態で、クラスをいろいろ変更してから手順1の保存データを読み込みました。

変更内容 結果(Java 21.0.10 で実行)
フィールドを追加 読み込める。追加分は null や 0
フィールドを削除 読み込める(Item{stock=12})。保存データ側の値は読み飛ばされる
フィールドの型を変更(int → long) InvalidClassException: Item; incompatible types for field stock
serialVersionUID を 2L に変更 InvalidClassException: Item; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2

「番号をそろえれば何でも読める」わけではなく、フィールドの型の変更のように互換性のない変更はエラーになります。 逆に、保存済みのデータを意図して読めなくしたいとき(意味が大きく変わったとき)は、番号を変えるのが明確な合図になります。

状況 serialVersionUID の決め方
新しく作るクラス 1L を明示する
フィールドの追加・削除だけ 番号はそのまま
フィールドの型や意味を変えた 番号を変える(保存済みのデータは読めなくなる)
番号なしで保存したデータが残っている エラーメッセージの stream classdesc の値を書く

serialver コマンドと -Xlint:serial で確認する

JDK には、クラスの serialVersionUID を表示する serialver コマンドが付属しています。クラスファイルのあるフォルダで実行します。

serialver Item

実行結果(番号を書いていない、手順1の Item):

Item:    private static final long serialVersionUID = -1691005804682638239L;

shelf フィールドを追加した後:

Item:    private static final long serialVersionUID = -7809510459183972499L;

明示していればその値が、していなければ自動計算の値が表示されます。出力はそのままソースに貼れる形になっています。クラスファイルが別の場所にあるときは serialver -classpath フォルダ Item と指定します。

番号を書き忘れたクラスは、コンパイル時に -Xlint:serial を付けると見つけられます。

javac -Xlint:serial Item.java
Item.java:3: warning: [serial] serializable class Item has no definition of serialVersionUID
public class Item implements Serializable {
       ^
1 warning

「1L を明示する」と「自動計算の値を貼る」のどちらを選んでも、動きに違いはありません。 新しいクラスなら、読みやすい 1L で十分です。

NotSerializableException を再現する

Serializable を実装したクラスの中に、実装していないクラスのフィールドがあると、保存のときに例外になります。

import java.io.*;

public class NotSer {
    // Serializable を実装していないクラス
    static class Address {
        String city = "東京";
    }

    static class Member implements Serializable {
        private static final long serialVersionUID = 1L;
        String name = "佐藤";
        Address address = new Address();   // 保存できない型のフィールド
    }

    public static void main(String[] args) throws Exception {
        try (ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream())) {
            out.writeObject(new Member());
        }
    }
}

実行結果(Java 21.0.10):

Exception in thread "main" java.io.NotSerializableException: NotSer$Address
    at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1200)
    (途中の5行を省略)
    at NotSer.main(NotSer.java:17)

例外メッセージに出ているのは、保存できなかったクラスの名前(NotSer$Address)です。オブジェクトの入れ子が深いと、どのフィールドから辿ったのかがわかりにくくなります。そのときは、起動オプション -Dsun.io.serialization.extendedDebugInfo=true を付けると経路が表示されます。

Exception in thread "main" java.io.NotSerializableException: NotSer$Address
    - field (class "NotSer$Member", name: "address", type: "class NotSer$Address")
    - root object (class "NotSer$Member", NotSer$Member@70b0b186)

「Member の address フィールドが原因」と読み取れます。

直し方 書き方 選ぶ目安
そのクラスも保存できるようにする class Address implements Serializable 自分で変更できるクラスで、一緒に保存したい
保存の対象から外す transient Address address; 保存しなくてよい値、読み込み後に作り直せる値
保存できる形に持ち替える String city; のように値で持つ 変更できないクラス(ライブラリのクラスなど)

transient・static・継承・record の扱い一覧

「どのフィールドが保存され、読み込み時に何が呼ばれるか」を一覧にします。

対象 保存されるか 読み込み後の値・動き
通常のフィールド(private でも可) される 保存時の値
final フィールド される 保存時の値
transient フィールド されない 参照型は null、数値は 0、boolean は false
static フィールド されない 読み込む側のクラスが持っている値のまま
Serializable な親クラスのフィールド される 保存時の値
Serializable でない親クラスのフィールド されない 親の引数なしコンストラクタが実行され、その初期値になる
保存するクラス自身のコンストラクタ ― 呼ばれない(フィールドに直接値が入る)
record 全コンポーネントが保存される コンストラクタが呼ばれる
enum 名前だけが保存される 同じ定数のインスタンスに戻る(== が true)
同じオブジェクトへの複数の参照 1回だけ保存される 読み込み後も同じ1つのインスタンスを指す

次のコードで確かめます。

import java.io.*;

public class Rules {
    // 親クラスは Serializable ではない(引数なしコンストラクタが必要)
    static class Base {
        String createdBy = "初期値";
        Base() {
            System.out.println("  Base() が呼ばれました");
        }
    }

    static class Account extends Base implements Serializable {
        private static final long serialVersionUID = 1L;
        static int counter = 0;              // static: 保存されない
        String loginId;                      // 通常: 保存される
        transient String password;           // transient: 保存されない
        transient int loginCount;            // transient の int は 0 に戻る
        final String role;                   // final でも保存される

        Account(String loginId, String password) {
            System.out.println("  Account(...) が呼ばれました");
            this.loginId = loginId;
            this.password = password;
            this.loginCount = 5;
            this.role = "admin";
            this.createdBy = "tanaka";
        }

        @Override
        public String toString() {
            return "loginId=" + loginId + ", password=" + password + ", loginCount=" + loginCount
                    + ", role=" + role + ", createdBy=" + createdBy + ", counter=" + counter;
        }
    }

    record Point(int x, int y) implements Serializable {
        Point {
            System.out.println("  Point のコンストラクタが呼ばれました x=" + x);
            if (x < 0) throw new IllegalArgumentException("x は 0 以上");
        }
    }

    enum Status { OPEN, CLOSED }

    static byte[] save(Object o) throws IOException {
        ByteArrayOutputStream buf = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(buf)) {
            out.writeObject(o);
        }
        return buf.toByteArray();
    }

    static Object load(byte[] bytes) throws Exception {
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            return in.readObject();
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("[1] 作成");
        Account a = new Account("sato", "secret");
        Account.counter = 99;
        System.out.println("  保存前: " + a);
        byte[] bytes = save(a);

        Account.counter = 0;   // 別のJVMで読み込んだ状態を想定して戻す
        System.out.println("[2] 読み込み");
        Account b = (Account) load(bytes);
        System.out.println("  読込後: " + b);

        System.out.println("[3] record");
        byte[] pb = save(new Point(3, 4));
        System.out.println("  -- ここから読み込み");
        System.out.println("  " + load(pb));

        System.out.println("[4] enum");
        Object s = load(save(Status.OPEN));
        System.out.println("  " + s + " 同じインスタンスか: " + (s == Status.OPEN));

        System.out.println("[5] 同じオブジェクトを2回参照");
        Point p = new Point(1, 1);
        Object[] arr = (Object[]) load(save(new Object[]{p, p}));
        System.out.println("  読込後も同じインスタンスか: " + (arr[0] == arr[1]));
    }
}

実行結果(Java 21.0.10):

[1] 作成
  Base() が呼ばれました
  Account(...) が呼ばれました
  保存前: loginId=sato, password=secret, loginCount=5, role=admin, createdBy=tanaka, counter=99
[2] 読み込み
  Base() が呼ばれました
  読込後: loginId=sato, password=null, loginCount=0, role=admin, createdBy=初期値, counter=0
[3] record
  Point のコンストラクタが呼ばれました x=3
  -- ここから読み込み
  Point のコンストラクタが呼ばれました x=3
  Point[x=3, y=4]
[4] enum
  OPEN 同じインスタンスか: true
[5] 同じオブジェクトを2回参照
  Point のコンストラクタが呼ばれました x=1
  Point のコンストラクタが呼ばれました x=1
  読込後も同じインスタンスか: true

結果の読み取り方

  • [2] では Account(...) が呼ばれていません。 通常のクラスは、コンストラクタを通らずにフィールドへ直接値が入ります。コンストラクタに書いた値の確認(「0以上であること」など)は、読み込みのときには働かないということです。
  • transient の password は null、loginCount は 0 に戻りました。パスワードや一時的な計算結果のように、保存データに残したくない値に使います。
  • 親クラス Base は Serializable ではないため、createdBy は保存されず、Base() が実行されて「初期値」に戻りました。 親のフィールドも保存したいときは、親クラスにも Serializable を実装します。
  • [3] の record は、読み込みのときにもコンストラクタが呼ばれます。 コンストラクタに書いた確認がそのまま働くため、値の確認を1か所にまとめられます。
  • [5] でコンストラクタが呼ばれたのは、new Point(1, 1) のときと読み込みのときの1回ずつです。同じオブジェクトは1回だけ保存されるため、配列の2つの要素は、読み込み後も同じ1つのインスタンスを指しています。

親クラスに引数なしコンストラクタがないとき

Serializable でない親クラスに引数なしコンストラクタがないと、保存はできても読み込みで例外になります(NoArg.java で確認)。

保存はできました
Exception in thread "main" java.io.InvalidClassException: NoArg$Child; no valid constructor

親クラスに引数なしコンストラクタを用意するか、親クラスにも Serializable を実装すると読み込めるようになります。

record と serialVersionUID

record は、通常のクラスとは照合の規則が違います。record Point(int x, int y) を保存した後、record Point(int x, int y, String label) に変更して読み込んだ結果です。

Point[x=3, y=4, label=null]

serialver Point の出力は、変更の前後どちらも次のとおりでした。

Point:    private static final long serialVersionUID = 0L;

record は serialVersionUID を書かなくても 0L として扱われ、コンポーネントを追加しても読み込めました。 保存データにないコンポーネントは null(数値は 0)がコンストラクタに渡されます。値を運ぶだけの入れ物は record で書くと、版の管理もコンストラクタでの確認もシンプルになります。

読み込み時に値を確認する(readObject)

通常のクラスで、読み込み時にも値を確認したいときは、readObject メソッドを定義します。

import java.io.*;

public class Validate {
    static class Stock implements Serializable {
        private static final long serialVersionUID = 1L;
        private final String name;
        private final int quantity;
        private transient String cacheKey;   // 保存せず、読み込み時に作り直す

        Stock(String name, int quantity) {
            if (quantity < 0) throw new IllegalArgumentException("quantity は 0 以上");
            this.name = name;
            this.quantity = quantity;
            this.cacheKey = "stock:" + name;
        }

        // 読み込み時に呼ばれる。コンストラクタと同じ確認をここにも書く
        @Serial
        private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
            in.defaultReadObject();
            if (quantity < 0) throw new InvalidObjectException("quantity は 0 以上: " + quantity);
            this.cacheKey = "stock:" + name;
        }

        @Override
        public String toString() {
            return "Stock{name=" + name + ", quantity=" + quantity + ", cacheKey=" + cacheKey + "}";
        }
    }

    public static void main(String[] args) throws Exception {
        ByteArrayOutputStream buf = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(buf)) {
            out.writeObject(new Stock("ノート", 12));
        }
        byte[] bytes = buf.toByteArray();
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            System.out.println("正常なデータ: " + in.readObject());
        }

        // 保存されたバイト列の quantity(12 = 0x0000000C)を -1 に書き換える
        for (int i = 0; i < bytes.length - 3; i++) {
            if (bytes[i] == 0 && bytes[i + 1] == 0 && bytes[i + 2] == 0 && bytes[i + 3] == 12) {
                bytes[i] = bytes[i + 1] = bytes[i + 2] = bytes[i + 3] = (byte) 0xFF;
            }
        }
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            System.out.println("書き換えたデータ: " + in.readObject());
        } catch (InvalidObjectException e) {
            System.out.println("書き換えたデータ: " + e);
        }
    }
}

実行結果(Java 21.0.10):

正常なデータ: Stock{name=ノート, quantity=12, cacheKey=stock:ノート}
書き換えたデータ: java.io.InvalidObjectException: quantity は 0 以上: -1

transient にした cacheKey も、readObject の中で作り直せています。@Serial(Java 14 以降)は、readObject や serialVersionUID などシリアライズ用の宣言に付ける目印で、名前や引数の書き間違いをコンパイラが -Xlint:serial で知らせてくれるようになります。

受け取ったデータを読み込むときの注意と ObjectInputFilter

readObject() は、バイト列に書かれたクラス名をもとに、クラスパス上にあるクラスなら何でも組み立てようとします。(Member) in.readObject() のキャストが働くのは、組み立てが終わった後です。そのため、ネットワーク越しに届いたデータや利用者がアップロードしたファイルのように、中身を自分で作っていないデータをそのまま readObject() に渡すと、想定していないクラスが組み立てられたり、とても大きなデータでメモリを使い切ったりすることがあります。

守る側の基本方針は次の3つです。

方針 内容
1. 読み込む範囲を決める 自分のアプリケーションが保存したデータだけを readObject() で読む
2. 許可リストを使う 外から届く可能性があるなら、ObjectInputFilter で読み込んでよいクラスだけを通す
3. 形式を選ぶ 外部とのやり取りは JSON などのデータ形式にする

ObjectInputFilter で許可リストを作る

ObjectInputFilter(Java 9 以降)は、読み込みの途中でクラスや大きさを確認し、許可するかどうかを決める仕組みです。パターン文字列で指定できます。

import java.io.*;
import java.util.HashMap;

public class Filter {
    record Task(String title, int priority) implements Serializable {}
    record Memo(String text) implements Serializable {}

    static byte[] save(Object o) throws IOException {
        ByteArrayOutputStream buf = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(buf)) {
            out.writeObject(o);
        }
        return buf.toByteArray();
    }

    // 許可リスト: Task と String と基本的な数値だけを通し、それ以外(!*)は拒否する。
    // あわせて、深さ・参照数・配列長・バイト数の上限も決める。
    static final ObjectInputFilter ALLOW = ObjectInputFilter.Config.createFilter(
            "maxdepth=5;maxrefs=100;maxarray=100;maxbytes=10000;"
            + "Filter$Task;java.lang.String;java.lang.Number;java.lang.Integer;!*");

    static Object loadWithFilter(byte[] bytes) throws Exception {
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            in.setObjectInputFilter(ALLOW);
            return in.readObject();
        }
    }

    static void tryLoad(String label, Object o) throws Exception {
        try {
            System.out.println(label + " -> " + loadWithFilter(save(o)));
        } catch (InvalidClassException e) {
            System.out.println(label + " -> 拒否: " + e);
        }
    }

    public static void main(String[] args) throws Exception {
        tryLoad("Task   ", new Task("棚卸し", 1));
        tryLoad("Memo   ", new Memo("許可していないクラス"));
        tryLoad("HashMap", new HashMap<String, String>());
        tryLoad("int[]  ", new int[1000]);
    }
}

実行結果(Java 21.0.10):

Task    -> Task[title=棚卸し, priority=1]
Memo    -> 拒否: java.io.InvalidClassException: filter status: REJECTED
HashMap -> 拒否: java.io.InvalidClassException: filter status: REJECTED
int[]   -> 拒否: java.io.InvalidClassException: filter status: REJECTED

許可した Task だけが読み込め、それ以外は filter status: REJECTED で止まりました。int[1000] は、配列の長さの上限(maxarray=100)を超えたために止まっています。

パターン 意味
com.example.Task このクラスを許可
com.example.* このパッケージのクラスを許可(サブパッケージは含まない)
com.example.** このパッケージとサブパッケージのクラスを許可
!com.example.Memo このクラスを拒否
!* ここまでに一致しなかったクラスをすべて拒否(許可リストの締め)
maxdepth=5 オブジェクトの入れ子の深さの上限
maxrefs=100 参照の数の上限
maxarray=100 配列の長さの上限
maxbytes=10000 読み込むバイト数の上限

パターンは左から順に調べられ、最初に一致したものが使われます。最後に !* を置くと「書いたものだけ許可」になります。 Oracle の公式ドキュメント(Java 21 の Serialization Filtering)でも、拒否するクラスを並べる方法より、許可するクラスを明示する方法が勧められています。

JVM 全体にフィルターをかける

コードを変更できないライブラリの中で readObject() が呼ばれる場合は、起動オプションで JVM 全体にフィルターを設定できます。

java -Djdk.serialFilter='java.lang.*;!*' Glob.java

ArrayList を保存して読み込むだけのプログラム(Glob.java)で確かめた結果です。

起動方法 ObjectInputFilter.Config.getSerialFilter() 読み込み結果
オプションなし null [](読み込めた)
-Djdk.serialFilter='java.lang.*;!*' java.lang.*;!* InvalidClassException: filter status: REJECTED

何も設定しない状態では、フィルターは null(確認なし)です。 外からデータが届く可能性のあるアプリケーションでは、ストリームごとの setObjectInputFilter か、jdk.serialFilter のどちらかを設定しておくと安心です。ストリームごとに使うフィルターを切り替えたい場合は、Java 17 以降でフィルターファクトリー(ObjectInputFilter.Config.setSerialFilterFactory)も用意されています(この記事では未実行。公式ドキュメントの記載を確認)。

JSON(Jackson)との使い分け

同じ record を、Java 標準のシリアライズと JSON(Jackson)の両方で出力して比べます。Jackson は外部ライブラリなので、jackson-databind・jackson-core・jackson-annotations の jar をクラスパスに指定して実行します。

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.*;
import java.util.List;

public class JsonVs {
    public record Task(String title, int priority, List<String> tags) implements Serializable {}

    public static void main(String[] args) throws Exception {
        Task task = new Task("棚卸し", 1, List.of("総務", "月次"));

        // Java 標準のシリアライズ
        ByteArrayOutputStream buf = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(buf)) {
            out.writeObject(task);
        }
        System.out.println("Java標準: " + buf.size() + " バイト(バイナリ)");

        // JSON(Jackson)
        ObjectMapper mapper = new ObjectMapper();
        String json = mapper.writeValueAsString(task);
        System.out.println("JSON    : " + json.getBytes("UTF-8").length + " バイト");
        System.out.println(json);

        Task back = mapper.readValue(json, Task.class);
        System.out.println("JSONから復元: " + back);
        System.out.println("equals: " + task.equals(back));
    }
}

実行結果(Java 21.0.10、Jackson 2.16.1):

Java標準: 183 バイト(バイナリ)
JSON    : 61 バイト
{"title":"棚卸し","priority":1,"tags":["総務","月次"]}
JSONから復元: Task[title=棚卸し, priority=1, tags=[総務, 月次]]
equals: true

手元で動かしたのは Jackson 2.16.1 です。ObjectMapper の writeValueAsString と readValue という基本の使い方は 2.x 系で共通ですが、3.x 系ではパッケージ名が変わっているため、使う版のドキュメントに合わせて import を書きます。

比べる点 Java 標準(Serializable) JSON(Jackson など)
形式 Java 専用のバイナリ テキスト。人が読める
ほかの言語との受け渡し Java 同士に限られる 多くの言語で扱える
必要なもの 標準ライブラリだけ 外部ライブラリ
クラスへの準備 implements Serializable 基本は不要(getter や record のコンポーネントから変換)
保存される範囲 private フィールドを含むオブジェクト全体、参照の共有も再現 公開されたプロパティ。参照の共有は再現されない
クラスを変更したとき serialVersionUID と互換性の規則に従う 項目名が合えば読める。未知の項目の扱いは設定で決める
読み込むクラスの決まり方 バイト列の中のクラス名 呼び出し側が指定した型(Task.class)
向いている用途 同じアプリケーション内での一時的な保存、Java の仕組みが求める場面 API のリクエストとレスポンス、設定ファイル、長く残すデータ

迷ったときの目安は「データを読むのが、同じ版の同じ Java アプリケーションだけかどうか」です。 そうであれば Serializable が手軽で、ほかのシステムや将来の版が読むなら JSON が扱いやすくなります。

Serializable が必要になる場面

自分で ObjectOutputStream を呼ばなくても、フレームワークやミドルウェアがシリアライズを使う場面があります。次の表は一般に知られた仕様をまとめたもので、この記事では実行していません。使う製品のドキュメントで設定を確認してください。

場面 なぜ必要になるか
HttpSession に入れるオブジェクト セッションをディスクに保存したり、複数のサーバーで共有したりする設定では、セッションの属性がシリアライズされる
キャッシュに入れるオブジェクト 別プロセスのキャッシュに保存するとき、値をバイト列にする必要がある(JSON など別の形式を選べる製品も多い)
RMI など Java 同士の遠隔呼び出し 引数と戻り値がシリアライズされて送られる
例外クラス Throwable が Serializable を実装しているため、独自の例外クラスも対象になる(-Xlint:serial の警告が出る)

セッションに入れたクラスが Serializable でないと、サーバーの設定によっては NotSerializableException がログに出ます。セッションやキャッシュに入れるクラスは、最初から Serializable と serialVersionUID を付けておくと、あとから構成を変えるときにも慌てずに済みます。

Java 21 と Java 25 の違い

この記事のコードを OpenJDK 25.0.4.1(Ubuntu のパッケージを展開して使用)でも実行しました。

確認した内容 Java 21.0.10 Java 25.0.4.1
最小コード(SaveLoad) 上記の結果 同じ出力
扱いの一覧・許可リスト・読み込み時の確認(Rules・Filter・Validate) 上記の結果 同じ出力
NotSerializableException・no valid constructor 上記の結果 例外とメッセージは同じ(スタックトレースの行番号だけが違う)
serialVersionUID の不一致 上記の結果 同じメッセージ
serialver の自動計算値 -7809510459183972499L 同じ値
JSON との比較(JsonVs) 上記の結果 同じ出力

シリアライズの形式と serialVersionUID の計算は、21 と 25 で変わっていません。

よくある質問

Q. serialVersionUID は必ず書く必要がありますか? A. 書かなくても動きますが、明示するのがおすすめです。書かない場合はクラスの構造から自動計算され、フィールドを1つ追加しただけで値が変わります。この記事の再現では、-1691005804682638239 から -7809510459183972499 に変わり、保存済みのデータが InvalidClassException で読めなくなりました。private static final long serialVersionUID = 1L; の1行で防げます。

Q. transient と static はどう違いますか? A. どちらも保存されませんが、理由が違います。static はクラスに属する値で、もともとオブジェクトの一部ではないため対象外です。transient は「このフィールドは保存しない」と明示するための修飾子で、読み込み後は null や 0 になります。

Q. record にも serialVersionUID は必要ですか? A. 必要ありません。record は serialVersionUID を書かなくても 0L として扱われ、この記事の確認ではコンポーネントを追加した後も保存済みのデータを読み込めました。読み込み時にコンストラクタが呼ばれるので、値の確認も1か所で済みます。

まとめ

  • Serializable は「バイト列にしてよい」という目印のインターフェース。 ObjectOutputStream.writeObject で保存し、ObjectInputStream.readObject で読み込む
  • serialVersionUID はクラスの版を表す番号。 書かないと自動計算され、フィールドを1つ足しただけで変わって InvalidClassException になる。1L を明示しておく
  • 保存済みのデータを読みたいときは、エラーメッセージの stream classdesc serialVersionUID の値をクラスに書く
  • transient と static は保存されない。 Serializable でない親クラスのフィールドは、親の引数なしコンストラクタの値に戻る
  • 通常のクラスは読み込み時にコンストラクタが呼ばれない。 record は呼ばれるので、値の確認を1か所にまとめられる
  • 外から届くデータを読むときは ObjectInputFilter の許可リスト(最後に !*)と、大きさの上限を設定する
  • Java 同士の一時的な保存には Serializable、ほかのシステムとのやり取りや長く残すデータには JSON が扱いやすい

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java Serializableとは|serialVersionUIDの意味と手順」

株式会社RJCでは、Javaのシリアライズのような基礎の仕組みを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?