Walt You - 行是知之始

《Effective Java》学习日志(十一)86:严格执行Serializable

2019-02-13


学习资料主要参考: 《Effective Java Third Edition》,作者:Joshua Bloch



允许序列化类的实例可以像在其声明中添加实现Serializable的单词一样简单。 因为这很容易做到,所以有一种常见的误解,即序列化对程序员来说需要很少的努力。 事实要复杂得多。 虽然使类可序列化的直接成本可以忽略不计,但长期成本通常很高。

实现Serializable的一个主要成本是它降低了一旦发布后更改类的实现的灵活性。 当类实现Serializable时,其字节流编码(或序列化形式)将成为其导出API的一部分。 在广泛分发类之后,通常需要永久支持序列化表单,就像您需要支持导出的API的所有其他部分一样。 如果您没有努力设计自定义序列化表单但仅接受默认值,则序列化表单将永远与类的原始内部表示相关联。 换句话说,如果您接受默认的序列化表单,则该类的私有和包私有实例字段将成为其导出API的一部分,并且最小化对字段的访问(第15项)的做法将失去其作为信息隐藏工具的有效性。

如果接受默认的序列化表单并稍后更改类的内部表示,则将导致序列化表单中的不兼容更改。 尝试使用旧版本的类序列化实例并使用新版本对其进行反序列化(反之亦然)的客户端将遇到程序失败。 可以在保持原始序列化形式(使用ObjectOutputStream.putFields和ObjectInputStream.readFields)的同时更改内部表示,但这可能很困难并且在源代码中留下可见的瑕疵。 如果您选择将类序列化,您应该仔细设计一个您愿意长期活跃的高质量序列化表单(第87,90项)。 这样做会增加开发的初始成本,但值得付出努力。 即使是精心设计的序列化形式也会限制一个类的演变; 一个设计不良的序列化形式可能会瘫痪。

可串行化所强加的进化约束的一个简单示例涉及流唯一标识符,通常称为串行版本UID。 每个可序列化的类都有一个与之关联的唯一标识号。 如果未通过声明名为serialVersionUID的静态最终长字段来指定此数字,则系统会在运行时通过将加密哈希函数(SHA-1)应用于类的结构来自动生成它。 此值受类的名称,它实现的接口及其大多数成员(包括编译器生成的合成成员)的影响。 如果您更改任何这些内容,例如,通过添加便捷方法,生成的串行版本UID会更改。 如果未能声明串行版本UID,则兼容性将被破坏,从而导致运行时出现InvalidClassException。

实现Serializable的第二个成本是它增加了错误和安全漏洞的可能性(第85项)。 通常,使用构造函数创建对象; 序列化是一种创建对象的语言机制。 无论您是接受默认行为还是覆盖默认行为,反序列化都是一个“隐藏的构造函数”,其中包含与其他构造函数相同的所有问题。 因为没有与反序列化相关联的显式构造函数,所以很容易忘记必须确保它保证构造函数建立的所有不变量,并且它不允许攻击者访问构造中的对象的内部。 依赖于默认的反序列化机制,可以轻松地将对象置于不变的损坏和非法访问之外(第88项)。

实现Serializable的第三个成本是它增加了与发布新版本类相关的测试负担。 修改可序列化类时,重要的是检查是否可以序列化新版本中的实例并在旧版本中反序列化,反之亦然。 因此,所需的测试量与可序列化类的数量和可能很大的发布数量的乘积成比例。 您必须确保序列化 - 反序列化过程成功并且它会导致原始对象的忠实副本。 如果在首次编写类时仔细设计自定义序列化表单,则需要进行测试(第87,90项)。

实施Serializable不是一个轻率的决定。 如果一个类要参与依赖于Java序列化进行对象传输或持久化的框架,那么这一点至关重要。 此外,它极大地简化了将类用作另一个必须实现Serializable的类的组件。 但是,实现Serializable会产生许多成本。 每次设计课程时,都要权衡成本和收益。 从历史上看,BigInteger和Instant实现的Serializable等值类以及集合类也是如此。 表示活动实体(如线程池)的类应该很少实现Serializable。

为继承而设计的类(第19项)应该很少实现Serializable,接口应该很少扩展它。 违反此规则会给扩展类或实现接口的任何人带来沉重的负担。 有时候违反规则是合适的。 例如,如果一个类或接口主要存在于要求所有参与者实现Serializable的框架中,那么实现或扩展Serializable可能对类或接口有意义。

专为实现Serializable的继承而设计的类包括Throwable和Component。 Throwable实现Serializable,因此RMI可以从服务器向客户端发送异常。 组件实现Serializable,因此可以发送,保存和恢复GUI,但即使在Swing和AWT的全盛时期,这个设施在实践中很少使用。

如果实现具有可序列化和可扩展的实例字段的类,则需要注意几个风险。 如果实例字段值上存在任何不变量,则防止子类覆盖finalize方法至关重要,该类可以通过重写finalize并将其声明为final来完成。 否则,该类将容易受到finalizer attacks(第8项)。 最后,如果类的实例字段初始化为其默认值(整数类型为零,布尔值为false,对象引用类型为null),则会违反不变量,必须添加此readObjectNoData方法:

// readObjectNoData for stateful extendable serializable classes
private void readObjectNoData() throws InvalidObjectException {
	throw new InvalidObjectException("Stream data required");
}

在Java 4中添加了此方法,以涵盖涉及向现有可序列化类添加可序列化超类的极端情况。

关于不实施Serializable的决定有一点需要注意。 如果为继承而设计的类不可序列化,则可能需要额外的努力才能编写可序列化的子类。 这种类的正常反序列化要求超类具有可访问的无参数构造函数。 如果您不提供这样的构造函数,则强制子类使用序列化代理模式(Item 90)。

内部类(第24项)不应实现Serializable。 它们使用编译器生成的合成字段来存储对封闭实例的引用,并存储来自封闭范围的局部变量的值。 这些字段如何对应于类定义是未指定的,匿名和本地类的名称也是如此。 因此,内部类的默认序列化形式是未定义的。 但是,静态成员类可以实现Serializable。

总而言之,实现Serializable的难易程度。 除非只在受保护的环境中使用类,其中版本永远不必进行互操作,并且服务器永远不会暴露给不受信任的数据,否则实现Serializable是一项严肃的承诺,应该非常谨慎。 如果一个类允许继承,则需要格外小心。


Content