Skip to main content

87. 考虑使用自定义的序列化形式

  当你在时间紧迫的情况下编写类时,通常应该将精力集中在设计最佳的 API 上。有时,这意味着发布一个「一次性」实现,你也知道在将来的版本中会替换它。通常这不是一个问题,但是如果类实现 Serializable 接口并使用默认的序列化形式,你将永远无法完全摆脱这个「一次性」的实现。它将永远影响序列化的形式。这不仅仅是一个理论问题。这种情况发生在 Java 库中的几个类上,包括 BigInteger

  在没有考虑默认序列化形式是否合适之前,不要接受它。 接受默认的序列化形式应该是一个三思而后行的决定,即从灵活性、性能和正确性的角度综合来看,这种编码是合理的。一般来说,设计自定义序列化形式时,只有与默认序列化形式所选择的编码在很大程度上相同时,才应该接受默认的序列化形式。

  对象的默认序列化形式,相对于它的物理表示法而言是一种比较有效的编码形式。换句话说,它描述了对象中包含的数据以及从该对象可以访问的每个对象的数据。它还描述了所有这些对象相互关联的拓扑结构。理想的对象序列化形式只包含对象所表示的逻辑数据。它独立于物理表征。

  如果对象的物理表示与其逻辑内容相同,则默认的序列化形式可能是合适的。 例如,默认的序列化形式对于下面的类来说是合理的,它简单地表示一个人的名字:

// Good candidate for default serialized form
public class Name implements Serializable {
    /**
     * Last name. Must be non-null.
     * @serial
     */
    private final String lastName;

    /**
     * First name. Must be non-null.
     * @serial
     */
    private final String firstName;

    /**
     * Middle name, or null if there is none.
     * @serial
     */
    private final String middleName;
    ... // Remainder omitted
}

  从逻辑上讲,名字由三个字符串组成,分别表示姓、名和中间名。Name 的实例字段精确地反映了这个逻辑内容。

  即使你认为默认的序列化形式是合适的,你通常也必须提供 readObject 方法来确保不变性和安全性。 对于 Name 类而言, readObject 方法必须确保字段 lastName 和 firstName 是非空的。第 88 条和第 90 条详细讨论了这个问题。

  注意,虽然 lastName、firstName 和 middleName 字段是私有的,但是它们都有文档注释。这是因为这些私有字段定义了一个公共 API,它是类的序列化形式,并且必须对这个公共 API 进行文档化。@serial 标记的存在告诉 Javadoc 将此文档放在一个特殊的页面上,该页面记录序列化的形式。

  与 Name 类不同,考虑下面的类,它是另一个极端。它表示一个字符串列表(使用标准 List 实现可能更好,但此时暂不这么做):

// Awful candidate for default serialized form
public final class StringList implements Serializable {
    private int size = 0;
    private Entry head = null;
    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }
    ... // Remainder omitted
}

  从逻辑上讲,这个类表示字符串序列。在物理上,它将序列表示为双向链表。如果接受默认的序列化形式,该序列化形式将不遗余力地镜像出链表中的所有项,以及这些项之间的所有双向链接。

  当对象的物理表示与其逻辑数据内容有很大差异时,使用默认的序列化形式有四个缺点:

  • 它将导出的 API 永久地绑定到当前的内部实现。 在上面的例子中,私有 StringList.Entry 类成为公共 API 的一部分。如果在将来的版本中更改了实现,StringList 类仍然需要接受链表形式的输出,并产生链表形式的输出。这个类永远也摆脱不掉处理链表项所需要的所有代码,即使不再使用链表作为内部数据结构。
  • 它会占用过多的空间。 在上面的示例中,序列化的形式不必要地表示链表中的每个条目和所有链接关系。这些链表项以及链接只不过是实现细节,不值得记录在序列化形式中。因为这样的序列化形式过于庞大,将其写入磁盘或通过网络发送将非常慢。
  • 它会消耗过多的时间。 序列化逻辑不知道对象图的拓扑结构,因此必须遍历开销很大的图。在上面的例子中,只要遵循 next 的引用就足够了。
  • 它可能导致堆栈溢出。 默认的序列化过程执行对象图的递归遍历,即使对于中等规模的对象图,这也可能导致堆栈溢出。用 1000-1800 个元素序列化 StringList 实例会在我的机器上生成一个 StackOverflowError。令人惊讶的是,序列化导致堆栈溢出的最小列表大小因运行而异(在我的机器上)。显示此问题的最小列表大小可能取决于平台实现和命令行标志;有些实现可能根本没有这个问题。

  StringList 的合理序列化形式就是列表中的字符串数量,然后是字符串本身。这构成了由 StringList 表示的逻辑数据,去掉了其物理表示的细节。下面是修改后的 StringList 版本,带有实现此序列化形式的 writeObjectreadObject 方法。提醒一下,transient 修饰符表示要从类的默认序列化表单中省略该实例字段:

// StringList with a reasonable custom serialized form
public final class StringList implements Serializable {
    private transient int size = 0;
    private transient Entry head = null;
    // No longer Serializable!

    private static class Entry {
        String data;
        Entry next;
        Entry previous;
    }
    // Appends the specified string to the list
    public final void add(String s) { ... }

    /**
     * Serialize this {@code StringList} instance.
     *
     * @serialData The size of the list (the number of strings
     * it contains) is emitted ({@code int}), followed by all of
     * its elements (each a {@code String}), in the proper
     * sequence.
     */
    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject();
        s.writeInt(size);
        // Write out all elements in the proper order.
        for (Entry e = head; e != null; e = e.next)
            s.writeObject(e.data);
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int numElements = s.readInt();
        // Read in all elements and insert them in list
        for (int i = 0; i < numElements; i++)
            add((String) s.readObject());
    }

    ... // Remainder omitted
}

  writeObject 做的第一件事是调用 defaultWriteObject, readObject 做的第一件事是调用 defaultReadObject,即使 StringList 的所有字段都是 transient 的。你可能听说过,如果一个类的所有实例字段都是 transient 的,那么你可以不调用 defaultWriteObjectdefaultReadObject,但是序列化规范要求你无论如何都要调用它们。这些调用的存在使得在以后的版本中添加非瞬态实例字段成为可能,同时保留了向后和向前兼容性。如果实例在较晚的版本中序列化,在较早的版本中反序列化,则会忽略添加的字段。如果早期版本的 readObject 方法调用 defaultReadObject 失败,反序列化将失败,并出现 StreamCorruptedException

  注意,writeObject 方法有一个文档注释,即使它是私有的。这类似于 Name 类中私有字段的文档注释。这个私有方法定义了一个公共 API,它是序列化的形式,并且应该对该公共 API 进行文档化。与字段的 @serial 标记一样,方法的 @serialData标记告诉 Javadoc 实用工具将此文档放在序列化形式页面上。

  为了给前面的性能讨论提供一定的伸缩性,如果平均字符串长度是 10 个字符,那么经过修改的 StringList 的序列化形式占用的空间大约是原始字符串序列化形式的一半。在我的机器上,序列化修订后的 StringList 的速度是序列化原始版本的两倍多,列表长度为 10。最后,在修改后的形式中没有堆栈溢出问题,因此对于可序列化的 StringList 的大小没有实际的上限。

  虽然默认的序列化形式对 StringList 不好,但是对于某些类来说,情况会更糟。对于 StringList,默认的序列化形式是不灵活的,并且执行得很糟糕,但是它是正确的,因为序列化和反序列化 StringList 实例会生成原始对象的无差错副本,而所有不变量都是完整的。对于任何不变量绑定到特定于实现的细节的对象,情况并非如此。

  例如,考虑哈希表的情况。物理表示是包含「键-值」项的哈希桶序列。一个项所在的桶是其键的散列代码的函数,通常情况下,不能保证从一个实现到另一个实现是相同的。事实上,它甚至不能保证每次运行都是相同的。因此,接受哈希表的默认序列化形式将构成严重的 bug。对哈希表进行序列化和反序列化可能会产生一个不变量严重损坏的对象。

  无论你是否接受默认的序列化形式,当调用 defaultWriteObject 方法时,没有标记为 transient 的每个实例字段都会被序列化。因此,可以声明为 transient 的每个实例字段都应该做这个声明。这包括派生字段,其值可以从主数据字段(如缓存的哈希值)计算。它还包括一些字段,这些字段的值与 JVM 的一个特定运行相关联,比如表示指向本机数据结构指针的 long 字段。在决定使字段非 transient 之前,请确信它的值是对象逻辑状态的一部分。 如果使用自定义序列化表单,大多数或所有实例字段都应该标记为 transient,如上面的 StringList 示例所示。

  如果使用默认的序列化形式,并且标记了一个或多个字段为 transient,请记住,当反序列化实例时,这些字段将初始化为默认值:对象引用字段为 null,数字基本类型字段为 0,布尔字段为 false [JLS, 4.12.5]。如果这些值对于任何 transient 字段都是不可接受的,则必须提供一个 readObject 方法,该方法调用 defaultReadObject 方法,然后将 transient 字段恢复为可接受的值(详见第 88 条)。或者,可以采用延迟初始化(详见第 83 条),在第一次使用这些字段时初始化它们。

  无论你是否使用默认的序列化形式,必须对对象序列化强制执行任何同步操作,就像对读取对象的整个状态的任何其他方法强制执行的那样。 例如,如果你有一个线程安全的对象(详见第 82 条),它通过同步每个方法来实现线程安全,并且你选择使用默认的序列化形式,那么使用以下 write-Object 方法:

// writeObject for synchronized class with default serialized form
private synchronized void writeObject(ObjectOutputStream s) throws IOException {
    s.defaultWriteObject();
}

  如果将同步放在 writeObject 方法中,则必须确保它遵守与其他活动相同的锁排序约束,否则将面临资源排序死锁的风险 [Goetz06, 10.1.5]。

  无论选择哪种序列化形式,都要在编写的每个可序列化类中声明显式的序列版本 UID。 这消除了序列版本 UID 成为不兼容性的潜在来源(详见第 86 条)。这么做还能获得一个小的性能优势。如果没有提供序列版本 UID,则需要执行高开销的计算在运行时生成一个 UID。

  声明序列版本 UID 很简单,只要在你的类中增加这一行:

private static final long serialVersionUID = randomLongValue;

  如果你编写一个新类,为 randomLongValue 选择什么值并不重要。你可以通过在类上运行 serialver 实用工具来生成该值,但是也可以凭空选择一个数字。串行版本 UID 不需要是唯一的。如果修改缺少串行版本 UID 的现有类,并且希望新版本接受现有的序列化实例,则必须使用为旧版本自动生成的值。你可以通过在类的旧版本上运行 serialver 实用工具(序列化实例存在于旧版本上)来获得这个数字。

  如果你希望创建一个新版本的类,它与现有版本不兼容,如果更改序列版本 UID 声明中的值,这将导致反序列化旧版本的序列化实例的操作引发 InvalidClassException不要更改序列版本 UID,除非你想破坏与现有序列化所有实例的兼容性。

  总而言之,如果你已经决定一个类应该是可序列化的(详见第 86 条),那么请仔细考虑一下序列化的形式应该是什么。只有在合理描述对象的逻辑状态时,才使用默认的序列化形式;否则,设计一个适合描述对象的自定义序列化形式。设计类的序列化形式应该和设计导出方法花的时间应该一样多,都应该严谨对待(详见第 51 条)。正如不能从未来版本中删除导出的方法一样,也不能从序列化形式中删除字段;必须永远保存它们,以确保序列化兼容性。选择错误的序列化形式可能会对类的复杂性和性能产生永久性的负面影响。