专业的JAVA编程教程与资源

网站首页 > java教程 正文

避免使用Java序列化(serializable 防止序列化)

temp10 2024-11-04 14:06:24 java教程 16 ℃ 0 评论

今天,大多数后端服务都是基于微服务架构实现的。服务根据业务功能进行划分,实现脱钩,但这也带来了新的挑战。不同业务服务之间的通信需要通过接口实现。要在两个服务之间共享数据对象,该对象必须转换为二进制流,通过网络传输到其他服务,然后转换回对象以供服务方法使用。这种编码和解码过程称为序列化和反序列化。

在并发请求量大的场景中,如果序列化缓慢,它可以增加请求响应时间;如果序列化数据大小很大,它可以降低网络吞吐量。因此,一个优秀的序列化框架可以提高系统的整体性能。

避免使用Java序列化(serializable 防止序列化)

我们知道Java提供了RMI(远程方法调用)框架,可用于公开和调用服务之间的接口,RMI对数据对象使用Java序列化。然而,今天的主流微服务框架很少使用Java序列化。例如,SpringCloud使用JSON序列化。为什么会这样?

今天,我们将深入研究Java序列化,并将其与近年来非常流行的Protobuf序列化进行比较,以了解Protobuf如何实现最佳序列化。

Java序列化

在讨论缺点之前,您需要了解什么是Java序列化以及它是如何工作的。

Java提供了一个序列化机制,可以将对象序列化为二进制形式(字节数组),以便写入磁盘或输出到网络。它还可以从网络或磁盘读取字节数组,并将其反序列化回对象以供在程序中使用。

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

ObjectOutputStream的默认序列化方法仅序列化对象的非瞬态实例变量。它不会序列化瞬态实例变量或静态变量。

在实现Serializable接口的类中,会生成serialVersionUID版本号。这个版本号的目的是什么?它在反序列化期间验证序列化对象是否与为反序列化加载的类匹配。如果类名相同,但版本号不同,反序列化将无法检索对象。

序列化专门由writeObjectreadObject方法实现。这些方法通常是默认的,但它们可以在实现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非常独特,其性能和安全性得到了行业的认可。我们可以根据自己的业务需求选择合适的序列化框架,以优化系统的序列化性能。

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表