Java I/O系統學習系列五:Java序列化機制
在Java的世界裡,建立好物件之後,只要需要,物件是可以長駐記憶體,但是在程式終止時,所有物件還是會被銷燬。這其實很合理,但是即使合理也不一定能滿足所有場景,仍然存在著一些情況,需要能夠在程式不執行的情況下保持物件,所以序列化機制應運而生。
1. 為什麼要有序列化
簡單來說序列化的作用就是將記憶體中的物件儲存起來,在需要時可以重建該物件,並且重建後的物件擁有與儲存之前的物件所擁有的資訊相同。在實際應用中,物件序列化常會用在如下場景:
- RPC框架的資料傳輸;
- 物件狀態的持久化儲存;
也許你會覺得,要達到這種持久化的效果,我們直接將資訊寫入檔案或資料庫也可以實現啊,為什麼還要序列化?這是一個好問題,試想如果我們採用前面所述的方法,在序列化物件和反序列化恢復物件時,我們必須考慮如何完整的儲存和恢復物件的資訊,這裡面會涉及到很多繁瑣的細節,稍加不注意就可能導致資訊的丟失。如果能夠有一種機制,只要將一個物件宣告為是“永續性”的,就能夠為我們處理掉所有細節,這樣豈不是很方便,這就是序列化要做的事情。Java已經將序列化的概念加入到語言中,本文的關於序列化的所有例子都是基於Java的。
Java提供的原生序列化機制功能強大,有其自己的一些特點:
- 序列化處理非常簡單,只需將物件實現Serializable介面即可;
- 能夠自動彌補不同作業系統之間的差異,即可以在執行Windows系統的計算機上建立一個物件,將其序列化,然後通過網路將它傳送給一臺執行Unix系統的計算機,然後在那裡準確地重新組裝,不必擔心資料在新的機器上表示會不同;
- 物件序列化不僅會儲存物件的“全景圖”,而且能夠追蹤物件內所包含的所有引用,並儲存那些物件;接著又能對物件內包含的每個這樣的引用進行追蹤,依此類推;
- 物件序列化儲存的是物件的”狀態”,即它的成員變數,所以物件序列化並不會處理類中的靜態變數;
2. 序列化機制的使用
Java的物件序列化機制是將那些實現了Serializable介面的物件轉換成一個位元組序列,並能夠在以後將這個位元組序列完全恢復為原來的物件。
要序列化一個物件,首先要建立一個OutputStream物件,然後將其封裝在一個ObjectOutputStream物件內,接著只需呼叫writeObject()方法即可將物件序列化,並將序列化後的位元組序列傳送給OutputStream。要將一個序列還原為一個物件,則需要將一個InputStream封裝在ObjectInputStream內,然後呼叫readObject(),該方法會返回一個引用,它指向一個向上轉型的Object,必須向下轉型才能直接使用。
我們來看一個例子,如何序列化和反序列化物件。
public class Worm implements Serializable{ private static Random rand = new Random(47); private Data[] d = { new Data(rand.nextInt(10)), new Data(rand.nextInt(10)), new Data(rand.nextInt(10)) }; private Worm next; private char c; public Worm(int i, char x){ System.out.println("Worm constructor: " + i); c = x; if(--i > 0){ next = new Worm(i,(char)(x + 1)); } } public Worm(){ System.out.println("Default constructor"); } public String toString(){ StringBuilder result = new StringBuilder(":"); result.append(c); result.append("("); for(Data dat : d){ result.append(dat); } result.append(")"); if(next != null){ result.append(next); } return result.toString(); } public static void main(String[] args) throws ClassNotFoundException, IOException{ Worm w = new Worm(6,'a'); System.out.println("w = " + w); ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("worm.out")); out.writeObject("Worm storage\n"); out.writeObject(w); out.close(); ObjectInputStream in = new ObjectInputStream(new FileInputStream("worm.out")); String s = (String)in.readObject(); Worm w2 = (Worm)in.readObject(); System.out.println(s + "w2 = " + w2); ByteArrayOutputStream bout = new ByteArrayOutputStream(); ObjectOutputStream out2 = new ObjectOutputStream(bout); out2.writeObject("Worm storage\n"); out2.writeObject(w); out2.flush(); ObjectInputStream in2 = new ObjectInputStream(new ByteArrayInputStream(bout.toByteArray())); s = (String)in2.readObject(); Worm w3 = (Worm)in2.readObject(); System.out.println(s + "w3 = " + w3); } } class Data implements Serializable{ private int n; public Data(int n){this.n = n;} public String toString(){return Integer.toString(n);} }
輸出結果如下:
Worm constructor: 6 Worm constructor: 5 Worm constructor: 4 Worm constructor: 3 Worm constructor: 2 Worm constructor: 1 w = :a(853):b(119):c(802):d(788):e(199):f(881) Worm storage w2 = :a(853):b(119):c(802):d(788):e(199):f(881) Worm storage w3 = :a(853):b(119):c(802):d(788):e(199):f(881)
這段程式碼通過對連結的物件生成一個worm(蠕蟲)對序列化機制進行測試,每個物件都與worm中的下一段連結,同時又與屬於不同類(Data)的物件引用陣列連結。
物件序列化不僅儲存了物件的“全景圖”,而且能追蹤物件內所包含的所有引用,並儲存那些物件;還能對物件內包含的每個這樣的引用進行追蹤;依此類推。
而且從上面的輸出結果還可以看出一個Serializable物件進行還原的過程中,沒有呼叫任何構造器,包括預設的構造器。整個物件都是通過從InputStream中取得資料恢復而來的。
3. 序列化需要什麼
前面我們有說到序列化的目的之一是支援rpc框架的資料傳輸,比如我們將一個物件序列化,並通過網路將其傳給另一臺計算機,另一臺計算機通過反序列化來還原這個物件,那是否只需要該序列化檔案就能還原物件呢?我們用下面的程式碼來測試一下。
public class Serialize implements Serializable{} } public class FreezeSerialize { public static void main(String[] args) throws Exception{ ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("X.file")); Serialize serialize = new Serialize(); os.writeObject(alien); } } public class ThawSerialize { public static void main(String[] args) throws Exception{ ObjectInputStream in = new ObjectInputStream(new FileInputStream("X.file")); Object mystery = in.readObject(); System.out.println(mystery.getClass()); } }
FreezeSerialize類用來把物件序列化到檔案中,ThawSerialize類用來反序列化物件,在測試電腦上如果同時執行這兩個類是沒有問題的,但如果我們將Serialize類的位置更改一下(或者直接將FreezeSerialize和Serialize刪掉),則執行ThawSerialize反序列化時會報錯誤--ClassNotFoundException,所以可以知道,反序列化時是需要原物件的Class物件的。
既然反序列化時需要對應的Class物件,那如果序列化時和反序列化時對應的Class版本不一樣會怎麼樣呢(這種情況是存在的)?為了模擬這種情況,我們先執行FreezeSerialize類的main方法,再給Serialize類新增一個屬性,這時再跑一下ThawSerialize類的main方法,可以發現報java.io.InvalidClassException異常,明明是能通過編譯的但是卻報錯了,這種情況有沒有什麼辦法解決呢?有的,我們可以給實現了Serializable介面的類新增一個long型別的成員:serialVersionUID,修飾符為private static final,並且指定一個隨機數即可。
這個serialVersionUID其實叫序列化版本號,如果不指定的話,編譯器會在編譯後的class檔案中預設新增一個,其值是根據當前類結構生成。但是這樣會帶來一個問題,如果類的結構發生了改變,那編譯之後對應的版本號也會發生改變,而虛擬機器是否允許反序列化,不僅取決於類路徑和功能程式碼是否一致,還有一個非常重要的一點是兩個類的序列化ID是否一致,如果不一致則不允許序列化並且會丟擲InvalidClassException異常,這就是前面不新增序列號時更改類結構再反序列化時會報錯的原因。所以建議給實現了Serializable介面的類新增一個序列化版本號serialVersionUID,並指定值。
關於序列化版本號還有一個點需要主意,版本號一致的情況下,若待反序列化的物件與當前類現有結構不一致時,則採用相容模式,即:該物件的屬性現有類有的則還原,沒有的則忽略。
4. 序列化控制
上面我們我們使用的是Java提供的預設序列化機制,即將物件成員全部序列化。但是,如果有特殊的需要呢?比如,只希望物件的某一部分被序列化。在這種特殊情況下,可以通過若干種方法來實現想要的效果,下面一一介紹。
4.1 實現Externalizable介面
通過實現Externalizable介面(代替實現Serializable),可以對序列化過程進行控制。這個Externalizable介面繼承了Serializable介面,同時增加了兩個方法:writeExternal()和readExternal()。這兩個方法會分別在序列化和反序列化還原的過程中被自動呼叫,這樣就可以在這兩個方法種指定執行一些特殊操作。下面來看一個簡單例子:
public class Blip implements Externalizable{ private int i; private String s; public Blip(){ System.out.println("Blip Constructor"); } public Blip(String x, int a){ System.out.println("Blip(String x, int a)"); s = x; i = a; } public String toString(){ return s + i; } public void writeExternal(ObjectOutput out) throws IOException{ System.out.println("Blip.writeExternal"); out.writeObject(s); out.writeInt(i); } public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException{ System.out.println("Blip.readExternal"); s = (String)in.readObject(); i = in.readInt(); } public static void main(String[] args) throws IOException, ClassNotFoundException{ System.out.println("Constructing objects:"); Blip b = new Blip("A String ",47); System.out.println(b); // 序列化物件 ObjectOutputStream o = new ObjectOutputStream(new FileOutputStream("Blip.out")); System.out.println("Saving object:"); o.writeObject(b); o.close(); // 還原物件 ObjectInputStream in = new ObjectInputStream(new FileInputStream("Blip.out")); System.out.println("Recovering b:"); b = (Blip)in.readObject(); System.out.println(b); } }
在這個例子中,物件繼承了Externalizable介面,成員s和i只在第二個構造器中初始化(而不是預設構造器),我們在writeExternal()方法中對要序列化儲存的成員執行寫入操作,在readExternal()方法中將其恢復。輸出結果如下:
Constructing objects: Blip(String x, int a) A String 47 Saving object: Blip.writeExternal Recovering b: Blip Constructor Blip.readExternal A String 47
這裡需要注意的幾個點:
- 物件實現了Externalizable之後,沒有任何成員可以自動序列化,需要在writeExternal()內部只對所需部分進行顯式的序列化,並且在readExternal()方法中將其恢復。
- 在將實現了Externalizable介面的物件進行反序列化操作時,會呼叫其預設建構函式,如果沒有,則會報錯java.io.InvalidClassException。所以如果物件實現了Externalizable介面,則還需要檢查其是否有預設建構函式。
4.2 transient(瞬時)關鍵字
在我們對序列化進行控制時,可能會碰到某個特定子物件不想讓Java的序列化機制自動儲存與恢復。比如一些敏感資訊(密碼),即使物件中的這些成員是由private修飾,一經序列化處理,通過讀取檔案或者網路抓包的方式還是能訪問到它。
前面說的通過實現Externalizable介面可以解決這個問題,但是假如物件有很多的成員,而我們只希望其中少量成員不被序列化,那通過實現Externalizable介面的方式就不合適了(因為需要在writeExternal()方法中做大量工作),這種情況下,transient關鍵就可以大顯身手了。在實現了Serializable介面的類中,被transient關鍵字修飾的成員是不會被序列化的。而且,由於Externalizable物件在預設情況下不會序列化物件的任何欄位,transient關鍵字只能和Serializable物件一起使用。
4.3 Externalizable的替代方法
除了上面兩種方法,還有一種相對不那麼“正規”的辦法--我們可以實現Serializable介面,並新增名為writeObject()和readObject()的方法。當物件被序列化或者被反序列化還原時,就會自動地分別呼叫這兩個方法(只要我們提供了這兩個方法,就會使用它們而不是預設的序列化機制)。但是需要注意的是這兩個方法必須有準確的方法特徵簽名:
private void writeObject(ObjectOutputStream stream) throws IOException; private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException;
在呼叫ObjectOutputStream.writeObject()時,會檢查所傳遞的Serializable物件,看看是否實現了它自己的writeObject()。如果是,就跳過正常的序列化過程並呼叫物件自己的writeObject()方法。readObject()的情況是類似的。這就是這種方式的原理。
還有一個技巧,在我們提供的writeObject()內部,可以呼叫defaultWriteObject()來選擇執行預設的writeObject()。類似,在readObject()內部,我們可以呼叫defaultReadObject()。下面看一個例子,如何對一個Serializable物件的序列化與恢復進行控制:
public class SerialCtl implements Serializable{ private String noTran; private transient String tran; public SerialCtl(String noTran, String tran){ this.noTran = "Not Transient: " + noTran; this.tran = "Transient: " + tran; } public String toString(){ return noTran + "\n" + tran; } private void writeObject(ObjectOutputStream stream) throws IOException { stream.defaultWriteObject(); stream.writeObject(tran); } private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException{ stream.defaultReadObject(); tran = (String)stream.readObject(); } public static void main(String[] args) throws IOException, ClassNotFoundException{ SerialCtl sc = new SerialCtl("papaya","mango"); System.out.println("Before:\n" + sc); ByteArrayOutputStream buf = new ByteArrayOutputStream(); ObjectOutputStream o = new ObjectOutputStream(buf); o.writeObject(sc); // 還原 ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(buf.toByteArray())); SerialCtl sc2 = (SerialCtl)in.readObject(); System.out.println("After:\n" + sc2); } }
輸出結果:
Before: Not Transient: papaya Transient: mango After: Not Transient: papaya Transient: mango
在這個例子中,有一個String欄位是普通欄位,而另一個是transient欄位,對比證明非transient欄位由defaultWriteObject()方法儲存,而transient欄位必須在程式中明確儲存和恢復。
在writeObject()內部第一行呼叫defaultWriteObject()方法是為了利用預設序列化機制序列化物件的非transient成員,同樣,在readObject()內部第一行呼叫defaultReadObject()方法是為了利用預設機制恢復非transient成員。注意,必須是第一行呼叫。
5. 再深入一點
使用序列化的一個主要目的是儲存程式的一些狀態,以便我們後面可以容易地將程式恢復到當前狀態。在這樣做之前,我們先考慮幾種情況。如果我們將兩個物件(它們都包含有有指向第三個物件的引用成員)進行序列化,會發生什麼情況?當我們從它們的序列化檔案中恢復這兩個物件時,第三個物件會只出現一次嗎?如果將這兩個物件序列化成獨立的檔案,然後在程式碼的不同部分對它們進行反序列化還原,又會怎樣呢?先看例子:
public class MyWorld { public static void main(String[] args) throws IOException, ClassNotFoundException{ House house = new House(); List<Animal> animals = new ArrayList<Animal>(); animals.add(new Animal("Bosco the dog", house)); animals.add(new Animal("Ralph the hamster", house)); animals.add(new Animal("Molly the cat",house)); System.out.println("animals: " + animals); ByteArrayOutputStream buf1 = new ByteArrayOutputStream(); ObjectOutputStream o1 = new ObjectOutputStream(buf1); o1.writeObject(animals); o1.writeObject(animals); // 寫入到另一個流中: ByteArrayOutputStream buf2 = new ByteArrayOutputStream(); ObjectOutputStream o2 = new ObjectOutputStream(buf2); o2.writeObject(animals); // 反序列化: ObjectInputStream in1 = new ObjectInputStream(new ByteArrayInputStream(buf1.toByteArray())); ObjectInputStream in2 = new ObjectInputStream(new ByteArrayInputStream(buf2.toByteArray())); List animals1 = (List)in1.readObject(),animals2 = (List)in1.readObject(),animals3 = (List)in2.readObject(); System.out.println("animals1: " + animals1); System.out.println("animals2: " + animals2); System.out.println("animals3: " + animals3); } } class House implements Serializable{} class Animal implements Serializable{ private String name; private House preferredHouse; Animal(String nm, House h){ name = nm; preferredHouse = h; } public String toString(){ return name + "[" + super.toString() + "]. " + preferredHouse + "\n"; } }
輸出結果:
animals: [Bosco the dog[testDemos.Animal@7852e922]. testDemos.House@4e25154f , Ralph the hamster[testDemos.Animal@70dea4e]. testDemos.House@4e25154f , Molly the cat[testDemos.Animal@5c647e05]. testDemos.House@4e25154f ] animals1: [Bosco the dog[testDemos.Animal@2d98a335]. testDemos.House@16b98e56 , Ralph the hamster[testDemos.Animal@7ef20235]. testDemos.House@16b98e56 , Molly the cat[testDemos.Animal@27d6c5e0]. testDemos.House@16b98e56 ] animals2: [Bosco the dog[testDemos.Animal@2d98a335]. testDemos.House@16b98e56 , Ralph the hamster[testDemos.Animal@7ef20235]. testDemos.House@16b98e56 , Molly the cat[testDemos.Animal@27d6c5e0]. testDemos.House@16b98e56 ] animals3: [Bosco the dog[testDemos.Animal@4f3f5b24]. testDemos.House@15aeb7ab , Ralph the hamster[testDemos.Animal@7b23ec81]. testDemos.House@15aeb7ab , Molly the cat[testDemos.Animal@6acbcfc0]. testDemos.House@15aeb7ab ]
這裡我們通過一個位元組陣列來使用物件序列化,這樣可以實現對任何可Serializable物件的“深度複製”(deep copy)--深度複製意味著複製的是整個物件網,而不僅僅是基本物件及其引用。
在這個例子中,我們從列印的結果可以看出,只要將任何物件序列化到單一流中,就可以恢復出與我們寫入時一樣的物件網,並且不會有任何意外重複複製出的物件,對比animals1和animals2中的House。
另一方面,在恢復animals3時,輸出的House與animals1和animals2是不同的,這說明了如果將物件序列化到不同的檔案中,然後在程式碼的不同部分對它們進行反序列化還原,這時會產生出兩個物件。
6. 總結
序列化的出現給儲存程式執行狀態提供了一種新的途徑,實際主要使用在RPC框架的資料傳輸以及物件狀態的持久化儲存等場景。
- 要將物件進行序列化處理,只需要實現Serializable介面,然後通過ObjectOutputStream的writeObject()方法即可完成物件的序列化;
- 在某個類實現了Serializable介面之後,為了保證能夠成功反序列化,通常建議再新增一個序列化版本號serialVersionUID,並指定值;
- 實現Serializable介面只能使用Java提供的預設序列化機制(即將物件所有部分序列化),若想自定義序列化過程,有如下三種方式:
- 實現Externalizable介面,並實現writeExternal()和readExternal()方法;
- 用transient修飾不希望被序列化的成員;
- 在類中新增名為writeObject()和readObject()的方法,在其中指定自己的邏輯;
- 實現Externalizable介面之後,沒有任何成員可以自動序列化,需要在writeExternal()內部只對所需部分進行顯式的序列化,並且在readExternal()方法中將其恢復;
- 在對實現了Serializable介面的類進行反序列化的過程中不會呼叫任何建構函式,而對實現了Externalizable介面的類進行反序列化時會呼叫其預設建構函式,如果沒有預設建構函式,則會報java.io.InvalidClassException錯誤;