Java序列化深入理解

1 序列化

1.1 基本概念理解

Java 对象序列化用于作为一种将 Java 对象的状态转换为字节数组,以便存储或传输的机制,以后,仍可以将字节数组转换回 Java 对象原有的状态。
序列化就是一种用来处理对象流的机制,所谓对象流也就是将对象的内容进行流化。可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。序列化是为了解决在对对象流进行读写操作时所引发的问题。

实际上,序列化的思想是 冻结 对象状态,传输对象状态(写到磁盘、通过网络传输等等),然后 解冻 状态,重新获得可用的 Java 对象。所有这些事情的发生有点像是魔术,这要归功于 ObjectInputStream/ObjectOutputStream 类、完全保真的元数据以及程序员愿意用Serializable 标识接口标记他们的类,从而 参与 这个过程
序列化的实现:将需要被序列化的类实现Serializable接口,然后使用一个输出流(如:FileOutputStream)来构造一个ObjectOutputStream(对象流)对象,接着,使用ObjectOutputStream对象的writeObject(Object obj)方法就可以将参数为obj的对象写出(即保存其状态),要恢复的话则用输入流

Serialization(序列化)是一种将对象以一连串的字节描述的过程;deserialization(反序列化)是一种将这些字节重建成一个对象的过程

1.2 串行序列化特点

1.2.1 序列化允许重构

序列化允许一定数量的类变种,甚至重构之后也是如此,ObjectInputStream 仍可以很好地将其读出来。
Java Object Serialization 规范可以自动管理的关键任务是:

  • 将新字段添加到类中,将字段从 static 改为非static
  • 将字段从 transient 改为非transient

取决于所需的向后兼容程度,转换字段形式(从非static 转换为 static 或从非 transient 转换为 transient)或者删除字段需要额外的消息传递。

1.2.2 序列化并不安全

Java 开发人员诧异并感到不快的是,序列化二进制格式完全编写在文档中,并且完全可逆。实际上,只需将二进制序列化流的内容转储到控制台,就足以看清类是什么样子,以及它包含什么内容。这对于安全性有着不良影响。
例如,当通过 RMI 进行远程方法调用时,通过连接发送的对象中的任何 private 字段几乎都是以明文的方式出现在套接字流中,这显然容易招致哪怕最简单的安全问题。幸运的是,序列化允许 hook 序列化过程,并在序列化之前和反序列化之后保护(或模糊化)字段数据。可以通过在 Serializable对象上提供一个writeObject方法来做到这一点。

假设 类中的敏感数据是 age 字段。我们可以在序列化之前模糊化该数据,将数位循环左移一位,然后在反序列化之后复位。(您可以开发更安全的算法,当前这个算法只是作为一个例子。)
为了 hook 序列化过程,我们将在类上实现一个 writeObject 方法;为了 hook 反序列化过程,我们将在同一个类上实现一个readObject 方法。

public class SerialEnty  implements Serializable {

    private static final long serialVersionUID = 1L;

    private int age;
    private String name;



    public SerialEnty(int age, String name) {

        this.age = age;
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    private void writeObject(java.io.ObjectOutputStream stream)throws java.io.IOException {
    // "Encrypt"/obscure the sensitive data
    age = age << 2;
    stream.defaultWriteObject();
    }

    private void readObject(java.io.ObjectInputStream stream) throws java.io.IOException, ClassNotFoundException {
        stream.defaultReadObject();

    // "Decrypt"/de-obscure the sensitive data
    age = age >> 2;
    }
}

public static void main(String[] args) throws IOException, ClassNotFoundException {
        SerialEnty hello = new SerialEnty(2, "hello");
        System.out.println(hello);
        ByteArrayOutputStream bos = new ByteArrayOutputStream ();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(hello);
        oos.flush();
        bos.flush();

        InputStream is = new ByteArrayInputStream(bos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(is);
        SerialEnty se = (SerialEnty)ois.readObject();
        System.out.println(se);
        System.out.println(JSON.toJSON(se));

        ois.close();
        is.close();

        oos.close();
        bos.close();
    }

1.2.3 序列化的数据可以被签名和密封

上一个技巧假设想模糊化序列化数据,而不是对其加密或者确保它不被修改。当然,通过使用 writeObjectreadObject 可以实现密码加密和签名管理,但其实还有更好的方式。
如果需要对整个对象进行加密和签名,最简单的是将它放在一个 javax.crypto.SealedObjectjava.security.SignedObject 包装器中。两者都是可序列化的,所以将对象包装在 SealedObject 中可以围绕原对象创建一种 “包装盒”。必须有对称密钥才能解密,而且密钥必须单独管理。同样,也可以将 SignedObject 用于数据验证,并且对称密钥也必须单独管理。
结合使用这两种对象,便可以轻松地对序列化数据进行密封和签名,而不必强调关于数字签名验证或加密的细节。

1.2.4 序列化允许将代理放在流中

很多情况下,类中包含一个核心数据元素,通过它可以派生或找到类中的其他字段。在此情况下,没有必要序列化整个对象。可以将字段标记为 transient,但是每当有方法访问一个字段时,类仍然必须显式地产生代码来检查它是否被初始化。

打包和解包代理
writeReplacereadResolve 方法使 类可以将它的所有数据(或其中的核心数据)打包到一个 PersonProxy 中,将它放入到一个流中,然后在反序列化时再进行解包。

1.2.5 串行化的继承

如果某个类能够被串行化,其子类也可以被串行化。
如果该类有父类,则分两种情况来考虑,如果该父类已经实现了可串行化接口。则其父类的相应字段及属性的处理和该类相同,即:父类实现序列化,子类自动实现序列化,不需要显式实现Serializable接口;
如果该类的父类没有实现可串行化接口,则该类的父类所有的字段属性将不会串行化。
对于父类的处理,如果父类没有实现串行化接口,则其必须有默认的构造函数(即没有参数的构造函数,如果只声明有参构造会报错),否则编译的时候就会报错。在反串行化的时候,默认构造函数会被调用。但是若把父类标记为可以串行化,则在反串行化的时候,其默认构造函数不会被调用。这是为什么呢?这是因为Java 对串行化的对象进行反串行化的时候,直接从流里获取其对象数据来生成一个对象实例,而不是通过其构造函数来完成

注意 :当一个对象的实例变量引用其他对象,序列化该对象时也把引用对象进行序列化

1.2.6 static和transient

声明为statictransient类型的成员数据不能被串行化。因为static代表类的状态, transient代表对象的临时数据
序列化会忽略静态变量,即序列化不保存静态变量的状态。静态成员属于类级别的,所以不能序列化。即 序列化的是对象的状态不是类的状态
这里的不能序列化的意思,是序列化信息中不包含这个静态成员域

1.2.7 序列化前和序列化后的对象的关系

==还是equal? or 是浅复制还是深复制?
答案:深复制,反序列化还原后的对象地址与原来的的地址不同
序列化前后对象的地址不同了,但是内容是一样的,而且对象中包含的引用也相同。换句话说,通过序列化操作,我们可以实现对任何可Serializable对象的深度复制(deep copy)——这意味着我们复制的是整个对象网,而不仅仅是基本对象及其引用。对于同一流的对象,他们的地址是相同,说明他们是同一个对象,但是与其他流的对象地址却不相同。也就说,只要将对象序列化到单一流中,就可以恢复出与我们写出时一样的对象网,而且只要在同一流中,对象都是同一个

1.3 相关类和方法

1.3.1 简介

java.io包中提供的涉及对象的串行化的类与接口有ObjectOutput接口、ObjectOutputStream类、ObjectInput接口、ObjectInputStream类。
ObjectOutput接口:它继承DataOutput接口并且支持对象的串行化,其内的writeObject()方法实现存储一个对象。
ObjectInput接口:它继承DataInput接口并且支持对象的串行化,其内的readObject()方法实现读取一个对象。
ObjectOutputStream类:它继承OutputStream类并且实现ObjectOutput接口。利用该类来实现将对象存储(调用ObjectOutput接口中的writeObject()方法)。ObjectInputStream类:它继承InputStream类并且实现ObjectInput接口。利用该类来实现读取一个对象(调用ObjectInput接口中的readObject()方法)。

1.3.2 SerializedLambda

SerializedLambda 是序列化过程中的一个特殊产物。它是 Lambda表达式(实现序列化接口)调用 writeReplace 方法后返回的那个替身对象。它自身也是一个标准的、可序列化的对象,里面装满了Lambda的元数据。
如果Lambda表达式没有实现序列化接口,则不会有 writeReplace 方法和SerializedLambda

使用示例:

@FunctionalInterface
interface MyFunc<T> extends Serializable {
    T get(T input);
}

public class LambdaSerializeExample {
    public static void main(String[] args) throws Exception {
        // 1. 创建一个可序列化的Lambda
        MyFunc<String> func = (s) -> s.toUpperCase();
        
        // 2. 使用反射获取其SerializedLambda
        Method writeReplace = func.getClass().getDeclaredMethod("writeReplace");
        writeReplace.setAccessible(true);
        SerializedLambda serializedLambda = (SerializedLambda) writeReplace.invoke(func);
        
        // 3. 查看Lambda的“指纹信息”
        System.out.println("实现类: " + serializedLambda.getImplClass());
        System.out.println("实现方法名: " + serializedLambda.getImplMethodName());
        System.out.println("函数接口方法名: " + serializedLambda.getCapturingClass());   
    }
}

1.3.3 主要方法

Java对象序列化涉及多个层次的方法,可以大致分为三类:

类别 方法名 所属接口/类 调用时机与目的 关键特性
序列化“后门”方法 writeReplace 在被序列化的类中定义 序列化之前被调用,用于返回一个替换原对象进行序列化的“替身”对象。 优先级最高,完全控制替换逻辑。
readResolve 在被序列化的类中定义 反序列化之后被调用,用于替换从流中读取到的对象(常用于实现单例模式) 最后一道工序,可以修改或替换反序列化结果
自定义序列化方法 writeObject 在被序列化的类中定义 当类实现此私有方法时,JVM会调用它来写入对象的非默认字段 细粒度控制如何写数据,但仍是写自己
readObject 在被序列化的类中定义 当类实现此私有方法时,JVM会调用它来读取并还原对象的非默认字段 细粒度控制如何读数据,与writeObject配对
外部化控制方法 writeExternal java.io.Externalizable 接口 由Externalizable对象调用,完全接管序列化的写入过程 比Serializable更彻底的控制,必须自己写所有数据
readExternal java.io.Externalizable 接口 由Externalizable对象调用,完全接管反序列化的读取和重构过程 比Serializable更彻底的控制,必须自己读所有数据

但是Externalizable 要求手动读写每个字段,容易遗漏或顺序错误导致数据混乱且不常用,若未正确维护版本兼容性,极易引发反序列化失败

@Override
public void writeExternal(ObjectOutput out) throws IOException {
    out.writeObject(name);
    out.writeInt(age); // 注意顺序必须一致
}

@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
    name = (String) in.readObject();
    age = in.readInt(); // 顺序必须匹配
}

2 serialVersionUID

2.1 serialVersionUID作用

serialVersionUID适用于Java的序列化机制。简单来说,Java的序列化机制是通过判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidCastException

2.2 添加方式

serialVersionUID有两种显示的生成方式:

  • 一是默认的1L,比如:private static final long serialVersionUID = 1L;
  • 二是根据类名、接口名、成员方法及属性等来生成一个64位的哈希字段,比如:private static final long serialVersionUID = xxxxL;

当一个类实现了Serializable接口,如果没有显示的定义serialVersionUID

  • Eclipse:
    Eclipse会提供相应的提醒。面对这种情况,我们只需要在Eclipse中点击类中warning图标一下,Eclipse就会自动给定两种生成的方式。如果不想定义,在Eclipse的设置中也可以把它关掉的,设置如下:Window ==> Preferences ==> Java ==> Compiler ==> Error/Warnings ==> Potential programming problems
    Serializable class without serialVersionUIDwarning改成ignore即可
  • IntellijIdea:
    IntellijIdea中没有相关提示,就需要相关设置:File ==> Settings ==> Editor ==> Inspections ==> Java ==> Serialization issus 或者搜索框中输入serialVersionUID关键字 ==> 勾选Serializable class without serialVersionUID,就完成了IntellijIdea设置
    使用时把光标放在类名上,按Alt+Enter键,这个时候可以看到Add serialVersionUID field提示信息,选中后就可以生成了

当实现java.io.Serializable接口的类没有显式地定义一个serialVersionUID变量时候,Java序列化机制会根据编译的Class自动生成一个serialVersionUID作序列化版本比较用,这种情况下,如果Class文件(类名,方法明等)没有发生变化(增加空格,换行,增加注释等等),就算再编译多次,serialVersionUID也不会变化的。
如果我们不希望通过编译来强制划分软件版本,即实现序列化接口的实体能够 兼容先前版本 ,就需要显式地定义一个名为serialVersionUID,类型为long的变量,不修改这个变量值的序列化实体都可以相互进行串行化和反串行化

2.3 相关案例说明

下面用代码说明一下serialVersionUID在应用中常见的几种情况。
序列化实体类

import java.io.Serializable;
public class Person implements Serializable
{
    private static final long serialVersionUID = 1234567890L;
    public int id;
    public String name;
 
    public Person(int id, String name)
    {
        this.id = id;
        this.name = name;
    }
 
    public String toString()
    {
        return "Person: " + id + " " + name;
    }
}

序列化功能:

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
 
public class SerialTest
{
 
    public static void main(String[] args) throws IOException
    {
        Person person = new Person(1234, "wang");
        System.out.println("Person Serial" + person);
        FileOutputStream fos = new FileOutputStream("Person.txt");
        ObjectOutputStream oos = new ObjectOutputStream(fos);
        oos.writeObject(person);
        oos.flush();
        oos.close();
    }
}

反序列化功能:

import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
public class DeserialTest
{
    public static void main(String[] args) throws IOException, ClassNotFoundException
    {        
        Person person;
        FileInputStream fis = new FileInputStream("Person.txt");
        ObjectInputStream ois = new ObjectInputStream(fis);
        person = (Person) ois.readObject();
        ois.close();
        System.out.println("Person Deserial" + person);
    } 
}
  • 情况一:假设Person类序列化之后,从A端传输到B端,然后在B端进行反序列化。在序列化Person和反序列化Person的时候,A端B端都需要存在一个相同的类。如果两处的serialVersionUID不一致,会产生什么错误呢?
    【答案】可以利用上面的代码做个试验来验证:
    先执行测试类SerialTest,生成序列化文件,代表A端序列化后的文件,然后修改serialVersion值,再执行测试类DeserialTest,代表B端使用不同serialVersion的类去反序列化,结果报错:
Exception in thread "main" java.io.InvalidClassException: test.Person; local class incompatible: stream classdesc serialVersionUID = 1234567890, local class serialVersionUID = 123456789
    at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:560)
    at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1580)
    at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1493)
    at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1729)
    at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1326)
    at java.io.ObjectInputStream.readObject(ObjectInputStream.java:348)
    at test.DeserialTest.main(DeserialTest.java:15)
  • 情况二:假设两处serialVersionUID一致,如果A端增加一个字段,然后序列化,而B端不变,然后反序列化,会是什么情况呢?
    【答案】新增 public int age; 执行SerialTest,生成序列化文件,代表A端。删除 public int age,反序列化,代表B端,最后的结果为:执行序列化,反序列化正常,但是A端增加的字段丢失(被B端忽略)。
  • 情况三:假设两处serialVersionUID一致,如果B端减少一个字段,A端不变,会是什么情况呢?
    【答案】序列化,反序列化正常,B端字段少于A端,A端多的字段值丢失(被B端忽略)。
  • 情况四:假设两处serialVersionUID一致,如果B端增加一个字段,A端不变,会是什么情况呢?
    验证过程如下:
    先执行SerialTest,然后在实体类Person增加一个字段age,如下所示,再执行测试类DeserialTest.
    【答案】序列化,反序列化正常,B端新增加的int字段被赋予了默认值0
    最后通过下面的图片,总结一下上面的几种情况
    在这里插入图片描述
posted @ 2021-10-30 12:22  上善若泪  阅读(446)  评论(0)    收藏  举报