談談 JAVA 的對象序列化

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

不少人關於『序列化』的認知只停留在 readObject 和 writeObject 這兩個方法的調用,但殊不知道爲何 JAVA 可以從一個二進制文件中「還原」出來一個完整的 JAVA 對象,也不知道一個對象到底是如何存儲在二進制文件中的。git

本文會帶你們分析二進制文件並結合序列化協議規則,去看看文件中的 JAVA 對象是個什麼模樣,可能枯燥,但必定會提升你對序列化的認知的。github

一種古老的序列化方式

在前面介紹字節流的相關文章中,咱們簡單提到過 DataInput/OutputStream 這個裝飾者流,它容許咱們以基本數據類型爲輸入,向文件進行寫入和讀出操做。算法

看個例子:數組

定義一個 People 類型:bash

image

稍顯複雜的 main 函數:微信

image

能夠看到,這種古老的序列化方式其實就是使用流 DataInput/OutputStream 將對象中字段的值逐個的寫入文件,完成所謂的『序列化操做』。函數

恢復對象的時候也必須按照寫入的順序一個字段一個字段的讀取,這種方式能夠說很是的反人類了,若是一個類有一百個字段,豈不是得手動寫入一百次。ui

這種方式準確意義上來講並不能算做『序列化』的一種實現,它是一種僞序列化,你們知道一下就行了。spa

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 的序列化規則的,每幾個字節用來存儲什麼都是規定好的,下面咱們一塊兒來看看。

一、魔數:這個是幾乎全部的二進制文件頭部都有的,用於標識當前二進制文件的文件類型,咱們的對象序列化文件的魔數是 AC ED,佔兩個字節。

二、序列化協議版本號:這指明 JAVA 採用什麼樣的序列化規則來生成二進制文件,這裏是 00 05,可能還有其餘協議,通常都是 5 號協議。

三、一個字節:接下來的一個字節用於描述當前的對象類型,0x73 表示這是一個普通的 Java 對象,其餘可選值:

image

注意,字符串和數組類型並無劃分到普通的 Java 對象這一類中,它們具備不一樣的數值標誌。咱們這裏的 People 是一個普通的 Java 對象,因此這裏是 0x73 。

四、一個字節:這一個字節指明當前的對象所屬的數據類型,是一個類或者是一個引用,這裏的引用區別於 Java 的引用指針。若是你對於同一個對象進行兩次序列化,Java 不會重複寫入文件,後者會保存爲一個引用類型,有關這一點,待會再詳細介紹。這裏的 People 是一個類,因此這裏的值就是,0x72 。

五、類的全限定名長度:0x0017 這兩個字節描述了當前對象的全限定名稱長度,因此接下來的 23 個字節是當前對象的全限定名稱,通過換算,這 23 個字節表述的值爲:TestSerializable.People。

接着看:

image

六、序列號版本:接下來的八個字節,3A -> B5 描述的是當前類對象的序列化版本號,這個值因爲咱們定義的 People 類中沒有顯式指明,因此編譯器會根據 People 類的相關信息以某種算法生成一個 serialVersionUID 佔八個字節。

七、序列化類型:一個字節,用於指明當前對象的序列化類型,0x02 即表明當前對象可序列化。

八、字段個數:兩個字節,指明當前對象中須要被序列化的字段個數,咱們這裏是,0x0002,對應的咱們 name 和 age 這兩個字段。

接下來就是對字段的描述了:

image

九、字段類型:一個字節,0x4C 對應的 ASCII 值爲 L,即表示當前字段的類型是一個普通類類型。

十、字段名長度:兩個字節,0x0003 指明接下來的三個字節表述了當前字段的全名稱,0x616765 正好對應字符 age。

十一、字段類型名:三個字節,0x740013 ,其中 0x74 是一個字段類型開始的標誌,即每一個描述字段類型名的三個字節裏,前一個字節都是 0x74,後面兩個字節描述了字段類型名稱的長度,0x0013 對應 19。因此接着的 19 個字節表述當前字段的完整類型名稱。這裏算了一下,正好是,Ljava/lang/Integer;。

接着就是描述咱們的第二個字段 name,具體過程是相似,這裏再也不贅述,咱們緊接着 name 字段以後繼續介紹。

image

十二、字段描述結束符:一個字節,固定值 0x78 標誌全部的字段類型信息描述結束。

1三、父類類型描述:一個字節,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
相關文章
相關標籤/搜索