1. 相容性問題
想像一下:你發布了應用程式的第一個版本,使用者開始儲存資料(例如使用者檔案或設定)。一個月後,你發現在類別 UserProfile 中少了欄位 email,於是把它加上。看起來一切都很順利……直到你嘗試載入舊檔。最好情況是新欄位為空,最糟則會拋出例外,讓使用者不悅。
序列化相容性是指程式能正確讀取由較舊版本的類別序列化的資料,反之亦然。在 Java(特別是透過 Serializable 進行的二進位序列化)中,這一點格外重要,因為 JVM 對類別結構的變更非常敏感。
常見會出問題的情境:
- 你在類別中新增了欄位。
- 你刪除了舊的欄位。
- 你變更了欄位型別(例如,從 int 改為 String)。
- 你重新命名了類別或把它移到其他套件。
- 你更新了負責序列化物件的函式庫或框架。
在上述情況下,舊的序列化資料可能對新版程式來說變得「無法讀取」。
2. serialVersionUID:序列化類別的版本識別碼
在 Java 中,每個可序列化的類別(也就是實作介面 Serializable)都有一個唯一的版本識別碼——serialVersionUID。JVM 會使用這個欄位來檢查是否能用當前類別來反序列化物件。如果識別碼不一致——就會拋出 InvalidClassException。
private static final long serialVersionUID = 1L;
如果你沒有顯式宣告這個欄位,Java 會根據類別的結構(欄位、方法、修飾子等)自動產生它。但只要你之後修改類別(即使很細微),自動產生的 serialVersionUID 也會改變,舊資料就會變得不相容。
檢查是怎麼運作的?
當物件被序列化時,serialVersionUID 的值會連同資料一起寫入串流;在反序列化時,JVM 會將這個識別碼與當前類別中宣告的值比對。如果相符——物件就能正常還原。但如果不同,處理會立刻以錯誤終止:JVM 認為類別已經有了重大變更,舊資料不再適用。
為什麼要顯式宣告 serialVersionUID?
如果由你自行設定 serialVersionUID,就能控制哪些變更被視為「可接受」。例如,你新增了一個欄位,但希望仍可載入舊物件?只要保持識別碼不變——反序列化就能順利進行。若仰賴自動生成,可能會被驚嚇:哪怕是極小的修改,都會導致舊的存檔無法開啟。
範例:
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
// ... getter 與 setter
}
現在你可以放心地加入新欄位(只要它們不是必填),舊物件的反序列化也不會壞掉。
3. 當類別發生變更時會發生什麼?
新增欄位
舊的序列化物件 → 含有額外欄位的新類別
- 新欄位會得到預設值(null、0、false)。
- 其餘部分會正確反序列化。
範例:
// 變更前:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
}
// 變更後:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private String email; // 新增欄位
}
結果: 舊物件可載入,email == null。
刪除欄位
舊的序列化物件包含某欄位,但新類別已移除該欄位
- 反序列化時會直接忽略這個欄位。
- 重點——不要更改 serialVersionUID。
變更欄位型別
例如,原本是 int age,後來改成 String age。
- 這屬於不相容的變更。嘗試反序列化時會出錯(通常是 InvalidClassException 或 ClassCastException)。
- 最好避免此類變更,或透過自訂序列化來維持相容性(見下文)。
重新命名類別或套件
這裡相當嚴格:如果你變更類別或套件名稱,反序列化就不會成功。序列化串流中保存了類別的完整名稱,而 JVM 期望看到完全相同的名稱。因此任何重新命名都被視為重大變更。若確實需要調整專案結構,就免不了進行手動資料移轉。
4. transient 與 static:哪些會被序列化,哪些不會?
- static 欄位完全不會被序列化——它們屬於類別,而非物件。
- transient 欄位表示這是暫時性資料,不應被序列化(例如快取、臨時權杖)。
範例:
public class Session implements Serializable {
private static final long serialVersionUID = 1L;
private String user;
private transient String sessionToken; // 不會被序列化
}
在反序列化時,sessionToken 會是 null,就算在序列化前它有填值。
5. 自訂序列化:writeObject/readObject
若你需要更複雜的相容性邏輯(例如把舊欄位轉換成新欄位、處理已變更的型別),可以實作以下特殊方法:
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
// 如有需要,可加入其他邏輯
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
// 其他邏輯,例如依據舊資料填入新欄位
}
演進範例:
public class User implements Serializable {
private static final long serialVersionUID = 2L;
private String name;
private int age; // 以前是 String birthYear
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
// 若存在欄位 birthYear,將其轉換成 age
// (若你將 birthYear 標記為 transient,可在此加入示例程式碼)
}
}
6. XML 與 JSON 的相容性:文字格式的彈性
與二進位序列化不同,XML 與 JSON 對類別結構的變更通常寬容得多。
XML(JAXB)與 JSON(Jackson、Gson)
在處理 XML 或 JSON 時,反序列化會寬鬆許多。若資料中出現你類別裡沒有的欄位,會被忽略;而類別中的新欄位如果在原始資料裡沒有,通常會取得預設值——對物件是 null,對數值是 0。元素或鍵的順序不影響解析,因此即使重新排列標籤或鍵,仍能正確解析。
註解提供了完整的控制:你可以指定在檔案中使用的名稱、哪些欄位為必填、哪些可省略,甚至調整格式。例如,在 JAXB 中,類別 User 可以長這樣:
public class User {
@XmlElement(required = true)
private String name;
@XmlElement
private String email; // 新增欄位,非必填
}
對於使用 Jackson 或 Gson 的 JSON,大致如下:
public class User {
@JsonProperty("name")
private String name;
@JsonProperty("email")
private String email; // 新增欄位
}
好處是顯而易見:舊的 JSON 或 XML 檔可以輕鬆載入;新欄位會得到 null,而資料中多餘的欄位會被忽略。你可以放心調整類別結構,而不必擔心破壞舊的儲存資料。
什麼時候需要更嚴格的控制?
當你把某欄位設為必填時,控制尤為重要。如果舊資料沒有該欄位,反序列化將會失敗。型別變更也一樣:如果先前欄位是字串,而你改成數值,舊資料可能無法通過解析。因此在做這類變更之前,務必評估其對既有儲存的影響,並在必要時準備資料移轉或設定合適的預設值。
7. 確保相容性的策略
- 明確宣告 serialVersionUID。這是控制二進位序列化相容性的主要手段。
- 僅新增非必填欄位。 新欄位應為 null 或具有預設值。
- 使用 transient 來標示暫時或不重要的資料。 這些欄位不會被序列化,也不會在類別演進時造成問題。
- 記錄類別的變更。 在類別的註解中說明哪些欄位被新增/移除,以及自哪個版本開始。
- 遇到複雜情況—— writeObject/readObject。可在「載入時」實作資料移轉。
- 對關鍵資料使用結構定義(XML Schema、JSON Schema)。 這有助於明確描述資料結構並在載入時驗證。
8. 實作示範:不相容與演進
演示 serialVersionUID 不一致時的錯誤
// 先用某個類別版本序列化物件
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
}
// 接著把 serialVersionUID 改掉(例如改成 2L),重新編譯,並嘗試載入舊檔
public class User implements Serializable {
private static final long serialVersionUID = 2L;
private String name;
}
結果:
java.io.InvalidClassException: User; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2
成功演進類別的範例
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
// 新增欄位
private String email;
}
如果先序列化舊物件(沒有 email),之後新增欄位且不改 serialVersionUID,反序列化會成功,email 會是 null。
9. 處理序列化相容性時的常見錯誤
錯誤一:未宣告 serialVersionUID。 如果沒有顯式宣告 serialVersionUID,JVM 會自動產生。即使是極小的類別變更(例如新增方法或變更欄位修飾子)都會導致 serialVersionUID 改變,進而無法反序列化舊資料。這是破壞向後相容性的典型方式。
錯誤二:變更欄位型別。 把欄位型別改掉(例如從 int 變成 String)——會得到例外或不正確的資料。這類變更需要特別謹慎,最好配合 writeObject/readObject 進行手動移轉。
錯誤三:刪除或重新命名類別/套件。 重新命名類別或變更套件會使舊物件無法反序列化。類別名稱與套件會被寫入序列化串流,而 JVM 無法將其對應起來。
錯誤四:濫用 transient。 如果把重要欄位(例如使用者 id)標記為 transient,它就不會被序列化,而在還原物件時該值會遺失。
錯誤五:不一致地變更集合。 新增新的集合欄位或改變集合型別(例如,從 List 變為 Set)——舊資料可能會反序列化不正確或拋出錯誤。
錯誤六:在 XML/JSON 中設定過於嚴格的限制。 如果在 XML/JSON 結構中將欄位標為必填(required = true),而舊資料沒有該欄位,載入就會失敗。請謹慎使用註解與結構定義!
GO TO FULL VERSION