Java 序列化界新貴 kryo 和熟悉的“老大哥”,就是 PowerJob 的序列化方案

削微寒 發表於 2020-09-10

本文適合有 Java 基礎知識的人群

Java 序列化界新貴 kryo 和熟悉的“老大哥”,就是 PowerJob 的序列化方案

作者:HelloGitHub-Salieri

HelloGitHub 推出的《講解開源專案》系列。

專案地址:

https://github.com/KFCFans/PowerJob

序列化與反序列化一直是分散式程式設計中無法繞開的話題。PowerJob 作為一個完全意義上的分散式系統,自然少不了節點通訊時不可避免的序列化問題。由於 PowerJob 定位是中介軟體,出於對效能的追求,在序列化上自然也是花費了不少時間去雕琢。以下是整個過程中的一些經驗與分享,希望對大家有所幫助。

一、序列化界新貴:kryo

kryo 作為目前最快的序列化框架,自然受到了我的青睞。在 PowerJob 中,kryo 是內建預設的序列化框架。下面為大家介紹 kryo 的用法。

1.1 基礎用法

對於序列化框架來說,API 其實都差不多,畢竟入參和出參都定義好了(一個是需要序列化的物件,一個是序列化後的結果,比如位元組陣列)。下面簡單介紹下 kryo 的基礎用法,由於序列化和反序列化類似,以下使用序列化來作為演示。

Kryo kryo = new Kryo();
try (Output opt = new Output(1024, -1)) {
    kryo.writeClassAndObject(opt, obj);
    opt.flush();
    return opt.getBuffer();
}

程式碼很簡單,首先需要建立兩個物件:Kryo 和 Output。其中,Kryo 是序列化主角,負責完成實際的序列化/反序列化工作。而 Output 則是 kryo 框架封裝的流物件,用於儲存序列化後的二進位制資料。當兩個物件都準備完畢後,呼叫 kryo.writeClassAndObject(opt, obj) 方法即可完成物件的序列化,最後呼叫 Output 流物件的 getBuffer() 方法獲取序列化結果,也就是二進位制陣列。

1.2 執行緒不安全

相信大家都用過 fastjson,初次接觸 fastjson 肯定會被它簡單的 API 所吸引,常用的序列化/反序列化統統一行程式碼搞定,比如 JSON.toJSONString()。通常來說,這種通過靜態方法暴露的 API,其背後的設計與實現都是執行緒安全的,也就是在多執行緒環境中,你可以安心的使用 fastjson 的靜態方法進行序列化和反序列化,那麼 kryo 可以嗎?

從上述程式碼不難看出,不可以~否則,人家為什麼要多次一舉讓你建立物件提高使用成本呢?

王進喜同志說過,沒有條件就創造條件。既然 kryo 官方不提供靜態方法讓我們簡單使用,那就自己封裝一個吧~

Java 序列化界新貴 kryo 和熟悉的“老大哥”,就是 PowerJob 的序列化方案

拋開效能因素,封裝一個工具類非常簡單,畢竟我們的目標是解決 kryo 的併發安全問題,而當沒有任何共享資源時,是不存在任何併發安全問題的。那麼我們只需要在剛剛的例項程式碼上,套上一個靜態方法,就完成了最簡單的kryo 工具類封裝,程式碼示例如下:

public static byte[] serialize(Object obj) {
    Kryo kryo = new Kryo();
    try (Output opt = new Output(1024, -1)) {
        kryo.writeClassAndObject(opt, obj);
        opt.flush();
        return opt.getBuffer();
    }
}

安全問題是解決了,但...事情往往不會那麼簡單。這種模式下,每一次呼叫都會重複建立 2 個新物件(Kryo 和 Output),這在高併發下會產生一筆不小的開銷。為了獲取效能的提升,自然要考慮到物件的複用問題。物件的複用常用解決方案有兩個,分別是物件池和 ThreadLocal,下面分別進行介紹。

1.3 物件池

在程式設計中,“池”這個名詞相信大家一定不陌生。執行緒池、連線池已經是併發程式設計中不可避免的一部分。“池”重複利用了複用的思想,將建立完後的物件通過某個容器儲存起來反覆使用,從而達到提升效能的作用。Kryo 物件池原理上便是如此。Kryo 框架自帶了物件池的實現,因此使用非常簡單,不外乎建立池、從池中獲取物件、歸還物件三步,以下為程式碼例項。

首先,建立 Kryo 物件池,通過重寫 Pool 介面的 create 方法,便可建立出自定義配置的物件池。

private static final Pool<Kryo> kryoPool = new Pool<Kryo>(true, false, 512) {
    @Override
    protected Kryo create() {
        Kryo kryo = new Kryo();
        // 關閉序列化註冊,會導致效能些許下降,但在分散式環境中,註冊類生成ID不一致會導致錯誤
        kryo.setRegistrationRequired(false);
        // 支援迴圈引用,也會導致效能些許下降 T_T
        kryo.setReferences(true);
        return kryo;
    }
};

當需要使用 kryo 時,呼叫 kryoPool.obtain() 方法即可,使用完畢後再呼叫 kryoPool.free(kryo) 歸還物件,就完成了一次完整的租賃使用。

public static byte[] serialize(Object obj) {
    Kryo kryo = kryoPool.obtain();
    // 使用 Output 物件池會導致序列化重複的錯誤(getBuffer返回了Output物件的buffer引用)
    try (Output opt = new Output(1024, -1)) {
        kryo.writeClassAndObject(opt, obj);
        opt.flush();
        return opt.getBuffer();
    }finally {
        kryoPool.free(kryo);
    }
}

物件池技術是所有併發安全方案中效能最好的,只要物件池大小評估得當,就能在佔用極小記憶體空間的情況下完美解決併發安全問題。這也是 PowerJob 誕生初期使用的方案,直到...PowerJob 正式推出容器功能後,才不得不放棄該完美方案。

在容器模式下,使用 kryo 物件池計算會有什麼問題呢?這裡簡單給大家提一下,至於看不看得懂,就要看各位造化了~

PowerJob 容器功能指的是動態載入外部程式碼進行執行,為了進行隔離,PowerJob 會使用單獨的類載入器完成容器中類的載入。因此,每一個 powerjob-worker 中存在著多個類載入器,分別是系統類載入器(負責專案的載入)和每個容器自己的類載入器(載入容器類)。序列化工具類自然是 powerjob-worker 的一部分,隨 powerjob-worker 的啟動而被建立。當 kryo 物件池被建立時,其使用的類載入器是系統類載入器。因此,當需要序列化/反序列化容器中的類時,kryo 並不能從自己的類載入器中獲取相關的類資訊,妥妥的丟擲 ClassNotFoundError!

因此,PowerJob 在引入容器技術後,只能退而求其次,採取了第二種併發安全方法:ThreadLocal。

1.4 ThreadLocal

ThreadLocal 是一種典型的犧牲空間來換取併發安全的方式,它會為每個執行緒都單獨建立本執行緒專用的 kryo 物件。對於每條執行緒的每個 kryo 物件來說,都是順序執行的,因此天然避免了併發安全問題。建立方法如下:

private static final ThreadLocal<Kryo> kryoLocal = ThreadLocal.withInitial(() -> {
    Kryo kryo = new Kryo();
    // 支援物件迴圈引用(否則會棧溢位),會導致效能些許下降 T_T
    kryo.setReferences(true); //預設值就是 true,新增此行的目的是為了提醒維護者,不要改變這個配置
    // 關閉序列化註冊,會導致效能些許下降,但在分散式環境中,註冊類生成ID不一致會導致錯誤
    kryo.setRegistrationRequired(false);
    // 設定類載入器為執行緒上下文類載入器(如果Processor來源於容器,必須使用容器的類載入器,否則妥妥的CNF)
    kryo.setClassLoader(Thread.currentThread().getContextClassLoader());
    return kryo;
});

之後,僅需要通過 *kryoLocal*.get() 方法從執行緒上下文中取出物件即可使用,也算是一種簡單好用的方案。(雖然理論效能比物件池差不少)

二、老牌框架:Jackson

大名鼎鼎的 Jackson 相信大家都聽說過,也是很多專案的御用 JSON 序列化/反序列化框架。在 PowerJob 中,本著不重複造輪子的原則,在 akka 通訊層,使用了 jackson-cbor 作為預設的序列化框架。

“什麼,你問我為什麼不用效能更好且已經在專案中整合了的 kryo?”

“那當然是因為 akka 官方沒有提供 kryo 的官方實現,於是......”

Java 序列化界新貴 kryo 和熟悉的“老大哥”,就是 PowerJob 的序列化方案

如果使用 kryo,則需要自己實現一大堆編解碼器,儼然有點寫 netty 的味道...而 jackson-cbor 呢?只需要一點小小的配置就能搞定~

actor {
    provider = remote
    allow-java-serialization = off
    serialization-bindings {
        "com.github.kfcfans.powerjob.common.OmsSerializable" = jackson-cbor
    }
  }

雖然絕對效能可能不及 kryo,但對比於自帶的 Java 序列化方式,效能已經提升 10 倍以上,在絕大部分場景都不會是效能瓶頸。所以~又有什麼理由拒絕它呢~

三、最後

好了,這就是本文的全部內容了。下篇文章將會為大家帶來 PowerJob 的獨一無二分散式計算功能背後的原理分析,如此重磅的文章作為本專欄的壓軸好戲也是再恰當不過了~

那麼,我們下期再見嘍~

『講解開源專案系列』——讓對開源專案感興趣的人不再畏懼、讓開源專案的發起者不再孤單。歡迎開源專案作者聯絡我(微信:xueweihan,備註:講解)加入我們,讓更多人愛上、貢獻開源~


Java 序列化界新貴 kryo 和熟悉的“老大哥”,就是 PowerJob 的序列化方案

關注 HelloGitHub 公眾號