深入理解Java 序列化(二)

深入理解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不同,分别是6206330369510978425854734779448916520

从这里可以看出,系统自己在序列化以及反序列化时生成了一个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

对象的序列化是通过ObjectOutPutStreamObjectInputStream实现的,带着刚才的问题,下面分析ObjectInputStreamreadObject()方法的执行情况。

为了节省篇幅,直接贴出readObject的调用栈

readObject() --->  readObject0() ---> readOrdinaryObject() ---> checkResolve()

这里看一下重点代码,readOrdinaryObject()方法的代码片段如下:

在这里插入图片描述

在这里插入图片描述

上面贴出了两部分代码,下面先分析第一部分:

在这里插入图片描述

这里创建的obj对象就是本方法要返回的对象,也可以理解为ObjectInputStreamreadObject()返回的对象,这段代码的解释如下:

  • 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规则,认为两个方法的返回值代表bjcsuccess两个字段的值,所以直接序列化为 {"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的反序列化漏洞等,有兴趣的同学可以去自行了解,由于篇幅问题,这里不再过多阐述。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值