分布式系统的基石序列化笔记

本文深入探讨Java序列化的意义、机制及其实现细节,包括序列化接口、序列化过程、序列化算法的优劣评估,以及Java内置序列化机制的局限性。同时,文章对比分析了多种序列化技术,如XML、JSON、Hessian、Protobuf等,讨论了各自的适用场景和技术选型建议。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

了解序列化的意义

8f29dfdbaf1f1ce4d5480fa40dd17a6e489.jpg

  • Java 平台允许我们在内存中创建可复用的Java 对象,
  • 但一般情况下,只有当JVM 处于运行时,这些对象才可能存在,即,这些对象的生命周期不会比JVM 的生命周期更长。
  • 但在现实应用中,就可能要求在JVM停止运行之后能够保存(持久化)指定的对象,并在将来重新读取被保存的对象。
  • Java 对象序列化就能够帮助我们实现该功能
  • 简单来说:
    • 序列化是把对象的状态信息转化为可存储传输的形式过程,也就是把对象转化为字节序列的过程称为对象的序列化
    • 反序列化是序列化的逆向过程,把字节数组反序列化为对象,把字节序列恢复为对象的过程成为对象的反序列化

评价一个序列化算法优劣的两个重要指标是:

  • 序列化以后的数据大小
  • 序列化操作本身的速度及系统资源开销(CPU、内存)

Java 语言本身提供了对象序列化机制,也是Java 语言本身最重要的底层机制之一,

  • Java 本身提供的序列化机制存在两个问题:
    • 序列化的数据比较大,传输效率低
    • 其他语言无法识别和对接

在Java 中,只要一个类实现了java.io.Serializable 接口,那么它就可以被序列化

  • 基于JDK 序列化方式实现
    • JDK 提供了Java 对象的序列化方式, 主要通过输出流java.io.ObjectOutputStream 和对象输入流java.io.ObjectInputStream来实现。
    • 被序列化的对象需要实现java.io.Serializable 接口。

58cdafa3d4eb1c6bac17bd82c5904355e8c.jpg

序列化的高阶认识:

  • serialVersionUID 的作用
    • Java 的序列化机制是通过判断类的serialVersionUID 验证版本一致性的。
      • 在进行反序列化时,JVM 会把传来的字节流中的serialVersionUID本地相应实体类的serialVersionUID 进行比较,
      • 如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidCastException
      • 如果没有为指定的class 配置serialVersionUID,那么java 编译器会自动给这个class 进行一个摘要算法
        • 类似于指纹算法,只要这个文件有任何改动,得到的UID 就会截然不同的,可以保证在这么多类中,这个编号是唯一的
  • serialVersionUID 有两种显示的生成方式:
    • 一是默认的1L,比如:private static final long serialVersionUID = 1L;
    • 二是根据类名、接口名、成员方法及属性等来生成一个64 位的哈希字段
  • 当实现java.io.Serializable 接口的类没有显式地定义一个serialVersionUID 变量时候:
    • Java 序列化机制会根据编译的Class 自动生成一个serialVersionUID 作序列化版本比较用
      • 这种情况下,如果Class 文件(类名,方法明等)没有发生变化(增加空格,换行,增加注释等等)
      • 就算再编译多次,serialVersionUID 也不会变化的
  • 静态变量序列化
    • 序列化并不保存静态变量

19ef6302bbf2c8fc2ab98a8983d7da5f895.jpg

  • 父类的序列化
    • 一个子类实现了 Serializable 接口,它的父类都没有实现 Serializable接口
      • 在子类中设置父类的成员变量的值,接着序列化该子类对象。
      • 再反序列化出来以后输出父类属性的值。结果应该是什么?
        • 如下,结论:
          1. 当一个父类没有实现序列化时,子类继承该父类并且实现了序列化。
            • 在反序列化该子类后,是没办法获取到父类的属性值的
          2. 当一个父类实现序列化,子类自动实现序列化,不需要再显示实现Serializable 接口
          3. 当一个对象的实例变量引用了其他对象,序列化该对象时也会把引用对象进行序列化
            • 但是前提是该引用对象必须实现序列化接口

2d4683e69102b9907db1f33f5e5fa9a96f8.jpg

Transient 关键字:

  • Transient 关键字的作用是控制变量的序列化,
    • 在变量声明前加上该关键字,可以阻止该变量被序列化到文件中
    • 在被反序列化后,transient变量的值被设为初始值,如 int 型的是 0,对象型的是 null
  • 绕开transient 机制的办法
    • writeObject和readObject 这两个私有的方法,既不属于Object、也不是Serializable,为什么能够在序列化的时候被调用呢?
    • 原因是,ObjectOutputStream使用了反射来寻找是否声明了这两个方法。
    • 因为ObjectOutputStream使用getPrivateMethod,所以这些方法必须声明为private 以至于供ObjectOutputStream 来使用

93f4c17f3a250934a14076607f953ea4cfd.jpg

序列化的存储规则

  • 同一对象两次(开始写入文件到最终关闭流这个过程算一次,下面的演示效果是不关闭流的情况才能演示出效果)写入文件
  • 打印出写入一次对象后的存储大小和写入两次后的存储大小,第二次写入对象时文件只增加了 5 字节
  • Java 序列化机制为了节省磁盘空间,具有特定的存储规则
  • 当写入文件的为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用
  • 该存储规则极大的节省了存储空间

8a5ed692b5e35aa3a852a6bce5e831e7602.jpg

  • 序列化实现深克隆
    • 在Java 中存在一个Cloneable 接口,通过实现这个接口的类都会具备clone 的能力
    • 同时clone 是在内存中进行在性能方面会比我们直接通过new 生成对象要高一些
      • 特别是一些大的对象的生成,性能提升相对比较明显
  • 浅克隆
    • 被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。
    • 新老对象指向同一个堆内存,改变其中一个另一个也会随之改变,显然大多数情况下这不是我们想要的
  • 深克隆
    • 被复制对象的所有变量都含有与原来的对象相同的值,除去那些引用其他对象的变量
    • 深拷贝把要复制的对象所引用的对象都复制了一遍
    • 使用序列化实现深拷贝
      • 原理是把对象序列化输出到一个流中,然后在把对象从序列化流中读取出来,这个对象就不是原来的对象了。

842ce189da374e55c74e6a6ddccb439ce75.jpg

常见的序列化技术

  • JAVA 进行序列化有他的优点,也有他的缺点:
    • 优点:JAVA 语言本身提供,使用比较方便和简单
    • 缺点:不支持跨语言处理、 性能相对不是很好,序列化以后产生的数据相对较大

XML 序列化框架

  • XML 序列化的好处在于可读性好,方便阅读和调试
  • 但是序列化以后的字节码文件比较大,而且效率不高,适用于对性能不高
  • 而且QPS 较低的企业级内部系统之间的数据交换的场景,同时XML 又具有语言无关性
  • 所以还可以用于异构系统之间的数据交换和协议。
  • 比如我们熟知的Webservice,就是采用XML 格式对数据进行序列化的

JSON 序列化框架

  • JSON(JavaScript Object Notation)是一种轻量级的数据交换格式
  • 相对于XML 来说,JSON 的字节流更小,而且可读性也非常好
  • 现在JSON数据格式在企业运用是最普遍的

JSON 序列化常用的开源工具有很多

Hessian 序列化框架

  • Hessian 是一个支持跨语言传输的二进制序列化协议,
  • 相对于Java 默认的序列化机制来说,Hessian 具有更好的性能和易用性,而且支持多种不同的语言
    • 实际上Dubbo 采用的就是Hessian 序列化来实现,只不过Dubbo 对Hessian 进行了重构,性能更高

Protobuf 序列化框架

  • Protobuf 是Google 的一种数据交换格式,它独立于语言、独立于平台
  • Protobuf 使用比较广泛,主要是空间开销小性能比较好,非常适合用于公司内部对性能要求高的RPC 调用
  • 另外由于解析性能比较高,序列化以后数据量相对较少,所以也可以应用在对象的持久化场景中
  • 但是要使用Protobuf 会相对来说麻烦些因为他有自己的语法,有自己的编译器

下载protobuf 工具

  • https://github.com/google/protobuf/releases
  • proto 的语法
    • 1. 包名
    • 2. option 选项
    • 3. 消息模型(消息对象、字段(字段修饰符-required/optional/repeated)字段类型(基本数据类型、枚举、消息对象)、字段名、标识号)
syntax="proto2";
package com.gupaoedu.serial;
option java_package = "com.gupaoedu.serial";
option java_outer_classname="UserProtos";
message User {
required string name=1;
required int32 age=2;
}

Protobuf 原理分析

  • 核心原理: protobuf 使用varint(zigzag)作为编码方式, 使用T-LV作为存储方式

varint 编码方式

  • varint 是一种数据压缩算法,其核心思想是利用bit 位来实现数据压缩
  • 比如:
    • 对于 int32 类型的数字,一般需要 4 个字节 表示;
    • 若采用Varint 编码,对于很小的 int32 类型 数字,则可以用 1 个字节
  • 假设我们定义了一个int32 字段值=296:
    • 第一步,转化为2 进制编码
      • 0607f8cab0f33ab547875fd1d4cfa0a19b2.jpg
    • 第二步,提取字节
      • 规则: 按照从字节串末尾选取7 位,并在最高位补1,构成一个字节
      • dcf1d55578fbbaa07bbb0df946044626b17.jpg
    • 第三步,继续提取字节
      • 整体右移7 位,继续截取7 个比特位,并且在最高位补0 。
      • 因为这个是最后一个有意义的字节了。补0 不影响结果
      • 991437aed7146aff11a277953df9bf78e33.jpg
    • 第四步,拼接成一个新的字节串
      • 将原来用4 个字节表示的整数,经过varint 编码以后只需要2 个字节了。
      • dcccce390b2aab2ffad1eed96df1835f8dd.jpg
      • varint 编码对于小于127 的数,可以最大化的压缩
  • varint 压缩小数据
    • 比如我们压缩一个var32 = 104 的数据
    • 第一步,转换为2 进制编码
      • 9692c1a5a73d70f3ff8e3828946e10ca575.jpg
    • 第二步,提取字节
      • 从末尾开始提取7 个字节并且在最高位最高位补0,因为这个是最后的7 位。
      • 4f5dfeeba20dd9ad64dc1fe23bb10b490e6.jpg
    • 第三步,形成新的字节
      • 6c9183c6a581972c8501cdaa188246dfcef.jpg
      • 也就是通过varint 对于小于127 以下的数字编码,只需要占用1 个字节。
  • zigzag 编码方式
    • 对于负数的处理,protobuf 使用zigzag 的形式来存储。
  • 在计算机中,定义了原码、反码和补码。来实现负数的表示。
    • 数字 8 的二进制表示为 0000 1000
    • 原码
      • 通过第一个位表示符号(0 表示非负数、1 表示负数)
      • (+8) = {0000 1000}
        (-8) = {1000 1000}
    • 反码
      • 因为第一位表示符号位,保持不变
      • 剩下的位,非负数保持不变、负数按位取反
        • (+8) = {0000 1000}原 ={0000 1000}反 非负数,剩下的位不变。所以和原码是保持一致
        • (-8) = {1000 1000}原 ={1111 0111}反 负数,符号位不动,剩下为取反
    • 但是通过原码和反码方式来表示二进制还存在一些问题
      • 第一个问题:
        • 0 这个数字,按照上面的反码计算,会存在两种表示
        • (+0) ={0000 0000}原= {0000 0000}反
          (-0) ={1000 0000}原= {1111 1111}反
      • 第二个问题:
        • 符号位参与运算,会得到一个错误的结果,比如
          1 + (-1)=
        • {0000 0001}原 +{1 0000 0001}原 ={1000 0010}原 =-2
          {0000 0001}反+ {1111 1110}反 = {1111 1111}反 =-0
      • 不管是原码计算还是反码计算。得到的结果都是错误的。所以为了解决这个问题,引入了补码的概念
    • 补码
      • 补码的概念:第一位符号位保持不变,剩下的位非负数保持不变负数按位取反且末位加1
      • (+8) = {0000 1000}原 = {0000 1000}原 ={0000 1000}补
        (-8) = {1000 1000}原 ={1111 0111}反={1111 1000}末位加一(补码)
      • 8+(-8)= {0000 1000}补 +{1111 1000}末位加一(补码) ={0000 0000}=0
    • 通过补码的方式,在进行符号运算的时候,计算机就不需要关心符号的问题,统一按照这个规则来计算。就没问题
  • zigzag 原理
    • 比如我们存储一个 int32 = -2
      • 原码{1 000 0010} ->取反 {1111 1101} ->整体加1 {111 1110}->{1111 1110}
      • zigzag 的核心思想是去掉无意义的0最大可能性的压缩数据
        • 对于负数,第一位表示符号位,如果补码的话,前面只能补1.
        • 就会导致陷入一个很尴尬的地步,负数似乎没办法压缩。
      • 所以zigzag 提供了一个方法,既然第一位是符号位,那么干脆把这个符号位放到补码的最后
      • 所以上面这个-2,将符号位移到最末尾,
    • zigzag 算法定义了对于非负数形式,则把符号位移动到最后,其他整体往左移动一位。
      • 对于非负数形式2,按照整体左移1 位,右边补零的形式来表示如下
      • 20d337eaded7713159250aebd37f17180bf.jpg
    • 而在zigzag 中的计算规则是:
      • 将-2 的二进制形式{1111 1110}按照正数的算法,左移一位,右边补零得到{11111100},如下图左边。
      • 按照负数的形式,讲符号位移动到最右边,右移31 位,得到下面右图。
      • 再将两者取异或算法。实现最终的压缩。

de0ced3748cd31d0af9b4f6c7948519264d.jpg

8bf1d722b08e0169291b04c933cdc41a13e.jpg

  • 最后,-2 在的结果是3. 占用一个比特位存储。
  • 就是最大限度的去掉多余的零,创造多余零,压缩算法

存储方式

  • 存储方式经过编码以后的数据,大大减少了字段值的占用字节数,然后基于T-LV的方式进行存储
  • tag 的取值为 field_number(字段数) << 3 | wire_type

8cab89e9849e30f39c98939a934baafbddb.jpg

Protocol总结:

  • Protocol Buffer 的性能好,主要体现在 序列化后的数据体积小 & 序列化速度快,最终使得传输效率高
  • 其原因如下:
    • 序列化速度快的原因:
      • 编码 / 解码 方式简单(只需要简单的数学运算 = 位移等等)
      • 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成
    • 序列化后的数据量体积小(即数据压缩效果好)的原因:
      • 采用了独特的编码方式,如Varint、Zigzag 编码方式等等
      • 采用T - L - V 的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑

序列化技术的选型

  • 技术层面
    1. 序列化空间开销,也就是序列化产生的结果大小,这个影响到传输的性能
    2. 序列化过程中消耗的时长,序列化消耗时间过长影响到业务的响应时间
    3. 序列化协议是否支持跨平台,跨语言。因为现在的架构更加灵活,如果存在异构系统通信需求,那么这个是必须要考虑的
    4. 可扩展性/兼容性,在实际业务开发中,系统往往需要随着需求的快速迭代来实现快速更新,
      • 这就要求我们采用的序列化协议基于良好的可扩展性/兼容性,
      • 比如在现有的序列化数据结构中新增一个业务字段,不会影响到现有的服务
    5. 技术的流行程度,越流行的技术意味着使用的公司多,那么很多坑都已经淌过并且得到了解决,技术解决方案也相对成熟
    6. 学习难度和易用性
  • 选型建议
    1. 对性能要求不高的场景,可以采用基于XML 的SOAP 协议
    2. 对性能和间接性有比较高要求的场景,那么Hessian、Protobuf、Thrift、Avro 都可以。
    3. 基于前后端分离,或者独立的对外的api 服务,选用JSON 是比较好的,对于调试、可读性都很不错
    4. Avro 设计理念偏于动态类型语言,那么这类的场景使用Avro 是可以的

 

转载于:https://my.oschina.net/u/3847203/blog/2875446

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值