深入理解Java 序列化(二)
PS:本文章重点从Java的序列化体系出发进行学习(后期也会持续更新,不断完善)
内容借鉴张洪亮老师的 《深入理解Java核心核心技术》 - 第十二章:序列化
目录导航
我们继续上一篇《深入理解Java 序列化(一)》继续,本篇将介绍一些其他方法的问题。
一、为什么不能随便修改serialVersionUID
我们知道,序列化提供了一种在JVM停机的情况下可以保存对象的方案。就像我们平常使用的U盘一样,把Java对象序列化成可传输的形式,如json、二进制流,比如保存在文件中,这样,我们再需要这个对象时,可以从文件中读取二进制流,再从二进制流中反序列化出对象。
但是,虚拟机是否允许序列化,不仅取决于类路径和功能代码是否一致,还有很重要的一点是两个类的序列化ID是否一致,这个所谓的序列化ID,就是我们在代码中定义的serialVersionUID
。
1、如果serialVersionUID变了会怎样?
如果serialVersionUID
变了会发生什么?例如:
public class User1 implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "User1{" +
"name='" + name + '\'' +
", age='" + age + '\'' +
'}';
}
}
public static void main(String[] args) {
final User1 user1 = new User1();
user1.setName("baijiechong");
user1.setAge(22);
ObjectOutputStream oos = null;
try {
//序列化
OutputStream outputStream = Files.newOutputStream(Paths.get(FILE_PATH));
oos = new ObjectOutputStream(outputStream);
oos.writeObject(user1);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
//org.apache.tomcat.util.http.fileupload.IOUtils
IOUtils.closeQuietly(oos);
}
}
先执行上面的代码,把一个User1对象写入文件,然后修改User1类,把serialVersionUID
的值改为2L
。
private static final long serialVersionUID = 2L;
再进行反序列化,将文件的内容反序列化出来
public static void main(String[] args) {
final User1 user1 = new User1();
user1.setName("baijiechong");
user1.setAge(22);
File file = new File(FILE_PATH);
ObjectInputStream ois = null;
try {
ois = new ObjectInputStream(Files.newInputStream(file.toPath()));
User1 user1 = (User1) ois.readObject();
System.out.println(user1);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(ois);
try {
FileUtils.forceDelete(file);
} catch (IOException e) {
e.printStackTrace();
}
}
}
执行结果如下:
可以发现,抛出了一个java.io.InvalidClassException
异常,并且指出serialVersionUID
不一致。
这是因为在反序列化操作时,JVM会把传来的字节流中的serialVersionUID
与本地相对应实体类的serialVersionUID
进行比较,如果相同就认为没有被篡改过,可以进行序列化,否则就会出现序列化版本不一致的异常,即InvalidClassException
(类无效异常)。
这也是《阿里巴巴Java开发手册》中规定,在兼容升级版本中,在修改类时,不要修改serialVersionUID
的原因,除非是两个完全不兼容的版本,所以,serialVersionUID
其实是用于验证版本一致性的,如果读者感兴趣,可以阅读各个版本的JDK代码,例如String类的serialVersionUID
一直都是-6849794470754667710L
;
这个规范还可以再严格点,即:
如果一个类实现了Serializable接口,则必须手动添加一个private static final long serialVersionUID
,并设置初始值。
2、为什么要明确定义一个serialVersionUID?
如果没有在类中定义一个serialVersionUID
,那么会发生什么呢?
修改上面的实例代码,同样使用User1.class , 不定义serialVersionUID
,将其序列化进文件。
public class User1 implements Serializable {
private String name;
private Integer age;
}
然后修改User1类,向其中添加一个sex
字段,再执行反序列化代码
public class User1 implements Serializable {
private String name;
private Integer age;
private Character sex;
}
运行结果:
同样,抛出了InvalidClassException
(类无效异常),并且指出两个serialVersionUID
不同,分别是6206330369510978425
和854734779448916520
。
从这里可以看出,系统自己在序列化以及反序列化时生成了一个serialVersionUID
。
所以,一旦类实现了Serializable接口,就建议明确的指定一个serialVersionUID
。否则,在修改类时就会发生异常。
serialVersionUID
有两种显式的生成方式,一是默认的1L,比如private static final long serialVersionUID = 1L
; 二是根据类名、接口名、成员方法及属性等生成一个64位的Hash字段,比如private static final long serialVersionUID = 6206330369510978425L
;
3、原理
下面通过源码分析为什么serialVersionUID
改变时会抛出异常?在没有明确定义serialVersionUID
时,默认的serialVersionUID
是怎么来的?
为了简化代码,反序列化的调用链如下:
readObject() --> readObject0() --> readOrdinaryObject() --> readClassDesc() --> readNonProxyDesc() --> ObjectStreamClass.initNonProxy()
在initNonProxy()
中,关键代码如图所示:
在反序列化过程中,对serialVersionUID
进行比较,如果发现不相等,则直接抛出异常。
getSerialVersionUID
方法如下:
在没有定义serialVersionUID
时会调用computeDefaultSUID()
方法生成一个默认的serialVersionUID
。
这也就找到了以上两个问题的根源,其实是在代码中做了严格的校验。
4、IDEA提示
为了我们不忘记定义serialVersionUID
,可以去idea中设置提醒
Editor -> Inspections ->JVM languages -> Serialization class without serialVersionUID,并将其勾选,保存即可,效果如下,直接一键生成。
5、小结
serialVersionUID
是用来验证版本一致性的,在做兼容升级时,不要改变类中的serialVersionUID
。
如果一个类实现了Serializable接口,那么一定要记得定义serialVersionUID
,否则修改了类之后,进行序列化就可能发生异常,可以在IDEA中配置避免忘记,一键快速生成serialVersionUID
。
之所以发生异常,是因为反序列化过程中做了校验,并且如果没有明确指定serialVersionUID
,则会根据类的属性自动生成一个serialVersionUID
。
二、序列化如何破坏单例模式
不止反射可以破坏单例,序列化 + 反序列化也可以实现对单例模式的破坏,假设我们使用传统的双重校验锁的方式定义一个单例。
public class Singleton {
private static volatile Singleton singleton = null;
private Singleton() {}
public static Singleton init() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
1、序列化对单例模式的破坏
我们尝试通过序列化/反序列化的技术来操作上面的单例类,先将其对象序列化写入文件,在反序列化为一个Java对象。
public class SingletonSerializableTest {
private static final String FILE_PATH = "H:\\user\\desktop\\serialization.txt";
public static void main(String[] args) {
try {
//通过代码获取到单例对象
final Singleton init = Singleton.init();
Path path = Paths.get(FILE_PATH);
//将单例对象序列化到文件
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(path));
oos.writeObject(init);
oos.close();
//通过文件反序列化出Singleton
ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(path));
Singleton sl = (Singleton) ois.readObject();
ois.close();
System.out.println("result : " + (init == sl));
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
}
输出结果为 result : false //通过对Singleton进行序列化和反序列化得到的对象是一个新的对象,这就破坏了Singleton的单例性。
在介绍如何解决这个问题之前,我们先深入分析一下为什么会这样?在反序列化的过程中发生了什么?
2、ObjectInputStream
对象的序列化是通过ObjectOutPutStream
和ObjectInputStream
实现的,带着刚才的问题,下面分析ObjectInputStream
的readObject()
方法的执行情况。
为了节省篇幅,直接贴出readObject的调用栈
readObject() ---> readObject0() ---> readOrdinaryObject() ---> checkResolve()
这里看一下重点代码,readOrdinaryObject()
方法的代码片段如下:
上面贴出了两部分代码,下面先分析第一部分:
这里创建的obj对象就是本方法要返回的对象,也可以理解为ObjectInputStream
的readObject()
返回的对象,这段代码的解释如下:
- isInstantiable(): 如果一个Serializable/Externalizable的类在运行时被实例化,那么该方法就返回true。
- desc.newInstance():该方法通过反射的方式调用无参构造方法新建一个对象。
至此,也就可以解释为什么序列化可以破坏单例模式了。
答:序列化会通过反射调用无参构造方法创建一个新的对象
接下来分析如何防止序列化/反序列化破坏单例模式。
3、防止序列化破坏单例模式
下面先给出解决方案,再具体分析原理。
只要在Singleton类中定义readResolve()
方法就可以解决该问题。
在readResolve()方法中调用init()方法而不是直接返回singleton,
因为如果没有人为调用init()
实例化之前,对Singleton进行反序列化操作,直接返回singleton结果一定是null,这样就得不偿失了,为了解决破坏单例问题,从而导致反序列化的结果为null?这样一定是不行的。
而在readResolve()
方法中调用init()
方法,就确保了即使在人为调用init()
之前进行反序列化,也不会出现单例被破坏的情况,因为不管人为还是反序列化,都调用的是init()
方法,只要是在一个jvm实例上,就一定是同一个实例!
public class Singleton implements Serializable {
private static volatile Singleton singleton = null;
private Singleton() {
}
public static Singleton init() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
private Object readResolve() {
//return singleton();
return init();
}
}
再运行测试类发现输出结果为true
我们继续分析readOrdinaryObject()
方法中的代码片段中的第二部分代码:
- hasReadResolveMethod():如果实现了
Serializable/Externalizable
接口的类中包含的readResolve()
则返回true。 - invokeReadResolve():通过反射的方式调用要被序列化的类的
readResolve()
方法。
在Singleton中定义readResolve()
方法,并在该方法中指定要返回的对象生成策略,就可以防止单例模式被破坏。
4、小结
在设计序列化的场景下,一定要格外注意序列化对单例模式的破坏。
三、使用序列化实现深拷贝
一提到拷贝可能大家就会想到Objec.clone()
方法,重写clone可以实现对对象的深拷贝,但是这样的做法有两个缺点:
-
如果在一个对象中包含多个子对象,那clone方法就会特别长。
-
如果在这个对象中新增属性,只要不是基本类型,则一定要修改clone方法内容。
有没有什么办法可以不改代码,一劳永逸呢?答案是有的。
其实我们可以借助序列化来实现深拷贝,原理就是先把对象序列化成流,再将流反序列化为对象,这样得到的对象就一定是新的对象了。
序列化的方式有很多种,我们可以使用各种JSON工具把对象序列化成Json字符串,再将字符串反序列化成对象。
如果使用fastjson,则代码如下:
Model newModel = JSON.parseObject(JSON.toJSONString(model), Model.class);
甚至可以重写clone方法为这样:
@Override
public Model clone() {
return JSON.parseObject(JSON.toJSONString(this), Model.class);
}
除此之外,我们还可以使用Jackson、Gson或者Apache Commons Lang中提供的SerializableUtils工具类实现深拷贝。
使用SerializableUtils工具类的话,我们需要修改Model类实现Serializable接口,否则无法进行序列化。
同样可以实现深拷贝
Model newModel = (Model) SerializableUtils.clone(molde);
四、JavaBean属性名对序列化的影响
JavaBean我们这里就理解为封装的字段实体类,不知道大家是否听说过开发规范中的一条:建议开发者使用success这种形式定义布尔类型的属性,而不是使用isSucces这样的形式。
如果使用isSuccess这样的形式定义属性可能会对序列化产生影响,那为什么会导致这样呢?
首先定义一个JavaBean:
/**
* java bean
*
* @author baijiechong
* @since 2023/5/21 18:51
**/
public class Model implements Serializable {
private static final long serialVersionUID = 17638329634L;
private boolean isSuccess;
public boolean isSuccess() {
return isSuccess;
}
public void setSuccess(boolean success) {
isSuccess = success;
}
public String getBjc() {
return "baijiechong";
}
}
在JavaBean中包含一个成员变量isSucces
和三个方法,分别是IDE帮助我们自动生成的isSuccess()
和setSuccess()
方法,另外一个是我自己添加的符合getter命名规范的方法。
我们分别使用不同的JSON序列化工具对这个对象进行序列化和反序列化。
/**
* @author baijiechong
* @since 2023/5/21 18:56
**/
public class JavaBeanSerializableTest {
public static void main(String[] args) throws JsonProcessingException {
//定义一个Model类型的对象
final Model model = new Model();
model.setSuccess(true);
//使用fastjson(v1.2.16) 序列化model为字符串输出
System.out.println("Serializable result with fastjson : " + JSON.toJSONString(model));
//使用Gson(v2.8.5) 序列化model为字符串输出
Gson gson = new Gson();
System.out.println("Serializable result with gson : " + gson.toJson(model));
//使用jackson(v2.9.7) 序列化model为字符串输出
ObjectMapper om = new ObjectMapper();
System.out.println("Serializable result with jackson : " + om.writeValueAsString(model));
}
}
<!-- maven版本依赖 -->
<dependencies>
<!-- https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.16</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.7</version>
</dependency>
</dependencies>
输出结果:
Serializable result with fastjson : {"bjc":"baijiechong","success":true}
Serializable result with gson : {"isSuccess":true}
Serializable result with jackson : {"success":true,"bjc":"baijiechong"}
在fastjson和jackson的结果中,原来类中的isSuccess
字段被序列化成success
,并且其中还包含bjc
值,而Gson中只有isSuccess
字段。
我们可以得出结论:fastjson和jackson把对象序列化成JSON字符串时,是通过反射遍历出该类所有getter方法得到的getBjc()
和isSuccess()
,然后根据JavaBean规则,认为两个方法的返回值代表bjc
和success
两个字段的值,所以直接序列化为 {"bjc":"baijiechong","success":true}
但Gson不是这么做的,它直接使用反射遍历该类中的所有属性,并把其值序列化成 {"isSuccess":true}
。
可以看到,由于使用了不同的序列化工具,在序列化对象时使用的策略不一样,所以,对于同一个类的同一个对象的序列化结果可能是不同的,如果我们使用fastjson对一个对象进行序列化, 再用Gson进行反序列化会发生什么呢?例如:
public static void main(String[] args) throws JsonProcessingException {
Model model = new Model();
model.setSuccess(true);
Gson gson = new Gson();
System.out.println(gson.fromJson(JSON.toJSONString(model), Model.class));
}
以上代码的输出结果是:
Model{isSuccess=false}
这和我们预期的结果完全相反,这是因为fastjson框架通过扫描所有的getter方法后发现有一个isSuccess()
方法,根据JavaBean规范,解析出对应的变量名为success
,把model对象序列化为字符串后的内容为{“success” : true}
Gson框架解析{“success” : true}
这个JSON串后,通过反射寻找Model类中的success
属性,但Model类中只有isSuccess
属性,所以最终反序列化后的Model类的对象中isSuccess
会使用默认值false。
一旦上面的情况出现在生产环境中,这绝对是一个致命的问题。
作为开发者,我们应该想尽办法避免出现问题,所以建议使用success而不是isSuccess这种形式。这样,该类中的成员变量是success
,getter方法是isSuccess
,这是完全符合JavaBean规范的,无论哪种序列化框架,执行结果都一样,因为序列化框架都是绝对遵循规范的,这样就从源头上避免了这个问题。
五、序列化漏洞
一些知名的Java开源框架都出现过十分严重的安全漏洞,例如 Apache-Cmmons-Collections的反序列化漏洞、fastjson的反序列化漏洞等,有兴趣的同学可以去自行了解,由于篇幅问题,这里不再过多阐述。