序列化/反序列化,我忍你很久了

CodeSheep發表於2020-06-01

本文 Github開源專案:github.com/hansonwang99/JavaCollection 中已收錄,有詳細自學程式設計學習路線、面試題和麵經、程式設計資料及系列技術文章等,資源持續更新中...

工具人

曾幾何時,對於Java的序列化的認知一直停留在:「實現個Serializbale介面」不就好了的狀態,直到 ...

所以這次抽時間再次重新捧起了塵封已久的《Java程式設計思想》,就像之前梳理《列舉部分知識》一樣,把「序列化和反序列化」這塊的知識點又重新審視了一遍。


序列化是幹啥用的?

序列化的原本意圖是希望對一個Java物件作一下“變換”,變成位元組序列,這樣一來方便持久化儲存到磁碟,避免程式執行結束後物件就從記憶體裡消失,另外變換成位元組序列也更便於網路運輸和傳播,所以概念上很好理解:

  • 序列化:把Java物件轉換為位元組序列。
  • 反序列化:把位元組序列恢復為原先的Java物件。

而且序列化機制從某種意義上來說也彌補了平臺化的一些差異,畢竟轉換後的位元組流可以在其他平臺上進行反序列化來恢復物件。

事情就是那麼個事情,看起來很簡單,不過後面的東西還不少,請往下看。


物件如何序列化?

然而Java目前並沒有一個關鍵字可以直接去定義一個所謂的“可持久化”物件。

物件的持久化和反持久化需要靠程式設計師在程式碼裡手動顯式地進行序列化和反序列化還原的動作。

舉個例子,假如我們要對Student類物件序列化到一個名為student.txt的文字檔案中,然後再通過文字檔案反序列化成Student類物件:

1、Student類定義

public class Student implements Serializable {

    private String name;
    private Integer age;
    private Integer score;
    
    @Override
    public String toString() {
        return "Student:" + '\n' +
        "name = " + this.name + '\n' +
        "age = " + this.age + '\n' +
        "score = " + this.score + '\n'
        ;
    }
    
    // ... 其他省略 ...
}

2、序列化

public static void serialize(  ) throws IOException {

    Student student = new Student();
    student.setName("CodeSheep");
    student.setAge( 18 );
    student.setScore( 1000 );

    ObjectOutputStream objectOutputStream = 
        new ObjectOutputStream( new FileOutputStream( new File("student.txt") ) );
    objectOutputStream.writeObject( student );
    objectOutputStream.close();
    
    System.out.println("序列化成功!已經生成student.txt檔案");
    System.out.println("==============================================");
}

3、反序列化

public static void deserialize(  ) throws IOException, ClassNotFoundException {
    ObjectInputStream objectInputStream = 
        new ObjectInputStream( new FileInputStream( new File("student.txt") ) );
    Student student = (Student) objectInputStream.readObject();
    objectInputStream.close();
    
    System.out.println("反序列化結果為:");
    System.out.println( student );
}

4、執行結果

控制檯列印:

序列化成功!已經生成student.txt檔案
==============================================
反序列化結果為:
Student:
name = CodeSheep
age = 18
score = 1000

Serializable介面有何用?

上面在定義Student類時,實現了一個Serializable介面,然而當我們點進Serializable介面內部檢視,發現它竟然是一個空介面,並沒有包含任何方法!

試想,如果上面在定義Student類時忘了加implements Serializable時會發生什麼呢?

實驗結果是:此時的程式執行會報錯,並丟擲NotSerializableException異常:

我們按照錯誤提示,由原始碼一直跟到ObjectOutputStreamwriteObject0()方法底層一看,才恍然大悟:

如果一個物件既不是字串陣列列舉,而且也沒有實現Serializable介面的話,在序列化時就會丟擲NotSerializableException異常!

哦,我明白了!

原來Serializable介面也僅僅只是做一個標記用!!!

它告訴程式碼只要是實現了Serializable介面的類都是可以被序列化的!然而真正的序列化動作不需要靠它完成。


serialVersionUID號有何用?

相信你一定經常看到有些類中定義瞭如下程式碼行,即定義了一個名為serialVersionUID的欄位:

private static final long serialVersionUID = -4392658638228508589L;

你知道這句宣告的含義嗎?為什麼要搞一個名為serialVersionUID的序列號?

繼續來做一個簡單實驗,還拿上面的Student類為例,我們並沒有人為在裡面顯式地宣告一個serialVersionUID欄位。

我們首先還是呼叫上面的serialize()方法,將一個Student物件序列化到本地磁碟上的student.txt檔案:

public static void serialize() throws IOException {

    Student student = new Student();
    student.setName("CodeSheep");
    student.setAge( 18 );
    student.setScore( 100 );

    ObjectOutputStream objectOutputStream = 
        new ObjectOutputStream( new FileOutputStream( new File("student.txt") ) );
    objectOutputStream.writeObject( student );
    objectOutputStream.close();
}

接下來我們在Student類裡面動點手腳,比如在裡面再增加一個名為studentID的欄位,表示學生學號:

這時候,我們拿剛才已經序列化到本地的student.txt檔案,還用如下程式碼進行反序列化,試圖還原出剛才那個Student物件:

public static void deserialize(  ) throws IOException, ClassNotFoundException {
    ObjectInputStream objectInputStream = 
        new ObjectInputStream( new FileInputStream( new File("student.txt") ) );
    Student student = (Student) objectInputStream.readObject();
    objectInputStream.close();
    
    System.out.println("反序列化結果為:");
    System.out.println( student );
}

執行發現報錯了,並且丟擲了InvalidClassException異常:

這地方提示的資訊非常明確了:序列化前後的serialVersionUID號碼不相容!

從這地方最起碼可以得出兩個重要資訊:

  • 1、serialVersionUID是序列化前後的唯一識別符號
  • 2、預設如果沒有人為顯式定義過serialVersionUID,那編譯器會為它自動宣告一個!

第1個問題: serialVersionUID序列化ID,可以看成是序列化和反序列化過程中的“暗號”,在反序列化時,JVM會把位元組流中的序列號ID和被序列化類中的序列號ID做比對,只有兩者一致,才能重新反序列化,否則就會報異常來終止反序列化的過程。

第2個問題: 如果在定義一個可序列化的類時,沒有人為顯式地給它定義一個serialVersionUID的話,則Java執行時環境會根據該類的各方面資訊自動地為它生成一個預設的serialVersionUID,一旦像上面一樣更改了類的結構或者資訊,則類的serialVersionUID也會跟著變化!

所以,為了serialVersionUID的確定性,寫程式碼時還是建議,凡是implements Serializable的類,都最好人為顯式地為它宣告一個serialVersionUID明確值!

當然,如果不想手動賦值,你也可以藉助IDE的自動新增功能,比如我使用的IntelliJ IDEA,按alt + enter就可以為類自動生成和新增serialVersionUID欄位,十分方便:


兩種特殊情況

  • 1、凡是被static修飾的欄位是不會被序列化的
  • 2、凡是被transient修飾符修飾的欄位也是不會被序列化的

對於第一點,因為序列化儲存的是物件的狀態而非類的狀態,所以會忽略static靜態域也是理所應當的。

對於第二點,就需要了解一下transient修飾符的作用了。

如果在序列化某個類的物件時,就是不希望某個欄位被序列化(比如這個欄位存放的是隱私值,如:密碼等),那這時就可以用transient修飾符來修飾該欄位。

比如在之前定義的Student類中,加入一個密碼欄位,但是不希望序列化到txt文字,則可以:

這樣在序列化Student類物件時,password欄位會設定為預設值null,這一點可以從反序列化所得到的結果來看出:


序列化的受控和加強

約束性加持

從上面的過程可以看出,序列化和反序列化的過程其實是有漏洞的,因為從序列化到反序列化是有中間過程的,如果被別人拿到了中間位元組流,然後加以偽造或者篡改,那反序列化出來的物件就會有一定風險了。

畢竟反序列化也相當於一種 “隱式的”物件構造 ,因此我們希望在反序列化時,進行受控的物件反序列化動作。

那怎麼個受控法呢?

答案就是: 自行編寫readObject()函式,用於物件的反序列化構造,從而提供約束性。

既然自行編寫readObject()函式,那就可以做很多可控的事情:比如各種判斷工作。

還以上面的Student類為例,一般來說學生的成績應該在0 ~ 100之間,我們為了防止學生的考試成績在反序列化時被別人篡改成一個奇葩值,我們可以自行編寫readObject()函式用於反序列化的控制:

private void readObject( ObjectInputStream objectInputStream ) throws IOException, ClassNotFoundException {

    // 呼叫預設的反序列化函式
    objectInputStream.defaultReadObject();

    // 手工檢查反序列化後學生成績的有效性,若發現有問題,即終止操作!
    if( 0 > score || 100 < score ) {
        throw new IllegalArgumentException("學生分數只能在0到100之間!");
    }
}

比如我故意將學生的分數改為101,此時反序列化立馬終止並且報錯:

對於上面的程式碼,有些小夥伴可能會好奇,為什麼自定義的privatereadObject()方法可以被自動呼叫,這就需要你跟一下底層原始碼來一探究竟了,我幫你跟到了ObjectStreamClass類的最底層,看到這裡我相信你一定恍然大悟:

又是反射機制在起作用!是的,在Java裡,果然萬物皆可“反射”(滑稽),即使是類中定義的private私有方法,也能被摳出來執行了,簡直引起舒適了。

單例模式增強

一個容易被忽略的問題是:可序列化的單例類有可能並不單例

舉個程式碼小例子就清楚了。

比如這裡我們先用java寫一個常見的「靜態內部類」方式的單例模式實現:

public class Singleton implements Serializable {

    private static final long serialVersionUID = -1576643344804979563L;

    private Singleton() {
    }

    private static class SingletonHolder {
        private static final Singleton singleton = new Singleton();
    }

    public static synchronized Singleton getSingleton() {
        return SingletonHolder.singleton;
    }
}

然後寫一個驗證主函式:

public class Test2 {

    public static void main(String[] args) throws IOException, ClassNotFoundException {

        ObjectOutputStream objectOutputStream =
                new ObjectOutputStream(
                    new FileOutputStream( new File("singleton.txt") )
                );
        // 將單例物件先序列化到文字檔案singleton.txt中
        objectOutputStream.writeObject( Singleton.getSingleton() );
        objectOutputStream.close();

        ObjectInputStream objectInputStream =
                new ObjectInputStream(
                    new FileInputStream( new File("singleton.txt") )
                );
        // 將文字檔案singleton.txt中的物件反序列化為singleton1
        Singleton singleton1 = (Singleton) objectInputStream.readObject();
        objectInputStream.close();

        Singleton singleton2 = Singleton.getSingleton();

        // 執行結果竟列印 false !
        System.out.println( singleton1 == singleton2 );
    }

}

執行後我們發現:反序列化後的單例物件和原單例物件並不相等了,這無疑沒有達到我們的目標。

解決辦法是:在單例類中手寫readResolve()函式,直接返回單例物件,來規避之:

private Object readResolve() {
    return SingletonHolder.singleton;
}

這樣一來,當反序列化從流中讀取物件時,readResolve()會被呼叫,用其中返回的物件替代反序列化新建的物件。


沒想到

本以為這篇會很快寫完,結果又扯出了這麼多東西,不過這樣一梳理、一串聯,感覺還是清晰了不少。

就這樣吧,下篇見。

本文 Github開源專案:github.com/hansonwang99/JavaCollection 中已收錄,有詳細自學程式設計學習路線、面試題和麵經、程式設計資料及系列技術文章等,資源持續更新中...

慢一點,才能更快

相關文章