序列化技術初窺之Kryo、Hessian、Json

目錄

何爲序列化

JDK序列化

Kryo序列化

依賴

快速入門

三種讀寫方式

類註冊

線程安全

循環引用

JDK序列化和Kryo序列化性能對比

整合RedisTemplate測試

Hessian序列化

依賴

快速入門

Fastjson序列化

依賴

快速入門


何爲序列化

簡而言之,序列化就是一種處理對象流的機制,即將對象的內容流化,將數據轉化成字節流,以便存儲在文件中或用於在網絡中傳輸,當然用的最多的肯定就是網絡傳輸,RPC在實現數據傳遞時便依賴序列化層,反序列化就是相反的過程。在選擇序列化協議時,往往有如下幾個指標可供參考:

  • 通用性:是否只能用於java間序列化/反序列化,是否跨語言、跨平臺
  • 性能:分爲空間開銷和時間開銷,序列化後的數據一般用於存儲或網絡傳輸,其大小是很重要的一個參數;當然解析的時間也影響了序列化協議的選擇
  • 易用性:API使用是否複雜,是否影響開發效率
  • 可擴展性:實體類的屬性變更會不會導致反序列化異常,這通常會在系統升級時會產生,參考性不是很大

 

JDK序列化

JDK默認就爲我們提供了序列化,不管你用沒用過Kyro、hessian或Protobuf等各種熱門高效的序列化技術,你肯定用過JDK默認的序列化實現,該方式只需要在對應的實體類上實現Serializable接口即可將該類標識爲可被序列化,一個簡單的demo如下:

public void test0 () throws Exception {
        // 需實現Serializable接口
        User user = new User("123", "jdks");
        // 將對象寫入到文件中
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("user.txt"));
        objectOutputStream.writeObject(user);
        objectOutputStream.close();
        // 將對象從文件中讀出來
        ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("user.txt"));
        User newUser = (User) inputStream.readObject();
        inputStream.close();
        assert "jdks".equals(newUser.getName());
    }

容易用的模型通常性能不是太好,JDK序列化就屬於這個,同時該協議在傳遞的對象中還包含了元數據信息,佔用空間較大。但是由於這是java自帶的,因此簡單、方便,且無需第三方依賴

 

Kryo序列化

Kryo 是一個快速序列化/反序列化工具,其使用了字節碼生成機制(底層依賴了 ASM 庫),因此具有比較好的運行速度。

Kryo 序列化出來的結果,是其自定義的、獨有的一種格式,不再是 JSON 或者其他現有的通用格式;而且,其序列化出來的結果是二進制的(即 byte[];而 JSON 本質上是字符串 String);二進制數據顯然體積更小,序列化、反序列化時的速度也更快。

Kryo 一般只用來進行序列化(然後作爲緩存,或者落地到存儲設備之中)、反序列化,而不用於在多個系統、甚至多種語言間進行數據交換 —— 目前 kryo 也只有 java 實現。

像Redis這樣的存儲工具,是可以安全的存儲二進制數據,所以一般項目中可使用Kryo來替代JDK序列化進行存儲。

依賴

引入Maven依賴:

<dependency>
    <groupId>com.esotericsoftware</groupId>
    <artifactId>kryo</artifactId>
    <version>4.0.2</version>
</dependency>

需要注意的是,由於kryo使用了較高版本的asm,可能會與業務現有依賴的asm產生衝突,這是一個比較常見的問題。只需將依賴改成:

<dependency>
    <groupId>com.esotericsoftware</groupId>
    <artifactId>kryo-shaded</artifactId>
    <version>4.0.2</version>
</dependency>

快速入門

首先看下使用Kryo進行序列化的一個案例,如下:

public void test1() throws Exception {
        Kryo kryo = new Kryo();
        User user = new User("123", "kryo");
        Output output = new Output(new FileOutputStream("userKryo.txt"));
        kryo.writeObject(output, user);
        output.close();

        Input input = new Input(new FileInputStream("userKryo.txt"));
        User newUser = kryo.readObject(input, User.class);
        input.close();
        assert "kryo".equals(newUser.getName());
    }

可以看到與JDK序列化過程其實非常類似,整個過程也很清楚。

三種讀寫方式

Kryo共支持三種讀寫方式,如果知道class字節碼,並且對象不爲空,便可直接使用入門案例中的方式:writeObject/readObject,如果對象可能爲空,Kryo也提供了另外一種方式:writeObjectOrNull/readObjectOrNull,當然Kryo也支持將字節碼的信息直接存放到序列化結果中,在反序列化時自行讀取字節碼信息:writeClassAndObject/readClassAndObject,此時反序列化後的是個obj對象,需要判斷使用。針對泛型對象的反序列化,Kryo的解析就方便很多了,例如List<User>,看下面一個案例:

public void test4() throws Exception {
        Kryo kryo = new Kryo();
        List<User> list = Lists.newArrayList(new User("123", "kryoR"));
        Output output = new Output(new FileOutputStream("userKryo3.txt"));
        kryo.writeObject(output, list);
        output.close();

        Input input = new Input(new FileInputStream("userKryo3.txt"));
        // 使用Kryo在反序列化自定義對象的list時無需像有些json工具一樣透傳泛型參數,因爲Kryo在序列化結果裏記錄了泛型參數的實際類型的信息,反序列化時會根據這些信息來實例化對象
        List newList = kryo.readObject(input, ArrayList.class);
        input.close();
        assert newList.get(0) instanceof User;
    }

上面的案例中這一行代碼:List newList = kryo.readObject(input, ArrayList.class); 如果ArrayList.class換成List.class,則會得到如下異常:

com.esotericsoftware.kryo.KryoException: Class cannot be created (missing no-arg constructor): java.util.List

因爲Kryo不支持包含無參構造器類的反序列化,若嘗試反序列化一個不包含無參構造器的類就會得到如上異常,當然爲每一個類都增加無參構造器是每一個程序員都應當遵守的編程規範。

還有一個重要的點就是Kryo 不支持 Bean 中增刪字段。如果使用 Kryo 序列化了一個類,存入了 Redis,然後對類進行了修改,會導致反序列化的異常。當然我們可以catch住異常,並清除該緩存,然後返回”緩存未命中“信息給上層調用者。

類註冊

當Kryo在序列化一個對象時,默認需要將類的全限定名稱寫入,將類名一同寫入序列化數據中是比較低效的,所以Kryo支持通過類註冊進行優化:

kryo.register(SomeClassA.class);
kryo.register(SomeClassB.class);
kryo.register(SomeClassC.class);

在類註冊時會給每一個class一個int類型的id值相關聯,日後序列化和反序列化時都以該id值來替換類名,這顯然比一大串的類名稱要高效,但同時也要求反序列化時候的id必須與序列化過程保持一致,這意味着id與class的關聯不能變,也就是註冊的順序非常重要,但是它有個很大的弊端,它不能保證同一個class每次註冊的號碼都相同,只與註冊的順序有關,這意味着不同的機器或同一個機器在重啓前後都有可能擁有不同的編號,這會導致反序列化產生問題,所以在分佈式項目中該問題就會被暴露出來,之前項目中也遇到過一次,反序列化後得到的對象一直爲null,因此在Kryo中註冊行爲默認是關閉的,如果分佈式項目非要用,可以在註冊時指定id值,這樣的話註冊順序就無關緊要了。

可以混合使用註冊和未註冊的類,默認使用id 0-9 註冊所有基本類型,基本類包裝器,String 和 void。所以要小心此範圍內的註冊覆蓋的情況。

當 Kryo#setRegistrationRequired 設置爲true時,可在遇到任何未註冊的類時拋出異常,這能阻止應用程序使用類名字符串來序列化。

線程安全

Kryo默認是線程不安全的,有兩種解決方式,一個是通過Threadlocal的方式爲某個線程存儲一個實例:

private static final ThreadLocal<Kryo> kryoThreadLocal = new ThreadLocal<Kryo>() {
        protected Kryo initialValue() {
            Kryo kryo = new Kryo();
            // 這裏可以增加一系列配置信息
            return kryo;
        }
    };

另外一種是通過KryoPool的方式,該方式在性能上也好於ThreadLocal:

public KryoPool createPool() {
        return new KryoPool.Builder(() -> {
            Kryo kryo = new Kryo();
            // 此處也可以進行一系列配置,可通過實現KryoFactory接口來滿足動態註冊,抽象該類
            return kryo;
        }).softReferences().build();
    }

循環引用

這是對循環引用的支持,可以有效防止棧內存溢出,kryo默認會打開這個屬性。當你確定不會有循環引用發生的時候,可以通過kryo.setReferences(false);關閉循環引用檢測,從而提高一些性能,但不是很推薦

 

JDK序列化和Kryo序列化性能對比

以10000個測試對象爲例:

Kryo序列化消耗的時間181

Kryo反序列化消耗的時間223

JDK序列化消耗的時間458

JDK反序列化消耗的時間563

@Test
    public void test5() throws Exception{
        long time = System.currentTimeMillis();
        Kryo kryo = new Kryo();
        Output output = new Output(new FileOutputStream("kryoPerformance.txt"));
        Map<String, String> map = new HashMap<>();
        map.put("key", "value");
        for (int i = 0;i < 10000; i++) {
            kryo.writeObject(output, new User(String.valueOf(i), "test", false, Lists.newArrayList(String.valueOf(i)), map));
        }
        output.close();
        System.out.println("Kryo序列化消耗的時間" + (System.currentTimeMillis() - time));
    }

    @Test
    public void test6() throws Exception{
        long time = System.currentTimeMillis();
        Kryo kryo = new Kryo();
        Input input = new Input(new FileInputStream("kryoPerformance.txt"));
        User user = null;
        try {
            while (null != (user = kryo.readObject(input, User.class))) {

            }
        } catch (KryoException e) {

        }
        input.close();
        System.out.println("Kryo反序列化消耗的時間" + (System.currentTimeMillis() - time));
    }

    @Test
    public void test7() throws Exception{
        long time = System.currentTimeMillis();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("JDKPerformance.txt"));
        Map<String, String> map = new HashMap<>();
        map.put("key", "value");
        for (int i = 0;i < 10000; i++) {
            oos.writeObject(new User(String.valueOf(i), "test", false, Lists.newArrayList(String.valueOf(i)), map));
        }
        oos.close();
        System.out.println("JDK序列化消耗的時間" + (System.currentTimeMillis() - time));
    }

    @Test
    public void test8() throws Exception{
        long time = System.currentTimeMillis();
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("JDKPerformance.txt"));
        User user = null;
        try {
            while (null != (user = (User) ois.readObject())) {

            }
        } catch (EOFException e) {

        }
        System.out.println("JDK反序列化消耗的時間" + (System.currentTimeMillis() - time));
    }

由於上面的User對象存在循環引用且參數較多,經過測試在1000個對象時,jdk的速度會快一點,在普通對象上而言,Kryo還是比JDK速度要快的多,也更緊湊,如果提前註冊將在程序中所使用的類的話,性能會更好一點。

整合RedisTemplate測試

但實際上Kryo經常被用於Redis中序列化自定義對象時,以替代JDK的序列化方式,以RedisTemplate爲例,value的序列化方式默認即爲JDK序列化,但是其性能確實比不上Kryo

    @Bean
    public RedisTemplate<String, Serializable> jdkRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer());
        return redisTemplate;
    }

那麼如果改用Kryo序列化的話,勢必要先自定義一個Kryo序列化類,如下:

public class KryoSerializer implements RedisSerializer<Object> {

    private KryoPool kryoPool;

    private static final Logger logger = LoggerFactory.getLogger(KryoSerializer.class);

    public KryoSerializer() {
        kryoPool = new KryoPool.Builder(Kryo::new).softReferences().build();
    }

    @Override
    public byte[] serialize(Object data) throws SerializationException {
        byte[] result = new byte[0];
        if (null == data)
            return result;
        Kryo kryo = kryoPool.borrow();
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        // 這裏採用默認的緩衝字節數組大小即可,若指定的過大效率會非常慢
        Output output = new Output(bos);
        kryo.writeClassAndObject(output, data);
        output.close();
        // 釋放當前實例
        kryoPool.release(kryo);
        result = bos.toByteArray();
        try {
            bos.close();
        } catch (IOException e) {
            logger.error("Close IO error:{}", e);
        }
        return result;
    }

    @Override
    public Object deserialize(byte[] bytes) throws SerializationException {
        Object result = null;
        if (null != bytes && bytes.length > 0) {
            Kryo kryo = kryoPool.borrow();
            Input input = new Input(bytes);
            result = kryo.readClassAndObject(input);
            kryoPool.release(kryo);
            input.close();
        }
        return result;
    }
}

然後設值到對應的redisTemplate實例中:

    @Bean
    public RedisTemplate<String, Object> kryoRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new KryoSerializer());
        return redisTemplate;
    }

然後測試了下循環往Redis中設值,兩者所要消耗的時間,由於循環了5000次,意味着需要向Redis發送5000次命令,因此效率其實都很低,第三種方式採用了管道即5000次命令一次性告訴Redis,但Redis並不支持事物,因此並不能保證全部成功,示例代碼如下:

if (type == 0) {
            // jdk序列化方式
            long start = System.currentTimeMillis();
            for (int i = 0; i < 5000; i++) {
                String key = String.valueOf(i);
                String keyName = "jdk:user" + key;
                User user = new User(key, keyName, i, "18888888888", "塞外", "[email protected]");
                jdkRedisTemplate.opsForValue().set(keyName, user);
            }
            log.info("Serializable 序列化方式耗時:" + (System.currentTimeMillis() - start));
        } else if (type == 1) {
            // kryo序列化方式
            long start = System.currentTimeMillis();
            for (int i = 0; i < 5000; i++) {
                String key = String.valueOf(i);
                String keyName = "kryo:user" + key;
                User user = new User(key, keyName, i, "18888888888", "塞外", "[email protected]");
                kryoRedisTemplate.opsForValue().set(keyName, user);
            }
            log.info("Kryo 序列化方式耗時:" + (System.currentTimeMillis() - start));
        } else if (type == 2) {
            // 使用管道方式批量增加
            long start = System.currentTimeMillis();
            List<Object> result = kryoRedisTemplate.executePipelined(new RedisCallback<Object>() {
                @Override
                public Object doInRedis(RedisConnection connection) throws DataAccessException {
                    // 打開管道
                    connection.openPipeline();
                    // 然後給本次管道內添加要一次執行的多條命令
                    KryoSerializer kryoSerializer = new KryoSerializer();
                    for (int i = 5000; i < 10001; i++) {
                        String key = String.valueOf(i);
                        String keyName = "kryo:user" + key;
                        User user = new User(key, keyName, i, "18888888888", "塞外", "[email protected]");
                        connection.set(keyName.getBytes(), kryoSerializer.serialize(user));
                    }
                    // 管道不需要手動關閉,否則拿不到返回值
                    return null;
                }
            });

            // 可以對結果集進行獲取 result
            log.info("Kryo 批量序列化方式耗時:" + (System.currentTimeMillis() - start));
        }

耗時如下:

Serializable 序列化方式耗時:8619
Kryo 序列化方式耗時:4478
批量序列化方式耗時:197

 

Hessian序列化

依賴

<dependency>
    <groupId>com.caucho</groupId>
    <artifactId>hessian</artifactId>
    <version>4.0.51</version>
</dependency>

快速入門

public byte[] hessianSerialize(Object data) throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        Hessian2Output out = new Hessian2Output(bos);
        out.writeObject(data);
        out.flush();
        return bos.toByteArray();
    }

    public <T> T hessianDeserialize(byte[] bytes, Class<T> clz) throws IOException {
        Hessian2Input input = new Hessian2Input(new ByteArrayInputStream(bytes));
        return (T) input.readObject(clz);
    }

hessian序列化的實現機制是着重於數據,附帶簡單的類型信息的方法。支持跨語言,序列化後字節數適中,API易用。是國內主流RPC框架Dubbo、motan 的默認序列化協議,由於也沒怎麼用過就簡單略過。

 

Fastjson序列化

依賴

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.48</version>
        </dependency>

快速入門

public byte[] jsonSerialize(Object data) throws IOException{
        SerializeWriter out = new SerializeWriter();
        JSONSerializer serializer = new JSONSerializer(out);
        // 注意補充對枚舉類型的特殊處理
        serializer.config(SerializerFeature.WriteEnumUsingToString, true);
        // 額外補充類名可以在反序列化時獲得更豐富的信息
        serializer.config(SerializerFeature.WriteClassName, true);
        serializer.write(data);
        return out.toBytes("UTF-8");
    }

    public <T> T jsonDeserialize(byte[] data, Class<T> tClass) throws IOException {
        return JSON.parseObject(new String(data), tClass);
    }

作爲一個json工具,被拉到序列化方案中似乎有點不妥,但新浪開源的motan RPC框架除了支持hessian之外,還支持了Fastjson的序列化,因此也可以將其作爲一個跨語言序列化的簡易實現方案。

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