Java序列化

2019, Sep 05    

Java序列化

序列化就是,将特定结构的数据转化为另一种能被存储和还原的格式。序列化主要是为了存储和网络传输。

1.序列化关注的问题

  • 正确性:对对象引用关系(特殊的比如循环引用)、继承关系以及泛型的支持是否完备,这些都可能带来不可预知的bug。
  • 开发成本:有的序列化方式可以无缝接入业务系统,有的则需要额外的配置,以具体场景能容忍的开发成本为准。
  • 时间开销:序列化过程中,JVM的GC机制、类加载反射机制会影响时间开销,另外由于IO的成本一般大于内存、CPU的计算的成本,时间开销也往往强依赖于序列化的空间开销,序列化文件越大相应的时间开销也越大。
  • 空间开销:包括序列化过程中动态的内存空间占用量,以及序列化后产出的文件大小。
  • 安全性:因为序列化常涉及到存储和传输,数据暴露在外,涉及到安全性的问题。但同时由于序列化的过程常发生在内部网络,对外输出的数据则有应用层的协议保障安全,故一般情况下,序列化对安全性要求不高。
  • 前向后向兼容:从业务演化的角度,可能出现序列化前后数据模式的改变。比如RPC过程中调用端和被调用端的Java类字段的改变。这就要求序列化有前向、后向兼容性,以此保证业务流程在业务演化过程中的稳定性。
  • 跨语言兼容:大多数情况下同一架构体系内的应用采用同样的语言框架,不用考虑跨语言兼容。当某些互相耦合的模块之间不可避免的需要不同语言的应用来支持时,就需要序列化协议具有跨语言的能力。

2.Java二进制序列化

序列化本质上就是一种编解码方式。

普通的Java对象的数据通过字段的方式组织,类似K-V的形式,序列化后的二进制数组需要能表达不同字段的拆分,即数据单元的拆分和映射。广义而言,有两种方式实现数据单元的拆分、映射:

  • 动态携带meta信息:即类似通过 消息头+消息体 的方式,将类信息携带到序列化后的二进制数据中。
    • Java原生序列化:将类信息用字符串的形式全量的写入二进制流,来粗暴的实现序列化。这是比较极端和稳妥的方式,效率低,但不会出错。
    • Hessian、Kryo等:将meta信息写入二进制流,但并不是每个对象都保存了meta信息.特殊的编码方式提高了序列化效率。同时kryo也提供了在序列化前注册类的方式,这其实是利用了反射机制来实现类似 静态meta信息文件 ,提升效率。
  • 静态的meta信息文件:通过定义额外的静态meta信息文件,来指导数据的拆分、映射。
    • protobuf、thrift等:借助中间文件,提升传输效率和兼容性。其中IDL(Interface Define Language)文件存储了字段的类型、顺序等信息,在二进制流中则包含了字段id,类型、长度信息,从而减少二进制流的大小,提升序列化速度。

其中前者空间开销偏大。而后者相当于缓存了meta信息,执行上更高效,当然这会引入开发流程中的额外工作量。有人或许会想到,是不是通过Java反射就可以实现IDL的方式,从而达到一种无成本接入、且效率高的序列化方式?实际上这是没法在做好前向后向兼容性的基础上实现的,因为Java Specification中没有规定Java类中字段的顺序,不同JVM底层实现可能不同,反射拿到的字段顺序不确定,在反序列化时数值没法一一对应上字段,自然就限制了前向后向兼容性。

常见的Java序列化框架,包括Java原生、Hessian、Kryo、proto-buffers、proto-stuff

1. Java原生序列化

实现了java.io.Serializable接口的类,可以被执行串行化操作。通过serialVersionUID来验证版本的一致性,否则抛出异常。

  • 优点
    • 稳定无bug,对Java继承体系提供完整的支持。
    • 开发接入成本低。
    • 支持自定义序列化的方式,可实现自定义的加密等措施(重写readObjectwriteObject方法)
    • 提供了安全性的支持(比如使用javax.crypto.SealedObjectjava.security.SignedObject做装饰)
  • 缺点
    • 文件大速度慢
      • 存储了所有元信息到每个对象的二进制流。
      • 所有字段信息用字符串写入二进制流,空间很大。
    • 无法跨语言,java强绑定
  • 使用中的坑
    • 如果内部的字段的类没有实现java.io.Serializable,也会报错。
    • 子类实现了java.io.Serializable接口,父类没有,则子类中属性能正确序列化,父类中的属性无法序列化,数据丢失。

序列化的示例代码如下,序列化对代码几乎无侵入,接入成本低。

        ByteArrayOutputStream baos = outputStream(data);
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(data);
        return baos.toByteArray();

另外还有一种Java原生的externalization的方式,通过自己实现writeExternal(ObjectOutput)readExternal(ObjectInput)来完成对象的序列化。这种全权交与开发者来处理序列化细节的方式在实践中很少见。

2. Hessian

hessian原本是一个RPC框架,基于http传输,通过自定义的序列化协议将数据转换成二进制流,支持跨语言兼容性。由于其中序列化协议在稳定性和效率的折中上表现优异,切接入零成本,被广泛在互联网应用中使用。见hessian串行化协议](http://hessian.caucho.com/doc/hessian-serialization.html)(中文译版)。

  • 特点
    • 时间、空间开销优于Java原生的方式,逊于Kryo、protobuff等其他方式。
    • 对继承支持不足。由于其跨语言的特请,没在继承关系上充足考虑,在继承体系上有很多bug。
    • 支持引用类型,支持循环引用。
  • 一些坑
    • 子类和父类同名字段会被覆盖
    • 对继承的支持不友好。比如继承自HashMap的类的添加字段无法被序列化
    • 不支持某些集合类, 如参考

使用Hessian序列化的类,同样需要实现java.io.Serializable接口。调用代码类似于Java原生序列化:

        ByteArrayOutputStream out = outputStream(data);
        Hessian2StreamingOutput hout = new Hessian2StreamingOutput(out);
        hout.writeObject(data);
        return out.toByteArray();
3.Kryo

Kryo实现思路和Hessian类似,性能优于Hessian,但兼容性、稳定性不如Hessian。在Twitter、Apache Hive、Akka等产品中被广泛使用。

优点:

  • 注册类:Kryo二进制流中没有类的字段描述信息,这减小了二进制流的大小。字段描述信息通过向kryo注册类来主动加载,或者在第一次被序列化、反序列化时被动加载。
  • 支持引用开关:开启引用功能后,支持循环引用。
  • 通过变长编码来记录int、long型,节省空间。

缺点有:

  • 前后向兼容差,不支持改动字段。因为二进制流中没有字段描述信息。
  • 跨语言不支持
  • 开发成本:必须有无参构造函数(hessian不需要),但不需要实现Serializable接口(hessian一定要实现)

类似的序列化框架还有fast-serialization,速度快、体系小,但缺点同样明显:不支持前后向兼容。

4. Proto buffers

Protocol buffers是Google开源的跨语言编码协议。Google内部的几乎所有RPC都使用了该协议。跟hessian类似,作为致力于跨语言的协议,pb并没有花精力在支持Java的继承体系上。

pb的开发成本很高。需要定义.proto文件,并用工具生成对应的辅助类。该类特有一些序列化的辅助方法,所有要序列化的对象,都需要先转化为辅助类的对象,这让序列化代码跟业务代码大量耦合,是侵入性较强的一种方式。由于代码量较大,这里不展开相应的调用代码,代码示例参见:protobuf的Java代码示例

  • 优点:
    • 跨语言兼容
    • 前后向兼容性好:字段匹配依赖于顺序,添加字段注意往后面加,不要删除字段。
    • 支持基本类型的自动转换,比如int转long
    • 性能优势:编码后size小,编解码速度快
  • 缺点:
    • 不支持继承。
    • 没有引用类型,不支持循环引用。
    • 需要自己写proto文件,定义schema,并生成对应的源码文件。开发繁琐,侵入了业务代码。

值得一提的是,在协议设计上,pb做了很多工作,让它拥有非常高的效率。包括并不限于以下点。有兴趣可以参照文档了解:protocol-buffers编码规则

  • 按照.proto文件定义的字段顺序,存储字段值,避免了重复记录字段名。
  • 紧凑编码:varint,用MSB(高bit位)确定总体长度。
  • zigzag编码,区别于补码实现的正负数,数值小时,负数所需字节少。
5. Proto stuff

proto stuff是一个集成了多重协议的序列化框架。像它的名字一样,它也提供了对protobuf协议的支持。这里介绍protostuff的runtime功能,兼具了不用手写IDL文件的省事儿,又能获得较好的性能优势。

  • 优点
    • 兼容protobuf
      • 向前向后兼容
      • 文件小,速度快
      • 支持基本类型转换
    • 支持引用类型:protostuff-graph 能支持对象图。
    • 支持运行时生成schema的runtime方式,不用手写,开发接入成本低。
  • 注意点:要保证足够的兼容性,建议使用@Tag标签定义字段顺序。

下面是protostuff-runtime的示例代码:

        final Schema<data.media.MediaContent> schema =         RuntimeSchema.getSchema(data.media.MediaContent.class);
        final LinkedBuffer buffer = LinkedBuffer.allocate(BUFFER_SIZE);

        public data.media.MediaContent deserialize(byte[] array) throws Exception
        {
            data.media.MediaContent mc = new data.media.MediaContent();
            ProtostuffIOUtil.mergeFrom(array, mc, schema);
            return mc;
        }

        public byte[] serialize(data.media.MediaContent content) throws Exception
        {
            try
            {
                return ProtostuffIOUtil.toByteArray(content, schema, buffer);
            }
            finally
            {
                buffer.clear();
            }
        }
6. JSON类等

JSON的格式本身有强语义,可读。把它作为中间结果,便于问题排查。其中较快的库有alibaba的fastJson。把它用作序列化框架,其特点有:

  • 中间结果可读,易于排查
  • 跨平台、跨语言支持
  • 速度快、但体积大
  • 支持引用类型,支持循环引用。
  • 由于多了层JSON的解析,引入了更多稳定应隐患,对继承、泛型的支持也不够好。

插播:fastjson最新漏洞!!!

2019年9月3日,fastjson在commit 995845170527221ca0293cf290e33a7d6cb52bf7上提交了旨在修复当字符串中包含\x转义字符时可能引发OOM的问题的修复。

漏洞详情

漏洞的关键点在com.alibaba.fastjson.parser.JSONLexerBase#scanString中,当传入json字符串时,fastjson会按位获取json字符串,当识别到字符串为\x为开头时,会默认获取后两位字符,并将后两位字符与\x拼接将其变成完整的十六进制字符来处理:

而当json字符串是以\x结尾时,由于fastjson并未对其进行校验,将导致其继续尝试获取后两位的字符。也就是说会直接获取到\u001A也就是EOF:

当fastjson再次向后进行解析时,会不断重复获取EOF,并将其写到内存中,直到触发oom错误:

// 漏洞代码
 				case 'x':
                        char x1 = ch = next();
                        char x2 = ch = next();

                        int x_val = digits[x1] * 16 + digits[x2];
                        char x_char = (char) x_val;
                        putChar(x_char);


// 死循环部分
 for (;;) {
            ch = next();

            if (ch == '\"') {
                break;
            }

            if (ch == EOI) {
                if (!isEOF()) {
                    putChar((char) EOI);
                    continue;
                }
                ....
7. 对照表
  正确性 开发成本 时间、空间开销 前向后向兼容 跨语言兼容 其他
Java 原生 低,需要实现Seriliable接口 文件大、速度慢 支持 支持  
Hessian 支持引用,继承、泛型支持差 低,需要实现Seriliable接口 相比Java原生有较大提升,但跟kryo等比有差距 支持 支持  
Kryo 引用支持可开关,继承、泛型支持差 低,对代码无侵入 快,接近protobuf 不支持 不支持  
proto-buffers 不支持引用,不支持继承,不支持泛型 高,需要手动成圣proto文件,对代码侵入高 文件小,速度快 支持,字段按顺序向后添加 支持  
proto-stuff 有多重协议方案,其中graph支持引用,继承、泛型支持差 低,可选侵入代码(通过@Tag注解指定字段编号以支持在任意位置添加字段的兼容性) 文件小,速度快 支持,可指定字段编号 支持 有多种协议供选择:proto-buf 支持原始proto-buf, proto-buf-runtime 支持运行时的schema生成,proto-stuff-graph-runtime 能支持对象图、循环引用。
fastjson 引用支持可开关,泛型、继承支持差          

fastjson在反序列化时,怎么解析对象中的继承(https://segmentfault.com/q/1010000002568216)

import com.alibaba.fastjson.JSON;

public class Text {

    private A a;

    class A {

        private String a;

        public String getA() {
            return a;
        }

        public void setA(String a) {
            this.a = a;
        }

    }

    class B extends A {

        private String b;

        public String getB() {
            return b;
        }

        public void setB(String b) {
            this.b = b;
        }

    }

    public A getA() {
        return a;
    }

    public void setA(A a) {
        this.a = a;
    }

    public static void main(String[] args) {
        Text text = new Text();

        B b = text.new B();
        b.setA("aaaaa");
        b.setB("bbbbb");

        text.setA(b);

        String ttt = JSON.toJSONString(text);
        System.out.println(ttt);
        Text text2 = JSON.parseObject(ttt, Text.class);

        System.out.println(JSON.toJSONString(text2));
    }
}

这里的输出结果分别是: {“a”:{“a”:”aaaaa”,”b”:”bbbbb”}} {“a”:{“a”:”aaaaa”}} 在第二次反序列化时候,由于Text对象中A存在继承关系。所以出现信息丢失