深入理解 Java 序列化
:notebook: 本文已歸檔到:「 blog 」
:keyboard: 本文中的示例程式碼已歸檔到:「 javacore 」
簡介
- 序列化(serialize) - 序列化是將物件轉換為位元組流。
- 反序列化(deserialize) - 反序列化是將位元組流轉換為物件。
- 序列化用途
- 序列化可以將物件的位元組序列持久化——儲存在記憶體、檔案、資料庫中。
- 在網路上傳送物件的位元組序列。
- RMI(遠端方法呼叫)
:bell: 注意:使用 Java 物件序列化,在儲存物件時,會把其狀態儲存為一組位元組,在未來,再將這些位元組組裝成物件。必須注意地是,物件序列化儲存的是物件的”狀態”,即它的成員變數。由此可知, 物件序列化不會關注類中的靜態變數 。
序列化和反序列化
Java 通過物件輸入輸出流來實現序列化和反序列化:
-
java.io.ObjectOutputStream
類的writeObject()
方法可以實現序列化; -
java.io.ObjectInputStream
類的readObject()
方法用於實現反序列化。
序列化和反序列化示例:
public class SerializeDemo01 { enum Sex { MALE, FEMALE } static class Person implements Serializable { private static final long serialVersionUID = 1L; private String name = null; private Integer age = null; private Sex sex; public Person() { } public Person(String name, Integer age, Sex sex) { this.name = name; this.age = age; this.sex = sex; } @Override public String toString() { return "Person{" + "name='" + name + '\'' + ", age=" + age + ", sex=" + sex + '}'; } } /** * 序列化 */ private static void serialize(String filename) throws IOException { File f = new File(filename); // 定義儲存路徑 OutputStream out = new FileOutputStream(f); // 檔案輸出流 ObjectOutputStream oos = new ObjectOutputStream(out); // 物件輸出流 oos.writeObject(new Person("Jack", 30, Sex.MALE)); // 儲存物件 oos.close(); out.close(); } /** * 反序列化 */ private static void deserialize(String filename) throws IOException, ClassNotFoundException { File f = new File(filename); // 定義儲存路徑 InputStream in = new FileInputStream(f); // 檔案輸入流 ObjectInputStream ois = new ObjectInputStream(in); // 物件輸入流 Object obj = ois.readObject(); // 讀取物件 ois.close(); in.close(); System.out.println(obj); } public static void main(String[] args) throws IOException, ClassNotFoundException { final String filename = "d:/text.dat"; serialize(filename); deserialize(filename); } } // Output: // Person{name='Jack', age=30, sex=MALE} 複製程式碼
Serializable 介面
被序列化的類必須屬於 Enum、Array 和 Serializable 型別其中的任何一種。
如果不是 Enum、Array 的類,如果需要序列化,必須實現 java.io.Serializable
介面,否則將丟擲 NotSerializableException
異常 。這是因為:在序列化操作過程中會對型別進行檢查,如果不滿足序列化型別要求,就會丟擲異常。
我們不妨做一個小嚐試:將 SerializeDemo01 示例中 Person 類改為如下實現,然後看看執行結果。
public class UnSerializeDemo { static class Person { // 其他內容略 } // 其他內容略 } 複製程式碼
輸出:結果就是出現如下異常資訊。
Exception in thread "main" java.io.NotSerializableException: ... 複製程式碼
serialVersionUID
請注意 serialVersionUID
欄位,你可以在 Java 世界的無數類中看到這個欄位。
serialVersionUID
有什麼作用,如何使用 serialVersionUID
?
serialVersionUID
是 Java 為每個序列化類產生的版本標識 。它可以用來保證在反序列時,傳送方傳送的和接受方接收的是可相容的物件。如果接收方接收的類的 serialVersionUID
與傳送方傳送的 serialVersionUID
不一致,會丟擲 InvalidClassException
。
如果可序列化類沒有顯式宣告 serialVersionUID
,則序列化執行時將基於該類的各個方面計算該類的預設 serialVersionUID
值。儘管這樣,還是 建議在每一個序列化的類中顯式指定 serialVersionUID
的值 。因為不同的 jdk 編譯很可能會生成不同的 serialVersionUID
預設值,從而導致在反序列化時丟擲 InvalidClassExceptions
異常。
serialVersionUID 欄位必須是 static final long 型別。
我們來舉個例子:
(1)有一個可序列化類 Person
public class Person implements Serializable { private static final long serialVersionUID = 1L; private String name; private Integer age; private String address; // 構造方法、get、set 方法略 } 複製程式碼
(2)開發過程中,對 Person 做了修改,增加了一個欄位 email,如下:
public class Person implements Serializable { private static final long serialVersionUID = 1L; private String name; private Integer age; private String address; private String email; // 構造方法、get、set 方法略 } 複製程式碼
由於這個類和老版本不相容,我們需要修改版本號:
private static final long serialVersionUID = 2L; 複製程式碼
再次進行反序列化,則會丟擲 InvalidClassException
異常。
綜上所述,我們大概可以清楚: serialVersionUID
用於控制序列化版本是否相容 。若我們認為修改的可序列化類是向後相容的,則不修改 serialVersionUID
。
預設序列化機制
如果僅僅只是讓某個類實現 Serializable
介面,而沒有其它任何處理的話,那麼就會使用預設序列化機制。
使用預設機制,在序列化物件時,不僅會序列化當前物件本身,還會對其父類的欄位以及該物件引用的其它物件也進行序列化。同樣地,這些其它物件引用的另外物件也將被序列化,以此類推。所以,如果一個物件包含的成員變數是容器類物件,而這些容器所含有的元素也是容器類物件,那麼這個序列化的過程就會較複雜,開銷也較大。
注意:這裡的父類和引用物件既然要進行序列化,那麼它們當然也要滿足序列化要求: 被序列化的類必須屬於 Enum、Array 和 Serializable 型別其中的任何一種 。
非預設序列化機制
在現實應用中,有些時候不能使用預設序列化機制。比如,希望在序列化過程中忽略掉敏感資料,或者簡化序列化過程。下面將介紹若干影響序列化的方法。
transient 關鍵字
當某個欄位被宣告為 transient 後,預設序列化機制就會忽略該欄位。
我們將 SerializeDemo01 示例中的內部類 Person 的 age 欄位宣告為 transient
,如下所示:
public class SerializeDemo02 { static class Person implements Serializable { transient private Integer age = null; // 其他內容略 } // 其他內容略 } // Output: // name: Jack, age: null, sex: MALE 複製程式碼
從輸出結果可以看出,age 欄位沒有被序列化。
Externalizable 介面
無論是使用 transient
關鍵字,還是使用 writeObject()
和 readObject()
方法,其實都是基於 Serializable
介面的序列化。
JDK 中提供了另一個序列化介面-- Externalizable
。
可序列化類實現 Externalizable 介面之後,基於 Serializable 介面的預設序列化機制就會失效。
我們來基於 SerializeDemo02 再次做一些改動,程式碼如下:
public class ExternalizeDemo01 { static class Person implements Externalizable { transient private Integer age = null; // 其他內容略 private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); out.writeInt(age); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); age = in.readInt(); } @Override public void writeExternal(ObjectOutput out) throws IOException { } @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { } } // 其他內容略 } // Output: // call Person() // name: null, age: null, sex: null 複製程式碼
從該結果,一方面可以看出 Person 物件中任何一個欄位都沒有被序列化。另一方面,如果細心的話,還可以發現這此次序列化過程呼叫了 Person 類的無參構造方法。
-
Externalizable
繼承於Serializable
,它增添了兩個方法:writeExternal()
與readExternal()
。這兩個方法在序列化和反序列化過程中會被自動呼叫,以便執行一些特殊操作 。當使用該介面時,序列化的細節需要由程式設計師去完成。如上所示的程式碼,由於writeExternal()
與readExternal()
方法未作任何處理,那麼該序列化行為將不會儲存/讀取任何一個欄位。這也就是為什麼輸出結果中所有欄位的值均為空。 - 另外, 若使用 Externalizable 進行序列化,當讀取物件時,會呼叫被序列化類的無參構造方法去建立一個新的物件;然後再將被儲存物件的欄位的值分別填充到新物件中 。這就是為什麼在此次序列化過程中 Person 類的無參構造方法會被呼叫。由於這個原因,實現
Externalizable
介面的類必須要提供一個無參的構造方法,且它的訪問許可權為public
。
對上述 Person 類作進一步的修改,使其能夠對 name 與 age 欄位進行序列化,但要忽略掉 gender 欄位,如下程式碼所示:
public class ExternalizeDemo02 { static class Person implements Externalizable { transient private Integer age = null; // 其他內容略 private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); out.writeInt(age); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); age = in.readInt(); } @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeObject(name); out.writeInt(age); } @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { name = (String) in.readObject(); age = in.readInt(); } } // 其他內容略 } // Output: // call Person() // name: Jack, age: 30, sex: null 複製程式碼
Externalizable 介面的替代方法
實現 Externalizable
介面可以控制序列化和反序列化的細節。它有一個替代方法:實現 Serializable
介面,並新增 writeObject(ObjectOutputStream out)
與 readObject(ObjectInputStream in)
方法。序列化和反序列化過程中會自動回撥這兩個方法。
示例如下所示:
public class SerializeDemo03 { static class Person implements Serializable { transient private Integer age = null; // 其他內容略 private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); out.writeInt(age); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); age = in.readInt(); } // 其他內容略 } // 其他內容略 } // Output: // name: Jack, age: 30, sex: MALE 複製程式碼
在 writeObject()
方法中會先呼叫 ObjectOutputStream
中的 defaultWriteObject()
方法,該方法會執行預設的序列化機制,如上節所述,此時會忽略掉 age 欄位。然後再呼叫 writeInt() 方法顯示地將 age 欄位寫入到 ObjectOutputStream
中。readObject() 的作用則是針對物件的讀取,其原理與 writeObject() 方法相同。
注意: writeObject()
與 readObject()
都是 private
方法,那麼它們是如何被呼叫的呢?毫無疑問,是使用反射。詳情可見 ObjectOutputStream
中的 writeSerialData
方法,以及 ObjectInputStream
中的 readSerialData
方法。
readResolve() 方法
當我們使用 Singleton 模式時,應該是期望某個類的例項應該是唯一的,但如果該類是可序列化的,那麼情況可能會略有不同。此時對第 2 節使用的 Person 類進行修改,使其實現 Singleton 模式,如下所示:
public class SerializeDemo04 { enum Sex { MALE, FEMALE } static class Person implements Serializable { private static final long serialVersionUID = 1L; private String name = null; transient private Integer age = null; private Sex sex; static final Person instatnce = new Person("Tom", 31, Sex.MALE); private Person() { System.out.println("call Person()"); } private Person(String name, Integer age, Sex sex) { this.name = name; this.age = age; this.sex = sex; } public static Person getInstance() { return instatnce; } private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); out.writeInt(age); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); age = in.readInt(); } public String toString() { return "name: " + this.name + ", age: " + this.age + ", sex: " + this.sex; } } /** * 序列化 */ private static void serialize(String filename) throws IOException { File f = new File(filename); // 定義儲存路徑 OutputStream out = new FileOutputStream(f); // 檔案輸出流 ObjectOutputStream oos = new ObjectOutputStream(out); // 物件輸出流 oos.writeObject(new Person("Jack", 30, Sex.MALE)); // 儲存物件 oos.close(); out.close(); } /** * 反序列化 */ private static void deserialize(String filename) throws IOException, ClassNotFoundException { File f = new File(filename); // 定義儲存路徑 InputStream in = new FileInputStream(f); // 檔案輸入流 ObjectInputStream ois = new ObjectInputStream(in); // 物件輸入流 Object obj = ois.readObject(); // 讀取物件 ois.close(); in.close(); System.out.println(obj); System.out.println(obj == Person.getInstance()); } public static void main(String[] args) throws IOException, ClassNotFoundException { final String filename = "d:/text.dat"; serialize(filename); deserialize(filename); } } // Output: // name: Jack, age: null, sex: MALE // false 複製程式碼
值得注意的是,從檔案中獲取的 Person 物件與 Person 類中的單例物件並不相等。 為了能在單例類中仍然保持序列的特性,可以使用 readResolve()
方法 。在該方法中直接返回 Person 的單例物件。我們在 SerializeDemo04 示例的基礎上新增一個 readObject
方法, 如下所示:
public class SerializeDemo05 { // 其他內容略 static class Person implements Serializable { // 新增此方法 private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); age = in.readInt(); } // 其他內容略 } // 其他內容略 } // Output: // name: Tom, age: 31, sex: MALE // true 複製程式碼
序列化工具
Java 官方的序列化存在許多問題,因此,很多人更願意使用優秀的第三方序列化工具來替代 Java 自身的序列化機制。
Java 官方的序列化主要體現在以下方面:
- Java 官方的序列化效能不高,序列化後的資料相對於一些優秀的序列化的工具,還是要大不少,這大大影響儲存和傳輸的效率。
- Java 官方的序列化一定需要實現 Serializable 介面。
- Java 官方的序列化需要關注 serialVersionUID。
- Java 官方的序列無法跨語言使用。
當然我們還有更加優秀的一些序列化和反序列化的工具,根據不同的使用場景可以自行選擇!