记录一次线上问题排查:JDK序列化问题

场景简要概述

新加了个字段,然后发版,上线就发现了报错

当时这个问题很简单,其实就是用的是 JDK序列化,当时这个类实现了 Serializable接口,但是没显示定义 serialVersionUID,这样一来序列化时会根据当前类的信息计算得到一个 serialVersionUID

当数据在序列化存入redis后,接着业务需要,就在代码里把要接收这个数据的类中新加了一个字段,这时候再从 redis 获取之前的值反序列化,由于当前的类还没有 serialVersionUID,于是就会很据当前的类信息计算的 serialVersionUID,而由于结构变了(新增了一个信息),类信息也就变了,所以计算出来的 serialVersionUID不一致。因此序列化就失败。

解决方式就是显示指定 serialVersionUID,这样就不需要动态计算了。

扩展知识:序列化和反序列化

  • 序列化:把对象转换为字节序列的过程称为对象的序列化.
  • 反序列化:把字节序列恢复为对象的过程称为对象的反序列化.

什么时候会用到

当只在本地 JVM 里运行下 Java 实例,这个时候是不需要什么序列化和反序列化的,但当出现以下场景时,就需要序列化和反序列化了:

  • 当需要将内存中的对象持久化到磁盘,数据库中时
  • 当需要与浏览器进行交互时
  • 当需要实现 RPC 时

但是当我们在与浏览器交互时,还有将内存中的对象持久化到数据库中时,好像都没有去进行序列化和反序列化,因为我们都没有实现 Serializable 接口,但一直正常运行?

先给出结论:只要我们对内存中的对象进行持久化或网络传输,这个时候都需要序列化和反序列化.

理由:服务器与浏览器交互时真的没有用到 Serializable 接口吗? JSON 格式实际上就是将一个对象转化为字符串,所以服务器与浏览器交互时的数据格式其实是字符串,我们来看来 String 类型的源码:

java 复制代码
public final class String implements java.io.Serializable,Comparable<String>,CharSequence {
    /\*\* The value is used for character storage. \*/
    private final char value\[\];

    /\*\* Cache the hash code for the string \*/
    private int hash; // Default to 0

    /\*\* use serialVersionUID from JDK 1.0.2 for interoperability \*/
    private static final long serialVersionUID = -6849794470754667710L;

    ......
}

String 类型实现了 Serializable 接口,并显示指定 serialVersionUID 的值.

然后再来看对象持久化到数据库中时的情况,Mybatis 数据库映射文件里的 insert 代码:

text 复制代码
<insert id="insertUser" parameterType="org.tyshawn.bean.User">
    INSERT INTO t\_user(name,age) VALUES (#{name},#{age})
</insert>

实际上并不是将整个对象持久化到数据库中,而是将对象中的属性持久化到数据库中,而这些属性(如Date/String)都实现了 Serializable 接口。

为什么要实现 Serializable 接口?

在 Java 中实现了 Serializable 接口后, JVM 在类加载的时候就会发现我们实现了这个接口,然后在初始化实例对象的时候就会在底层实现序列化和反序列化。如果被写对象类型不是String、数组、Enum,并且没有实现Serializable接口,那么在进行序列化的时候,将抛出NotSerializableException。源码如下:

java 复制代码
// remaining cases
if (obj instanceof String) {
    writeString((String) obj, unshared);
} else if (cl.isArray()) {
    writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
    writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
    writeOrdinaryObject(obj, desc, unshared);
} else {
    if (extendedDebugInfo) {
        throw new NotSerializableException(
            cl.getName() + "\n" + debugInfoStack.toString());
    } else {
        throw new NotSerializableException(cl.getName());
    }
}

为什么要显示指定 serialVersionUID 的值?

如果不显示指定 serialVersionUID,JVM 在序列化时会根据属性自动生成一个 serialVersionUID,然后与属性一起序列化,再进行持久化或网络传输。在反序列化时,JVM 会再根据属性自动生成一个新版 serialVersionUID,然后将这个新版 serialVersionUID 与序列化时生成的旧版 serialVersionUID 进行比较,如果相同则反序列化成功,否则报错.

如果显示指定了 serialVersionUID,JVM 在序列化和反序列化时仍然都会生成一个 serialVersionUID,但值为显示指定的值,这样在反序列化时新旧版本的 serialVersionUID 就一致了.

当然了,如果类写完后不再修改,那么不指定serialVersionUID,不会有问题,但这在实际开发中是不可能的,类会不断迭代,一旦类被修改了,那旧对象反序列化就会报错。 所以在实际开发中,都会显示指定一个 serialVersionUID。

static 属性为什么不会被序列化?

因为序列化是针对对象而言的,而 static 属性优先于对象存在,随着类的加载而加载,所以不会被序列化.

看到这个结论,是不是有人会问,serialVersionUID 也被 static 修饰,为什么 serialVersionUID 会被序列化? 其实 serialVersionUID 属性并没有被序列化,JVM 在序列化对象时会自动生成一个 serialVersionUID,然后将显示指定的 serialVersionUID 属性值赋给自动生成的 serialVersionUID。

如果有些字段不想进行序列化怎么办?transient关键字的作用?

Java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。

也就是说被transient修饰的成员变量,在序列化的时候其值会被忽略,在被反序列化后, transient 变量的值被设为初始值, 如 int 型的是 0,对象型的是 null。

为什么不推荐使用 JDK 自带的序列化?

我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:

  • 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
  • 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
  • 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。

常见序列化的方式

序列化只是定义了拆解对象的具体规则,那这种规则肯定也是多种多样的,比如现在常见的序列化方式有:JDK 原生、JSON、ProtoBuf、Hessian、Kryo等。

  • JDK 原生

作为一个成熟的编程语言,JDK自带了序列化方法。只需要类实现了Serializable接口,就可以通过ObjectOutputStream类将对象变成byte[]字节数组。

JDK 序列化会把对象类的描述信息和所有的属性以及继承的元数据都序列化为字节流,所以会导致生成的字节流相对比较大。

另外,这种序列化方式是 JDK 自带的,因此不支持跨语言。

简单总结一下:JDK 原生的序列化方式生成的字节流比较大,也不支持跨语言,因此在实际项目和框架中用的都比较少。

  • ProtoBuf

谷歌推出的,是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于通信协议、数据存储等。序列化后体积小,一般用于对传输性能有较高要求的系统。

  • Hessian

Hessian 是一个轻量级的二进制 web service 协议,主要用于传输二进制数据。

在传输数据前 Hessian 支持将对象序列化成二进制流,相对于 JDK 原生序列化,Hessian序列化之后体积更小,性能更优。

  • Kryo

Kryo 是一个 Java 序列化框架,号称 Java 最快的序列化框架。Kryo 在序列化速度上很有优势,底层依赖于字节码生成机制。

由于只能限定在 JVM 语言上,所以 Kryo 不支持跨语言使用。

  • JSON

上面讲的几种序列化方式都是直接将对象变成二进制,也就是byte[]字节数组,这些方式都可以叫二进制方式。

JSON 序列化方式生成的是一串有规则的字符串,在可读性上要优于上面几种方式,但是在体积上就没什么优势了。

另外 JSON 是有规则的字符串,不跟任何编程语言绑定,天然上就具备了跨平台。

总结一下:JSON 可读性强,支持跨平台,体积稍微逊色。

JSON 序列化常见的框架有:fastJSONJacksonGson 等。

序列化技术的选型

上面列举的这些序列化技术各有优缺点,不能简单地说哪一种就是最好的,不然也不会有这么多序列化技术共存了。

既然有这么多序列化技术可供选择,那在实际项目中如何选型呢?

我认为需要结合具体的项目来看,比较技术是服务于业务的。你可以从下面这几个因素来考虑:

  • 协议是否支持跨平台:如果一个大的系统有好多种语言进行混合开发,那么就肯定不适合用有语言局限性的序列化协议,比如 JDK 原生、Kryo 这些只能用在 Java 语言范围下,你用 JDK 原生方式进行序列化,用其他语言是无法反序列化的。

  • 序列化的速度:如果序列化的频率非常高,那么选择序列化速度快的协议会为你的系统性能提升不少。

  • 序列化生成的体积:如果频繁的在网络中传输的数据那就需要数据越小越好,小的数据传输快,也不占带宽,也能整体提升系统的性能,因此序列化生成的体积就很关键了。

相关推荐
jackson凌8 分钟前
【Java学习笔记】冒泡排序
java·笔记·学习
RainbowSea20 分钟前
通用型产品发布解决方案(SpringBoot+SpringCloud+Spring CloudAlibaba+Vue+ElementUI+MyBatis-Plu
java·后端·spring cloud
武昌库里写JAVA24 分钟前
vuex源码分析(一)——初始化vuex
java·开发语言·spring boot·学习·课程设计
冼紫菜25 分钟前
SpringBoot配置RestTemplate并理解单例模式详解
java·spring boot·后端·spring·单例模式
不思念一个荒废的名字27 分钟前
【刷题Day29】Python/JAVA - 03(浅)
java·开发语言·jvm·python
小小年纪不学好44 分钟前
【60.组合总和】
java·算法·面试
Miku161 小时前
基于SpringAI实现简易聊天对话
java·ai编程
24k小善1 小时前
FlinkJobmanager深度解析
java·大数据·flink·云计算
forestsea1 小时前
Maven多模块工程版本管理:flatten-maven-plugin扁平化POM
java·maven
CodeFox1 小时前
线上 nacos 挂了 !cp 模式下,naming server down 掉问题深度解析!
java·后端·架构