1、Java 序列化及其缺陷

Java 提供了一种序列化机制,这种机制能够将一个对象序列化为二进制形式(字节数组),用于写入磁盘或输出到网络,同时也能从网络或磁盘中读取字节数组,反序列化成对象,在程序中使用。

 

JDK 提供的两个输入、输出流对象 ObjectInputStream 和 ObjectOutputStream,它们只能对实现了 Serializable 接口的类的对象进行反序列化和序列化。

 

在实现了 Serializable 接口的类的对象中,会生成一个 serialVersionUID 的版本号,这个版本号有什么用呢?它会在反序列化过程中来验证序列化对象是否加载了反序列化的类,如果是具有相同类名的不同版本号的类,在反序列化中是无法获取对象的。

 

存在以下缺陷:

1. 无法跨语言

如果是两个基于不同语言编写的应用程序相互通信,则无法实现两个应用服务之间传输对象的序列化与反序列化。

2. 易被攻击

Java 序列化是不安全的,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。

2015 年 FoxGlove Security 安全团队的 breenmachine 发布过一篇长博客,主要内容是:通过 Apache Commons Collections,Java 反序列化漏洞可以实现攻击。

 

Apache Commons Collections 就是一个第三方基础库,它扩展了 Java 标准库里的 Collection 结构,提供了很多强有力的数据结构类型,并且实现了各种集合工具类。

实现攻击的原理就是:Apache Commons Collections 允许链式的任意的类函数反射调用,攻击者通过“实现了 Java 序列化协议”的端口,把攻击代码上传到服务器上,再由 Apache Commons Collections 里的 TransformedMap 来执行。

 

3. 序列化后的流太大

Java 序列后的流会变大,最终会影响到系统的吞吐量。

Java 序列化实现的二进制编码完成的二进制数组大小,比 ByteBuffer 实现的二进制编码完成的二进制数组大小要大上几倍。

4. 序列化性能太差

序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间。

	User user = new User();
    	user.setUserName("test");
    	user.setPassword("test");
    	
    	long startTime = System.currentTimeMillis();
    	
    	for(int i=0; i<1000; i++) {
    		ByteArrayOutputStream os =new ByteArrayOutputStream();
        	ObjectOutputStream out = new ObjectOutputStream(os);
        	out.writeObject(user);
        	out.flush();
        	out.close();
        	byte[] testByte = os.toByteArray();
        	os.close();
    	}
    
    	
    	long endTime = System.currentTimeMillis();
    	System.out.print("ObjectOutputStream 序列化时间:" + (endTime - startTime) + "\n");
long startTime1 = System.currentTimeMillis();
    	for(int i=0; i<1000; i++) {
    		ByteBuffer byteBuffer = ByteBuffer.allocate( 2048);
 
            byte[] userName = user.getUserName().getBytes();
            byte[] password = user.getPassword().getBytes();
            byteBuffer.putInt(userName.length);
            byteBuffer.put(userName);
            byteBuffer.putInt(password.length);
            byteBuffer.put(password);
            
            byteBuffer.flip();
            byte[] bytes = new byte[byteBuffer.remaining()];
    	}
    	long endTime1 = System.currentTimeMillis();
    	System.out.print("ByteBuffer 序列化时间:" + (endTime1 - startTime1)+ "\n");

运行结果:

ObjectOutputStream 序列化时间:29
ByteBuffer 序列化时间:6

通过以上案例,我们可以清楚地看到:Java 序列化中的编码耗时要比 ByteBuffer 长很多。

 

 

2、使用 Protobuf 序列化替换 Java 序列化

最近几年比较流行的 Json、Kryo、Protobuf、Hessian 等。这里推荐使用 Protobuf 序列化框架。

Protobuf 是由 Google 推出且支持多语言的序列化框架,目前在主流网站上的序列化框架性能对比测试报告中,Protobuf 无论是编解码耗时,还是二进制流压缩大小,都名列前茅。

Protobuf 以一个 .proto 后缀的文件为基础,这个文件描述了字段以及字段类型,通过工具可以生成不同语言的数据结构文件。在序列化该数据对象的时候,Protobuf 通过.proto 文件描述来生成 Protocol Buffers 格式的编码。

 

 

Protobuf 定义了一套自己的编码方式,几乎可以映射 Java/Python 等语言的所有基础数据类型。不同的编码方式对应不同的数据类型,还能采用不同的存储格式。如下图所示:

 

对于存储 Varint 编码数据,由于数据占用的存储空间是固定的,就不需要存储字节长度 Length,所以实际上 Protocol Buffers 的存储方式是 T - V,这样就又减少了一个字节的存储空间。

Protobuf 定义的 Varint 编码方式是一种变长的编码方式,每个数据类型一个字节的最后一位是一个标志位 (msb),用 0 和 1 来表示,0 表示当前字节已经是最后一个字节,1 表示这个数字后面还有一个字节。

对于 int32 类型数字,一般需要 4 个字节表示,若采用 Varint 编码方式,对于很小的 int32 类型数字,就可以用 1 个字节来表示。对于大部分整数类型数据来说,一般都是小于 256,所以这种操作可以起到很好地压缩数据的效果。