本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
在前面幾節,我們在將物件儲存到檔案時,使用的是DataOutputStream,從檔案讀入物件時,使用的是DataInputStream, 使用它們,需要逐個處理物件中的每個欄位,我們提到,這種方式比較囉嗦,Java中有一種更為簡單的機制,那就是序列化。
簡單來說,序列化就是將物件轉化為位元組流,反序列化就是將位元組流轉化為物件。在Java中,具體如何來使用呢?它是如何實現的?有什麼優缺點?本節就來探討這些問題,我們先從它的基本用法談起。
基本用法
Serializable
要讓一個類支援序列化,只需要讓這個類實現介面java.io.Serializable,Serializable沒有定義任何方法,只是一個標記介面。比如,對於57節提到的Student類,為支援序列化,可改為:
public class Student implements Serializable {
String name;
int age;
double score;
public Student(String name, int age, double score) {
...
}
...
}
複製程式碼
宣告實現了Serializable介面後,儲存/讀取Student物件就可以使用另兩個流了ObjectOutputStream/ObjectInputStream。
ObjectOutputStream/ObjectInputStream
ObjectOutputStream是OutputStream的子類,但實現了ObjectOutput介面,ObjectOutput是DataOutput的子介面,增加了一個方法:
public void writeObject(Object obj) throws IOException
複製程式碼
這個方法能夠將物件obj轉化為位元組,寫到流中。
ObjectInputStream是InputStream的子類,它實現了ObjectInput介面,ObjectInput是DataInput的子介面,增加了一個方法:
public Object readObject() throws ClassNotFoundException, IOException
複製程式碼
這個方法能夠從流中讀取位元組,轉化為一個物件。
使用這兩個流,57節介紹的儲存學生列表的程式碼就可以變為:
public static void writeStudents(List<Student> students) throws IOException {
ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("students.dat")));
try {
out.writeInt(students.size());
for (Student s : students) {
out.writeObject(s);
}
} finally {
out.close();
}
}
複製程式碼
而從檔案中讀入學生列表的程式碼可以變為:
public static List<Student> readStudents() throws IOException,
ClassNotFoundException {
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(
new FileInputStream("students.dat")));
try {
int size = in.readInt();
List<Student> list = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
list.add((Student) in.readObject());
}
return list;
} finally {
in.close();
}
}
複製程式碼
實際上,只要List物件也實現了Serializable (ArrayList/LinkedList都實現了),上面程式碼還可以進一步簡化,讀寫只需要一行程式碼,如下所示:
public static void writeStudents(List<Student> students) throws IOException {
ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("students.dat")));
try {
out.writeObject(students);
} finally {
out.close();
}
}
public static List<Student> readStudents() throws IOException,
ClassNotFoundException {
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(
new FileInputStream("students.dat")));
try {
return (List<Student>) in.readObject();
} finally {
in.close();
}
}
複製程式碼
是不是很神奇?只要將類宣告實現Serializable介面,然後就可以使用ObjectOutputStream/ObjectInputStream直接讀寫物件了。我們之前介紹的各種類,如String, Date, Double, ArrayList, LinkedList, HashMap, TreeMap等,都實現了Serializable。
複雜物件
上面例子中的Student物件是非常簡單的,如果物件比較複雜呢?比如:
- 如果a, b兩個物件都引用同一個物件c,序列化後c是儲存兩份還是一份?在反序列化後還能讓a, b指向同一個物件嗎?
- 如果a, b兩個物件有迴圈引用呢?即a引用了b,而b也引用了a。
我們分別來看下。
引用同一個物件
我們看個簡單的例子,類A和類B都引用了同一個類Common,它們都實現了Serializable,這三個類的定義如下:
class Common implements Serializable {
String c;
public Common(String c) {
this.c = c;
}
}
class A implements Serializable {
String a;
Common common;
public A(String a, Common common) {
this.a = a;
this.common = common;
}
public Common getCommon() {
return common;
}
}
class B implements Serializable {
String b;
Common common;
public B(String b, Common common) {
this.b = b;
this.common = common;
}
public Common getCommon() {
return common;
}
}
複製程式碼
有三個物件, a, b, c,如下所示:
Common c = new Common("common");
A a = new A("a", c);
B b = new B("b", c);
複製程式碼
a和b引用同一個物件c,如果序列化這兩個物件,反序列化後,它們還能指向同一個物件嗎?答案是肯定的,我們看個實驗。
ByteArrayOutputStream bout = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bout);
out.writeObject(a);
out.writeObject(b);
out.close();
ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(bout.toByteArray()));
A a2 = (A) in.readObject();
B b2 = (B) in.readObject();
if (a2.getCommon() == b2.getCommon()) {
System.out.println("reference the same object");
} else {
System.out.println("reference different objects");
}
複製程式碼
輸出為:
reference the same object
複製程式碼
這也是Java序列化機制的神奇之處,它能自動處理這種引用同一個物件的情況。更神奇的是,它還能自動處理迴圈引用的情況,我們來看下。
迴圈引用
我們看個例子,有Parent和Child兩個類,它們相互引用,類定義如下:
class Parent implements Serializable {
String name;
Child child;
public Parent(String name) {
this.name = name;
}
public Child getChild() {
return child;
}
public void setChild(Child child) {
this.child = child;
}
}
class Child implements Serializable {
String name;
Parent parent;
public Child(String name) {
this.name = name;
}
public Parent getParent() {
return parent;
}
public void setParent(Parent parent) {
this.parent = parent;
}
}
複製程式碼
定義兩個物件:
Parent parent = new Parent("老馬");
Child child = new Child("小馬");
parent.setChild(child);
child.setParent(parent);
複製程式碼
序列化parent, child兩個物件,Java能正確序列化嗎?反序列化後,還能保持原來的引用關係嗎?答案是肯定的,我們看程式碼實驗:
ByteArrayOutputStream bout = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bout);
out.writeObject(parent);
out.writeObject(child);
out.close();
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(
bout.toByteArray()));
parent = (Parent) in.readObject();
child = (Child) in.readObject();
if (parent.getChild() == child && child.getParent() == parent
&& parent.getChild().getParent() == parent
&& child.getParent().getChild() == child) {
System.out.println("reference OK");
} else {
System.out.println("wrong reference");
}
複製程式碼
輸出為:
reference OK
複製程式碼
神奇吧?
定製序列化
預設的序列化機制已經很強大了,它可以自動將物件中的所有欄位自動儲存和恢復,但這種預設行為有時候不是我們想要的。
比如,對於有些欄位,它的值可能與記憶體位置有關,比如預設的hashCode()方法的返回值,當恢復物件後,記憶體位置肯定變了,基於原記憶體位置的值也就沒有了意義。還有一些欄位,可能與當前時間有關,比如表示物件建立時的時間,儲存和恢復這個欄位就是不正確的。
還有一些情況,如果類中的欄位表示的是類的實現細節,而非邏輯資訊,那預設序列化也是不適合的。為什麼不適合呢?因為序列化格式表示一種契約,應該描述類的邏輯結構,而非與實現細節相繫結,繫結實現細節將使得難以修改,破壞封裝。
比如,我們在容器類中介紹的LinkedList,它的預設序列化就是不適合的,為什麼呢?因為LinkedList表示一個List,它的邏輯資訊是列表的長度,以及列表中的每個物件,但LinkedList類中的欄位表示的是連結串列的實現細節,如頭尾節點指標,對每個節點,還有前驅和後繼節點指標等。
那怎麼辦呢?Java提供了多種定製序列化的機制,主要的有兩種,一種是transient關鍵字,另外一種是實現writeObject和readObject方法。
將欄位宣告為transient,預設序列化機制將忽略該欄位,不會進行儲存和恢復。比如,類LinkedList中,它的欄位都宣告為了transient,如下所示:
transient int size = 0;
transient Node<E> first;
transient Node<E> last;
複製程式碼
宣告為了transient,不是說就不儲存該欄位了,而是告訴Java預設序列化機制,不要自動儲存該欄位了,可以實現writeObject/readObject方法來自己儲存該欄位。
類可以實現writeObject方法,以自定義該類物件的序列化過程,其宣告必須為:
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException
複製程式碼
可以在這個方法中,呼叫ObjectOutputStream的方法向流中寫入物件的資料。比如,LinkedList使用如下程式碼序列化列表的邏輯資料:
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// Write out any hidden serialization magic
s.defaultWriteObject();
// Write out size
s.writeInt(size);
// Write out all elements in the proper order.
for (Node<E> x = first; x != null; x = x.next)
s.writeObject(x.item);
}
複製程式碼
需要注意的是第一行程式碼:
s.defaultWriteObject();
複製程式碼
這一行是必須的,它會呼叫預設的序列化機制,預設機制會儲存所有沒宣告為transient的欄位,即使類中的所有欄位都是transient,也應該寫這一行,因為Java的序列化機制不僅會儲存純粹的資料資訊,還會儲存一些後設資料描述等隱藏資訊,這些隱藏的資訊是序列化之所以能夠神奇的重要原因。
與writeObject對應的是readObject方法,通過它自定義反序列化過程,其宣告必須為:
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException
複製程式碼
在這個方法中,呼叫ObjectInputStream的方法從流中讀入資料,然後初始化類中的成員變數。比如,LinkedList的反序列化程式碼為:
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// Read in any hidden serialization magic
s.defaultReadObject();
// Read in size
int size = s.readInt();
// Read in all elements in the proper order.
for (int i = 0; i < size; i++)
linkLast((E)s.readObject());
}
複製程式碼
注意第一行程式碼:
s.defaultReadObject();
複製程式碼
這一行程式碼也是必須的。
序列化的基本原理
稍微總結一下:
- 如果類的欄位表示的就是類的邏輯資訊,如上面的Student類,那就可以使用預設序列化機制,只要宣告實現Serializable介面即可。
- 否則的話,如LinkedList,那就可以使用transient關鍵字,實現writeObject和readObject來自定義序列化過程。
- Java的序列化機制可以自動處理如引用同一個物件、迴圈引用等情況。
但,序列化到底是如何發生的呢?關鍵在ObjectOutputStream的writeObject和ObjectInputStream的readObject方法內。它們的實現都非常複雜,正因為這些複雜的實現才使得序列化看上去很神奇,我們簡單介紹下其基本邏輯。
writeObject的基本邏輯是:
- 如果物件沒有實現Serializable,丟擲異常NotSerializableException。
- 每個物件都有一個編號,如果之前已經寫過該物件了,則本次只會寫該物件的引用,這可以解決物件引用和迴圈引用的問題。
- 如果物件實現了writeObject方法,呼叫它的自定義方法。
- 預設是利用反射機制(反射我們留待後續文章介紹),遍歷物件結構圖,對每個沒有標記為transient的欄位,根據其型別,分別進行處理,寫出到流,流中的資訊包括欄位的型別即完整類名、欄位名、欄位值等。
readObject的基本邏輯是:
- 不呼叫任何構造方法。
- 它自己就相當於是一個獨立的構造方法,根據位元組流初始化物件,利用的也是反射機制。
- 在解析位元組流時,對於引用到的型別資訊,會動態載入,如果找不到類,會丟擲ClassNotFoundException。
版本問題
上面的介紹,我們忽略了一個問題,那就是版本問題。我們知道,程式碼是在不斷演化的,而序列化的物件可能是持久儲存在檔案上的,如果類的定義發生了變化,那持久化的物件還能反序列化嗎?
預設情況下,Java會給類定義一個版本號,這個版本號是根據類中一系列的資訊自動生成的。在反序列化時,如果類的定義發生了變化,版本號就會變化,與流中的版本號就會不匹配,反序列化就會丟擲異常,型別為java.io.InvalidClassException。
通常情況下,我們希望自定義這個版本號,而非讓Java自動生成,一方面是為了更好的控制,另一方面是為了效能,因為Java自動生成的效能比較低,怎麼自定義呢?在類中定義如下變數:
private static final long serialVersionUID = 1L;
複製程式碼
在Java IDE如Eclipse中,如果宣告實現了Serializable而沒有定義該變數,IDE會提示自動生成。這個變數的值可以是任意的,代表該類的版本號。在序列化時,會將該值寫入流,在反序列化時,會將流中的值與類定義中的值進行比較,如果不匹配,會丟擲InvalidClassException。
那如果版本號一樣,但實際的欄位不匹配呢?Java會分情況自動進行處理,以儘量保持相容性,大概分為三種情況:
- 欄位刪掉了:即流中有該欄位,而類定義中沒有,該欄位會被忽略。
- 新增了欄位:即類定義中有,而流中沒有,該欄位會被設為預設值。
- 欄位型別變了:對於同名的欄位,型別變了,會丟擲InvalidClassException。
高階自定義
除了自定義writeObject/readObject方法,Java中還有如下自定義序列化過程的機制:
- Externalizable介面
- readResolve方法
- writeReplace方法
這些機制實際用到的比較少,我們簡要說明下。
Externalizable是Serializable的子介面,定義瞭如下方法:
void writeExternal(ObjectOutput out) throws IOException
void readExternal(ObjectInput in) throws IOException, ClassNotFoundException
複製程式碼
與writeObject/readObject的區別是,如果物件實現了Externalizable介面,則序列化過程會由這兩個方法控制,預設序列化機制中的反射等將不再起作用,不再有類似defaultWriteObject和defaultReadObject呼叫,另一個區別是,反序列化時,會先呼叫類的無參構造方法建立物件,然後才呼叫readExternal。預設的序列化機制由於需要分析物件結構,往往比較慢,通過實現Externalizable介面,可以提高效能。
readResolve方法返回一個物件,宣告為:
Object readResolve()
複製程式碼
如果定義了該方法,在反序列化之後,會額外呼叫該方法,該方法的返回值才會被當做真正的反序列化的結果。這個方法通常用於反序列化單例物件的場景。
writeReplace也是返回一個物件,宣告為:
Object writeReplace()
複製程式碼
如果定義了該方法,在序列化時,會先呼叫該方法,該方法的返回值才會被當做真正的物件進行序列化。
writeReplace和readResolve可以構成一種所謂的序列化代理模式,這個模式描述在 第二版78條中,Java容器類中的EnumSet使用了該模式,我們一般用的比較少,就不詳細介紹了。
序列化特點分析
序列化的主要用途有兩個,一個是物件持久化,另一個是跨網路的資料交換、遠端過程呼叫。
Java標準的序列化機制有很多優點,使用簡單,可自動處理物件引用和迴圈引用,也可以方便的進行定製,處理版本問題等,但它也有一些重要的侷限性:
- Java序列化格式是一種私有格式,是一種Java語言特有的技術,不能被其他語言識別,不能實現跨語言的資料交換。
- Java在序列化位元組中儲存了很多描述資訊,使得序列化格式比較大。
- Java的預設序列化使用反射分析遍歷物件結構,效能比較低。
- Java的序列化格式是二進位制的,不方便檢視和修改。
由於這些侷限性,實踐中往往會使用一些替代方案。在跨語言的資料交換格式中,XML/JSON是被廣泛採用的文字格式,各種語言都有對它們的支援,檔案格式清晰易讀,有很多檢視和編輯工具,它們的不足之處是效能和序列化大小,在效能和大小敏感的領域,往往會採用更為精簡高效的二進位制方式如ProtoBuf, Thrift, MessagePack等。
小結
本節介紹了Java的標準序列化機制,我們介紹了它的用法和基本原理,最後分析了它的特點,它是一種神奇的機制,通過簡單的Serializable介面就能自動處理很多複雜的事情,但它也有一些重要的限制,最重要的是不能跨語言。
在接來下的幾節中,我們來看一些替代方案,包括XML/JSON和MessagePack。
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。