分佈式專題-分佈式架構基礎03-序列化與反序列化

前言

Tips:

1.瞭解序列化的意義

2.如何實現一個序列化操作

3.序列化的高階認識

4.常見的序列化技術及應用

5.Protobuf 實現原理分析

6.序列化框架的選型

瞭解序列化的意義

在這裏插入圖片描述
Java 平臺允許我們在內存中創建可複用的 Java 對象,但一般情況下,只有當 JVM 處於運行時,這些對象纔可能存在,即,這些對象的生命週期不會比 JVM 的生命週期更長。但在現實應用中,就可能要求在 JVM 停止運行之後能夠保存(持久化)指定的對象,並在將來重新讀取被保存的對象。Java 對象序列化就能夠幫助我們實現該功能

簡單來說
序列化是把對象的狀態信息轉化爲可存儲或傳輸的形式過程,也就是把對象轉化爲字節序列的過程稱爲對象的序列化
反序列化是序列化的逆向過程,把字節數組反序列化爲對象,把字節序列恢復爲對象的過程成爲對象的反序列化

序列化面臨的挑戰

評價一個序列化算法優劣的兩個重要指標是:序列化以後的數據大小;序列化操作本身的速度及系統資源開銷(CPU、內存);

Java 語言本身提供了對象序列化機制,也是 Java 語言本身最重要的底層機制之一,Java 本身提供的序列化機制存在兩個問題

1.序列化的數據比較大,傳輸效率低

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

如何實現一個序列化操作

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

  • 定義接口
public interface ISerializer {

    <T> byte[] serializer(T obj);

    <T> T deSerializer(byte[] data,Class<T> clazz);
}
  • 基於 JDK 序列化方式實現
    JDK 提 供 了 Java 對 象 的 序 列 化 方 式 , 主 要 通 過 輸 出 流 java.io.ObjectOutputStream 和對象輸入流 java.io.ObjectInputStream 來實現。其中,被序列化的對象需要實現 java.io.Serializable 接口
public class JavaSerializer implements ISerializer {

    @Override
    public <T> byte[] serializer(T obj) {
        ObjectOutputStream objectOutputStream=null;
        try {
            objectOutputStream=new ObjectOutputStream(new FileOutputStream(new File("user")));
            objectOutputStream.writeObject(obj);
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if(objectOutputStream!=null){
                try {
                    objectOutputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return null;
    }

    @Override
    public <T> T deSerializer(byte[] data, Class<T> clazz) {
        ObjectInputStream objectInputStream=null;
        try {
            objectInputStream=new ObjectInputStream(new FileInputStream(new File("user")));
            return (T)objectInputStream.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } finally {
            if(objectInputStream!=null){
                try {
                    objectInputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return null;
    }
}
  • 具體實現

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

public class User extends SuperClass  {

    public static int num=5;

    private String name;
    private int age;

    private transient String hobby;

    //序列化對象
    private void writeObject(ObjectOutputStream objectOutputStream) throws IOException {
        objectOutputStream.defaultWriteObject();
        objectOutputStream.writeObject(hobby);
    }

    //反序列化
    private void readObject(ObjectInputStream objectInputStream) throws IOException, ClassNotFoundException {
        objectInputStream.defaultReadObject();
        hobby=(String)objectInputStream.readObject();
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getHobby() {
        return hobby;
    }

    public void setHobby(String hobby) {
        this.hobby = hobby;
    }

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", hobby='" + hobby + '\'' +
                '}';
    }
}

測試:

    public static void main( String[] args ) {
        ISerializer iSerializer=new FastjsonSerializer();
        User user=new User();
        user.setAge(18);
        user.setName("Mic");
        user.setHobby("菲菲");
        user.setSex("男");

        byte[] rs=iSerializer.serializer(user);

        System.out.println(new String(rs));
        
        User user1=iSerializer.deSerializer(rs,User.class);
        System.out.println(user1+"->"+user1.getSex());
        
    }

序列化的高階認識

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 的值

在這裏插入圖片描述
測試:

    public static void main( String[] args ) {
        ISerializer iSerializer=new FastjsonSerializer();
        User user=new User();
        user.setAge(18);
        user.setName("Mic");
        user.setHobby("菲菲");
        user.setSex("男");
        User.num = 10;
        byte[] rs=iSerializer.serializer(user);

        System.out.println(new String(rs));
        
        User user1=iSerializer.deSerializer(rs,User.class);
        System.out.println(user1+"->"+user1.getSex());
        System.out.println(user1+":"+User.num);
    }

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

父類的序列化

一個子類實現了 Serializable 接口,它的父類都沒有實現 Serializable接口,在子類中設置父類的成員變量的值,接着序列化該子類對象。再反序列化出來以後輸出父類屬性的值。結果應該是什麼?
在這裏插入圖片描述
發現父類的 sex 字段的值爲 null。也就是父類沒有實現序列化

結論:

1.當一個父類沒有實現序列化時,子類繼承該父類並且實現了序列化。在反序列化該子類後,是沒辦法獲取到父類的屬性值的
2.當一個父類實現序列化,子類自動實現序列化,不需要再顯示實現
Serializable 接口
3.當一個對象的實例變量引用了其他對象,序列化該對象時也會把引用對象進行序列化,但是前提是該引用對象必須實現序列化接口

Transient 關鍵字

Transient 關鍵字的作用是控制變量的序列化,在變量聲明前加上該關

鍵字,可以阻止該變量被序列化到文件中,在被反序列化後,transient

變量的值被設爲初始值,如 int 型的是 0,對象型的是 null

在這裏插入圖片描述

繞開 transient 機制的辦法

    //序列化對象
    private void writeObject(ObjectOutputStream objectOutputStream) throws IOException {
        objectOutputStream.defaultWriteObject();
        objectOutputStream.writeObject(hobby);
    }

    //反序列化
    private void readObject(ObjectInputStream objectInputStream) throws IOException, ClassNotFoundException {
        objectInputStream.defaultReadObject();
        hobby=(String)objectInputStream.readObject();
    }

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

序列化的存儲規則

public static void main(String[] args) throws IOException {
        ObjectOutputStream outputStream=
                new ObjectOutputStream(new FileOutputStream(new File("user")));
        User user=new User();
        user.setAge(18);
        user.setName("Mic");
        user.setHobby("菲菲");
        user.setSex("男");
        outputStream.flush();
        outputStream.writeObject(user);
        System.out.println(new File("user").length());
        outputStream.writeObject(user);
        outputStream.flush();
        outputStream.close();
        System.out.println(new File("user").length());

    }

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

Java 序列化機制爲了節省磁盤空間,具有特定的存儲規則,當寫入文件的爲同一對象時,並不會再將對象的內容進行存儲,而只是再次存儲一份引用,上面增加的 5 字節的存儲空間就是新增引用和一些控制信息的空間。反序列化時,恢復引用關係.該存儲規則極大的節省了存儲空間。

序列化實現深克隆

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

淺克隆

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

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

public class Email implements Serializable {


    private String content;

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

Person:

@Data
public class Person implements Cloneable,Serializable {

    private String name;

    private Email email;

    @Override
    protected Person clone() throws CloneNotSupportedException {
        return (Person)super.clone();
    }

    public Person deepClone() throws IOException, ClassNotFoundException {
        ByteArrayOutputStream bos=new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream=
                new ObjectOutputStream(bos);
        objectOutputStream.writeObject(this);

        ByteArrayInputStream bis=new ByteArrayInputStream(bos.toByteArray());
        ObjectInputStream objectInputStream=new ObjectInputStream(bis);
        return (Person) objectInputStream.readObject();

    }
}

測試:

 public static void main(String[] args) throws CloneNotSupportedException, IOException, ClassNotFoundException {
        Email email=new Email();
        email.setContent("今天晚上20:00有課程");
        Person p1=new Person();
        p1.setName("Mic");
        p1.setEmail(email);

//        Person p2=p1.clone();
        Person p2=p1.deepClone();
        p2.setName("黑白");
        p2.getEmail().setContent("今天晚上是20:30上課");

        System.out.println(p1.getName()+"->"+p1.getEmail().getContent());
        System.out.println(p2.getName()+"->"+p2.getEmail().getContent());

    }

但是,當我們只希望,修改“黑白”的上課時間,調整爲 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.1-win32.zip

編寫 proto 文件

syntax="proto2";

package com.test.serial;




option java_package = "com.test.serial"; option java_outer_classname="UserProtos";




message User {

required string name=1;

required int32 age=2;

}

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 不影響結果

在這裏插入圖片描述
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 位,得到下面右圖。再將兩者取異或算法。實現最終的壓縮。

在這裏插入圖片描述

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

異或運算是

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 是可以的

後記

代碼地址:

序列化 github代碼地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章