聊聊序列化

假若世間有十萬個未知的謎題,我便前去尋,那十萬個謎底。web

什麼是序列化與反序列化

第一次接觸到序列化是在我大一的時候,那個時候正好是期末考完要課設,其中數據結構的課設題目咱們選了一個家譜管理系統,須要用 C++ 實現。在作課設的時候遇到一個問題是這樣的——如何將家譜樹的結構存儲到文本中,而後再次打開程序讀取文本的時候將其中的內容 load 到系統中呢?面試

後來咱們查閱資料就發現了 序列化反序列化 這兩個東西,我朋友又在網上看了一些代碼樣例準備試試(當時課設是小組制的,每一個人都有本身的分工)。安全

次日,朋友拖着疲憊的身體,頂着沉重的黑眼圈過來跟我講:「made ,昨晚肝到兩點,終於搞定了!」 因而他打開了管理系統的程序,給我演示瞭如何將樹的結構進行 序列化 到文本中,而後再將文本中的內容 反序列化 映射到程序中。看着朋友的熊貓眼,我心裏不由豎起了大拇指(大一都這麼能肝,工做了還得了)。因此,我深深地記住了這兩個詞。數據結構

對於多叉樹和二叉樹的序列化和反序列化在 leetcode 上有原題,這也算是一個面試的高頻題吧,並且實現起來仍是有些難度的,感興趣的同窗能夠挑戰一下。編輯器

二叉樹的序列化與反序列化 難度:困難ide

序列化和反序列化二叉搜索樹 難度:中等函數

序列化和反序列化 N 叉樹 難度: 困難post

那個時候我所理解的序列化和反序列化其實就是 將對象轉換爲字節序列和將字節序列轉換爲對象的過程性能

後來接觸了 Java ,認識到了 Serializable 接口,慢慢又看了不少書,才意識到關於序列化和反序列化的學問遠不止這些。測試

Java 中如何實現序列化和反序列化

咱們讓對象支持序列化和反序列化僅僅須要實現一個 Serializable 接口就好了。而對於 Serializable 接口,你查看源碼會發現,這東西就一個接口聲明,再沒其餘的了。

public interface Serializable {
}
複製代碼

對於這個接口的解釋在文件註釋中也寫的很清楚了,only to identify the semantics of being serializable 僅僅用來判斷對象是否支持序列化和反序列化。

序列化原理淺析

那麼如何進行對象的序列化和反序列化操做呢?

主要經過 ObjectOutputStreamObjectInputStream 這兩個類來實現。例如,咱們能夠將 Boy 的一個實例對象序列化到文件中,而後再反序列化構建另外一個 Boy 對象。

@Data
public class Boy implements Serializable {

    private int girlFriendCount;

}

// 測試方法
public static void main(String[] args) throws IOException, ClassNotFoundException {
    Boy b1 = new Boy();
    b1.setGirlFriendCount(10);
    // 序列化
    ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(new File("test")));
    objectOutputStream.writeObject(b1);
    // 反序列化
    ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("test"));
    Boy b2 = (Boy)objectInputStream.readObject();
    // 獲得結果爲10
    System.out.println(b2.getGirlFriendCount());
}
複製代碼

而在 writeObject(obj) 這個方法中(序列化),是如何判斷可否進行序列化呢?其實答案很簡單,咱們能夠 debug 查看核心源碼。

writeObject0
writeObject0

這段代碼中就是判斷了須要序列化對象的類型,若是走到 instanceof Serializable 仍是不符合的話就會拋出 NoSerializableException 異常。因此若是你聲明的類沒有實現 Serializable 接口那麼就會拋出這個異常。

以此類推,若是我對一個實現了 Serializable 接口的類的對象進行序列化,可是它持有的一個對象並無實現 Serializable 接口,此過程是否必定會拋出 NoSerializableException 異常呢 ?我看不少博客中都寫到會拋出,其實我以爲不必定。若是說,該對象持有的那個未實現 Serializable 接口的對象並無進行初始化(也就是說爲 null ),那麼此時是不會報錯的,其緣由也在源代碼中。

首先判斷是否爲null
首先判斷是否爲null

其實你能夠嘗試一下對 null 進行序列化,也是不會報錯的。

而對於 Serialize 還有一個注意點就是,若是 父類實現了 Serialize 接口,子類繼承了父類,子類是也默認實現了 Serialize 。這實際上是一個設計的問題,沒有那麼多爲何,若是究其緣由仍是在那句 obj instanceof Serializable ,由於繼承實現了 Serializable 接口的類的類,必然符合這個條件。

對於一個類的 靜態成員 來講是不會被序列化的,而序列化自己就是對 對象 狀態的記錄,須要的是對象的屬性而不是類的屬性。可是有個有意思的地方,內部靜態類 能夠實現序列化接口,甚至咱們還可使用靜態類來建立 序列化代理 以此來提高序列化的安全能力。

public class Test implements Serializable {

    private static final long serialVersionUID = 3005560411086043165L;

    private int age;
    private boolean sex;

    private static class SerializableProxy implements Serializable {

        private static final long serialVersionUID = 2677759736303136145L;

        private final int age;
        private final boolean sex;

        SerializableProxy(Test test) {
            this.age = test.age;
            this.sex = test.sex;
        }
    }

    private void readObject(ObjectInputStream stream) throws InvalidObjectException {
        System.out.println("執行了 Test 的 readObject 方法");
        throw new InvalidObjectException("須要代理!");
    }

    private Object writeReplace() {
        System.out.println("執行了替代方法");
        return new SerializableProxy(this);
    }

}
複製代碼

在上面代碼中的 readObjectwriteReplace 方法是序列化過程當中會被調用的方法(能夠理解爲鉤子函數),你可能會有疑問,不是調用的 objectInputStream.readObject(obj) 這個方法麼,這兩個鉤子函數爲何會被執行?它們沒有繼承 ObjectInputStream 而且重寫 readObject 方法呀,爲何會被調用?

其實緣由也很簡單,就在 ObjectInputStream 的執行流程中,這裏我展現一下 ObjectOutpuStream 中的相關調用流程,原理都是同樣的。

序列化中的反射
序列化中的反射

你能夠進行 debug 而後查看裏面具體的調用流程。而對於 Java 的序列化來講能夠實現不少鉤子函數,writeReplace() 亦是如此。

使用序列化代理來提升安全性

繼續來講說,使用靜態類來進行 序列化代理 的目的是什麼?爲何要這麼麻煩呢?Java 中爲何要定義這麼多序列化鉤子函數呢?

其實答案就是 安全 ,爲了安全咱們甚至能夠犧牲必定的 性能開銷 。若是一個類決定了實現 Serializable 接口那麼也就意味着咱們能夠經過 語言機制 之外的方式去建立實例(由於咱們能夠調用 readObject,經過字節流建立了呀)。能夠這麼理解,反序列化其實就是一個 隱藏的構造器 ,反序列化過程當中咱們能夠去違反類本來的構造器 約束 ,甚至去幹一些構造之外的事情。

這一定會帶來不可估量的安全問題,好比說,業界中經常提到的 反序列化炸彈 來實現 DoS 拒絕服務攻擊 ,咱們能夠經過互相引用的200個 HashSet 互相引用實例來構建 2^100 次的 hashCode 方法調用;而攻擊者還能根據 反序列化期間被調用的方法 ,造成根據序列化造成的調用代碼來 任意 的在程序中進行執行,這個後果也就意味着攻擊者能直接控制你所謂的程序。

序列化破壞單例

因此,是否該實現序列化是一個慎重的決定,例如若是讓 單例 的類去實現 Serializable 接口,那麼就會破話單例模式。

固然你能夠再次使用一個鉤子函數 readResolve ,當對象已經經過 readObject 方法產生了,若是說你書寫了這個 readResolve 方法,那麼就會調用這個方法而且返回你想要的真正的對象。

private Object readResolve() {
    // 直接返回單例
    return INSTANCE;
}
複製代碼

可是這種方式也 並非絕對安全 ,若是一個單例包含一個非瞬時(未被 transient 修飾)對象,那麼這個域的內容就能夠在單例的 readResolve 方法運行以前被反序列化,具體編碼方式能夠參考 《Effective Java》,這裏不作過多描述。

談談 serialVersionUID

在上文中的 Test 類以及它的序列化代理類我都添加了 serialVersionUID 這個私有靜態不可變屬性,爲何要添加呢?

所謂 serialVersionUID ,顧名思義,其實就是 序列化版本ID ,序列化爲何要和版本牽扯關係呢?

首先須要明確的是即便咱們不加上 serialVersionUID 這個字段,Java 也會根據這個類的 類名稱、所實現接口的名稱、以及全部 public protected 字段 經過加密散列函數來生成一個默認的 serialVersionUID。這個序列號在反序列化過程當中會用於驗證序列化對象的發送者和接收者是否爲該對象加載了與序列化兼容的類。若是接收者加載的該對象的類的 serialVersionUID 與對應的發送者的類的 版本號不一樣 ,則反序列化將會致使 InvalidClassException

因此給類加上一個 serialVersionUID 字段,以確保兼容問題是一個明智的選擇。

其餘序列化方式以及安全防範

現現在有不少前沿的序列化機制,例如 JSONProtoBuf 等,他們能更好地支撐不一樣平臺之間的序列化問題,而對於 Java 原生的序列化由於其性能,安全,應用的問題也有可能會在將來被淘汰。

也並非說其餘序列化方式沒有漏洞,就好比咱們常使用的 fastJSON 就被曝出過好幾回漏洞,由於反序列化自己就是一個範圍比較廣的安全問題,若是黑客利用反序列化漏洞構造執行鏈就至關於控制了你的整個程序,他就能夠隨心所欲了。

而對於如何進行安全防範也是一個比較頭疼的問題。好比說如何去阻止黑客去構造調用鏈,其實對於黑客來講構建調用鏈都是基於類的方法,咱們能夠去添加一個黑名單,讓一些本沒必要要執行序列化的類歸入到 黑名單 中。

像咱們前面提到的不少 鉤子函數 ,這也是一種序列化安全的解決方案 RASP 。咱們能夠在鉤子函數中加入一層規則判斷,判斷是否有非正常代碼的執行,甚至咱們能夠直接限制程序的序列化反序列化過程。

參考

《Effective Java》第三版

Java工程師成神之路 | 2020正式版

相關文章
相關標籤/搜索