ITEM 87: CONSIDER USING A CUSTOM SERIALIZED FORM
当您在时间压力下编写类时,通常应该将精力集中在设计最佳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 是非空的。item 88 和 item 90 详细讨论了这个问题。
注意,有关于 lastName、firstName 和 middleName 字段的文档注释,即使它们是私有的。这是因为这些私有字段定义了一个公共API,它是类的序列化形式,并且这个公共 API 必须被记录。@serial 标记的存在告诉 Javadoc 将该文档放在一个记录序列化表单的特殊页面上。
与 Name 相反,考虑下面的类,它表示一个字符串列表(暂时忽略你可能最好使用一个标准列表实现):
// 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。入口类成为公共 API 的一部分。如果表示法在将来的版本中发生了更改,StringList 类仍然需要在输入时接受链表表示法,并在输出时生成它。类永远不会摆脱所有处理链表条目的代码,即使它不再使用它们。
• 它会占用过多的空间。在上面的示例中,序列化的表单不必要地表示链表中的每个条目和所有链接。这些条目和链接只是实现细节,不值得包含在序列化的形式中。由于序列化的表单过于庞大,将其写入磁盘或通过网络发送将会非常缓慢。
• 它会消耗过多的时间。序列化逻辑不了解对象图的拓扑结构,因此它必须经历一次代价高昂的图遍历。在上面的示例中,只要考虑 next 引用就足够了。
• 它会导致堆栈溢出。默认的序列化过程对对象图执行递归遍历,这可能导致堆栈溢出,即使对于中等大小的对象图也是如此。序列化包含 1,000-1,800 个元素的StringList 实例会在我的机器上生成 StackOverflowError。令人惊讶的是,串行化导致堆栈溢出的最小列表大小(在我的机器上)因运行而异。显示这个问题的最小列表大小可能取决于平台实现和命令行标志;有些实现可能根本没有这个问题。
合理的 StringList 序列化形式是列表中的字符串数量,后面跟着字符串本身。这构成了由 StringList 表示的逻辑数据,去掉了其物理表示的细节。下面是修改后的StringList,带有 writeobject 和 readObject 方法,它们实现了这个序列化的表单。提醒一下,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 的字段都是瞬时的。您可能听说过,如果一个类的所有实例字段都是瞬态的,那么您可以不必调用 defaultWriteObject 和defaultReadObject,但是序列化规范要求您无论如何都要调用它们。这些调用的存在使得在以后的版本中添加非瞬态实例字段成为可能,同时保持向后和向前的兼容性。如果实例在以后的版本中序列化,在以前的版本中反序列化,添加的字段将被忽略。如果早期版本的 readObject 方法未能调用 defaultReadObject,反序列化将失败,并出现 StreamCorruptedException.
请注意,writeObject 方法有一个文档注释,尽管它是私有的。这类似于对 Name 类中的私有字段的文档注释。这个私有方法定义了一个公共 API,它是序列化的形式,并且这个公共API应该被记录下来。与字段的 @serial 标记一样,方法的 @serialData 标记告诉 Javadoc 实用程序将该文档放在序列化的表单页面上。
为了给前面的性能讨论增加一些伸缩性,如果字符串的平均长度是 10 个字符,那么修改后的 StringList 序列化形式所占的空间大约是原始序列化形式的一半。在我的机器上,序列化修改后的 StringList 版本的速度是序列化原始版本(列表长度为10)的两倍多。最后,修改后的格式不存在堆栈溢出问题,因此可以序列化的 StringList 的大小实际上没有上限。
虽然默认的序列化形式对 StringList 不好,但对于某些类来说,它可能更糟。对于StringList,默认的序列化形式是不灵活的,性能也很差,但是它是正确的,因为序列化和反序列化一个 StringList 实例会产生一个原始对象的忠实副本,并且它的所有不变量都保持不变。不变量绑定到特定于实现的细节的任何对象都不是这样。
例如,考虑哈希表的情况。物理表示是包含键-值项的散列桶序列。条目所在的bucket 是其键的哈希码的函数,通常不能保证在各个实现中都是相同的。事实上,它甚至不能保证每次运行都是相同的。因此,接受哈希表的默认序列化形式会造成严重的错误。对哈希表进行序列化和反序列化会产生一个不变量严重损坏的对象。
无论您是否接受默认的序列化表单,当调用 defaultWriteObject 方法时,每个没有标记为 transient 的实例字段都将被序列化。因此,每个可以声明为 transient 的实例字段都应该是。这包括派生字段,它们的值可以从主要数据字段计算,比如缓存的哈希值。它还包括一些字段,这些字段的值绑定到 JVM 的一个特定运行,例如表示本地数据结构指针的长字段。在决定使字段非瞬态之前,请确信它的值是对象逻辑状态的一部分。如果使用自定义序列化表单,那么大多数或所有实例字段都应该标记为transient,如上面的 StringList 示例所示。
如果您使用默认的序列化形式,并且已经将一个或多个字段标记为 transient,请记住,当实例被反序列化时,这些字段将被初始化为默认值:对象引用字段为 null,数值基元字段为零,布尔字段为false [JLS, 4.12.5]。如果这些值对于任何临时字段都是不可接受的,那么必须提供一个 readObjectmethod 来调用 defaultReadObject 方法,然后将临时字段恢复为可接受的值(item 88)。或者,这些字段可以在第一次使用时惰性地初始化(item 83)。
无论是否使用默认的序列化形式,都必须在对象序列化上强加任何同步,而这种同步可能会强加在读取对象的整个状态的任何其他方法上。例如,如果你有一个线程安全的对象(item 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 作为不兼容性的潜在来源(item 86)。还有一个小小的性能好处。如果没有提供串行版本 UID,则在运行时执行昂贵的计算来生成一个 UID。声明一个串行版本的 UID 很简单。只需将这一行添加到您的类:
private static final long serialVersionUID = randomLongValue;
如果您编写一个新类,为 randomLongValue 选择什么值并不重要。您可以通过在类上运行 serialver 实用程序来生成值,但也可以凭空选择一个数字。串行版本 uid 不需要是唯一的。如果修改缺少串行版本 UID 的现有类,并且希望新版本接受现有串行化实例,则必须使用为旧版本自动生成的值。您可以通过在旧版本的类上运行serialver 实用程序来获得这个数字,旧版本的类即存在序列化实例的类。
如果您希望创建与现有版本不兼容的类的新版本,只需更改串行版本 UID 声明中的值。这将导致试图反序列化以前版本的序列化实例时抛出 InvalidClassException 异常。不要更改串行版本 UID,除非您想破坏与一个类的所有现有序列化实例的兼容性。
总而言之,如果您决定一个类应该是可序列化的(item 86),请认真考虑序列化的形式应该是什么。只有当该默认序列化形式是对象逻辑状态的合理描述时,才使用该默认序列化形式;否则,设计一个适合描述对象的自定义序列化表单。您应该分配与设计导出方法相同的时间来设计类的序列化形式(item 51)。就像你不能从以后的版本中删除导出的方法一样,你也不能从序列化的表单中删除字段;它们必须被永久保存,以确保序列化的兼容性。选择错误的序列化形式会对类的复杂性和性能产生永久性的负面影响。