「Java 进阶之路」系列 Day30
写在前面
模块四"异常与IO"写到这篇收官。serialVersionUID这个字段几乎每个用过Serializable的人都见过,IDE还老是提醒你加一个,但真要问"不写它会怎样、写了又有什么用",很多人答不上来。这篇把序列化的基本原理和这个字段的真正作用讲清楚。
一、是什么:把对象状态变成字节流,再变回来
序列化是把一个对象当前的状态转换成字节流的过程,这样就能把对象存到文件里、或者通过网络传输出去;反序列化是反过来,把字节流重新还原成一个对象。一个类要能被序列化,必须实现Serializable这个标记接口(没有任何方法需要实现,只是一个标记,告诉JVM"这个类允许被序列化")。
java
public class User implements Serializable {
private String name;
private int age;
}
二、为什么需要 serialVersionUID:反序列化时的"版本对暗号"
serialVersionUID本质上是一个"版本号":对象序列化的时候,这个版本号会被一起写进字节流里;反序列化的时候,JVM会拿字节流里记录的这个版本号,和当前 这个类里定义的版本号做比较------两者一致才认为"这份字节流和当前类版本是兼容的",允许正常反序列化;不一致就直接抛出InvalidClassException,拒绝反序列化。
如果不显式声明这个字段会怎样 :JVM会在编译时根据类的结构 (字段、方法、接口实现等)自动帮你计算出一个隐式的serialVersionUID。问题就出在这个自动计算的过程高度依赖编译器的具体实现细节------哪怕源码本身完全没有变化,只是换了个编译器版本、换了个平台重新编译,这个自动算出来的值都可能不一样。
一个真实会踩到的场景 :某个类第一次编译上线,序列化了一批对象存到磁盘或者发给了别的服务;后来这个类新增了一个字段(这种改动理论上是向后兼容的,正常应该还能反序列化旧数据),重新编译上线。如果一直没有显式声明serialVersionUID,两次编译出来的隐式版本号很可能对不上,结果就是:明明这次改动本身是兼容的,却因为版本号"意外地"变了,导致所有旧数据反序列化时集体报错。
正确做法是显式声明一个固定的serialVersionUID(IDE通常会提示自动生成一个):
java
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
}
只要这个值自己不手动去改,类结构的正常演进(比如新增字段)就不会意外触发版本号变化,反序列化旧数据依然能成功(新增的字段会用默认值填充)。只有当确实做了不兼容的改动 (比如删除字段、修改字段类型)时,才应该手动升级这个版本号,明确告诉JVM"这一版和之前不兼容,别让旧数据硬着头皮反序列化"。
三、怎么用:两个容易被忽略的细节
transient 关键字:跳过不需要持久化的字段
java
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private transient String password; // 密码这类敏感字段不希望被序列化
}
被transient修饰的字段不会参与序列化,反序列化恢复出来的对象里,这个字段会是对应类型的默认值(引用类型是null,基本类型是0/false)。典型场景是密码这类敏感信息、或者可以重新计算得到的临时缓存字段,没必要(也不应该)把它们持久化下来。
静态字段本来就不会被序列化,不需要额外标记
序列化针对的是对象实例的状态 ,而static字段属于类本身、不属于任何实例(这一点Day18讲static关键字时提过),本来就和"这个对象长什么样"无关,所以static字段天然就不会被序列化,不需要额外用transient去标记它。
四、面试追问
Q1:serialVersionUID 的作用是什么?
它是序列化机制里的一个版本号,序列化时会连同对象数据一起写进字节流;反序列化时JVM会拿字节流里记录的这个版本号,和当前类定义的版本号做比较,一致才允许正常反序列化,不一致会直接抛出InvalidClassException。它的作用是让JVM能够判断"当前的类版本"和"当初序列化时的类版本"是否兼容。
Q2:如果不显式声明 serialVersionUID 会有什么风险?
JVM会根据类结构在编译时自动计算一个隐式的版本号,这个计算过程高度依赖具体的编译器实现细节。哪怕源码没有实质性改动,只是换了编译器版本或者平台重新编译,这个隐式算出来的版本号都可能发生变化,导致本该兼容的旧序列化数据在反序列化时意外失败,抛出InvalidClassException。
Q3:类新增了一个字段,之前显式声明的 serialVersionUID 需要跟着改吗?
不需要。新增字段属于向后兼容的改动,只要serialVersionUID本身没有被手动修改,反序列化旧数据依然能成功,新增的字段会用默认值填充。只有做了真正不兼容的改动(比如删除已有字段、修改字段的数据类型)时,才应该主动升级这个版本号,用来明确告知不再兼容旧版本的数据。
Q4:transient 关键字的作用是什么,什么场景该用它?
transient修饰的字段不会参与序列化过程,反序列化恢复对象时这个字段会是对应类型的默认值。适合用在不希望被持久化的敏感字段(比如密码),或者可以通过其他字段重新计算得出、没必要占用序列化空间的临时缓存字段。
Q5:static 字段需要用 transient 修饰才能避免被序列化吗?
不需要。序列化操作的对象是"对象实例的状态",而static字段本身就属于类、不属于任何具体实例,天然就不在序列化的范围内,不需要额外用transient去标记它。
下一篇预告
模块四"异常与IO"到这篇正式收官。Day31 开始进入模块五"JVM与GC",第一篇讲JVM的内存模型------堆、栈、方法区这几块内存区域到底各自存放什么。