1. 程式人生 > >Java I/O系統學習系列五:Java序列化機制

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

  這裡需要注意的幾個點:

  1. 物件實現了Externalizable之後,沒有任何成員可以自動序列化,需要在writeExternal()內部只對所需部分進行顯式的序列化,並且在readExternal()方法中將其恢復。
  2. 在將實現了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提供的預設序列化機制(即將物件所有部分序列化),若想自定義序列化過程,有如下三種方式:
  1. 實現Externalizable介面,並實現writeExternal()和readExternal()方法;
  2. 用transient修飾不希望被序列化的成員;
  3. 在類中新增名為writeObject()和readObject()的方法,在其中指定自己的邏輯;
  • 實現Externalizable介面之後,沒有任何成員可以自動序列化,需要在writeExternal()內部只對所需部分進行顯式的序列化,並且在readExternal()方法中將其恢復;
  • 在對實現了Serializable介面的類進行反序列化的過程中不會呼叫任何建構函式,而對實現了Externalizable介面的類進行反序列化時會呼叫其預設建構函式,如果沒有預設建構函式,則會報java.io.InvalidClassException錯誤;