作为一个Java开发者,为什么我建议你在开发中尽量避免使用Java序列化?现在的后端服务大部分都是基于微服务架构实现的,服务是根据业务进行拆分来实现服务的解耦,但是也带来了一些新的问题,比如不同业务之间的通信需要通过接口调用。两个服务之间共享一个数据对象,需要将该对象转换为二进制流,通过网络传输,发送给另一个服务,然后再转换为对象供服务方法调用。这种编码和解码的过程称为序列化和反序列化。在高并发系统中,序列化的速度会影响请求的响应时间,序列化后传输的数据量大会导致网络吞吐量下降。因此,一个优秀的序列化框架可以提高系统的整体性能。我们都知道Java提供了RMI框架来实现服务之间的接口暴露和调用,而RMI中数据对象的序列化就是使用Java序列化。但是目前主流的框架很少使用Java序列化,比如SpringCloud使用的Json序列化。Dubbo虽然兼容Java序列化,但默认还是使用Hessian序列化。Java序列化首先,让我们了解一下什么是Java序列化及其实现原理。Java提供了一种序列化机制,可以将对象序列化为二进制形式写入磁盘或输出到网络,同时将从网络或磁盘读取的字节数组反序列化为对象,在程序中使用。JDK提供的两个输入输出流对象ObjectInputStream和ObjectOutputStream只能对实现了Serializable接口的类的对象进行反序列化和序列化。ObjectOutputStream默认的序列化方法只序列化对象的非瞬态实例变量,不序列化对象的瞬态实例变量,也不序列化静态变量。在实现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方法的次数呈指数级爆炸,从而引发栈溢出异常。例如,下面的案例就可以很好地说明。Setroot=newHashSet();Sets1=root;Sets2=newHashSet();for(inti=0;i<100;i++){Sett1=newHashSet();Sett2=newHashSet();t1.add("test");//使t2不等于t1文中提到:通过ApacheCommonsCollections,可以攻击Java反序列化漏洞,曾席卷最新版本的WebLogic、WebSphere、JBoss、Jenkins、OpenNMS,各大JavaWebServer已被射击。实际上,ApacheCommonsCollections是一个第三方基础库,扩展了Java标准库中的Collection结构,提供了很多强大的数据结构类型,实现了各种收集工具类。攻击原理:ApacheCommonsCollections允许链式任意类函数反射调用。攻击者通过实现Java序列化协议的端口将攻击代码上传到服务器,然后由ApacheCommonsCollections中的TransformedMap执行。如何解决这个漏洞?许多序列化协议已经开发了一套数据结构来保存和检索对象。比如JSON序列化、ProtocolBuf等,它们只支持一些基本类型和数组数据类型,可以避免反序列化创建一些不确定的实例。它们虽然设计简单,但足以满足目前大多数系统的数据传输需求。我们还可以通过反序列化对象的白名单来控制反序列化对象。我们可以覆盖resolveClass方法并在该方法中验证对象名称。代码如下所示:@OverrideprotectedClassresolveClass(ObjectStreamClassdesc)throwsIOException,ClassNotFoundException{if(!desc.getName().equals(Bicycle.class.getName())){thrownewInvalidClassException("Unauthorizeddeserializationattempt",desc.getName());}returnsuper.resolveClass(desc);}3.序列化流过大序列化二进制流的大小可以反映序列化性能。序列化后的二进制数组越大,占用的存储空间越大,存储硬件的成本也越高。如果我们在进行网络传输,会占用更多的带宽,影响系统的吞吐量。Java序列化中使用ObjectOutputStream将对象转为二进制编码,那么这种序列化机制实现的二进制编码完成的二进制数组大小与NIO中ByteBuffer实现的二进制编码完成的数组大小有区别吗??我们可以通过一个简单的例子来验证:user);byte[]testByte=os.toByteArray();System.out.print("ObjectOutputStream字节编码长度:"+testByte.length+"\n");ByteBufferbyteBuffer=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=newbyte[byteBuffer.remaining()];System.out.print("ByteBuffer字节编码长度:"+bytes.length+"\n");运行结构:ObjectOutputStream字节编码长度:99ByteBuffer字节编码长度:16这里我们可以清楚的看到:Java序列化实现的二进制编码完成的二进制数组的大小大于ByteBuffer实现的二进制编码完成的二进制数组的大小要大几倍。因此,Java序列之后的流会变大,最终会影响系统的吞吐量。4.序列化性能太差。序列化速度也是反映序列化性能的重要指标。如果序列化速度慢,会影响网络通信的效率,从而增加系统的响应时间。下面我们用上面的例子来比较NIO中Java序列化和ByteBuffer编码的性能:Useruser=newUser();user.setUserName("test");user.setPassword("test");longstartTime=System。currentTimeMillis();for(inti=0;i<1000;i++){ByteArrayOutputStreamos=newByteArrayOutputStream();ObjectOutputStreamout=newObjectOutputStream(os);out.writeObject(user);out.flush();out.close();byte[]testByte=os.toByteArray();os.close();}longendTime=System.currentTimeMillis();System.out.print("ObjectOutputStream序列化时间:"+(endTime-startTime)+"\n");longstartTime1=System.currentTimeMillis();for(inti=0;i<1000;i++){ByteBufferbyteBuffer=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=newbyte[byteBuffer.remaining()];}longendTime1=System.currentTimeMillis();系统m.out.print("ByteBuffer序列化时间:"+(endTime1-startTime1)+"\n");运行结果:ObjectOutputStream序列化时间:29ByteBuffer序列化时间:6通过这个案例,我们可以清楚的看到:Java序列化的编码时间比ByteBuffer长很多。上面提到的Java序列化的四大缺点,其实业界有很多序列化框架可以替代Java序列化,而且大部分都规避了Java默认的一些序列化。缺陷,比如比较流行的FastJson、Kryo、Protobuf、Hessian等,这里简单介绍一下Protobuf序列化框架。Protobuf是谷歌推出的支持多种语言的序列化框架。目前,在主流网站的序列化框架性能对比测试报告中,Protobuf在编解码耗时、二进制流压缩大小等方面均名列前茅。Protobuf基于一个带有.proto后缀的文件。这个文件描述了字段和字段类型,工具可以生成不同语言的数据结构文件。Protobuf在序列化数据对象时,通过.proto文件描述生成ProtocolBuffers格式的编码。那么ProtocolBuffers的存储格式是什么?ProtocolBuffers是一种轻量级高效的结构化数据存储格式。它使用T-L-V(Identity-Length-FieldValue)的数据格式来存储数据。T表示字段的正数序列(tag)。ProtocolBuffers将对象中的每个字段与正数序列相关联。对应关系的信息由生成的代码提供保证。序列化时,用整数值代替字段名,可以大大减少传输流量;L表示Value的字节长度,一般只占一个字节;V表示字段值的编码值。这种数据格式不需要分隔符,不需要空格,减少了冗余的字段名。Protobuf定义了自己的编码方式,可以映射Java/Python等语言的几乎所有基本数据类型。不同的编码方式对应不同的数据类型,也可以采用不同的存储格式。如下图:对于存储Varint编码的数据,由于数据占用的存储空间是固定的,不需要存储字节长度Length,所以实际上ProtocolBuffers的存储方式是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文件格式和ProtocolBuffer特有的编码格式。它只需要简单的数据操作和位移操作就可以完成编解码。可以说Protobuf的整体性能非常好。总结Java默认的序列化是通过Serializable接口实现的。只要该类实现了该接口并生成一个默认的版本号,就不需要我们手动设置,该类会自动实现序列化和反序列化。Java默认的序列化虽然实现起来方便,但是存在安全漏洞、不跨语言、性能差等缺陷,所以我强烈建议大家避免使用Java序列化。纵观主流的序列化框架,FastJson、Protobuf、Kryo都颇具特色,其性能和安全性得到了业界的认可。我们可以结合自己的业务来选择合适的序列化框架来优化系统性能的序列化。
