为什么不使用Java序列化

发布时间:2021-10-21 10:52:14 作者:iii
来源:亿速云 阅读:159

这篇文章主要介绍“为什么不使用Java序列化”,在日常操作中,相信很多人在为什么不使用Java序列化问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答”为什么不使用Java序列化”的疑惑有所帮助!接下来,请跟着小编一起来学习吧!

作为一名Java开发,我为什么建议你在开发中避免使用Java序列化?

如今大部分的后端服务都是基于微服务架构实现的,服务按照业务划分被拆分,实现了服务的解耦,同时也带来了一些新的问题,比如不同业务之间的通信需要通过接口实现调用。两个服务之间要共享一个数据对象,就需要从对象转换成二进制流,通过网络传输,传送到对方服务,再转换成对象,供服务方法调用。这个编码和解码的过程我们称之为序列化和反序列化。

在高并发系统中,序列化的速度快慢,会影响请求的响应时间,序列化后的传输数据体积大,会导致网络吞吐量下降,所以,一个优秀的序列化框架可以提高系统的整体性能。

我们都知道Java提供了RMI框架可以实现服务与服务之间的接口暴露和调用,RMI中对数据对象的序列化采用的是Java序列化。而目前主流的框架却很少使用到Java序列化,如SpringCloud使用的Json序列化,Dubbo虽然兼容了Java序列化,但是默认还是使用的Hessian序列化。

Java序列化

首先,来看看什么是Java序列化和实现原理。Java提供了一种序列化机制,这种机制能将一个对象序列化成二进制形式,用于写入磁盘或输出到网络,同时将从网络或者磁盘中读取的字节数组,反序列化成对象,在程序中使用。

为什么不使用Java序列化

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

ObjectOutputStream 的默认序列化方式,仅对对象的非 transient 的实例变量进行序列化,而不会序列化对象的 transient  的实例变量,也不会序列化静态变量。

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

具体实现序列化的是writeObject和readObject,通常这两个方法是默认的,我们也可以在实现Serializable接口的类中对其重写,定制属于自己的序列化和反序列化机制。

Java序列化类中还定义了两个重写方法:writeReplace()和readResolve(),前者是用来在序列化之前替换序列化对象的,后者是用来在序列化之后对返回对象进行处理的。

Java序列化缺陷

我们在用过的RPC通信框架中,很少会发现使用JDK提供的序列化,主要是因为JDK默认的序列化存在着如下一些缺陷:无法跨语言、易被攻击、序列化后的流太大、序列化性能太差等。

1. 无法跨语言

现在很多系统的复杂度很高,采用多种语言来编码,而Java序列化目前只支持Java语言实现的框架,其它语言大部分都没有使用Java的序列化框架,也没有实现Java序列化这套协议,因此,如果两个基于不同语言编写的应用程序之间通信,使用Java序列化,则无法实现两个应用服务之间传输对象的序列化和反序列化。

2. 易被攻击

Java官网安全编码指导方针里有说明,“对于不信任数据的反序列化,从本质上来说是危险的,应该避免“。可见Java序列化并不是安全的。

我们知道对象是通过在 ObjectInputStream 上调用 readObject()  方法进行反序列化的,这个方法其实是一个神奇的构造器,它可以将类路径上几乎所有实现了 Serializable  接口的对象都实例化。这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。

对于需要长时间进行反序列化的对象,不需要执行任何代码,也可以发起一次攻击。攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致  hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。例如下面这个案例就可以很好地说明。

Set root = new HashSet();   Set s1 = root;   Set s2 = new HashSet();   for (int i = 0; i < 100; i++) {      Set t1 = new HashSet();      Set t2 = new HashSet();      t1.add("test"); //使t2不等于t1      s1.add(t1);      s1.add(t2);      s2.add(t1);      s2.add(t2);      s1 = t1;      s2 = t2;    }

之前FoxGlove Security安全团队的一篇论文中提到的:通过Apache Commons  Collections,Java反序列化漏洞可以实现攻击,一度横扫了 WebLogic、WebSphere、JBoss、Jenkins、OpenNMS  的最新版,各大 Java Web Server 纷纷躺枪。

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

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

如何解决这个漏洞?

很多序列化协议都制定了一套数据结构来保存和获取对象。例如,JSON 序列化、ProtocolBuf  等,它们只支持一些基本类型和数组数据类型,这样可以避免反序列化创建一些不确定的实例。虽然它们的设计简单,但足以满足当前大部分系统的数据传输需求。我们也可以通过反序列化对象白名单来控制反序列化对象,可以重写  resolveClass 方法,并在该方法中校验对象名字。代码如下所示:

@Override protected Class resolveClass(ObjectStreamClass desc) throws IOException,ClassNotFoundException {   if (!desc.getName().equals(Bicycle.class.getName())) {     throw new InvalidClassException(     "Unauthorized deserialization attempt", desc.getName());   }   return super.resolveClass(desc); }

3. 序列化后的流太大

序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。

Java 序列化中使用了 ObjectOutputStream 来实现对象转二进制编码,那么这种序列化机制实现的二进制编码完成的二进制数组大小,相比于  NIO 中的 ByteBuffer 实现的二进制编码完成的数组大小,有没有区别呢?

我们可以通过一个简单的例子来验证下:

User user = new User(); user.setUserName("test"); user.setPassword("test");        ByteArrayOutputStream os =new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(os); out.writeObject(user); byte[] testByte = os.toByteArray(); System.out.print("ObjectOutputStream 字节编码长度:" + testByte.length + "\n");
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()]; System.out.print("ByteBuffer 字节编码长度:" + bytes.length+ "\n");

运行结构:

ObjectOutputStream 字节编码长度:99 ByteBuffer 字节编码长度:16

这里我们可以清楚地看到:Java 序列化实现的二进制编码完成的二进制数组大小,比 ByteBuffer  实现的二进制编码完成的二进制数组大小要大上几倍。因此,Java 序列后的流会变大,最终会影响到系统的吞吐量。

4. 序列化性能太差

序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间。我们再来通过上面这个例子,来对比下 Java  序列化与 NIO 中的 ByteBuffer 编码的性能:

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 长很多。

上边说了4个Java序列化的缺点,其实业界有很多可以代替Java序列化的序列化框架,大部分都避免了Java默认序列化的一些缺陷,例如比较流行的FastJson、Kryo、Protobuf、Hessian等,这里就来简单的介绍一下Protobuf序列化框架。

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

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

那么什么是Protocol Buffers存储格式?

Protocol Buffers 是一种轻便高效的结构化数据存储格式。它使用 T-L-V(标识 - 长度 - 字段值)的数据格式来存储数据,T  代表字段的正数序列 (tag),Protocol Buffers  将对象中的每个字段和正数序列对应起来,对应关系的信息是由生成的代码来保证的。在序列化的时候用整数值来代替字段名称,于是传输流量就可以大幅缩减;L 代表  Value 的字节长度,一般也只占一个字节;V 则代表字段值经过编码后的值。这种数据格式不需要分隔符,也不需要空格,同时减少了冗余字段名。

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

为什么不使用Java序列化

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

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

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

我们知道 int32 代表正负数,所以一般最后一位是用来表示正负值,现在 Varint 编码方式将最后一位用作了标志位,那还如何去表示正负整数呢?如果使用  int32/int64 表示负数就需要多个字节来表示,在 Varint 编码类型中,通过 Zigzag 编码进行转换,将负数转换成无符号数,再采用  sint32/sint64 来表示负数,这样就可以大大地减少编码后的字节数。

rotobuf 的这种数据存储格式,不仅压缩存储数据的效果好, 在编码和解码的性能方面也很高效。Protobuf 的编码和解码过程结合.proto  文件格式,加上 Protocol Buffer 独特的编码格式,只需要简单的数据运算以及位移等操作就可以完成编码与解码。可以说 Protobuf  的整体性能非常优秀。

到此,关于“为什么不使用Java序列化”的学习就结束了,希望能够解决大家的疑惑。理论与实践的搭配能更好的帮助大家学习,快去试试吧!若想继续学习更多相关知识,请继续关注亿速云网站,小编会继续努力为大家带来更多实用的文章!

推荐阅读:
  1. Java序列化介绍
  2. java序列化的实现方法

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

java

上一篇:客户端请求是怎么到达服务器的

下一篇:ReentrantLock源码解析是什么

相关阅读

您好,登录后才能下订单哦!

密码登录
登录注册
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》