一文帶你全面瞭解java物件的序列化和反序列化

華為雲開發者社群發表於2021-05-10
摘要:這篇文章主要給大家介紹了關於java中物件的序列化與反序列化的相關內容,文中通過詳細示例程式碼介紹,希望能對大家有所幫助。

本文分享自華為雲社群《java中什麼是序列化和反序列化?》,原文作者:dayu_dls 。

這篇文章主要給大家介紹了關於java中物件的序列化與反序列化的相關內容,文中通過詳細示例程式碼介紹,希望能對大家有所幫助。

1、序列化是幹啥用的?

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

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

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

2、物件序列化的方式?

在Java中,如果一個物件要想實現序列化,必須要實現下面兩個介面之一:

  • Serializable 介面
  • Externalizable 介面

那這兩個介面是如何工作的呢?兩者又有什麼關係呢?我們分別進行介紹。

2.1 Serializable 介面

一個物件想要被序列化,那麼它的類就要實現此介面或者它的子介面。

這個物件的所有屬性(包括private屬性、包括其引用的物件)都可以被序列化和反序列化來儲存、傳遞。不想序列化的欄位可以使用transient修飾。

由於Serializable物件完全以它儲存的二進位制位為基礎來構造,因此並不會呼叫任何建構函式,因此Serializable類無需預設建構函式,但是當Serializable類的父類沒有實現Serializable介面時,反序列化過程會呼叫父類的預設建構函式,因此該父類必需有預設建構函式,否則會拋異常。

使用transient關鍵字阻止序列化雖然簡單方便,但被它修飾的屬性被完全隔離在序列化機制之外,導致了在反序列化時無法獲取該屬性的值,而通過在需要序列化的物件的Java類里加入writeObject()方法與readObject()方法可以控制如何序列化各屬性,甚至完全不序列化某些屬性或者加密序列化某些屬性。

2.2 Externalizable 介面

它是Serializable介面的子類,使用者要實現的writeExternal()和readExternal() 方法,用來決定如何序列化和反序列化。

因為序列化和反序列化方法需要自己實現,因此可以指定序列化哪些屬性,而transient在這裡無效。

對Externalizable物件反序列化時,會先呼叫類的無參構造方法,這是有別於預設反序列方式的。如果把類的不帶引數的構造方法刪除,或者把該構造方法的訪問許可權設定為private、預設或protected級別,會丟擲java.io.InvalidException: no valid constructor異常,因此Externalizable物件必須有預設建構函式,而且必需是public的。

2.3 對比

使用時,你只想隱藏一個屬性,比如使用者物件user的密碼pwd,如果使用Externalizable,併除了pwd之外的每個屬性都寫在writeExternal()方法裡,這樣顯得麻煩,可以使用Serializable介面,並在要隱藏的屬性pwd前面加上transient就可以實現了。如果要定義很多的特殊處理,就可以使用Externalizable。

當然這裡我們有一些疑惑,Serializable 中的writeObject()方法與readObject()方法科可以實現自定義序列化,而Externalizable 中的writeExternal()和readExternal() 方法也可以,他們有什麼異同呢?

  • readExternal(),writeExternal()兩個方法,這兩個方法除了方法簽名和readObject(),writeObject()兩個方法的方法簽名不同之外,其方法體完全一樣。
  • 需要指出的是,當使用Externalizable機制反序列化該物件時,程式會使用public的無參構造器建立例項,然後才執行readExternal()方法進行反序列化,因此實現Externalizable的序列化類必須提供public的無參構造。
  • 雖然實現Externalizable介面能帶來一定的效能提升,但由於實現ExternaLizable介面導致了程式設計複雜度的增加,所以大部分時候都是採用實現Serializable介面方式來實現序列化。

3、Serializable 如何序列化物件?

3.1 Serializable演示

然而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

3.2 Serializable介面有何用?

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

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

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

一文帶你全面瞭解java物件的序列化和反序列化

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

一文帶你全面瞭解java物件的序列化和反序列化

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

原來Serializable介面也僅僅只是做一個標記用!!!它告訴程式碼只要是實現了Serializable介面的類都是可以被序列化的!然而真正的序列化動作不需要靠它完成。

3.3 serialVersionUID號有何用?

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

private static final long serialVersionUID = -4392658638228508589L;

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

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

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

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

public class Student implements Serializable {
    private String name;
    private Integer age;
    private Integer score;
    private Integer id;

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

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

一文帶你全面瞭解java物件的序列化和反序列化

這地方提示的資訊非常明確了:序列化前後的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文字,則可以:

public class Student implements Serializable {
    private static final long serialVersionUID = -4392658638228508589L;
    private transient String name;
    private Integer age;
    private Integer score;
    private transient String passwd;

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

public static void serialize() throws IOException {

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

一文帶你全面瞭解java物件的序列化和反序列化

4、實現Externalizable

public UserInfo() {
    userAge=20;//這個是在第二次測試使用,判斷反序列化是否通過構造器
}
public void writeExternal(ObjectOutput out) throws IOException  {
    //  指定序列化時候寫入的屬性。這裡仍然不寫入年齡
    out.writeObject(userName);
    out.writeObject(usePass);
}
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException  {
    // 指定反序列化的時候讀取屬性的順序以及讀取的屬性
    // 如果你寫反了屬性讀取的順序,你可以發現反序列化的讀取的物件的指定的屬性值也會與你寫的讀取方式一一對應。因為在檔案中裝載物件是有序的
    userName=(String) in.readObject();
    usePass=(String) in.readObject();
}

我們在序列化物件的時候,由於這個類實現了Externalizable 介面,在writeExternal()方法裡定義了哪些屬性可以序列化,哪些不可以序列化,所以,物件在經過這裡就把規定能被序列化的序列化儲存檔案,不能序列化的不處理,然後在反序列的時候自動呼叫readExternal()方法,根據序列順序挨個讀取進行反序列,並自動封裝成物件返回,然後在測試類接收,就完成了反序列。

Externalizable 例項類的唯一特性是可以被寫入序列化流中,該類負責儲存和恢復例項內容。 若某個要完全控制某一物件及其超型別的流格式和內容,則它要實現 Externalizable 介面的 writeExternal 和 readExternal 方法。這些方法必須顯式與超型別進行協調以儲存其狀態。這些方法將代替定製的 writeObject 和 readObject 方法實現。

  • writeExternal(ObjectOutput out)
    該物件可實現 writeExternal 方法來儲存其內容,它可以通過呼叫 DataOutput 的方法來儲存其基本值,或呼叫 ObjectOutput 的 writeObject 方法來儲存物件、字串和陣列。
  • readExternal(ObjectInput in)
    物件實現 readExternal 方法來恢復其內容,它通過呼叫 DataInput 的方法來恢復其基礎型別,呼叫 readObject 來恢復物件、字串和陣列。

externalizable和Serializable的區別:

1、實現serializable介面是預設序列化所有屬性,如果有不需要序列化的屬性使用transient修飾。externalizable介面是serializable的子類,實現這個介面需要重寫writeExternal和readExternal方法,指定物件序列化的屬性和從序列化檔案中讀取物件屬性的行為。

2、實現serializable介面的物件序列化檔案進行反序列化不走構造方法,載入的是該類物件的一個持久化狀態,再將這個狀態賦值給該類的另一個變數。實現externalizable介面的物件序列化檔案進行反序列化先走構造方法得到控物件,然後呼叫readExternal方法讀取序列化檔案中的內容給對應的屬性賦值。

5、序列化的受控和加強

5.1 約束性加持

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

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

那怎麼個受控法呢?

答案就是: 自行編寫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,此時反序列化立馬終止並且報錯:

一文帶你全面瞭解java物件的序列化和反序列化

對於上面的程式碼,為什麼自定義的private的readObject()方法可以被自動呼叫,跟一下底層原始碼來一探究竟,跟到了ObjectStreamClass類的最底層,是反射機制在起作用!是的,在Java裡,果然萬物皆可“反射”(滑稽),即使是類中定義的private私有方法,也能被摳出來執行了,簡直引起舒適了。

5.2 單例模式增強

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

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

比如這裡我們先用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;
}
package serialize.test;

import java.io.Serializable;

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;
    }
 
    private Object readResolve() {
        return SingletonHolder.singleton;
    }
}

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

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章