在前面幾節,我們在將對象保存到文件時,使用的是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都引用了同一個類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();
這一行代碼也是必須的。
序列化的基本原理
稍微總結一下:
但,序列化到底是如何發生的呢?關鍵在ObjectOutputStream的writeObject和ObjectInputStream的readObject方法內。它們的實現都非常復雜,正因為這些復雜的實現才使得序列化看上去很神奇,我們簡單介紹下其基本邏輯。
writeObject的基本邏輯是:
readObject的基本邏輯是:
版本問題
上面的介紹,我們忽略了一個問題,那就是版本問題。我們知道,代碼是在不斷演化的,而序列化的對象可能是持久保存在文件上的,如果類的定義發生了變化,那持久化的對象還能反序列化嗎?
默認情況下,Java會給類定義一個版本號,這個版本號是根據類中一系列的信息自動生成的。在反序列化時,如果類的定義發生了變化,版本號就會變化,與流中的版本號就會不匹配,反序列化就會拋出異常,類型為java.io.InvalidClassException。
通常情況下,我們希望自定義這個版本號,而非讓Java自動生成,一方面是為了更好的控制,另一方面是為了性能,因為Java自動生成的性能比較低,怎麼自定義呢?在類中定義如下變量:
private static final long serialVersionUID = 1L;
在Java IDE如Eclipse中,如果聲明實現了Serializable而沒有定義該變量,IDE會提示自動生成。這個變量的值可以是任意的,代表該類的版本號。在序列化時,會將該值寫入流,在反序列化時,會將流中的值與類定義中的值進行比較,如果不匹配,會拋出InvalidClassException。
那如果版本號一樣,但實際的字段不匹配呢?Java會分情況自動進行處理,以盡量保持兼容性,大概分為三種情況:
高級自定義
除了自定義writeObject/readObject方法,Java中還有如下自定義序列化過程的機制:
這些機制實際用到的比較少,我們簡要說明下。
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可以構成一種所謂的序列化代理模式,這個模式描述在<Effective Java> 第二版78條中,Java容器類中的EnumSet使用了該模式,我們一般用的比較少,就不詳細介紹了。
序列化特點分析
序列化的主要用途有兩個,一個是對象持久化,另一個是跨網絡的數據交換、遠程過程調用。
Java標准的序列化機制有很多優點,使用簡單,可自動處理對象引用和循環引用,也可以方便的進行定制,處理版本問題等,但它也有一些重要的局限性:
由於這些局限性,實踐中往往會使用一些替代方案。在跨語言的數據交換格式中,XML/JSON是被廣泛采用的文本格式,各種語言都有對它們的支持,文件格式清晰易讀,有很多查看和編輯工具,它們的不足之處是性能和序列化大小,在性能和大小敏感的領域,往往會采用更為精簡高效的二進制方式如ProtoBuf, Thrift, MessagePack等。
小結
本節介紹了Java的標准序列化機制,我們介紹了它的用法和基本原理,最後分析了它的特點,它是一種神奇的機制,通過簡單的Serializable接口就能自動處理很多復雜的事情,但它也有一些重要的限制,最重要的是不能跨語言。
在接來下的幾節中,我們來看一些替代方案,包括XML/JSON和MessagePack。
----------------
未完待續,查看最新文章,敬請關注微信公眾號“老馬說編程”(掃描下方二維碼),從入門到高級,深入淺出,老馬和你一起探索Java編程及計算機技術的本質。用心原創,保留所有版權。