Effective Java(3rd)-Item87 考虑使用自定义序列化表单

  当您在时间紧迫的情况下编写类时,通常应该将精力集中在设计最佳API上。有时这意味着发布一个“一次性”实现,您知道您将在将来的版本中替换它。通常这不是一个问题,但是如果类实现Serializable并使用默认的序列化形式,您将永远无法完全摆脱一次性实现。它将永远指示序列化的形式。这不仅仅是一个理论问题。这种情况发生在Java库中的几个类上,包括BigInteger。
  在没有考虑默认序列化形式是否合适之前,不要接受它。接受默认的序列化形式应该是一个有意识的决定,即从灵活性、性能和正确性的角度来看,这种编码是合理的。一般来说,只有当默认序列化表单与设计自定义序列化表单时所选择的编码在很大程度上相同时,才应该接受默认的序列化表单。
  对象的默认序列化形式是对象图的物理表示形式的一种相当有效的编码,该表示形式以对象为根。换句话说,它描述了对象中包含的数据以及从该对象可以访问的每个对象中的数据。它还描述了所有这些对象相互关联的拓扑结构。理想的对象序列化形式只包含对象所表示的逻辑数据。它独立于物理表征。
  如果对象的物理表示与其逻辑内容相同,则默认的序列化形式可能是合适的。例如,默认的序列化形式对于下面的类来说是合理的,它简单地表示一个人的名字:

image.png

image.png

  从逻辑上讲,名称由三个字符串组成,分别表示姓、名和中间名。名称中的实例字段精确地反映了这个逻辑内容。
  即使您认为默认的序列化形式是合适的,您通常也必须提供readObject方法来确保不变量和安全性。在Name的情况下,readObject方法必须确保lastName和firstName是非空。项目88和90详细讨论了这个问题。
  注意,虽然lastName、firstName和middleName字段是私有的,但是它们都有文档注释。这是因为这些私有字段定义了一个公共API(它是类的序列化形式)和这个公共API,API必须文档化。@serial标记的存在告诉Javadoc将此文档放在一个特殊的页面上,该页面记录序列化的表单。
  在名称的另一端,考虑下面的类,代表字符串的列表(暂时忽略您最好使用标准列表实现之一):

image.png

  从逻辑上讲,这个类表示字符串序列。在物理上,它将序列表示为双链表。如果接受默认的序列化表单,则序列化表单将费力地在两个方向镜像链表中的每个条目和条目之间的所有链接。
  当对象的物理表示与其逻辑数据内容有很大差异时,使用默认的序列化形式有四个缺点:

  • 它将导出的API永久地绑定到当前的内部表示。在上面的例子中,私有StringList。Entry类成为公共API的一部分。如果表示在将来的版本中更改,则StringList类仍然需要接受输入上的链表表示,并在输出上生成它。该类永远不会删除处理链表项的所有代码,即使不再使用它们。
  • 它会占用过多的空间。在上面的示例中,序列化的表单不必要地表示链表中的每个条目和所有链接。这些条目和链接只是实现细节,不值得包含在序列化的表单中。由于序列化的表单太大,将其写入磁盘或通过网络发送将非常慢。
  • 它会消耗过多的时间。序列化逻辑不知道对象图的拓扑结构,因此必须遍历开销很大的图。在上面的例子中,只要遵循下面的引用就足够了。
  • 它可能导致堆栈溢出。默认的序列化过程执行对象图的递归遍历,即使对于中等大小的对象图,这也可能导致堆栈溢出。序列化StringList实例1000 - 1800个元素在我的机器上生成一个堆栈溢出错误。令人惊讶的是,序列化导致堆栈溢出的最小列表大小因运行而异(在我的机器上)。显示此问题的最小列表大小可能取决于平台实现和命令行标志;有些实现可能根本没有这个问题。
      StringList的合理序列化形式就是列表中的字符串数量,然后是字符串本身。这构成了由StringList表示的逻辑数据,去掉了其物理表示的细节。下面是修改后的StringList版本,带有实现此序列化表单的writeObject和readObject方法。提醒一下,transient修饰符表示要从类的默认序列化表单中省略一个实例字段:
    image.png

  writeObject做的第一件事是调用defaultWriteObject, readObject做的第一件事是调用defaultReadObject,尽管所有的
StringList的字段是transient的。您可能听说过,如果一个类的所有实例字段都是transient的,那么您可以不调用defaultWriteObject和defaultReadObject,但是序列化规范要求您无论如何都要调用它们。这些调用的存在使得在以后的版本中添加非瞬态实例字段成为可能,同时保留了向后和向前兼容性。如果实例在较晚的版本中序列化,在较早的版本中反序列化,则会忽略添加的字段。如果早期版本的readObject方法调用defaultReadObject失败,反序列化将会失败StreamCorruptedException。
  注意,writeObject方法有一个文档注释,即使它是私有的。这类似于Name类中私有字段的文档注释。这个私有方法定义了一个公共API,它是序列化的形式,并且应该对该公共API进行文档化。与字段的@serial标记一样,方法的@serialData标记告诉Javadoc实用程序将此文档放在序列化表单页面上。
  为了给之前的性能讨论提供一些规模感,如果平均字符串长度为10个字符,修订版的序列化形式
StringList占用的空间约为原始序列化形式的一半。在我的机器上,序列化修订后的StringList的速度是序列化原始版本的两倍多,列表长度为10。最后,在修改后的表单中没有堆栈溢出问题,因此对于可序列化的StringList的大小没有实际的上限。
  虽然默认的序列化表单对StringList不好,但是对于某些类来说,情况会更糟。对于StringList,默认的序列化形式是不灵活的,并且执行得很糟糕,但是它是正确的,因为序列化和反序列化StringList实例会生成原始对象的忠实副本,而所有不变量都是完整的。对于任何其不变量绑定到特定于实现的细节的对象,情况并非如此。
  例如,考虑hash Table的情况。物理表示是包含键值项的哈希桶序列。一个条目所在的bucket是其键的散列代码的函数,通常情况下,不能保证从一个实现到另一个实现是相同的。事实上,它甚至不能保证每次运行都是相同的。因此,接受哈希表的默认序列化形式将构成严重的错误。对哈希表进行序列化和反序列化可能会产生一个不变量严重损坏的对象。
  无论您是否接受默认的序列化表单,当调用defaultWriteObject方法时,没有标记为transient的每个实例字段都会被序列化。因此,可以声明为transient的每个实例字段都应该是。这包括派生字段,其值可以从主数据字段(如缓存的哈希值)计算。它还包括一些字段,这些字段的值与JVM的一个特定运行相关联,比如表示指向本机数据结构指针的长字段。在决定使字段非瞬态之前,请确信它的值是对象逻辑状态的一部分。如果使用自定义序列化表单,则大多数或所有实例字段都应该标记为transient,如上面的StringList示例所示。

  如果使用默认的序列化表单,并且标记了一个或多个字段为transient,请记住,当反序列化实例时,这些字段将初始化为默认值:对象引用字段为null,数字基元字段为零,布尔字段为false [JLS, 4.12.5]。如果这些值对于任何瞬态字段都是不可接受的,则必须提供一个readObject方法,该方法调用defaultReadObject方法,然后将瞬态字段恢复为可接受的值( item88 )。或者,可以在第一次使用这些字段时惰性地初始化它们( item83 )。

  无论您是否使用默认的序列化表单,您必须对对象序列化施加同步,而您将对读取对象的整个状态的任何其他方法施加同步。例如,如果您有一个线程安全的对象(第82项),它通过同步每个方法来实现线程安全,并且您选择使用默认的序列化形式,那么请使用以下writeObject方法:

image.png

  果将同步放在writeObject方法中,则必须确保它遵守与其他活动相同的锁排序约束,否则将面临资源排序死锁的风险[Goetz06, 10.1.5]。
  无论选择哪种序列化形式,都要在编写的每个可序列化类中声明显式的串行版本UID。这消除了串行版本UID作为不兼容性的潜在来源( item86)。还有一个小的性能优势。如果没有提供串行版本UID,则需要执行昂贵的计算来在运行时生成一个UID。

  声明串行版本UID很简单。只需要在你的类中添加这一行:
private static final long serialVersionUID = randomLongValue;
  如果您编写一个新类,为randomLongValue选择什么值并不重要。您可以通过在类上运行serialver实用程序来生成该值,但是也可以凭空选择一个数字。串行版本uid不需要是惟一的。如果修改缺少串行版本UID的现有类,并且希望新版本接受现有的串行实例,则必须使用为旧版本自动生成的值。您可以通过在类的旧版本上运行serialver实用程序(序列化实例存在于旧版本上)来获得这个数字。
  如果您希望创建与现有版本不兼容的类的新版本,只需更改串行版本UID声明中的值。这将导致反序列化以前版本的序列化实例的尝试引发InvalidClassException。不要更改串行版本UID,除非您想破坏与类的所有现有序列化实例的兼容性。
  总之,如果您已经决定一个类应该是可序列化的 ( item86 ),仔细想想序列化的形式应该是什么。只有在合理描述对象的逻辑状态时,才使用默认的序列化形式;否则,设计一个适合描述对象的自定义序列化表单。设计类的序列化形式和设计导出方法的时间应该一样多( item51)。正如不能从未来版本中删除导出的方法一样,也不能从序列化表单中删除字段;必须永远保存它们,以确保序列化兼容性。选择错误的序列化形式可能会对类的复杂性和性能产生永久性的负面影响。
本文写于2019.7.24,历时1天

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容