Effective Java 第三版—— 87. 考虑使用自定义序列化形式

96
码匠安徒生
0.6 2019.04.03 12:37* 字数 3444

Tips
书中的源代码地址:https://github.com/jbloch/effective-java-3e-source-code
注意,书中的有些代码里方法是基于Java 9 API中的,所以JDK 最好下载 JDK 9以上的版本。

Effective Java, Third Edition

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

当在时间紧迫的情况下编写类时,通常应该将精力集中在设计最佳API上。有时这意味着发布一个“一次性使用(throwaway)”实现,将在将来的版本中替换它。通常这不是一个问题,但是如果类实现Serializable并使用默认的序列化形式,将永远无法完全“摆脱一次性使用”的实现了。它永远决定序列化的形式。这不仅仅是一个理论问题。这种情况发生在Java类库中的几个类上,包括BigInteger。

如果没有考虑是否合适,请不要接受默认的序列化形式。 接受默认的序列化形式应该有意识地决定,从灵活性,性能和正确性的角度来看这种编码是合理的。 一般来说,只有在与设计自定义序列化形式时所选择的编码大致相同的情况下,才应接受默认的序列化形式。

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

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

// 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
}

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

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

注意,虽然lastName、firstName和middleName属性是私有的,但是它们都有文档注释。这是因为这些私有属性定义了一个公共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
}

从逻辑上讲,这个类表示字符串序列。在物理上,它将序列表示为双链表。如果接受默认的序列化形式,则序列化形式将煞费苦心地镜像链表中的每个entry,以及每一个entry之间的所有双向链接。

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

  • 它将导出的API永久绑定到当前类的内部表示。 在上面的示例中,私有StringList.Entry类成为公共API的一部分。 如果在将来的版本中更改了表示,则StringList类仍需要接受输入上的链表表示,并在输出时生成它。 该类永远不会消除处理链表entry的所有代码,即使不再使用它们。

  • 它会消耗过多的空间。 在上面的示例中,序列化形式不必要地表示链接列表中的每个entry和所有链接。 这些entry和链接仅仅是实现细节,不值得包含在序列化形式中。 由于序列化形式过大,将其写入磁盘或通过网络发送将会非常慢。

  • 它会消耗过多的时间。 序列化逻辑不了解对象图的拓扑结构,因此必须经历昂贵的图遍历。 在上面的例子中,仅仅遵循下一个引用就足够了。

  • 它会导致堆栈溢出。 默认的序列化过程执行对象图的递归遍历,即使对于中等大小的对象图,也可能导致堆栈溢出。 使用1,000-1,800个元素序列化StringList实例,就会在我的机器上生成StackOverflowError异常。 令人惊讶的是,序列化导致堆栈溢出的最小列表大小因运行而异(在我的机器上)。 显示此问题的最小列表大小可能取决于平台实现和命令行标记; 某些实现可能根本没有这个问题。

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
}

writeObject做的第一件事就是调用defaultWriteObject方法,而readObject做的第一件事就是调用defaultReadObject,即使所有StringList的属性都是瞬时状态(transient)的。 你可能会听到它说如果所有类的实例属性都是瞬时状态的,那么可以省去调用defaultWriteObject和defaultReadObject,但序列化规范要求无论如何都要调用它们。 这些调用的存在使得可以在以后的版本中添加非瞬时状态的实例属性,同时保持向后和向前兼容性。 如果实例在更高版本中序列化,并在早期版本中反序列化,则添加的属性将被忽略。 如果早期版本的readObject方法无法调用defaultReadObject,则反序列化将失败,抛出StreamCorruptedException异常。

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

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

虽然默认的序列化形式对于StringList来说是不好的,但是对于有些类会可能更糟糕。 对于StringList,默认的序列化形式是不灵活的,并且执行得很糟糕,但是在序列化和反序列化StringList实例,它产生了原始对象的忠实副本,其所有不变性都是完整的。 对于其不变性与特定实现的详细信息相关联的任何对象,情况并非如此。

例如,考虑哈希表(hash table)的情况。它的物理表示是一系列包含键值(key-value)项的哈希桶。每一项所在桶的位置,是其键的散列代码的方法决定的,通常情况下,不能保证从一个实现到另一个实现是相同的。事实上,它甚至不能保证每次运行都是相同的。因此,接受哈希表的默认序列化形式会构成严重的错误。对哈希表进行序列化和反序列化可能会产生一个不变性严重损坏的对象。

无论是否接受默认的序列化形式,当调用defaultWriteObject方法时,没有标记为transient的每个实例属性都会被序列化。因此,可以声明为transient的每个实例属性都应该是。这包括派生(derived)属性,其值可以从主要数据属性(primary data fields)(如缓存的哈希值)计算。它还包括一些属性,这些属性的值与JVM的一个特定运行相关联,比如表示指向本地数据结构指针的long型属性。在决定使非瞬时状态的属性之前,请确信它的值是对象逻辑状态的一部分。如果使用自定义序列化形式,则大多数或所有实例属性都应该标记为transient,如上面的StringList示例所示。

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

无论是否使用默认的序列化形式,必须对对象序列化加以同步,也要对读取对象的整个状态的任何方法施加同步。。 因此,例如如果有一个线程安全的对象(条目 82)通过同步每个方法来实现其线程安全,并且选择使用默认的序列化形式,请使用以下write-Object方法:

// 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。这消除了序列版本UID作为不兼容性的潜在来源(条目 86)。还有一个小的性能优势。如果没有提供序列版本UID,则需要执行昂贵的计算来在运行时生成一个UID。

声明序列版本UID很简单。只需要在类中添加这一行:

private static final long serialVersionUID = randomLongValue;

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

如果想要创建与现有版本不兼容的类的新版本,只需更改序列版本UID声明中的值即可。 这将导致尝试反序列化先前版本的序列化实例抛出InvalidClassException异常。 不要更改序列版本UID,除非想破坏与类的所有现有序列化实例的兼容性

总而言之,如果你已确定某个类应该可序列化(条目 86),请仔细考虑序列化形式应该是什么。 仅当它是对象逻辑状态的合理描述时,才使用默认的序列化形式;否则设计一个适当描述对象的自定义序列化形式。 在分配设计导出方法时,应该分配尽可能多的时间来设计类的序列化形式(条目 51)。 正如无法从将来的版本中删除导出的方法一样,也无法从序列化形式中删除属性;必须永久保存它们以确保序列化兼容性。 选择错误的序列化形式会对类的复杂性和性能产生永久性的负面影响。

日记本
Gupao