当你在时间紧迫的情况下设计一个类时,一般合理的做法是把工作重心几种在设计最佳的API上。有时候,这意味着要发行一个“用完后即丢弃”的实现,因为你知道以后会在新版本中将它替换掉。正常情况下,这不成问题,但是,如果这个类实现了Serializable接口,并且使用了默认的序列化形式,你就无法彻底摆脱那个应该丢弃的实现了。它将永远牵制住这个类的序列化形式。这不只是一个纯理论的问题,在Java平台类库中已经有几个类出现了这样的问题,比如BigInteger。
如果没有先认真考虑默认的序列化形式是否合适,就不要贸然接受 。接受默认的序列化形式前应该有意识地从灵活性、性能和正确性多个角度来考察这种编码的合理性。一般来讲,只有当你自定义序列化形式与默认的序列化形式基本相同时,才能接受默认的序列化形式。
考虑以一个对象为根的对象图,相对于它的*物理(physical)表示法而言,该对象的默认序列化形式是一种比较有效的编码形式。换句话说,默认的序列化形式描述了该对象内部所包含的数据,以及每一个可以从这个对象到达的其他对象的内部数据。它也描述了所有这些对象被链接起来后的拓扑结构。对于一个对象来说,理想的序列化形式应该只包含该对象所表示的逻辑(logical)*数据,而逻辑数据与物理表示法应该是各自独立的。
如果一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式 。例如,对于下面这些仅仅表示人名的类,默认的序列化形式就是合理的:
// 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是非null的。第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
}
从逻辑意义上讲,这个类表示了一个字符串序列。但是从物理意义上将,它把该序列表示成一个双向链表。如果你接受了默认的序列化形式,该序列化形式将不遗余力地镜像出(mirror)链表中的所有项,以及这些项之间的所有双向链接。
当一个对象的物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下4个缺点 :
-
它使这个类导出的API永远地束缚在该类的内部表示法上 。在上面的例子中,私有的StringList.Entry类变成了公有API的一部分。如果在将来的版本中,内部表示法发生了变化,StringList类仍将需要接受链表形式的输入,并产生链表形式的输出。这个类永远也抱脱不了维护链表需要的代码,即使它不再使用链表项作为内部数据结构。
-
它会消耗过多的空间 。在上面的例子中,序列化形式既表示了链表中的每个项,也表示了所有的链接关系,这是不必要的。这些链表项以及链接只不过是实现细节,不值得记录在序列化形式中。因为这样的序列化形式过于庞大,所以,把它写到磁盘中,或者在网络上发送都将非常慢。
-
它会消耗过多的实践 。序列化逻辑并不了解对象图的拓扑关系,所以它必须要经过一个昂贵的图遍历(traversal)过程在上面的例子中,沿着next引用进行遍历是非常简单的。
-
它会引起栈溢出 。默认的序列化过程要对对象图执行递归遍历,即使对于中等大小的对象图,也可能导致堆栈溢出。在我的机器上,如果StringList实例包含1000~1800个元素,对它进行序列化就会导致堆栈溢出。令人惊讶的是,序列化导致堆栈溢出的最小列表的大小在每次运行的时候都不一样(在我的机器上)。出现该问题的最小列表的大小可能取决于平台的实现和命令行标志;某些实现可能根本没有这个问题。
对于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的所有域都是瞬时的(transient)。你可能听到有一种说法是:如果类的所有实例域都是瞬时的,您可以省去调用defaultWriteObject和defaultReadObject,但序列化规范要求您无论如何都要调用它们。这些调用的存在使得可以在以后的版本中添加非瞬时的实例域,同时保持向后和向前的兼容性。如果某一个实例将在未来版本中被序列化,然后在前一个版本中被反序列化,那么,后增加的域将被忽略掉。如果旧版本中的readObject方法没有调用defaultReadObject,反序列化过程将失败,引发StreamCorruptedException异常。
注意,尽管writeObject方法是私有的,它也有文档注释。这与Name类中私有域的文档注释是同样的道理。该私有方法定义了一个共有的API,即序列化形式,并且这个公有的API应该建立文档。如同域的@serial标签一样,方法的@serialData标签也告知Javadoc工具,要把该文档信息放在有关序列化形式的文档页上。
套用以前对性能的讨论形式,如果平均字符串长度为10个字符,StringList修订版本的序列化形式就只占用原序列化形式一半的空间。在我的机器上,对于同样是10个字符串长度的情况下,StringList修订版的序列化速度比原版本的快2倍。最终,修订版中不存在栈溢出的问题,因此,对于可被序列化的StringList的大小也没有实际的上限。
虽然对于StringList来说【使用】默认的序列化形式并不好,但是【对于】有些类会更糟糕。对于StringList,默认的序列化形式不够灵活,并且执行效果不佳,但是序列化和反序列化StringList实例会产生对原始对象的忠实拷贝,它的约束关系没有被破坏,从这个意义上讲,这个序列化形式是正确的。但是,如果对象的约束关系要依赖于特定于实现的细节,对于它们来说,情况就不是这样的了。
例如,考虑散列表的情形。它的物理表示法是一系列包含“键-值(key-value)项的散列桶。一个项属于哪一个桶,这是该键关于散列码的一个函数,一般情况下,不同的实现不保证会有相同的效果。实际上,即使在相同的实现中,也无法保证,每次运行【产生的结果】都是一样的。因此,对于散列表而言,接受默认的序列化形式将会构成一个严重的BUG。序列化和反序列哈希表产生的对象,其约束关系会遭到严重的破坏。
无论你是否使用默认的序列化形式,当defaulWriteObject方法被调用的时候,每一个未被标记为transient的实例域都会被序列化。因此,每一个可以被标记为transient的实例都应该做上这样的标记。这包括那些冗余的域,即它们的值可以根据其他“基本数据域”计算而得到的域,比如缓存起来的散列值。它也包括那些“其值依赖于JVM的某一次运行”的域,比如一个long域代表了一个指向本地数据结构的指针。在决定将一个域做成非transient的之前,请一定要确信它的值将是该对象逻辑状态的一部分 。如果你正在使用一种自定义的序列化形式,大多数实例域,或者所有的实例域则都应该被标记为transient,就像上面例子中的StringList那样。
如果你正在使用默认的序列化形式,并且把一个或者多个域标记为transient,则要记住,当一个实例域被反序列化的时候,这些域将被初始化为它们的默认值(default value):对于对象引用域,默认值为null;对于数值基本域,默认值为0;对于boolean域,默认值为false[JLS, 4.12.5]。如果这些值不能被任何transient域所接受,你就必须提供一个readObject方法,它首先调用defaultReadObject,然后把这些transient域恢复为可接受的值(第88项)。另一种方法是,这些域可以在第一次使用时进行延迟初始化(第83项)。
无论你是否使用默认的序列化形式,如果在读取整个对象状态的任何其他方法上强制任何同步,则也必须在对象序列化上强制这种同步 。因此,如果你有一个线程安全的对象(第82项),它通过同步每个方法实现了它的线程安全,并且你选择使用默认的序列化形式,就要使用下列的writeObject方法:
// writeObject for synchronized class with default serialized form
private synchronized void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
}
如果你把同步放在writeObject方法中,就必须确保它遵守与其他动作相同的锁排列(lock-ordering)约束条件,否则就有遭遇资源排列(resource-ordering)死锁的危险 [Goetz06, 10.1.5]。
不管你选择了哪种系列化形式,都要为自己编写的每个可序列化的类声明一个显示的序列版本UID(serial version UID) 。这样可以避免序列版本UID成为潜在的不兼容根源(第86项)。而且这样做也会带来小小的性能好处。如果没有提供显示的序列版本UID,就需要在运行时通过一个高开销的计算过程产生一个序列版本UID。
要声明一个序列版本UID非常简单,只要在你的类中增加下面一行:
private static final long serialVersionUID = randomLongValue;
如果你编写一个新的类,为randomLongValue选择什么值并不重要。通过在该类上运行serialver工具,你就可以得到一个这样的值,但是,如果你凭空编造一个数值,那也是可以的。如果你想修改一个没有序列版本UID的现有的类,并希望新的版本能够接受现有的序列化实例,就必须使用那个自动为旧版本生成的值。如通过在旧版本的类上运行serialver工具,可以得到这个数值——存在序列化实例的那个数值【有一些实例是通过那个值序列化的】。
如果你想为一个类生成一个新的版本,这个类与现有的类不兼容(incompatible),那么你只需要修改序列版本UID声明中的值即可。这会导致,前一个版本的实例经过序列化之后,再做反序列化时会抛出InvalidClassException异常而失败。除非您要破坏与现有类的所有序列化实例的兼容性,否则请勿更改序列版本UID 。
总而言之,当你决定要将一个类做成可序列化的时候(第86项),请仔细考虑应该采用什么样的序列化形式。之后当默认的序列化形式能够合理地描述对象的逻辑状态时,才能使用默认的序列化形式;否则就要设计一个自定义的序列化形式,通过它合理地描述对象的状态。你应该分配足够多的时间来设计类的序列化形式,就好像分配足够多的实时间来设计它的导出方法一样(第51项)。正如你无法在将来的版本中去掉导出的方法一样,你也不能去掉序列化形式中的域;它们必须被永久地保留下去,以确保序列化兼容性(serialization compalibility)。选择错误的序列化形式对于一个类的复杂性和性能都会有永久的负面影响。