87. 考虑使用自定义的序列化形式

      在没有考虑默认序列化形式是否合适之前,不要接受它。 接受默认的序列化形式应该是一个三思而后行的决定,即从灵活性、性能和正确性的角度综合来看,这种编码是合理的。一般来说,设计自定义序列化形式时,只有与默认序列化形式所选择的编码在很大程度上相同时,才应该接受默认的序列化形式。

      对象的默认序列化形式,相对于它的物理表示法而言是一种比较有效的编码形式。换句话说,它描述了对象中包含的数据以及从该对象可以访问的每个对象的数据。它还描述了所有这些对象相互关联的拓扑结构。理想的对象序列化形式只包含对象所表示的逻辑数据。它独立于物理表征。

      如果对象的物理表示与其逻辑内容相同,则默认的序列化形式可能是合适的。 例如,默认的序列化形式对于下面的类来说是合理的,它简单地表示一个人的名字:

      从逻辑上讲,名字由三个字符串组成,分别表示姓、名和中间名。Name 的实例字段精确地反映了这个逻辑内容。

      即使你认为默认的序列化形式是合适的,你通常也必须提供 readObject 方法来确保不变性和安全性。 对于 Name 类而言, readObject 方法必须确保字段 lastName 和 firstName 是非空的。第 88 条和第 90 条详细讨论了这个问题。

      注意,虽然 lastName、firstName 和 middleName 字段是私有的,但是它们都有文档注释。这是因为这些私有字段定义了一个公共 API,它是类的序列化形式,并且必须对这个公共 API 进行文档化。@serial 标记的存在告诉 Javadoc 将此文档放在一个特殊的页面上,该页面记录序列化的形式。

      与 Name 类不同,考虑下面的类,它是另一个极端。它表示一个字符串列表(使用标准 List 实现可能更好,但此时暂不这么做):

      当对象的物理表示与其逻辑数据内容有很大差异时,使用默认的序列化形式有四个缺点:

      StringList 的合理序列化形式就是列表中的字符串数量,然后是字符串本身。这构成了由 StringList 表示的逻辑数据,去掉了其物理表示的细节。下面是修改后的 StringList 版本,带有实现此序列化形式的 writeObjectreadObject 方法。提醒一下,transient 修饰符表示要从类的默认序列化表单中省略该实例字段:

       做的第一件事是调用 defaultWriteObject, readObject 做的第一件事是调用 defaultReadObject,即使 StringList 的所有字段都是 transient 的。你可能听说过,如果一个类的所有实例字段都是 transient 的,那么你可以不调用 defaultWriteObjectdefaultReadObject,但是序列化规范要求你无论如何都要调用它们。这些调用的存在使得在以后的版本中添加非瞬态实例字段成为可能,同时保留了向后和向前兼容性。如果实例在较晚的版本中序列化,在较早的版本中反序列化,则会忽略添加的字段。如果早期版本的 readObject 方法调用 defaultReadObject 失败,反序列化将失败,并出现 StreamCorruptedException

      注意,writeObject 方法有一个文档注释,即使它是私有的。这类似于 Name 类中私有字段的文档注释。这个私有方法定义了一个公共 API,它是序列化的形式,并且应该对该公共 API 进行文档化。与字段的 @serial 标记一样,方法的 @serialData标记告诉 Javadoc 实用工具将此文档放在序列化形式页面上。

      为了给前面的性能讨论提供一定的伸缩性,如果平均字符串长度是 10 个字符,那么经过修改的 StringList 的序列化形式占用的空间大约是原始字符串序列化形式的一半。在我的机器上,序列化修订后的 的速度是序列化原始版本的两倍多,列表长度为 10。最后,在修改后的形式中没有堆栈溢出问题,因此对于可序列化的 StringList 的大小没有实际的上限。

      虽然默认的序列化形式对 StringList 不好,但是对于某些类来说,情况会更糟。对于 StringList,默认的序列化形式是不灵活的,并且执行得很糟糕,但是它是正确的,因为序列化和反序列化 StringList 实例会生成原始对象的无差错副本,而所有不变量都是完整的。对于任何不变量绑定到特定于实现的细节的对象,情况并非如此。

      例如,考虑哈希表的情况。物理表示是包含「键-值」项的哈希桶序列。一个项所在的桶是其键的散列代码的函数,通常情况下,不能保证从一个实现到另一个实现是相同的。事实上,它甚至不能保证每次运行都是相同的。因此,接受哈希表的默认序列化形式将构成严重的 bug。对哈希表进行序列化和反序列化可能会产生一个不变量严重损坏的对象。

      如果使用默认的序列化形式,并且标记了一个或多个字段为 transient,请记住,当反序列化实例时,这些字段将初始化为默认值:对象引用字段为 null,数字基本类型字段为 0,布尔字段为 false [JLS, 4.12.5]。如果这些值对于任何 transient 字段都是不可接受的,则必须提供一个 readObject 方法,该方法调用 defaultReadObject 方法,然后将 transient 字段恢复为可接受的值(详见第 88 条)。或者,可以采用延迟初始化(详见第 83 条),在第一次使用这些字段时初始化它们。

      无论你是否使用默认的序列化形式,必须对对象序列化强制执行任何同步操作,就像对读取对象的整个状态的任何其他方法强制执行的那样。 例如,如果你有一个线程安全的对象(详见第 82 条),它通过同步每个方法来实现线程安全,并且你选择使用默认的序列化形式,那么使用以下 write-Object 方法:

      如果将同步放在 writeObject 方法中,则必须确保它遵守与其他活动相同的锁排序约束,否则将面临资源排序死锁的风险 [Goetz06, 10.1.5]。

      无论选择哪种序列化形式,都要在编写的每个可序列化类中声明显式的序列版本 UID。 这消除了序列版本 UID 成为不兼容性的潜在来源(详见第 86 条)。这么做还能获得一个小的性能优势。如果没有提供序列版本 UID,则需要执行高开销的计算在运行时生成一个 UID。

      声明序列版本 UID 很简单,只要在你的类中增加这一行:

      如果你编写一个新类,为 选择什么值并不重要。你可以通过在类上运行 serialver 实用工具来生成该值,但是也可以凭空选择一个数字。串行版本 UID 不需要是唯一的。如果修改缺少串行版本 UID 的现有类,并且希望新版本接受现有的序列化实例,则必须使用为旧版本自动生成的值。你可以通过在类的旧版本上运行 serialver 实用工具(序列化实例存在于旧版本上)来获得这个数字。

      如果你希望创建一个新版本的类,它与现有版本不兼容,如果更改序列版本 UID 声明中的值,这将导致反序列化旧版本的序列化实例的操作引发 InvalidClassException不要更改序列版本 UID,除非你想破坏与现有序列化所有实例的兼容性。