效率编程 之「序列化」

时间:2022-06-22
本文章向大家介绍效率编程 之「序列化」,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

对象序列化提供了一个框架,用来将对象编码成字节流,并从字节流编码中重新构建对象。“将一个对象编码成一个字节流”,称作将该对象序列化;相反的处理过程称为反序列化。一旦对象被序列化后,它的编码就可以从一台正在运行的虚拟机被传递到另一台虚拟机上,或者被存储到磁盘上,供以后反序列化时使用。序列化技术为远程通信提供了标准的线路级对象表示法,也为 JavaBean 组件结构提供了标准的持久化数据格式。

第 1 条:谨慎地实现Serializable接口

想要使一个类的实例可被序列化,非常简单,只要在它的声明中加入implements Serializable字样即可。正因为太容易了,所以普遍存在这样一种误解,认为程序员毫不费力就可以实现序列化。实际上情形要复杂的多。虽然使一个类可被序列化的直接开销非常低,甚至可以忽略不计,但是为了序列化而付出的长期开销往往是实实在在的。

  • 实现Serializable接口而付出的最大代价是,一旦一个类被发布,就大大降低了“改变这个类的实现”的灵活性。序列化会使类的演变收到限制,这种限制的一个例子与流的唯一标识号有关,通常它也被称为序列版本 UID(serial version UID)。每个可序列化的类都有一个唯一标识号与它相关联。如果我们没有在一个名为serialVersionUID的私有静态finallong域中显式地指定该标识号,系统就会自动地根据这个类来调用一个复杂的运算过程,从而在运行时产生该标识号。这个自动产生的值会受到类名称、它所实现的接口的名称、以及所有公有的和受保护的名称所影响。如果我们通过任何方式改变了这些信息,比如,增加了一个不是很重要的工具方法,自动产生的序列版本 UID 也会发生变化。因此,如果我们没有声明一个显式的序列版本 UID,兼容性将会遭到破坏,在运行时导致InvalidClassException异常。
  • 实现Serializable接口的第二个代价是,它增加了出现 Bug 和安全漏洞的可能性。通常情况下,对象是利用构造器来创建的;序列化机制是一种语言之外的对象创建机制。无论我们是接受了默认的行为,还是覆盖了默认的行为,反序列化机制都是一个“隐藏的构造器”,具备与其他构造器相同的特点。因为反序列化机制中没有显式的构造器,所以我们很容易忘记要确保:反序列化过程中也要保证所有“由真正的构造器建立起来的约束关系”,并且不允许攻击者访问正在构造过程中的对象的内部信息。依靠默认的反序列化机制,很容易使对象的约束关系遭到破坏,以及遭受到非法访问。
  • 实现Serializable接口的第三个代价是,随着类发行新的版本,相关的测试负担也增加了。当一个可序列化的类被修订的时候,很重要的一点,要检查是否可以“在新版本中序列化一个实例,然后在旧版本中反序列化”,反之亦然。因此,测试所需的工作量和“可序列化的类的数量和发行版本号”的乘积成正比,这个乘积可能会非常大。这些测试不可能自动构造,因为除了二进制兼容以外,我们还必须测试语义兼容性。换句话说,我们必须既要确保“序列化–反序列化”过程成功,也要确保结果产生的对象真正是原始对象的复制品。可序列化类的变化越大,它就越需要测试。如果在最初编写一个类的时候,就精心设计了自定义的序列化形式,测试的要求就可以有所降低,但是也不能完全没有测试。

实现Serializable接口并不是一个很轻松就可以做出的决定。为了继承而设计的类应该尽可能少地去实现Serializable接口,用户的接口也应该尽可能少地继承Serializable接口。在为了继承而设计的类中,真正实现了Serializable接口的有Throwable类、ComponentHttpServlet客户端。因为Throwable类实现了Serializable接口,所以 RMI 的异常可以从服务器传到客户端;Component实现了Serializable接口,因此 GUI 可以被发送、保存和恢复;HttpServlet实现了Serializable接口,因此会话状态可以被缓存。

对于一个为了继承而设计的类,在“允许子类实现Serializable接口”或“禁止子类实现Serializable接口”两者之间的一个折衷方案是,提供一个可供访问的无参构造器。这种设计允许(但不要求)子类实现Serializable接口。此外,内部类不应该实现Serializable接口,因为内部类的默认序列化形式是定义不清楚的。

第 2 条:考虑使用自定义的序列化形式

如果没有先认真考虑默认的序列化形式是否合适,就不要贸然接受默认的序列化形式。如果一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式。但是,即使我们确定了默认的序列化形式是合适的,通常还必须提供一个readObject方法以保证约束关系和安全性。当一个对象的物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下 4 个缺点:

  • 它是这个类的导出 API 永远地束缚在该类的内部表示法上;
  • 它会消耗过多的空间;
  • 它会消耗过多的时间;
  • 它会引起栈溢出。

在 Java 中,transient修饰符表明这个实例域将从一个类的默认序列化形式中省略掉,但writeObject方法的首要任务仍是调用defaultWriteObjectreadObject方法的首要任务则是调用defaultReadObject。如果所有的实例域都是瞬时的,从技术角度而言,不调用defaultWriteObjectdefaultReadObject也是允许的,但是不推荐这样做。

无论我们是否使用默认的序列化形式,当defaultWriteObject方法被调用的时候,每一个未被标记为transient的实例域都会被序列化,在决定将一个域做成非transient的之前,请一定要确信它的值将是该对象逻辑状态的一部分。无论我们是否使用默认的序列化形式,如果在读取整个对象状态的任何其他方法上强制任何同步,则也必须在对象序列化上强制这种同步。因此,如果有一个线程安全的对象,它通过同步每个方法来实现了它的线程安全,并且我们选择使用默认的序列化形式,就要使用下列的writeObject方法:

private synchronized void writeObject(ObjectOutputStream s) throws IOException {
    s.defaultWriteObject();
}

无论我们是否使用默认的序列化形式,都要为自己编写的每个可序列化的类声明一个现实的序列版本 UID。这样可以避免序列版本 UID 成为潜在的不兼容根源,而且这样做也会带来小小的性能好处。如果没有通过显式的序列版本 UID,就需要在运行时通过一个高开销的计算过程产生一个序列版本 UID。