1. 程式人生 > >分散式系統的基石序列化及反序列化

分散式系統的基石序列化及反序列化

瞭解序列化的意義

       Java 平臺允許我們在記憶體中建立可複用的 Java 物件,但一般情況下,只有當 JVM 處於執行時,這些物件才可能存在,即,這些物件的生命週期不會比 JVM 的生命週期更長。但在現實應用中,就可能要求在 JVM停止執行之後能夠儲存(持久化)指定的物件,並在將來重新讀取被儲存的物件。Java 物件序列化就能夠幫助我們實現該功能

簡單來說
       序列化是把物件的狀態資訊轉化為可儲存或傳輸的形式過程,也就是把物件轉化為位元組序列的過程稱為物件的序列化

       反序列化是序列化的逆向過程,把位元組陣列反序列化為物件,把位元組序列恢復為物件的過程成為物件的反序列化

序列化面臨的挑戰

       評價一個序列化演算法優劣的兩個重要指標是:序列化以後的資料大小;序列化操作本身的速度及系統資源開銷(CPU、記憶體);Java 語言本身提供了物件序列化機制,也是 Java 語言本身最重要的底層機制之一,Java 本身提供的序列化機制存在兩個問題

       1. 序列化的資料比較大,傳輸效率低

       2. 其他語言無法識別和對接

如何實現一個序列化操作

         在 Java 中,只要一個類實現了 java.io.Serializable 介面,那麼它就可以被序列化

定義介面

基於 JDK 序列化方式實現

      JDK 提 供 了 Java 對 象 的 序 列 化 方 式 , 主 要 通 過 輸 出 流java.io.ObjectOutputStream 和物件輸入流java.io.ObjectInputStream來實現。其中,被序列化的物件需要實現 java.io.Serializable 介面

具體實現

通過對一個 user 物件進行序列化操作

 

序列化的高階認識

serialVersionUID 的作用

       Java 的序列化機制是通過判斷類的 serialVersionUID 來驗證版本一致性的。在進行反序列化時,JVM 會把傳來的位元組流中的 serialVersionUID與本地相應實體類的 serialVersionUID 進行比較,如果相同就認為是一致的,可以進行反序列化,否則就會出現序列化版本不一致的異常,即是 InvalidCastException

        如果沒有為指定的 class 配置 serialVersionUID,那麼 java 編譯器會自動給這個 class 進行一個摘要演算法,類似於指紋演算法,只要這個檔案有任何改動,得到的 UID 就會截然不同的,可以保證在這麼多類中,這個編號是唯一的

serialVersionUID 有兩種顯示的生成方式:

一是預設的 1L,比如:private static final long serialVersionUID = 1L; 

二是根據類名、介面名、成員方法及屬性等來生成一個 64 位的雜湊欄位

       當實現 java.io.Serializable 接 口 的 類 沒 有 顯 式 地 定 義 一 個serialVersionUID 變數時候,Java 序列化機制會根據編譯的 Class 自動生成一個 serialVersionUID 作序列化版本比較用,這種情況下,如果Class 檔案(類名,方法明等)沒有發生變化(增加空格,換行,增加註釋等等),就算再編譯多次,serialVersionUID 也不會變化的。

靜態變數序列化 

       在 User 中新增一個全域性的靜態變數 num , 在執行序列化以後修改num 的值為 10, 然後通過反序列化以後得到的物件去輸出 num 的值

       最後的輸出是 10,理論上列印的 num 是從讀取的物件裡獲得的,應該是儲存時的狀態才對。之所以列印 10 的原因在於序列化時,並不儲存靜態變數,這其實比較容易理解,序列化儲存的是物件的狀態,靜態變數屬於類的狀態,因此 序列化並不儲存靜態變數。

父類的序列化 

       一個子類實現了 Serializable 介面,它的父類都沒有實現 Serializable 介面,在子類中設定父類的成員變數的值,接著序列化該子類物件。再反序列化出來以後輸出父類屬性的值。結果應該是什麼?

發現父類的 sex 欄位的值為 null。也就是父類沒有實現序列化

結論:

1. 當一個父類沒有實現序列化時,子類繼承該父類並且實現了序列化。在反序列化該子類後,是沒辦法獲取到父類的屬性值的

2. 當一個父類實現序列化,子類自動實現序列化,不需要再顯示實現Serializable 介面

3. 當一個物件的例項變數引用了其他物件,序列化該物件時也會把引用物件進行序列化,但是前提是該引用物件必須實現序列化介面

Transient 關鍵字 

       Transient 關鍵字的作用是控制變數的序列化,在變數宣告前加上該關鍵字,可以阻止該變數被序列化到檔案中,在被反序列化後,transient 變數的值被設為初始值,如 int 型的是 0,物件型的是 null

繞開 transient 機制的辦法 

注意:writeObject和 readObject 這兩個私有的方法,既不屬於 Object、也不是 Serializable,為什麼能夠在序列化的時候被呼叫呢?原因是,ObjectOutputStream使用了反射來尋找是否聲明瞭這兩個方法。因為 ObjectOutputStream使用getPrivateMethod,所以這些方法必須宣告為 priate 以至於供ObjectOutputStream 來使用

序列化的儲存規則

       同一物件兩次(開始寫入檔案到最終關閉流這個過程算一次,上面的演示效果是不關閉流的情況才能演示出效果)寫入檔案,打印出寫入一次物件後的儲存大小和寫入兩次後的儲存大小,第二次寫入物件時檔案只增加了 5 位元組

       Java 序列化機制為了節省磁碟空間,具有特定的儲存規則,當寫入檔案的為同一物件時,並不會再將物件的內容進行儲存,而只是再次儲存一份引用,上面增加的 5 位元組的儲存空間就是新增引用和一些控制資訊的空間。反序列化時,恢復引用關係.該儲存規則極大的節省了儲存空間。

序列化實現深克隆

        在 Java 中存在一個 Cloneable 介面,通過實現這個介面的類都會具備clone 的能力,同時 clone 是在記憶體中進行,在效能方面會比我們直接通過 new 生成物件要高一些,特別是一些大的物件的生成,效能提升相對比較明顯。那麼在 Java 領域中,克隆分為深度克隆和淺克隆

淺克隆

       被複制物件的所有變數都含有與原來的物件相同的值,而所有的對其他物件的引用仍然指向原來的物件。

實現一個郵件通知功能,告訴每個人今天晚上的上課時間,通過淺克隆
實現如下

        但是,當我們只希望,修改“黑白”的上課時間,調整為 20:30 分。通過結果發現,所有人的通知訊息都發生了改變。這是因為 p2 克隆的這個物件的 Email 引用地址指向的是同一個。這就是淺克隆

深克隆

       被複制物件的所有變數都含有與原來的物件相同的值,除去那些引用其他物件的變數。那些引用其他物件的變數將指向被複制過的新物件,而不再是原有的那些被引用的物件。換言之,深拷貝把要複製的物件所引用的物件都複製了一遍

       這樣就能實現深克隆效果,原理是把物件序列化輸出到一個流中,然後在把物件從序列化流中讀取出來,這個物件就不是原來的物件了。

常見的序列化技術

使用 JAVA 進行序列化有他的優點,也有他的缺點
優點:JAVA 語言本身提供,使用比較方便和簡單
缺點:不支援跨語言處理、 效能相對不是很好,序列化以後產生的資料相對較大

XML 序列化框架 

       XML 序列化的好處在於可讀性好,方便閱讀和除錯。但是序列化以後的位元組碼檔案比較大,而且效率不高,適用於對效能不高,而且 QPS 較低的企業級內部系統之間的資料交換的場景,同時 XML 又具有語言無關性,所以還可以用於異構系統之間的資料交換和協議。比如我們熟知的 Webservice,就是採用 XML 格式對資料進行序列化的

JSON 序列化框架

        JSON(JavaScript Object Notation)是一種輕量級的資料交換格式,相對於 XML 來說,JSON 的位元組流更小,而且可讀性也非常好。現在 JSON資料格式在企業運用是最普遍的

JSON 序列化常用的開源工具有很多

1. Jackson (https://github.com/FasterXML/jackson)
2. 阿里開源的 FastJson (https://github.com/alibaba/fastjon)
3. Google 的 GSON (https://github.com/google/gson)

        這幾種 json 序列化工具中,Jackson 與 fastjson 要比 GSON 的效能要好,但是 Jackson、GSON 的穩定性要比 Fastjson 好。而 fastjson 的優勢在於提供的 api 非常容易使用

Hessian 序列化框架

       Hessian 是一個支援跨語言傳輸的二進位制序列化協議,相對於 Java 預設的序列化機制來說,Hessian 具有更好的效能和易用性,而且支援多種不同的語言

      實際上 Dubbo 採用的就是 Hessian 序列化來實現,只不過 Dubbo 對Hessian 進行了重構,效能更高

Protobuf 序列化框架

      Protobuf 是 Google 的一種資料交換格式,它獨立於語言、獨立於平臺。

      Google 提供了多種語言來實現,比如 Java、C、Go、Python,每一種實現都包含了相應語言的編譯器和庫檔案

      Protobuf 使用比較廣泛,主要是空間開銷小和效能比較好,非常適合用於公司內部對效能要求高的 RPC 呼叫。 另外由於解析效能比較高,序列化以後資料量相對較少,所以也可以應用在物件的持久化場景中但是但是要使用 Protobuf 會相對來說麻煩些,因為他有自己的語法,有自己的編譯器

下載 protobuf 工具

https://github.com/google/protobuf/releases 找到 protoc-3.5.1win32.zip

編寫 proto 檔案 

syntax="proto2";
package com.gupaoedu.serial;
option java_package = "com.gupaoedu.serial";
option java_outer_classname="UserProtos";
message User {
required string name=1;
required int32 age
}

proto 的語法

1. 包名

2. option 選項

3. 訊息模型(訊息物件、欄位(欄位修飾符-required/optional/repeated)欄位型別(基本資料型別、列舉、訊息物件)、欄位名、標識號)

生成實體類 

在 protoc.exe 安裝目錄下執行如下命令

.\protoc.exe --java_out=./ ./user.proto

執行檢視結果

將生成以後的 UserProto.java 拷貝到專案中

Protobuf 原理分析

核心原理: protobuf 使用 varint(zigzag)作為編碼方式, 使用 T-L-V 作為儲存方式

varint 編碼方式 

varint 是一種資料壓縮演算法,其核心思想是利用 bit 位來實現資料壓縮。
比如:對於 int32 型別的數字,一般需要 4 個位元組 表示;若採用 Varint 編碼,對於很小的 int32 型別 數字,則可以用 1 個位元組
假設我們定義了一個 int32 欄位值=296.

第一步,轉化為 2 進位制編碼

第二步,提取位元組

規則: 按照從位元組串末尾選取 7 位,並在最高位補 1,構成一個位元組

第三步,繼續提取位元組

       整體右移 7 位,繼續擷取 7 個位元位,並且在最高位補 0 。因為這個是最後一個有意義的位元組了。補 0 不影響結果

第四步,拼接成一個新的位元組串

將原來用 4 個位元組表示的整數,經過 varint 編碼以後只需要 2 個位元組了。

varint 編碼對於小於 127 的數,可以最大化的壓縮

varint 壓縮小資料 

比如我們壓縮一個 var32 = 104 的資料

第一步,轉換為 2 進位制編碼

第二步,提取位元組

從末尾開始提取 7 個位元組

並且在最高位最高位補 0,因為這個是最後的 7 位。

第三步,形成新的位元組

也就是通過varint對於小於127以下的數字編碼,只需要佔用1個位元組。

zigzag 編碼方式 

對於負數的處理,protobuf 使用 zigzag 的形式來儲存。為什麼負數需要用 zigzag 演算法?

計算機語言中如何表示負整數?

       在計算機中,定義了原碼、反碼和補碼。來實現負數的表示。我們以一個位元組 8 個 bit 來演示這幾個概念數字 8 的二進位制表示為 0000 1000

原碼

          通過第一個位表示符號(0 表示非負數、1 表示負數)
         (+8) = {0000 1000}
         (-8) = {1000 1000}

反碼

        因為第一位表示符號位,保持不變。剩下的位,非負數保持不變、負數按位取反。那對於上面的原碼按照這個規則得到的結果(+8) = {0000 1000}原 ={0000 1000}反 非負數,剩下的位不變。所以和原碼是保持一致(-8) = {1000 1000}原 ={1111 0111}反 負數,符號位不動,剩下為取反

但是通過原碼和反碼方式來表示二進位制,還存在一些問題。

第一個問題:

0 這個數字,按照上面的反碼計算,會存在兩種表示
(+0) ={0000 0000}原= {0000 0000}反
(-0) ={1000 0000}原= {1111 1111}反

第二個問題:

符號位參與運算,會得到一個錯誤的結果,比如
1 + (-1)=
{0000 0001}原 +{1 0000 0001}原 ={1000 0010}原 =-2
{0000 0001}反+ {1111 1110}反 = {1111 1111}反 =-0
不管是原碼計算還是反碼計算。得到的結果都是錯誤的。所以為了解決
這個問題,引入了補碼的概念。

補碼

補碼的概念:第一位符號位保持不變,剩下的位非負數保持不變,負數按位取反且末位加 1

(+8) = {0000 1000}原 = {0000 1000}原 ={0000 1000}補
(-8) = {1000 1000}原 ={1111 0111}反={1111 1000}末位加一(補碼)
8+(-8)= {0000 1000}補 +{1111 1000}末位加一(補碼) ={0000 0000}=0

通過補碼的方式,在進行符號運算的時候,計算機就不需要關心符號的問題,統一按照這個規則來計算。就沒問題沒問題

zigzag 原理 

有了前面這塊的基礎以後,我們再來了解下 zigzag 的實現原理
比如我們儲存一個 int32 = -2 按照上面提到的負數表現形式如下
原碼{1 000 0010} ->取反 {1111 1101} ->整體加 1 {111 1110}->{1111 1110}

zigzag 的核心思想是去掉無意義的 0,最大可能性的壓縮資料。但是對於負數。第一位表示符號位,如果補碼的話,前面只能補 1. 就會導致陷入一個很尷尬的地步,負數似乎沒辦法壓縮。

所以 zigzag 提供了一個方法,既然第一位是符號位,那麼幹脆把這個符號位放到補碼的最後。整體右移。

所以上面這個-2,將符號位移到最末尾,也就是右移 31 位。得到如下結果(對於負數形式,整體右移 31 位,把符號位移動到最後邊; 為什麼要移動到最後呢,因為對於負數形式,補碼位永遠是 1,那麼如果他站在最高位,就永遠沒辦法壓縮。所以做了一個移動)

但是對於上面這個操作,並不能解決壓縮的問題,因為值越小,那麼前導的 1 越多。所以 zigzag 演算法考慮到是否能夠將符號位不變,整體取反呢?

那這樣就能夠實現壓縮的需求了?(這裡如果是單純的這麼實現,是沒辦法實現反序列化的。)所以還需要下面這個過程。

所以對於同樣(-2)的正數形式(2),在二進位制中的表現為 {00000010}那 zigzag 演算法定義了對於非負數形式,則把符號位移動到最後,其他整體往左移動一位。得到如下的效果

(對於非負數形式 2,按照整體左移 1 位,右邊補零的形式來表示如下)

這樣一來,對於(2)這個數字,正負數都有表示的方法了。那麼 zigzag結合了兩種表示方法,來進行計算。計算規則是將正數形式和負數形式進行異或運算。按照上面的兩種表現形式的異或運算結果是

而在 zigzag 中的計算規則是

將-2 的二進位制形式{1111 1110}按照正數的演算法,左移一位,右邊補零得到{11111100},如下圖左邊。 按照負數的形式,講符號位移動到最右邊,右移 31 位,得到下面右圖。再將兩者取異或演算法。實現最終的壓縮。

然後再將兩個結果進行 “異或” 運算

ps:

異或運算是
0 異或 0 =0
1 異或 1 =0
1 異或 0 =1
0 異或 1 =1

最後,-2 在的結果是 3. 佔用一個位元位儲存。

儲存方式

經過編碼以後的資料,大大減少了欄位值的佔用位元組數,然後基於 T-L-V 的方式進行儲存

tag 的取值為 field_number(欄位數) << 3 | wire_type
296 被 varint 編碼後的位元組為 10101000 00000010



總結 

Protocol Buffer 的效能好,主要體現在 序列化後的資料體積小 & 序列化速度快,最終使得傳輸效率高,其原因如下:

序列化速度快的原因:

a. 編碼 / 解碼 方式簡單(只需要簡單的數學運算 = 位移等等)
b. 採用 Protocol Buffer 自身的框架程式碼 和 編譯器 共同完成序列化後的資料量體積小(即資料壓縮效果好)的原因:
a. 採用了獨特的編碼方式,如 Varint、Zigzag 編碼方式等等
b. 採用 T - L - V 的資料儲存方式:減少了分隔符的使用 & 資料儲存得緊湊

各個序列化技術的效能比較

這 個 地 址 有 針 對 不 同 序 列 化 技 術 進 行 性 能 比 較 :https://github.com/eishay/jvm-serializers/wiki

序列化技術的選型

技術層面

1. 序列化空間開銷,也就是序列化產生的結果大小,這個影響到傳輸的效能

2. 序列化過程中消耗的時長,序列化消耗時間過長影響到業務的響應時間

3. 序列化協議是否支援跨平臺,跨語言。因為現在的架構更加靈活,如果存在異構系統通訊需求,那麼這個是必須要考慮的

4. 可擴充套件性/相容性,在實際業務開發中,系統往往需要隨著需求的快速迭代來實現快速更新,這就要求我們採的可擴充套件性/相容性,比如在現有的序列化資料結構中新增一個業務欄位,不會影響到現有的服務

5. 技術的流行程度,越流行的技術意味著使用的公司多,那麼很多坑都已經淌過並且得到了解決,技術解決方案也相對成熟

6. 學習難度和易用性

選型建議

1. 對效能要求不高的場景,可以採用基於 XML 的 SOAP 協議
2. 對效能和間接性有比較高要求的場景,那麼 Hessian、Protobuf、Thrift、Avro 都可以。
3. 基於前後端分離,或者獨立的對外的 api 服務,選用 JSON 是比較好的,對於除錯、可讀性都很不錯
4. Avro 設計理念偏於動態型別語言,那麼這類的場景使用 Avro 是可以的