序列化

概念

Java平台允许我们在内存中创建可复用的Java对象,但一般情况下,只有当JVM处于运行时,这些对象才可能存在,即,这些对象的生命周期不会比JVM的生命周期更长。

但在现实应用中,就可能要求在JVM停止运行之后能够保存(持久化)指定的对象,并在将来重新读取被保存的对象。Java对象序列化就能够帮助我们实现该功能。

使用Java对象序列化,在保存对象时,会把其状态保存为一组字节,在未来,再将这些字节组装成对象。

必须注意地是,对象序列化保存的是对象的”状态”,即它的成员变量。

由此可知,对象序列化不会关注类中的静态变量。

应用场景

  • 当你想把的内存中的对象状态保存到一个文件中或者数据库中时候;

  • 当你想用套接字在网络上传送对象的时候;

  • 当你想通过RMI传输对象的时候;

比如存入数据库、redis,rpc 调用。Http 请求等。

简单例子

  • User.java
public class User implements Serializable {

    private static final long serialVersionUID = 877904565615808690L;

    private String username;

    private String password;

    //Getter & Setter
    //toString()
}
  • Main.java
public static void main(String[] args) throws IOException, ClassNotFoundException {
    File file = new File("user.out");
    FileOutputStream fos = new FileOutputStream(file);
    ObjectOutputStream out = new ObjectOutputStream(fos);
    User user = new User();
    user.setUsername("ryo");
    user.setPassword("123456");
    out.writeObject(user);
    out.close();
    // 创建文件
    file.createNewFile();
    FileInputStream fis = new FileInputStream(file);
    ObjectInputStream in = new ObjectInputStream(fis);
    User userRead = (User)in.readObject();
    in.close();
    System.out.println(userRead);
}

日志

User{username='ryo', password='123456'}

user.out 存储了对应的内容。

Serializable

作用

为什么一个类实现了 Serializable 接口,它就可以被序列化呢?

ObjectOutputStream.writeObject0() 源码如下:

/**
 * Underlying writeObject/writeUnshared implementation.
 */
private void writeObject0(Object obj, boolean unshared)
    throws IOException
{
    //...

    // 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());
        }
    }

    //...
}

从上述代码可知,如果被写对象的类型是String,或数组,或Enum,或Serializable,那么就可以对该对象进行序列化,否则将抛出NotSerializableException。

默认序列化机制

如果仅仅只是让某个类实现Serializable接口,而没有其它任何处理的话,则就是使用默认序列化机制。

使用默认机制,在序列化对象时,不仅会序列化当前对象本身,还会对该对象引用的其它对象也进行序列化,同样地,这些其它对象引用的另外对象也将被序列化,以此类推。

所以,如果一个对象包含的成员变量是容器类对象,而这些容器所含有的元素也是容器类对象,那么这个序列化的过程就会较复杂,开销也较大。

基础知识

1、在Java中,只要一个类实现了 java.io.Serializable 接口,那么它就可以被序列化。

2、通过 ObjectOutputStreamObjectInputStream 对对象进行序列化及反序列化。

3、虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化 ID 是否一致(就是 private static final long serialVersionUID

4、序列化并不保存静态变量。

5、要想将父类对象也序列化,就需要让父类也实现 Serializable 接口。

6、Transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null。

7、服务器端给客户端发送序列化对象数据,对象中有一些数据是敏感的,比如密码字符串等,希望对该密码字段在序列化时,进行加密,而客户端如果拥有解密的密钥,只有在客户端进行反序列化时,才可以对密码进行读取,这样可以一定程度保证序列化对象的数据安全。

Trasient

当某个字段被声明为transient后,默认序列化机制就会忽略该字段。

还是上面的例子,设置 username 为 transient,其他保持不变。

transient private String username;

private String password;

日志:

User{username='null', password='123456'}

父类问题

要想将父类对象也序列化,就需要让父类也实现 Serializable 接口。

ps: 如果不想让字段序列化,可以使用 transient 关键字。

或者将字段放在父类,且父类不实现 Serializable 接口。

自定义序列化方法

影响序列化

  • transient关键字

  • writeObject()方法与readObject()方法

  • Externalizable 接口

  • readResolve() 方法

调用流程

在序列化过程中,虚拟机会试图调用对象类里的 writeObject 和 readObject 方法,进行用户自定义的序列化和反序列化,如果没有这样的方法,则默认调用是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。

用户自定义的 writeObject 和 readObject 方法可以允许用户控制序列化的过程,比如可以在序列化的过程中动态改变序列化的数值。

脱敏加密

基于这个原理,可以在实际应用中得到使用,用于敏感字段的加密工作,清单 3 展示了这个过程。

  • User.java

User 类中添加 2 个方法如下,其他保持不变。

private void writeObject(ObjectOutputStream out) {
    try {
        ObjectOutputStream.PutField putFields = out.putFields();
        System.out.println("原密码:" + password);
        //模拟加密
        password = "encryption";
        putFields.put("password", password);
        System.out.println("加密后的密码" + password);
        out.writeFields();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

private void readObject(ObjectInputStream in) {
    try {
        ObjectInputStream.GetField readFields = in.readFields();
        Object object = readFields.get("password", "");
        System.out.println("要解密的字符串:" + object.toString());
        //模拟解密,需要获得本地的密钥
        password = "pass";
    } catch (IOException | ClassNotFoundException e) {
        e.printStackTrace();
    }
}

运行日志:

原密码:123456
加密后的密码encryption
要解密的字符串:encryption
User{username='null', password='pass', id='1'}

Externalizable

无论是使用transient关键字,还是使用writeObject()和readObject()方法,其实都是基于Serializable接口的序列化。

JDK中提供了另一个序列化接口–Externalizable,使用该接口之后,之前基于Serializable接口的序列化机制就将失效。

  • Externalizable.java
public interface Externalizable extends java.io.Serializable {}
  • ExtUser.java
public class ExtUser implements Externalizable {

    private static final long serialVersionUID = -6486845795910490036L;
    
    private String username;

    private String password;

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeObject(username);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        username = (String) in.readObject();
    }

    // Getter & Setter
    // toString()
}
  • main()
public static void main(String[] args) throws IOException, ClassNotFoundException {
    File file = new File("person.out");
    ObjectOutputStream oout = new ObjectOutputStream(new FileOutputStream(file));
    ExtUser user = new ExtUser();
    user.setUsername("ryo");
    user.setPassword("123456");
    oout.writeObject(user);
    oout.close();
    ObjectInputStream oin = new ObjectInputStream(new FileInputStream(file));
    Object newPerson = oin.readObject();
    oin.close();
    System.out.println(newPerson);
}

日志信息

ExtUser{username='ryo', password='null'}

readResolve()

反序列单例问题

public class SingleUser implements Serializable {

    private static final long serialVersionUID = -1393401178212187059L;

    private static class InstanceHolder {
        private static final SingleUser SINGLE_USER = new SingleUser("John");
    }

    private final String username;

    public SingleUser(String username) {
        this.username = username;
    }

    public static SingleUser getInstance() {
        return InstanceHolder.SINGLE_USER;
    }
}
  • main()
public static void main(String[] args) throws IOException, ClassNotFoundException {
    File file = new File("user.out");
    ObjectOutputStream oout = new ObjectOutputStream(new FileOutputStream(file));
    SingleUser user = SingleUser.getInstance();
    oout.writeObject(user);
    oout.close();
    ObjectInputStream oin = new ObjectInputStream(new FileInputStream(file));
    Object newUser = oin.readObject();
    oin.close();
    System.out.println(user == newUser);
}

输出日志

false

可是,对于单例而言。我们希望只有一个实例。

改进版本

  • SingleUser.java

中添加如下代码

private Object readResolve() throws ObjectStreamException {
    return InstanceHolder.SINGLE_USER;
}

则可以达到我们的预期

readResolve

无论是实现Serializable接口,或是Externalizable接口,当从I/O流中读取对象时,readResolve()方法都会被调用到。

实际上就是用readResolve()中返回的对象直接替换在反序列化过程中创建的对象,而被创建的对象则会被垃圾回收掉。

序列化存储规则

说明

Java 序列化机制为了节省磁盘空间,具有特定的存储规则,当写入文件的为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用;

序列化到同一个文件时,如第二次修改了相同对象属性值再次保存时候,虚拟机根据引用关系知道已经有一个相同对象已经写入文件,因此只保存第二次写的引用,所以读取时,都是第一次保存的对象。

  • 优点

该存储规则极大的节省了存储空间。

实例

  • SerialRuleTest.java
public class SerialRuleTest implements Serializable {

    private static final long serialVersionUID = 6464900554231479673L;

    private final int id;

    public SerialRuleTest(int id) {
        this.id = id;
    }

    public int getId() {
        return id;
    }
}
  • main()
public static void main(String[] args) throws IOException, ClassNotFoundException {
    ObjectOutputStream out = new ObjectOutputStream(
            new FileOutputStream("result.obj"));
    SerialRuleTest test = new SerialRuleTest(1);
    //试图将对象两次写入文件
    out.writeObject(test);
    out.flush();
    System.out.println(new File("result.obj").length());
    out.writeObject(test);
    out.close();
    System.out.println(new File("result.obj").length());
    ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
            "result.obj"));

    //从文件依次读出两个文件
    SerialRuleTest t1 = (SerialRuleTest) oin.readObject();
    SerialRuleTest t2 = (SerialRuleTest) oin.readObject();
    oin.close();
    //判断两个引用是否指向同一个对象
    System.out.println(t1 == t2);
}

日志信息

84
89
true
  • 解释说明

中对同一对象两次写入文件,打印出写入一次对象后的存储大小和写入两次后的存储大小,然后从文件中反序列化出两个对象,比较这两个对象是否为同一对象。

一般的思维是,两次写入对象,文件大小会变为两倍的大小,反序列化时,由于从文件读取,生成了两个对象,判断相等时应该是输入 false 才对。

Java 序列化机制为了节省磁盘空间,具有特定的存储规则,当写入文件的为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用,上面增加的 5 字节的存储空间就是新增引用和一些控制信息的空间。

反序列化时,恢复引用关系,t1 和 t2 指向唯一的对象,二者相等,输出 true。

该存储规则极大的节省了存储空间。

为什么序列化不是默认添加

其中最大的问题是对象的引用

假如我有两个类,分别是A和B,B类中含有一个指向A类对象的引用,现在我们对两个类进行实例化

A a = new A();
B b = new B();

这时在内存中实际上分配了两个空间,一个存储对象a,一个存储对象b,接下来我们想将它们写入到磁盘的一个文件中去,就在写入文件时出现了问题!

因为对象b包含对对象a的引用,所以系统会自动的将a的数据复制一份到b中。

这样的话当我们从文件中恢复对象时(也就是重新加载到内存中)时

内存分配了三个空间,而对象a同时在内存中存在两份。

这样的问题会很多,必须对a进行修改等操作,需要维护每一份的拷贝来达到数据的一致性。

很大程度上浪费空间和影响性能。

安全机制

不是默认序列化很重要的一个原因就是为了安全,java的类安全机制是做的很好的.

对于一个你要传输的对象,比如写到文件,或者进行rmi传输等等,在传输的过程中,这个对象的private等域是不受保护的。

资源分配

还有就是一些资源分配的问题,比如thread,序列化是很难对他重新分配资源,

所以并非所有的类都可以序列化.

同时添加序列化,会进行一系列的比较操作,可参考序列化机制,也会占资源,所以不需要流传输的就大可不必序列化。

序列化的漏洞

如果Java应用对用户输入,即不可信数据做了反序列化处理,那么攻击者可以通过构造恶意输入,让反序列化产生非预期的对象,非预期的对象在产生过程中就有可能带来任意代码执行。

所以这个问题的根源在于类ObjectInputStream在反序列化时,没有对生成的对象的类型做限制;假若反序列化可以设置Java类型的白名单,那么问题的影响就小了很多。

序列化 ID 的问题

作用

简单来说,Java的序列化机制是通过判断类的serialVersionUID来验证版本一致性的。

在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进行比较, 如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidCastException。

虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化 ID 是否一致。

最佳实践

  1. 为每一个序列化对象,添加 serialVersionUID。避免每次生成消耗性能。

  2. 保证同一个对象的 serialVersionUID 的一致性,否则反序列化会失败。

ps: 第一条《Thinking in Java》 有提到过。

1L 还是随机?

序列化 ID 在 Eclipse/Idea 下提供了两种生成策略,一个是固定的 1L,一个是随机生成一个不重复的 long 类型数据(实际上是使用 JDK 工具生成)。

在这里有一个建议,如果没有特殊需求,就是用默认的 1L 就可以,这样可以确保代码一致时反序列化成功。

那么随机生成的序列化 ID 有什么作用呢,有些时候,通过改变序列化 ID 可以用来限制某些用户的使用。

应用

比如 Facade 模式中,外部系统调用我们的序列化实例。

当对象出现更新时,我们更新 serialVersionUID 的值,让其必须也随之强制更新,否则直接报错。

参考资料

http://www.blogjava.net/jiangshachina/archive/2012/02/13/369898.html

  • 序列化与反序列化

http://www.importnew.com/24490.html

http://www.importnew.com/24490.html

java序列化和序列化ID的作用

  • serialVersionUID

https://www.jianshu.com/p/5a85011de960

https://blog.csdn.net/qq_27093465/article/details/78544505