- JAVA
Java Serializableとは|serialVersionUIDの意味と手順
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期連続の黒字決算。