Java 文件高级技术(四):标准序列化机制

  1. 序列化/反序列化的概念?
    答:

    • 序列化:将对象转化为字节流
    • 反序列化:将字节流转化为对象
  2. Java 中标准序列化机制的基本用法?
    答:

    • 要让一个类支持序列化,只需要让这个类实现接口 java.io.Serializable
    • Serializable 没有定义任何方法,只是一个标记接口
    • 声明实现了 Serializable 接口后,保存/读取对象就可以使用 ObjectOutputStream/ObjectInputStream `流了。
    • 之前保存/读取对象使用的是 DataOutputStream/DataInputStream,需要逐个处理对象中的每个字段,比较繁琐。
    • ObjectOutputStreamOutputStream 的子类,实现了 ObjectOutput 接口。ObjectOutput 接口是 DataOutput 的子接口,增加了一个方法:public void writeObject(Object obj) throws IOException
    • writeObject(Object obj) 方法能够将内存中的一个对象 obj 转化为字节,写到流中(序列化)
    • ObjectInputStreamInputStream 的子类,实现了 ObjectInput 接口。ObjectInput 接口是 DataInput 的子接口,增加了一个方法:public Object readObject() throws ClassNotFoundException, IOException
    • readObject() 方法能够从流中读取字节,转化为内存中的一个对象(反序列化)
    • StringDateDoubleArrayListLinkedListHashMapTreeMap 等,都实现了 Serializable 接口
  3. 【笔试题】使用流 ObjectOutputStream/ObjectInputStream 保存/读入学生列表(假设学生类 Student 已实现 Serializable 接口)?
    答:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    // 保存学生列表(序列化)
    public static void writeStudents(List<Student> students) throws IOException {
    ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream("students.dat")));
    try {
    out.writeInt(students.size());
    for(Student s : students) {
    out.writeObjects(s); // 与之前相比,不用单独处理每个字段
    }
    } finally {
    out.close();
    }
    }

    // 读入学生列表(反序列化)
    public static List<Student> readStudents() throws IOException, ClassNotFoundException {
    ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream("students.dat")));
    try {
    int size = in.readInt();
    List<Student> list = new ArrayList<> (size);
    for(int i=0; i<size; i++) {
    list.add((Student) in.readObject()); // 与之前相比,不用单独处理每个字段
    }
    return list;
    } finally {
    in.close();
    }
    }
  1. 【笔试题】接上题,上面代码的优化?
    答:思路:实际上,只要 List 对象也实现了 SerializableArrayList/LinkedList 都实现了),上面代码还可以进一步简化,读写只需要一行代码,省去了循环相关代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 保存学生列表(序列化)
    public static void writeStudents(List<Student> students) throws IOException {
    ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream("students.dat")));
    try {
    out.writeObject(students); // 与上题相比,省去了循环代码
    } finally {
    out.close();
    }
    }

    // 读入学生列表(反序列化)
    public static List<Student> readStudents() throws IOException, ClassNotFoundException {
    ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream("students.dat")));
    try {
    return (List<Student>) in.readObject(); // 与上题相比,省去了循环代码
    } finally {

    }
    }
  1. Java 序列化机制对复杂对象的处理?
    答:Java 序列化机制能自动处理引用同一个对象的情况,也能自动处理循环引用的情况(具体例子可以参看公号“老马说编程”第 62 篇文章)。

    • 如果 ab 两个对象都引用同一个对象 c,序列化后 c 是保存两份还是一份?在反序列化后还能让 ab 指向同一个对象吗?答案是,c 只会保存一份,反序列化后指向相同对象
    • 如果 ab 两个对象有循环引用呢?即 a 引用了 b,而 b 也引用了 a。答案是,这种情况 Java 也没问题,可以保持引用关系
  2. 定制序列化的原因/场景/理由是?
    答:

    • 默认的序列化机制已经很强大了,它可以自动将对象中的所有字段自动保存和恢复但这种默认行为有时候不是我们想要的
    • 对于有些字段,它的值可能与内存位置有关。比如默认的 hashCode() 方法的返回值,当恢复对象后,内存位置肯定变了,基于原内存位置的值也就没有意义了。
    • 对于有些字段,可能与当前时候有关,比如表示对象创建时的时间,保存和恢复这个字段就是不正确的。
  3. 如果类中的字段表示的是类的实现细节,而非逻辑信息,那默认序列化也是不合适的。为什么?
    答:

    • 因为序列化格式表示一种契约,应该描述类的逻辑结构,而非与实现细节相绑定,绑定实现细节将使得难以修改、破坏封装。
    • 比如 LinkedList,它的默认序列化就是不合适的。因为 LinkedList 表示一个 List,它的逻辑信息是列表的长度以及列表中的每个对象;但 LinkedList 类中的字段表示的是链表的实现细节,如头尾节点指针,对每个节点,还有前驱和后继节点指针等。
  4. 怎样自定义序列化?
    答:

    • transient 关键字:将字段声明为 transient,默认序列化机制将忽略该字段,不会自动保存和恢复该字段。可以实现 writeObject()/readObject() 方法来自己保存该字段。
    • 类可以实现 writeObject() 方法,已自定义该类对象的序列化过程,其声明必须为:private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException
    • writeObject() 对应的是 readObject() 方法,通过它自定义反序列化过程,其声明必须为:private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException
    • 除了自定义 writeObject()/readObject() 方法,还有一些自定义序列化过程的机制:Externalizable 接口、realResolve() 方法和 writeReplace() 方法,这些机制用得相对较少。
  5. LinkedList 的序列化和反序列化的源码中代码 s.defaultWriteObject()/s.defaultReadObject() 的作用是?
    答:

    • 这行代码时必需的,它会调用默认的序列化机制,默认机制会保存所有没声明为 transient 的字段,即使类中的所有字段都是 transient,也应该写这一行。
    • 这是因为 Java 的序列化机制不仅会保存纯粹的数据信息,还会保存一些元数据描述等隐藏信息,这些隐藏的信息是序列化之所以能够神奇的重要原因。
  6. 总结一下 Java 的序列化机制?
    答:

    • 如果类的字段表示的就是类的逻辑信息,那就可以使用默认序列化机制,只要声明实现 Serializable 接口即可
    • 否则的话,比如 LinkedList,那就可以使用 transient 关键字,实现 writeObject()readObject() 自定义序列化过程
    • Java 的序列化机制可以自动处理引用同一个对象、循环引用等情况
  7. 序列化的基本原理是?或者说序列化到底是如何发生的?
    答:

    • 关键在 ObjectOutputStreamwriteObject()ObjectInputStreamreadObject() 方法内。这两个方法的实现都非常复杂,正因为这些复杂的实现才使得序列化看上去很神奇。
    • writeObject() 方法的基本逻辑是

      • 如果对象没有实现 Serializable,抛出异常 NotSerializableException
      • 每个对象都有一个编号,如果之前已经写过该对象了,则本次只会写该对象的引用,这可以解决对象引用和循环引用的问题。
      • 默认是利用反射机制,遍历对象结构图,对每个没有标记为 transient 的字段,根据其类型,分别进行处理,写出到流,流中的信息包括字段的类型,即完整类名、字段名、字段值等。
    • readObject() 方法的基本逻辑是

      • 不调用任何构造方法
      • 它自己就相当于是一个独立的构造方法,根据字节流初始化对象,利用的也是反射机制
      • 在解析字节流时,对于引用到的类型信息,会动态加载,如果找不到类,会抛出 ClassNotFoundException
  8. 代码是在不断演化的,而序列化的对象可能是持久化保存在文件上的,如果类的定义发生了变化,那持久化的对象还能反序列化吗?或者说 Java 序列化机制怎样解决版本问题
    答:

    • 默认情况下,Java 会给类定义一个版本号,这个版本号是根据类中一系列信息自动生成的。反序列化时,如果类的定义发生了变化,版本号就会变化,与流中的版本号就会不匹配,反序列化就会抛出异常,类型是 java.io.InvalidClassException
    • 通常情况下,我们希望自定义这个版本号,而非让 Java 自动生成,一方面是为了更好地控制,另一方面是为了性能,因为 Java 自动生成的性能比较低
    • 自定义这个版本号的方法:在类中定义变量:private static final long serialVersionUID = 1L;,这个变量的值可以是任意的,代表该类的版本号。在序列化时,会将该值写入流;反序列化时,会将流中的值与类定义中的值进行比较。如果不匹配,会抛出 InvalidClassException
    • 如果版本号一样,但实际的字段不匹配呢?Java 会分情况自动进行处理,已尽量保持兼容性,大概分为三种情况:

      • 字段删掉了:即流中有该字段,而类定义中没有,该字段会被忽略
      • 新增了字段:即类定义中有,而流中没有,该字段会被设为默认值
      • 字段类型变了:对于同名的字段,类型变了,会抛出 InvalidClassException
  9. Java 标准序列化机制的用途
    答:

    • 对象持久化
    • 跨网络的数据交换、远程过程调用
  10. Java 标准序列化机制的优点
    答:

    • 使用简单
    • 可自动处理对象引用循环引用
    • 可以方便地进行定制
    • 可以方便地处理版本问题
  11. Java 序列化机制的局限性
    答:

    • Java 序列化格式是一种私有格式,是一种 Java 特有的技术,不能被其他语言识别,不能实现跨语言的数据交换
    • Java 在序列化字节中保存了很多描述信息,使得序列化格式比较大
    • Java 的默认序列化使用反射分析遍历对象结构,性能比较低
    • Java 的序列化格式是二进制的,不方便查看和修改
    • 在性能和大小敏感的领域,往往会采用更为高效的二进制方式,如 ProtoBuf、Thrift、MessagePack 等。