为什么不能轻易的实现序列化(implements Serializable) 摘自《Effective Java》
`第11章 序列化
本章关注对象的序列化(object serialization)API,它提供了一个框架,用来将对象编码成字节流,以及从字节流编码中重新构建对象。”将一个对象编码成一个字节流”,这就称作序列化(serializing)该对象;相反的处理过程被称作反序列化(deserializing)。一旦对象被序列化后,它的编码就可以从一台正在运行的虚拟机被传递到另一台虚拟机上,或者被存储到磁盘上,供以后反序列化时用。序列化技术为远程通信提供了标准的线路级(wire-level)对象表示法,也为JavaBeans组件结构提供了标准的持久数据格式。本章中有一项值得特别提及的特性,就是序列化代理(serialization proxy)模式(见第78条),它可以帮助你避免对象序列化的许多缺陷。
第74条:谨慎地实现Serializable(1)
要想使一个类的实例可被序列化,非常简单,只要在它的声明中加入”implements Serializable”字样即可。正因为太容易了,所以普遍存在这样一种误解,认为程序员只需要做极少量的工作就可以支持序列化了。实际的情形要复杂得多。虽然使一个类可被序列化的直接开销低到甚至可以忽略不计,但是为了序列化而付出的长期开销往往是实实在在的。
为实现Serializable而付出的最大代价是,一旦一个类被发布,就大大降低了”改变这个类的实现”的灵活性。如果一个类实现了Serializable,它的字节流编码(或者说序列化形式,serialized form)就变成了它的导出的API的一部分。一旦这个类被广泛使用,往往必须永远支持这种序列化形式,就好像你必须要支持导出的API的所有其他部分一样。如果你不努力设计一个自定义的序列化形式(custom serialized form),而仅仅接受了默认的序列化形式,这种序列化形式将被永远地束缚在该类最初的内部表示法上。换句话说,如果你接受了默认的序列化形式,这个类中私有的和包级私有的实例域将都变成导出的API的一部分,这不符合”最低限度地访问域”的实践准则(见第13条),从而它就失去了作为信息隐藏工具的有效性。
如果你接受了默认的序列化形式,并且以后又要改变这个类的内部表示法,结果可能导致序列化形式的不兼容。客户端程序企图用这个类的旧版本来序列化一个类,然后用新版本进行反序列化,结果将导致程序失败。在改变内部表示法的同时仍然维持原来的序列化形式(使用ObjectOutputStream.putFields和ObjectInputStream.readFields),这也是可能的,但是做起来比较困难,并且会在源代码中留下一些可以明显的隐患。因此,你应该仔细地设计一种高质量的序列化形式,并且在很长时间内都愿意使用这种形式(见第75,78条)。这样做将会增加开发的初始成本,但这是值得的。设计良好的序列化形式也许会给类的演变带来限制;但是设计不好的序列化形式则可能会使类根本无法演变。
序列化会使类的演变受到限制,这种限制的一个例子与流的唯一标识符(stream unique identifier)有关,通常它也被称为序列版本UID(serial version UID)。每个可序列化的类都有一个唯一标识号与它相关联。如果你没有在一个名为serialVersionUID的私有静态final的long域中显式地指定该标识号,系统就会自动地将一个复杂的过程作用在这个类上,从而在运行时产生该标识号。这个自动产生的值会受到类名称、它所实现的接口的名称、以及所有公有的和受保护的成员的名称所影响。如果你通过任何方式改变了这些信息,比如,增加了一个不是很重要的工具方法,自动产生的序列版本UID也会发生变化。因此,如果你没有声明一个显式的序列版本UID,兼容性将会遭到破坏,在运行时导致InvalidClassException异常。
实现Serializable的第二个代价是,它增加了出现Bug和安全漏洞的可能性。通常情况下,对象是利用构造器来创建的;序列化机制是一种语言之外的对象创建机制(extralinguistic mechanism)。无论你是接受了默认的行为,还是覆盖了默认的行为,反序列化机制(deserialization)都是一个”隐藏的构造器”,具备与其他构造器相同的特点。因为反序列化机制中没有显式的构造器,所以你很容易忘记要确保:反序列化过程必须也要保证所有”由构造器建立起来的约束关系”,并且不允许攻击者访问正在构造过程中的对象的内部信息。依靠默认的反序列化机制,可以很容易地使对象的约束关系遭到破坏,以及遭受到非法访问(见第76条)。
实现Serializable的第三个代价是,随着类发行新的版本,相关的测试负担也增加了。当一个可序列化的类被修订的时候,很重要的一点是,要检查是否可以”在新版本中序列化一个实例,然后在旧版本中反序列化”,反之亦然。因此,测试所需要的工作量与”可序列化的类的数量和发行版本号”的乘积成正比,这个乘积可能会非常大。这些测试不可能自动构建,因为除了二进制兼容性(binary compatibility)以外,你还必须测试语义兼容性(semantic compatibility)。换句话说,你必须既要确保”序列化-反序列化”过程成功,也要确保结果产生的对象真正是原始对象的复制品。可序列化类的变化越大,它就越需要测试。如果在最初编写一个类的时候,就精心设计了自定义的序列化形式,测试的要求就可以有所降低,但是也不能完全没有测试。
实现Serializable接口并不是一个很轻松就可以做出的决定。它提供了一些实在的益处:如果一个类将要加入到某个框架中,并且该框架依赖于序列化来实现对象传输或者持久化,对于这个类来说,实现Serializable接口就非常有必要。更进一步来看,如果这个类要成为另一个类的一个组件,并且后者必须实现Serializable接口,若前者也实现了Serializable接口,它就会更易于被后者使用。然而,有许多实际的开销都与实现Serializable接口有关。每当你实现一个类的时候,都需要权衡一下所付出的代价和带来的好处。根据经验,比如Date和BigInteger这样的值类应该实现Serializable,大多数的集合类也应该如此。代表活动实体的类,比如线程池(thread pool),一般不应该实现Serializable。
为了继承而设计的类(见第17条)应该很少实现Serializable,接口也应该很少会扩展它。如果违反了这条规则,扩展这个类或者实现这个接口的程序员就会背上沉重的负担。然而在有些情况下违反这条规则却是合适的。例如,如果一个类或者接口存在的目的主要是为了参与到某个框架中,该框架要求所有的参与者都必须实现Serializable,那么,对于这个类或者接口来说,实现或者扩展Serializable就是非常有意义的。
为了继承而设计的类中真正实现了Serializable的有Throwable、Component和HttpServlet。因为Throwable实现了Serializable,所以RMI的异常可以从服务器端传到客户端。Component实现了Serializable,因此GUI可以被发送、保存和恢复。HttpServlet实现了Serializable,因此会话状态可以被缓存。
第74条:谨慎地实现Serializable(2)
如果你实现了一个带有实例域的类,它是可序列化和可扩展的,你就应该担心这样一条告诫。如果类有一些约束条件,当类的实例域被初始化成它们的默认值(整数类型为0,boolean为false,对象引用类型为null)时,就会违背这些约束条件,这时候你就必须给这个类添加这个readObjectNoData方法:
// readObjectNoData for stateful extendable serializable classes
private void readObjectNoData() throws InvalidObjectException {
throw new InvalidObjectException(“Stream data required”);
}
Java 1.4的版本中就增加了这个readObjectNoData方法,还包含了一些冷僻的用例,包括给现有的可序列化类添加可序列化的超类。如果你有兴趣,可以在序列化规范中找到详细的信息[Serialization,3.5]。
有一条告诫与”不要实现Serializable接口”有关。如果一个专门为了继承而设计的类不是可序列化的,就不可能编写出可序列化的子类。特别是,如果超类没有提供可访问的无参构造器,子类也不可能做到可序列化。因此,对于为继承而设计的不可序列化的类,你应该考虑提供一个无参构造器。这通常并不需要付出特别的努力,因为许多为继承而设计的类都不具有状态,但是情况并不总是这样的。
最好在所有的约束关系都已经建立的情况下再创建对象(见第15条)。如果为了建立这些约束关系而要求客户端提供一些数据,这实际上就排除了使用无参构造器的可能性。盲目地为一个类增加无参构造器和单独的初始化方法,而它的约束关系仍由其他的构造器来建立,这样做会使该类的状态空间更加复杂,并且增加出错的可能性。
有一种办法可以给”不可序列化但可扩展的类”增加无参构造器,同时避免以上的不足。假设该类有这样一个构造器:
public AbstractFoo(int x, int y) { … }
下面的转换增加了一个受保护的无参构造器,和一个初始化方法。初始化方法与正常的构造器具有相同的参数,并且也建立起同样的约束关系。注意保存对象状态(x和y)的变量不能是final的,因为它们是由initialize方法设置的:
// Nonserializable stateful class allowing serializable subclass
public abstract class AbstractFoo {
private int x, y; // Our state
// This enum and field are used to track initialization
private enum State { NEW, INITIALIZING, INITIALIZED };
private final AtomicReference init =
new AtomicReference(State.NEW);
public AbstractFoo(int x, int y) { initialize(x, y); }
// This constructor and the following method allow
// subclass’s readObject method to initialize our state.
protected AbstractFoo() { }
protected final void initialize(int x, int y) {
if (!init.compareAndSet(State.NEW, State.INITIALIZING))
throw new IllegalStateException(
“Already initialized”);
this.x = x;
this.y = y;
… // Do anything else the original constructor did
init.set(State.INITIALIZED);
}
// These methods provide access to internal state so it can
// be manually serialized by subclass’s writeObject method.
protected final int getX() { checkInit(); return x; }
protected final int getY() { checkInit(); return y; }
// Must call from all public and protected instance methods
private void checkInit() {
if (init.get() != State.INITIALIZED)
throw new IllegalStateException(“Uninitialized”);
}
… // Remainder omitted
}
AbstractFoo中所有公有的和受保护的实例方法在开始做任何其他工作之前都必须先调用checkInit。这样可以确保如果有编写不好的子类没有初始化实例,该方法调用就可以快速而干净地失败。注意init域是一个原子引用(atomic reference)(java.util.concurrent.atomic.AtomicReference)。在遇到确定的对手时,确保对象的完整性是很有必要的。如果没有这样的防范机制,万一有个线程要在某一个实例上调用initialize,而另一个线程又要企图使用这个实例,第二个线程就有可能看到这个实例处于不一致的状态。在compareAndSet方法里操作枚举中的原子引用,这是一个很好的线程安全状态机(thread-safe state machine)通用目的的实现模式。如果有了这样的机制做保证,实现一个可序列化的子类就非常简单明了:
// Serializable subclass of nonserializable stateful class
public class Foo extends AbstractFoo implements Serializable {
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
// Manually deserialize and initialize superclass state
int x = s.readInt();
int y = s.readInt();
initialize(x, y);
}
private void writeObject(ObjectOutputStream s)
throws IOException {
s.defaultWriteObject();
// Manually serialize superclass state
s.writeInt(getX());
s.writeInt(getY());
}
// Constructor does not use the fancy mechanism
public Foo(int x, int y) { super(x, y); }
private static final long serialVersionUID = 1856835860954L;
}
内部类(inner class)(见第22条)不应该实现Serializable。它们使用编译器产生的合成域(synthetic field)来保存指向外围实例(enclosing instance)的引用,以及保存来自外围作用域的局部变量的值。”这些域如何对应到类定义中”并没有明确的规定,就好像没有指定匿名类和局部类的名称一样。因此,内部类的默认序列化形式是定义不清楚的。然而,静态成员类(static member class)却可以实现Serializable。
简而言之,千万不要认为实现Serializable会很容易。除非一个类在用了一段时间之后就会被抛弃,否则,实现Serializable就是个很严肃的承诺,必须认真对待。如果一个类是为了继承而设计的,则更加需要加倍小心。对于这样的类而言,在”允许子类实现Serializable”或”禁止子类实现Serializable”两者之间的一个折衷设计方案是,提供一个可访问的无参构造器。这种设计方案允许(但不要求)子类实现Serializable。
第75条:考虑使用自定义的序列化形式(1)
当你在时间紧迫的情况下设计一个类时,合理的做法一般是把工作重心集中在设计最佳的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是非null的。第76条和第78条将详细地讨论这个问题。
注意,虽然lastName、firstName和middleInitial域是私有的,但是它们仍然有相应的注释文档。这是因为,这些私有域定义了一个公有的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实例包含1258个元素,对它进行序列化就会导致栈溢出。到底多少个元素会引发栈溢出,这要取决于JVM的具体实现以及Java启动时的命令行参数,有些实现可能根本不存在这样的问题。
对于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
}
注意,尽管StringList的所有域都是瞬时的(transient),但writeObject方法的首要任务仍是调用defaultWriteObject,readObject方法的首要任务则是调用defaultReadObject。如果所有的实例域都是瞬时的,从技术角度而言,不调用defaultWriteObject和defaultReadObject也是允许的,但是不推荐这样做。即使所有的实例域都是瞬时的,调用defaultWriteObject也会影响该类的序列化形式,从而极大地增强灵活性。这样得到的序列化形式允许在以后的发行版本中增加非瞬时的实例域,并且还能保持向前或者向后兼容性。如果某一个实例将在未来的版本中被序列化,然后在前一个版本中被反序列化,那么,后增加的域将被忽略掉。如果旧版本的readObject方法没有调用defaultReadObject,反序列化过程将失败,引发StreamCorrupted Exception异常。
第75条:考虑使用自定义的序列化形式(2)
注意,尽管writeObject方法是私有的,它也有文档注释。这与Name类中私有域的文档注释是同样的道理。该私有方法定义了一个公有的API,即序列化形式,并且这个公有的API应该建立文档。如同域的@serial标签一样,方法的@serialData标签也告知Javadoc工具,要把该文档信息放在有关序列化形式的文档页上。
套用以前对性能的讨论形式,如果平均字符串长度为10个字符,StringList修订版本的序列化形式就只占用原序列化形式一半的空间。在我的机器上,同样是10个字符长度的情况下,StringList修订版的序列化速度比原版本的快2倍。最终,修订版中不存在栈溢出的问题,因此,对于可被序列化的StringList的大小也没有实际的上限。
虽然默认的序列化形式对于StringList类来说只是不适合而已,对于有些类,情况却变得更加糟糕。对于StringList,默认的序列化形式不够灵活,并且执行效果不佳,但是序列化和反序列化StringList实例会产生对原始对象的忠实拷贝,它的约束关系没有被破坏,从这个意义上讲,这个序列化形式是正确的。但是,如果对象的约束关系要依赖于特定于实现的细节,对于它们来说,情况就不是这样了。
例如,考虑散列表的情形。它的物理表示法是一系列包含”键-值(key-value)”项的散列桶(has bucket)。到底一个项将被放在哪个桶中,这是该键的散列码的一个函数,一般情况下,不同的JVM实现不保证会有同样的结果。实际上,即使在同一个JVM实现中,也无法保证每次运行都会一样。因此,对于散列表而言,接受默认的序列化形式将会构成一个严重的Bug。对散列表对象进行序列化和反序列化操作所产生的对象,其约束关系会遭到严重的破坏。
无论你是否使用默认的序列化形式,当defaultWriteObject方法被调用的时候,每一个未被标记为transient的实例域都会被序列化。因此,每一个可以被标记为transient的实例域都应该做上这样的标记。这包括那些冗余的域,即它们的值可以根据其他”基本数据域”计算而得到的域,比如缓存起来的散列值。它也包括那些”其值依赖于JVM的某一次运行”的域,比如一个long域代表了一个指向本地数据结构的指针。在决定将一个域做成非瞬时的之前,请一定要确信它的值将是该对象逻辑状态的一部分。如果你正在使用一种自定义的序列化形式,大多数实例域,或者所有的实例域则都应该被标记为transient,就像上面例子中的StringList那样。
如果你正在使用默认的序列化形式,并且把一个或者多个域标记为transient,则要记住,当一个实例被反序列化的时候,这些域将被初始化为它们的默认值(default value):对于对象引用域,默认值为null;对于数值基本域,默认值为0;对于boolean域,默认值为false[JLS, 4.12.5]。如果这些值不能被任何瞬时域所接受,你就必须提供一个readObject方法,它首先调用defaultReadObject,然后把这些瞬时域恢复为可接受的值(见第76条)。另一种方法是,这些域可以被延迟到第一次被使用的时候才真正被初始化(见第71条)。
无论你是否使用默认的序列化形式,如果在读取整个对象状态的任何其他方法上强制任何同步,则也必须在对象序列化上强制这种同步。因此,如果你有一个线程安全的对象(见第70条),它通过同步每个方法实现了它的线程安全,并且你选择使用默认的序列化形式,就要使用下列的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成为潜在的不兼容根源 (见第74条)。而且,这样做也会带来小小的性能好处。如果没有提供显式的序列版本UID,就需要在运行时通过一个高开销的计算过程产生一个序列版本UID。
要声明一个序列版本UID非常简单,只要在你的类中增加下面一行:
private static final long serialVersionUID = randomLongValue ;
在编写新的类时,为randomLongValue选择什么值并不重要。通过在该类上运行serialver工具,你就可以得到一个这样的值,但是,如果你凭空编造一个数值,那也是可以的。如果你想修改一个没有序列版本UID的现有的类,并希望新的版本能够接受现有的序列化实例,就必须使用那个自动为旧版本生成的值。如通过在旧版的类上运行serialver工具,可以得到这个数值–被序列化的实例为之存在的那个数值。
如果你想为一个类生成一个新的版本,这个类与现有的类不兼容(incompatible),那么你只需修改序列版本UID声明中的值即可。结果,前一版本的实例经序列化之后,再做反序列化时会引发InvalidClassException异常而失败。
总而言之,当你决定要将一个类做成可序列化的时候(见第74条),请仔细考虑应该采用什么样的序列化形式。只有当默认的序列化形式能够合理地描述对象的逻辑状态时,才能使用默认的序列化形式;否则就要设计一个自定义的序列化形式,通过它合理地描述对象的状态。你应该分配足够多的时间来设计类的序列化形式,就好像分配足够多的时间来设计它的导出方法一样(见第40条)。正如你无法在将来的版本中去掉导出方法一样,你也不能去掉序列化形式中的域;它们必须被永久地保留下去,以确保序列化兼容性(serialization compalibility)。选择错误的序列化形式对于一个类的复杂性和性能都会有永久的负面影响。
第76条:保护性地编写readObject方法(1)
第39条介绍了一个不可变的日期范围类,它包含可变的私有数据域Date。该类通过在其构造器和访问方法(accessor)中保护性地拷贝Date对象,极力地维护其约束条件和不可变性。下面就是这个类:
// Immutable class that uses defensive copying
public final class Period {
private final Date start;
private final Date end;
/**
* @param start the beginning of the period
* @param end the end of the period; must not precede start
* @throws IllegalArgumentException if start is after end
* @throws NullPointerException if start or end is null
*/
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(
start + ” after ” + end);
}
public Date start () { return new Date(start.getTime()); }
public Date end () { return new Date(end.getTime()); }
public String toString() { return start + ” – ” + end; }
… // Remainder omitted
}
假设你决定要把这个类做成可序列化的。因为Period对象的物理表示法正好反映了它的逻辑数据内容,所以,使用默认的序列化形式并没有什么不合理的(见第75条)。因此,为了使这个类成为可序列化的,似乎你所需要做的也就是在类的声明中增加”implements Serializable”字样。然而,如果你真的这样做,那么这个类将不再保证它的关键约束了。
问题在于,readObject方法实际上相当于另一个公有的构造器,如同其他的构造器一样,它也要求注意同样的所有注意事项。构造器必须检查其参数的有效性(见第38条),并且在必要的时候对参数进行保护性拷贝(见第39条),同样地,readObject方法也需要这样做。如果readObject方法无法做到这两者之一,对于攻击者来说,要违反这个类的约束条件相对就比较简单了。
不严格地说,readObject是一个”用字节流作为唯一参数”的构造器。在正常使用的情况下,对一个正常构造的实例进行序列化可以产生字节流。但是,当面对一个人工仿造的字节流时,readObject产生的对象会违反它所属的类的约束条件,这时问题就产生了。假设我们仅仅在Period类的声明中加上了”implements Serializable”字样。那么,这个不完整的程序将产生一个Period实例,它的结束时间比起始时间还要早:
public class BogusPeriod {
// Byte stream could not have come from real Period instance!
private static final byte[] serializedForm = new byte[] {
(byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
(byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
0x00, 0x78 };
public static void main(String[] args) {
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
// Returns the object with the specified serialized form
private static Object deserialize(byte[] sf) {
try {
InputStream is = new ByteArrayInputStream(sf);
ObjectInputStream ois = new ObjectInputStream(is);
return ois.readObject();
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
}
被用来初始化serializedForm的byte数组常量是这样产生的:首先对一个正常的Period实例进行序列化,然后对得到的字节流进行手工编辑。对于这个例子而言,字节流的细节并不重要,但是如果你很好奇的话,可以在《JavaTM Object Serialization Specification》[Serialization, 6]中查到有关序列化字节流格式的描述信息。如果你运行这个程序,它会打印出”Fri Jan 01 12:00:00 PST 1999 – Sun Jan 01 12:00:00 PST 1984”。只要把Period声明成可序列化的,就使得我们能够创建出违反其类约束条件的对象。
为了修正这个问题,你可以为Period提供一个readObject方法,该方法首先调用defaultReadObject,然后检查被反序列化之后的对象的有效性。如果有效性检查失败,readObject方法就抛出一个InvalidObjectException异常,使反序列化过程不能成功地完成:
// readObject method with validity checking
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
// Check that our invariants are satisfied
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start +” after “+ end);
}
第76条:保护性地编写readObject方法(2)
尽管这样的修正避免了攻击者创建出无效的Period实例,但是,这里仍然隐藏着一个更为微妙的问题。通过伪造字节流,要想创建可变的Period实例仍是有可能的,做法是:字节流以一个有效的Period实例开头,然后附加上两个额外的引用,指向Period实例中的两个私有的Date域。攻击者从ObjectInputStream中读取Period实例,然后读取附加在其后面的”恶意编制的对象引用”。这些对象引用使得攻击者能够访问到Period对象内部的私有Date域所引用的对象。通过改变这些Date实例,攻击者可以改变Period实例。下面的类演示了这种攻击:
public class MutablePeriod {
// A period instance
public final Period period;
// period’s start field, to which we shouldn’t have access
public final Date start;
// period’s end field, to which we shouldn’t have access
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos =
new ByteArrayOutputStream();
ObjectOutputStream out =
new ObjectOutputStream(bos);
// Serialize a valid Period instance
out.writeObject(new Period(new Date(), new Date()));
/*
* Append rogue “previous object refs” for internal
* Date fields in Period. For details, see “Java
* Object Serialization Specification,” Section 6.4.
*/
byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // Ref #5
bos.write(ref); // The start field
ref[4] = 4; // Ref # 4
bos.write(ref); // The end field
// Deserialize Period and “stolen” Date references
ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
start = (Date) in.readObject();
end = (Date) in.readObject();
} catch (Exception e) {
throw new AssertionError(e);
}
}
}
运行下面的程序,可以看到攻击的效果:
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
Date pEnd = mp.end;
// Let’s turn back the clock
pEnd.setYear(78);
System.out.println(p);
// Bring back the 60s!
pEnd.setYear(69);
System.out.println(p);
}
运行这个程序,产生如下的输出结果:
Wed Apr 02 11:04:26 PDT 2008 – Sun Apr 02 11:04:26 PST 1978
Wed Apr 02 11:04:26 PDT 2008 – Wed Apr 02 11:04:26 PST 1969
虽然Period实例被创建之后,它的约束条件没有被破坏,但是要随意地修改它的内部组件仍然是有可能的。一旦攻击者获得了一个可变的Period实例,他就可以将这个实例传递给一个”安全性依赖于Period的不可变性”的类,从而造成更大的危害。这种推断并不牵强:实际上,有许多类的安全性就是依赖于String的不可变性。
问题的根源在于,Period的readObject方法并没有完成足够的保护性拷贝。当一个对象被反序列化的时候,对于客户端不应该拥有的对象引用,如果哪个域包含了这样的对象引用,就必须要做保护性拷贝,这是非常重要的。因此,对于每个可序列化的不可变类,如果它包含了私有的可变组件,那么在它的readObject方法中,必须要对这些组件进行保护性拷贝。下面的readObject方法可以确保Period的约束条件不会遭到破坏,以保持它的不可变性:
// readObject method with defensive copying and validity checking
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
// Defensively copy our mutable components
start = new Date(start.getTime());
end = new Date(end.getTime());
// Check that our invariants are satisfied
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start +” after “+ end);
}
注意,保护性拷贝是在有效性检查之前进行的,而且,我们没有使用Date的clone方法来执行保护性拷贝。这两个细节对于保护Period免受攻击是必要的(见第39条)。同时也要注意到,对于final域,保护性拷贝是不可能的。为了使用readObject方法,我们必须要将start和end域做成非final的。这是很遗憾的,但是这还算是相对比较好的做法。有了这个新的readObject方法,并去掉了start和end域的final修饰符之后,MutablePeriod类将不再有效。此时,上面的攻击程序会产生这样的输出:
Wed Apr 02 11:05:47 PDT 2008 – Wed Apr 02 11:05:47 PDT 2008
Wed Apr 02 11:05:47 PDT 2008 – Wed Apr 02 11:05:47 PDT 2008
在Java 1.4发行版本中,为了阻止恶意的对象引用攻击,同时节省保护性拷贝的开销,在ObjectOutputStream中增加了writeUnshared和readUnshared方法[Serialization]。遗憾的是,这些方法都很容易受到复杂的攻击,即本质上与第77条中所述的ElvisStealer攻击相似的攻击。不要使用writeUnshared和readUnshared方法。它们通常比保护性拷贝更快,但是它们不提供必要的安全性保护。
有一个简单的”石蕊”测试,可以用来确定默认的readObject方法是否可以接受。测试方法:增加一个公有的构造器,其参数对应于该对象中每个非瞬时的域,并且无论参数的值是什么,都是不进行检查就可以保存到相应的域中的。对于这样的做法,你是否会感到很舒适?如果你对这个问题不能回答”是”,就必须提供一个显式的readObject方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝。另一种方法是,可以使用序列化代理模式(serialization proxy pattern,见第78条)。
对于非final的可序列化的类,在readObject方法和构造器之间还有其他类似的地方。readObject方法不可以调用可被覆盖的方法,无论是直接调用还是间接调用都不可以(见第17条)。如果违反了这条规则,并且覆盖了该方法,被覆盖的方法将在子类的状态被反序列化之前先运行。程序很可能会失败[Bloch05,Puzzle91]。
总而言之,每当你编写readObject方法的时候,都要这样想:你正在编写一个公有的构造器,无论给它传递什么样的字节流,它都必须产生一个有效的实例。不要假设这个字节流一定代表着一个真正被序列化过的实例。虽然在本条目的例子中,类使用了默认的序列化形式,但是,所有讨论到的有可能发生的问题也同样适用于使用自定义序列化形式的类。下面以摘要的形式给出一些指导方针,有助于编写出更加健壮的readObject方法:
对于对象引用域必须保持为私有的类,要保护性地拷贝这些域中的每个对象。不可变类的可变组件就属于这一类别。
对于任何约束条件,如果检查失败,则抛出一个InvalidObjectException异常。这些检查动作应该跟在所有的保护性拷贝之后。
如果整个对象图在被反序列化之后必须进行验证,就应该使用ObjectInputValidation接口[JavaSE6,Serialization]。
无论是直接方式还是间接方式,都不要调用类中任何可被覆盖的方法。