談談 JAVA 的物件序列化

YangAM發表於2018-07-16

所謂的『JAVA 物件序列化』就是指,將一個 JAVA 物件所描述的所有內容以檔案 IO 的方式寫入二進位制檔案的一個過程。關於序列化,主要涉及兩個流,ObjectInputStream 和 ObjectOutputStream。

很多人關於『序列化』的認知只停留在 readObject 和 writeObject 這兩個方法的呼叫,但卻不知道為什麼 JAVA 能夠從一個二進位制檔案中「還原」出來一個完整的 JAVA 物件,也不知道一個物件究竟是如何儲存在二進位制檔案中的。

本文會帶大家分析二進位制檔案並結合序列化協議規則,去看看檔案中的 JAVA 物件是個什麼模樣,可能枯燥,但一定會提高你對序列化的認知的。

一種古老的序列化方式

在前面介紹位元組流的相關文章中,我們簡單提到過 DataInput/OutputStream 這個裝飾者流,它允許我們以基本資料型別為輸入,向檔案進行寫入和讀出操作。

看個例子:

定義一個 People 型別:

image

稍顯複雜的 main 函式:

image

可以看到,這種古老的序列化方式其實就是使用流 DataInput/OutputStream 將物件中欄位的值逐個的寫入檔案,完成所謂的『序列化操作』。

恢復物件的時候也必須按照寫入的順序一個欄位一個欄位的讀取,這種方式可以說非常的反人類了,如果一個類有一百個欄位,豈不是得手動寫入一百次。

這種方式準確意義上來說並不能算作『序列化』的一種實現,它是一種偽序列化,大家知道一下就好了。

JAVA 標準序列化

之所以需要將一個物件序列化儲存到磁碟目錄中的一個原因就是,有些物件可能很重要但卻佔用不小的空間,往往一時半會還用不到,那麼將它們放置記憶體中顯然是一種浪費,而丟棄又將導致額外的操作來建立這些物件。

所以,一種折中解決辦法就是,先將這些物件序列化儲存進檔案,用的時候再從磁碟讀取,而這就是『序列化』。

想要序列化一個物件,JAVA 要求該類必須繼承 「java.io.Serializable」介面,而 serializable 介面內並沒有定義任何方法,它是一個「標記介面」。

虛擬機器執行序列化指令的時候會檢查,要序列化的物件所對應的型別是否繼承了 Serializable 介面,如果沒有將拒絕執行序列化指令並丟擲異常。

java.io.NotSerializableException

而序列化的一般用法如下:

image

輸出結果:

single
23
複製程式碼

ObjectOutputStream 某種意義上來看也是一種裝飾者流,內部所有的位元組流操作都依賴我們構造例項時傳入的 OutputStream 例項。

這個類的實現很複雜,光內部類就定義了很多,同時它也封裝了我們的 DataOutputStream,所以 DataOutputStream 那一套寫基本資料型別的方法,這裡也有。除此之外的是,它還提供了 DataOutputStream 沒有的 writeObject 方法用於將一個繼承 Serializable 介面的 Java 物件直接寫入磁碟。

當然,ObjectInputStream 是相反的,它用於從磁碟讀取並恢復一個 Java 物件。

writeObject 方法接受一個 Object 引數,並將該引數所代表的 Java 物件序列化進磁碟檔案,這裡會寫入很多東西而不是簡簡單單的將欄位的值寫入檔案,它是有一個參照格式的,就像我們編譯器會按照一定的格式生成位元組碼檔案一樣。

遵循同樣的規則將會使得恢復起來很方便,下面我們來看看這個規則的具體內容。

序列化的儲存規則

上一小節我們序列化了一個 People 的例項物件到檔案中,現在我們開啟這個二進位制檔案。

image

序列化後的物件需要用這麼多的二進位制位進行儲存,這些二進位制位都是符合 JAVA 的序列化規則的,每幾個位元組用來儲存什麼都是規定好的,下面我們一起來看看。

1、魔數:這個是幾乎所有的二進位制檔案頭部都有的,用於標識當前二進位制檔案的檔案型別,我們的物件序列化檔案的魔數是 AC ED,佔兩個位元組。

2、序列化協議版本號:這指明 JAVA 採用什麼樣的序列化規則來生成二進位制檔案,這裡是 00 05,可能還有其他協議,一般都是 5 號協議。

3、一個位元組:接下來的一個位元組用於描述當前的物件型別,0x73 表示這是一個普通的 Java 物件,其他可選值:

image

注意,字串和陣列型別並沒有劃分到普通的 Java 物件這一類中,它們具有不同的數值標誌。我們這裡的 People 是一個普通的 Java 物件,所以這裡是 0x73 。

4、一個位元組:這一個位元組指明當前的物件所屬的資料型別,是一個類或者是一個引用,這裡的引用區別於 Java 的引用指標。如果你對於同一個物件進行兩次序列化,Java 不會重複寫入檔案,後者會儲存為一個引用型別,有關這一點,待會再詳細介紹。這裡的 People 是一個類,所以這裡的值就是,0x72 。

5、類的全限定名長度:0x0017 這兩個位元組描述了當前物件的全限定名稱長度,所以接下來的 23 個位元組是當前物件的全限定名稱,經過換算,這 23 個位元組表述的值為:TestSerializable.People。

接著看:

image

6、序列號版本:接下來的八個位元組,3A -> B5 描述的是當前類物件的序列化版本號,這個值由於我們定義的 People 類中沒有顯式指明,所以編譯器會根據 People 類的相關資訊以某種演算法生成一個 serialVersionUID 佔八個位元組。

7、序列化型別:一個位元組,用於指明當前物件的序列化型別,0x02 即代表當前物件可序列化。

8、欄位個數:兩個位元組,指明當前物件中需要被序列化的欄位個數,我們這裡是,0x0002,對應的我們 name 和 age 這兩個欄位。

接下來就是對欄位的描述了:

image

9、欄位型別:一個位元組,0x4C 對應的 ASCII 值為 L,即表示當前欄位的型別是一個普通類型別。

10、欄位名長度:兩個位元組,0x0003 指明接下來的三個位元組表述了當前欄位的全名稱,0x616765 正好對應字元 age。

11、欄位型別名:三個位元組,0x740013 ,其中 0x74 是一個欄位型別開始的標誌,即每個描述欄位型別名的三個位元組裡,前一個位元組都是 0x74,後面兩個位元組描述了欄位型別名稱的長度,0x0013 對應 19。所以接著的 19 個位元組表述當前欄位的完整型別名稱。這裡算了一下,正好是,Ljava/lang/Integer;。

接著就是描述我們的第二個欄位 name,具體過程是類似,這裡不再贅述,我們緊接著 name 欄位之後繼續介紹。

image

12、欄位描述結束符:一個位元組,固定值 0x78 標誌所有的欄位型別資訊描述結束。

13、父類型別描述:一個位元組,0x70 代表 null,即沒有父類,不算 Object 類。

接下來這一段其實是 Java 序列化一個 Integer 物件的過程,然後到 0x7872,即 Integer 類還有父類,於是又去序列化一個父類 Number 例項。為什麼這麼做,我想你應該清楚,每個子類物件的建立都會對應一個父類物件的建立。

所以,直到

image

最後一個 0x7870,說明所有的物件資訊都已經序列化完成,下面是各個欄位的資料部分。

前四個位元組,0x00000017 是我們第一個欄位 age 的值,也就是 23 。0x74 指明第二個欄位的型別是 String 型別,值的長度 0x0006,最後六個位元組剛好是字串 single。

至此,整個序列化檔案的格式我們已經全部介紹完成了,總結一下:

整個序列化檔案分為兩個部分,欄位型別描述和欄位資料部分。其中,如果欄位的型別是普通的 JAVA 型別的話,會繼續序列化其父類物件,理解這一點很重要,像我們這個例子中,一共序列化了三個物件,分別是 People,Integer,Number 這三個物件,如果它們的欄位有被外部賦值過,這些值也將此排序儲存。

序列化的幾點高階認識

迴圈引用的序列化

考慮這樣兩個類:

image

image

這兩個類的定義幾乎就是相同的,內部都定義了一個 People 欄位。

image

讓 ClassA 和 ClassB 的兩個物件公用同一個 People 例項,那麼有一個問題,我去序列化這兩個物件,這個公用的 People 物件會被序列化兩次嗎?

我們開啟二進位制檔案,這次的二進位制檔案要複雜一點了:

image

我圈出來了幾個 0x7870,它標誌著一個物件型別資訊的序列化結束,我們簡單分析一下,不會詳細的說了,具體參照上面的內容。

第一部分其實是在序列化 ClassA 型別,它指明瞭 ClassA 型別只有一個欄位,並且該欄位是一個物件型別,記錄下欄位的型別名稱等資訊。

第二部分在序列化 People 型別,包括序列化其中的 name 欄位,並儲存了 name 欄位的外部賦的值,字串:single。

第三部分,序列化 ClassB 型別,ClassB 的型別序列化相對 ClassA 要少一點,雖然它們內部具有相同的定義。

image

其中,陰影部分是 ClassB 類的全限定名,紅線框是該類的版本序列號,由於我們沒有顯式指定,這是由編譯器自動生成的。接著指明具有一個欄位,欄位型別是物件型別,名稱長度六個位元組。

0x71 指明這個欄位是一個引用,按慣例來說,這部分應該進行該欄位的型別名稱描述,但是由於這種型別已經序列化過了,所以使用引用直接指向前面已經完成序列化的 People 型別。

最後一部分按慣例應該進行欄位資料的描述,描述資料的型別,值的長度,以及值本身。但是由於我們 ClassB 型別的 people 欄位值公用的 ClassA 的 people 欄位值,所以虛擬機器不會傻到重新序列化一遍該 people 物件,而是給出上面該 people 物件的引用編號。

說了這麼多,得出的結論是什麼呢,如果你要序列化的多個物件中,有相同的類型別,Java 只會描述一次該型別,並且如果一份序列化檔案中存在對同一物件的多次序列化,Java 也只會儲存一份物件資料,後面的都用引用指向這裡。

定製序列化

對於所有繼承了 Serializable 介面的類而言,進行序列化時,虛擬機器會序列化這些類中所有的欄位,無視訪問修飾符,但是有時候我們並不需要將所有的欄位都進行序列化,而只是選擇性的序列化其中的某些欄位。

我們只需要在不想序列化的欄位前面使用 transient 關鍵字進行修飾即可。

private transient String name;
複製程式碼

即便你給你的物件的 name 欄位賦值了,最終也不會被儲存進檔案中,當你反序列化的時候,這個物件的 name 欄位依然是系統預設值 null。

除此之外,JAVA 還允許我們重寫 writeObject 或 readObject 來實現我們自己的序列化邏輯。

但是這兩個方法的宣告必須是固定的。

private void writeObject(java.io.ObjectOutputStream s) 

private void readObject(java.io.ObjectInputStream s) 
複製程式碼

沒錯,它就是 private 修飾的,在你通過 ObjectOutputStream 的 writeObject 方法對某個物件進行序列化時,虛擬機器會自動檢測該物件所對應的類是否有以上兩種方法的實現,如果有,將轉而呼叫類中我們自定的該方法,放棄 JDK 所實現的相應方法。

我們看個例子:

image

name 被關鍵字 transient 修飾,即預設的序列化機制不會序列化該欄位,並且我們重寫了 writeObject 和 readObject,在其中呼叫了預設的序列化方法之後,我們分別將 name 欄位寫入和讀出。

image

輸出結果:

single
20
複製程式碼

有興趣的同學可以自己去看看序列化後的二進位制檔案,其中是沒有關於 name 欄位的描述資訊的,但是整個 people 物件描述之後,緊隨其後的就是我們的字元 「single」。

而反序列化的過程也是類似的,先按照 JDK 的預設反序列化機制反射生成一個 people 物件,再讀取檔案末尾的字串賦值給當前 people 物件。

序列化的版本問題

序列化的版本 ID,我們一直都有提到它,但是始終沒有說明這個版本 ID 到底有什麼用。用得好的可以拿來實現許可權管理機制,用不好也可能導致你反序列化失敗。

JAVA 建議每個繼承 Serializable 介面的類都應當定義一個序列化版本欄位。

private static final long serialVersionUID = xxxxL;
複製程式碼

這個值可以理解為是當前型別的一個唯一標識,每個物件在序列化時都會寫入外部型別的這個版本號,反序列化時首先就會檢查二進位制檔案中的版本號與目標型別中的版本號是否一樣,如果不一樣將拒絕反序列化。

這個值不是必須的,如果你不提供,那麼編譯器將根據當前類的基本資訊以某種演算法生成一個唯一的序列號,可是如果你的類發生了一點點的改動,這個值就變了,已經序列化好的檔案將無法反序列化了,因為你也不知道這個值變成什麼了。

所以,JAVA 建議我們都自己來定義這麼一個版本號,這樣你可以控制已經序列化的物件能否反序列化成功。

至此,我們簡單的介紹了序列化的相關內容,很多的都是結合著二進位制檔案進行描述的,可能枯燥,但是看完想必是能夠提高你原先對於 JAVA 物件序列化的認知的。有什麼問題,可以留言一起探討交流 !


文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:OneJavaCoder,所有文章都將同步在公眾號上。

image

相關文章