网站首页 > java教程 正文
今天,大多数后端服务都是基于微服务架构实现的。服务根据业务功能进行划分,实现脱钩,但这也带来了新的挑战。不同业务服务之间的通信需要通过接口实现。要在两个服务之间共享数据对象,该对象必须转换为二进制流,通过网络传输到其他服务,然后转换回对象以供服务方法使用。这种编码和解码过程称为序列化和反序列化。
在并发请求量大的场景中,如果序列化缓慢,它可以增加请求响应时间;如果序列化数据大小很大,它可以降低网络吞吐量。因此,一个优秀的序列化框架可以提高系统的整体性能。
我们知道Java提供了RMI(远程方法调用)框架,可用于公开和调用服务之间的接口,RMI对数据对象使用Java序列化。然而,今天的主流微服务框架很少使用Java序列化。例如,SpringCloud使用JSON序列化。为什么会这样?
今天,我们将深入研究Java序列化,并将其与近年来非常流行的Protobuf序列化进行比较,以了解Protobuf如何实现最佳序列化。
Java序列化
在讨论缺点之前,您需要了解什么是Java序列化以及它是如何工作的。
Java提供了一个序列化机制,可以将对象序列化为二进制形式(字节数组),以便写入磁盘或输出到网络。它还可以从网络或磁盘读取字节数组,并将其反序列化回对象以供在程序中使用。
JDK提供了两个流对象,ObjectInputStream和ObjectOutputStream,它们只能对实现Serializable接口的类对象进行反序列化和序列化。
ObjectOutputStream的默认序列化方法仅序列化对象的非瞬态实例变量。它不会序列化瞬态实例变量或静态变量。
在实现Serializable接口的类中,会生成serialVersionUID版本号。这个版本号的目的是什么?它在反序列化期间验证序列化对象是否与为反序列化加载的类匹配。如果类名相同,但版本号不同,反序列化将无法检索对象。
序列化专门由writeObject和readObject方法实现。这些方法通常是默认的,但它们可以在实现Serializable接口的类中重写,以自定义序列化和反序列化机制。
此外,Java序列化定义了其他两种方法:writeReplace()和readResolve()前者用于在序列化前替换序列化对象,后者用于在反序列化后处理返回的对象。
Java序列化的缺点
如果您使用过一些RPC通信框架,您会注意到这些框架很少使用JDK提供的序列化。一般来说,很少使用的东西往往是不切实际的。让我们来看看JDK中默认序列化的缺点。
1.无法跨语言
现代系统设计越来越多样化,许多系统使用多种语言来开发应用程序。例如,一些大型游戏是使用多种语言开发的:C++用于游戏服务,Java/Go用于外围服务,Python用于监控应用程序。
然而,Java序列化目前仅适用于在Java中实现的框架。大多数其他语言不使用Java的序列化框架或实现Java序列化协议。因此,如果两个用不同语言编写的应用程序需要相互通信,它们就不能序列化和反序列化对象以在两个服务之间传输。
2.攻击的脆弱性
根据Java安全编码指南,“不受信任数据的反序列化本质上是危险的,应该避免。”这表明Java序列化不安全。
我们知道,通过调用readObject()方法onObjectInputStream来反序列化对象。这种方法本质上是一个神奇的构造函数,几乎可以实例化任何实现类路径中找到的Serializable接口的对象。
这意味着在字节流的反序列化过程中,这种方法可以执行任意类型的代码,这非常危险。
对于需要长反序列化时间的对象,可以在不执行任何代码的情况下发起攻击。攻击者可以创建一个循环对象链,然后将序列化对象传输到程序中进行反序列化。这种情况可能会导致hashCode方法调用数量呈指数级增长,导致堆栈溢出异常。下面的例子很好地说明了这一点。
设置根=新HashSet();
设置s1 = root;
设置s2 = new HashSet();
for (int i = 0; i < 100; i++) {
设置t1 = new HashSet();
设置t2 = new HashSet();
t1.add("foo"); // 使t2不等于t1
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1 = t1;
s2 = t2;
}
2015年,FoxGlove安全团队的breenmachine发表了一篇长篇博客文章,指出可以利用Java反序列化漏洞,通过Apache Commons Collections执行攻击。此漏洞影响了最新版本的WebLogic、WebSphere、JBoss、Jenkins和OpenNMS,导致主要的Java Web服务器容易受到攻击。
Apache Commons Collections是一个第三方库,它扩展了Java标准库中的Collection框架,提供强大的数据结构类型和各种Collection实用程序类。
攻击原则在于Apache Commons Collections,允许链式任意类函数反射调用。攻击者可以通过“Java序列化协议”实现的端口将恶意代码上传到服务器,然后由Apache Commons Collections中的TransformedMap执行。
那么,这个漏洞最终是如何解决的呢?
许多序列化协议定义了一组用于存储和检索对象的数据结构。例如,JSON序列化、协议缓冲区等仅支持一些基本类型和数组数据类型,从而避免在反序列化期间创建不确定的实例。虽然它们的设计很简单,但它们足以满足当今大多数系统的数据传输需求。
缓解此漏洞的一种方法是通过白名单控制反序列化对象。这可以通过覆盖resolveClass方法并在此方法中验证对象名称来实现。代码可能看起来像这样:
@Override
受保护的类 resolveClass(ObjectStreamClass desc)抛出IOException,ClassNotFoundException {
如果(!desc.getName().equals(Bicycle.class.getName())) {
抛出新的InvalidClassException(
"未经授权的反序列化尝试", desc.getName());
}
返回super.resolveClass(desc);
}
3.序列化流太大
序列化后二进制流的大小反映了序列化的性能。序列化后的二进制阵列越大,它占用的存储空间就越大,存储硬件成本就越高。如果我们通过网络传输,它会消耗更多的带宽,这可能会影响系统的吞吐量。
在Java序列化中,ObjectOutputStream用于将对象转换为二进制编码。与NIO中ByteBuffer生成的二进制数组相比,这种序列化机制产生的二进制数组的大小是否有差异?
我们可以用一个简单的例子来验证这一点:
用户用户=新用户();
user.setUserName("测试");
user.setPassword("测试");
ByteArrayOutputStream os = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(os);
out.writeObject(用户);
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(用户名称.长度);
byteBuffer.put(用户名);
byteBuffer.putInt(密码长度);
byteBuffer.put(密码);
byteBuffer.flip();
字节[]字节=新字节[byteBuffer.remaining()];
System.out.print("ByteBuffer 字节编码长度: " + bytes.length+ "\n");
执行结果:
ObjectOutputStream字节编码长度:99
字节缓冲字节编码长度:16
在这里,我们可以清楚地看到,Java序列化生成的二进制数组的大小比ByteBuffer生成的二进制数组的大小大几倍。因此,Java序列化后的流将变大,最终影响系统的吞吐量。
4.序列化性能差
序列化的速度也是序列化性能的重要指标。如果序列化缓慢,将影响网络通信的效率,从而增加系统的响应时间。让我们使用上面的示例来比较Java序列化的性能和在NIO中使用ByteBuffer进行编码:
用户用户=新用户();
user.setUserName("测试");
user.setPassword("测试");
long startTime = System.currentTimeMillis();
for(int i=0; i<1000; i++) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(os);
out.writeObject(用户);
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(用户名称.长度);
byteBuffer.put(用户名);
byteBuffer.putInt(密码长度);
byteBuffer.put(密码);
byteBuffer.flip();
字节[]字节=新字节[byteBuffer.remaining()];
}
long endTime1 = System.currentTimeMillis();
System.out.print("ByteBuffer序列化时间: " + (endTime1 - startTime1)+ "\n");
执行结果:
ObjectOutputStream序列化时间:29
字节缓冲器序列化时间:6
通过上述示例,我们可以清楚地看到,Java序列化中的编码时间比ByteBuffer中的编码时间长得多。
用Protobuf序列化取代Java序列化
目前,行业中有许多优秀的序列化框架,其中大多数避免了Java默认序列化的一些缺点。例如,近年来流行的框架包括FastJson、Kryo、Protobuf和Hessian。我们可以用这些框架之一完全取代Java序列化,在这里我建议使用Protobuf序列化框架。
Protobuf是由谷歌开发的序列化框架,支持多种语言。无论是在编码和解码时间以及二进制流的大小方面,它在主流网站上的比较测试中都一直表现良好。
Protobuf基于一个.proto文件,该文件描述了字段及其类型。使用此文件,可以生成不同的语言特定数据结构文件。当序列化数据对象时,Protobuf根据.proto文件描述生成协议缓冲区格式编码。
稍微扩展一下,让我解释一下什么是协议缓冲区存储格式以及它是如何工作的。
协议缓冲区是一种轻量级且高效的结构化数据存储格式。它使用T-L-V(标签-长度-值)数据格式来存储数据。在这里,T表示字段的正序列(标签),协议缓冲区将对象中的每个字段与正序列相关联。通信信息由生成的代码保证。序列化时,整数用于表示字段名称,大大减少传输流量;L表示值的字节长度,通常也只有一个字节;V表示字段值的编码值。这种数据格式不需要分隔符或空格,减少了冗余字段名称。
Protobuf定义了自己的编码方法,该方法几乎可以映射Java/Python等语言中的所有基本数据类型。不同的编码方法对应于不同的数据类型,也可以使用不同的存储格式。
作者的图片
对于存储Varint编码的数据,由于数据占用的空间是固定的,因此无需存储字节长度(长度)。因此,协议缓冲区的实际存储格式是T-V,这减少了一个字节的存储空间。
Protobuf定义了一种Varint编码方法,这是一种可变长度编码方法。数据类型每个字节的最后一个位是一个标志位(msb),用于指示当前字节之后是否有另一个字节。0表示当前字节是最后一个字节,1表示该字节之后还有另一个字节。
对于int32类型编号,通常需要4个字节来表示。使用Varint编码方法,非常小的int32数字可以用1字节表示。对于大多数整数类型数据,值通常小于256,因此此操作可以有效地压缩数据。
我们知道int32代表正数和负数,所以最后一个位用于表示正值和负值。现在,使用Varint编码方法,最后一个位被用作标志位。我们如何表示正整数和负整数?如果int32/int64用于表示负数,则需要多个字节。在Varint编码类型中,负数通过之字形编码转换为无符号数字,然后表示为sint32/sint64来表示负数。这大大减少了编码后的字节数。
Protobuf的这种数据存储格式不仅对存储的数据具有良好的压缩效果,而且在编码和解码性能方面效率很高。Protobuf的编码和解码过程,结合.proto文件格式,以及协议缓冲区的独特编码格式,可以通过简单的数据操作和位移位操作完成。可以说,Protobuf具有出色的整体性能。
结论
无论是网络传输还是磁盘持久性,我们都需要将数据编码为字节码。我们在程序中使用的数据类型或对象基于内存,因此我们需要通过编码将这些数据转换为二进制字节流。当我们需要接收或重用这些数据时,我们需要通过解码将二进制字节流转换回内存数据。我们通常将这两个过程称为序列化和反序列化。
Java的默认序列化是通过可序列化接口实现的。只要一个类实现此接口并生成默认版本号(我们不需要手动设置),该类将自动实现序列化和反序列化。
虽然Java的默认序列化很方便,但它存在安全漏洞、缺乏跨语言支持和性能差等缺陷。因此,我强烈建议避免使用Java序列化。
从主流序列化框架来看,FastJson、Protobuf和Kryo非常独特,其性能和安全性得到了行业的认可。我们可以根据自己的业务需求选择合适的序列化框架,以优化系统的序列化性能。
猜你喜欢
- 2024-11-04 快速处理Kafka反序列化错误(kafka自定义反序列化)
- 2024-11-04 又一个反序列化漏洞,我服了...(反序列化漏洞修复方案)
- 2024-11-04 Java代码示例:如何使用 serialVersionUID处理序列化
- 2024-11-04 Java 序列化机制(java序列化过程)
- 2024-11-04 SpringBoot整合Grpc实现跨语言RPC通讯
- 2024-11-04 php和java及python3.10的序列化和反序列化
- 2024-11-04 Java修炼终极指南:133 避免在反序列化时发生DoS攻击
- 2024-11-04 聊聊fastjson反序列化的那些坑(fastjson反序列化原理)
- 2024-11-04 Java序列化 3 连问,这太难了吧(在线序列化工具)
- 2024-11-04 试验java反序列化炸弹碰到的一个HashSet问题
你 发表评论:
欢迎- 最近发表
- 标签列表
-
- java反编译工具 (77)
- java反射 (57)
- java接口 (61)
- java随机数 (63)
- java7下载 (59)
- java数据结构 (61)
- java 三目运算符 (65)
- java对象转map (63)
- Java继承 (69)
- java字符串替换 (60)
- 快速排序java (59)
- java并发编程 (58)
- java api文档 (60)
- centos安装java (57)
- java调用webservice接口 (61)
- java深拷贝 (61)
- 工厂模式java (59)
- java代理模式 (59)
- java.lang (57)
- java连接mysql数据库 (67)
- java重载 (68)
- java 循环语句 (66)
- java反序列化 (58)
- java时间函数 (60)
- java是值传递还是引用传递 (62)
本文暂时没有评论,来添加一个吧(●'◡'●)