本章主要讲序列化API相关的问题和技巧。序列化API主要是用来将对象编码成字节流,传送字节流到某个地方,然后从字节流中重新构建对象。本章是最后一章,文末会跟一个全书的总结。

第七十四条 谨慎地实现Serializable接口

只在非常必要的时候实现Serializable接口,因为一旦实现它,就会有一些限制和安全隐患。 如下: - 一旦实现Serializable接口,就大大降低了改变类实现的灵活性。如果接受默认的序列化形式,类的私有域和私有方法也会成为导出API的一部分。如果忘记显式地声明序列版本号UID私有静态字段,改变内部表示法就变的更加困难(因为自动生成的UID受到类的内部表示法的影响,比如类名等)。 - 增加了出现bug和安全漏洞的可能性,因为反序列化实际上就像一个隐藏构造器,它独立于现有构造器之外,却可以创建对象,因此在初始化对象的时候所要建立起来的约束条件都不能同样地建立在反序列化过程中。此外,攻击者可以利用反序列化过程来非法访问正在构造过程中的对象。 - 随着类发行新的版本,测试的负担增加了,每增加一个版本,测试就要保证任何新版本序列化老版本反序列化,老版本序列化新版本反序列化的过程是可用的,每个版本都成倍地增加测试的工作量,除了检查是否可用以外(即二进制兼容性),还要检查用起来是否如预期那样产生原始对象的复制品(语义兼容性)。

综上所述,除非你在使用的时候,所要融入的框架需求你的对象必须实现Serializable接口,或者你有其他序列化对象的需求,否则尽量避免实现该接口。 为继承设计的类,应该尽可能少地实现该接口,用户的接口也应该尽可能少地继承该接口。 内部类也不应该实现该接口,但是静态成员类是可以的。

第七十五条 考虑使用自定义的序列化形式

上条我们讲述了序列化接口的诸多问题,实际上,序列化接口的诸多问题主要是由默认的序列化形式导致的,如果考虑使用自定义的序列化形式,某些情况能够得到改善。 如果一个对象的物理表示法等同于它的逻辑内容,则适用于默认的序列化形式。比如:一个表示名字的对象有lastName,firstName,middleName三个实例域,它的物理表示法精确地表示了它的逻辑内容,可以不用自己自定义序列化形式。 但是,下面这个例子就不行:

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) {
        // Implementation omitted
    }

    /**
     * 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());
    }

    private static final long serialVersionUID = 93248094385L;
}

这个例子是一个自己实现的存放string的数组链表,如果使用默认的序列化形式,有以下几个缺点: - 它使类的导出API永远束缚在内部表示法之上,比如上面这个例子,其内部私有的静态成员类Entry也成为了导出API的一部分,在未来想要改变内部表示法的结构,比如不要Entry了,也不想使用链表来实现了,抱歉,那是不可以的。 - 它会消耗过多的空间,由于默认的序列化形式是将对象所有的内容忠实地遍历并写入字节流中,因此包括Entry在内,所有的内部实现细节都一并写入字节流里了,实际上不需要这么做,这会导致字节流膨胀,浪费空间。 - 它会消耗过多的时间,理由同上,它会经历一次昂贵的图遍历来将内部所有的细节写入字节流。 - 会引起内存不足溢出,理由同上。

这些缺点迫使我们必须自己实现序列化过程,上面的例子已经给出了,主要是去实现writeObject和readObject方法,由于JDK早期的糟糕设计,它们没有被包含在接口定义中,所以注意不要打错名字。 可以看到自定义的序列化形式并没有将所有的实现细节都写入字节流中,只是将数组的大小以及数组中的string的值写入到字节流中,这大大减少了空间占用,节省序列化和反序列化时间。 注意,如果你不想要某个域参与到默认的序列化过程中,就可以使用transient修饰符来声明,这样域就是瞬时的,不会参与默认序列化过程。然后你在自定义的序列化过程中按需来使用这些域即可。 另外,要注意,即使你所有的域都是transient的,你也必须在自定义的序列化方法中优先调用s.defaultReadObject()方法和s.defaultWriteObject方法,这两个方法会先进行默认的序列化过程,原因是以后升级版本,你很难保证新增的域依然是瞬时的。调用它们可以增强灵活性和扩展性。 还有就是所有的私有域,一旦序列化了,就有可能成为公有API的一部分,那么你就应该为它们编写Javadoc文档,用@serial@serialData两个标签提示Javadoc生成工具将注释内容放到有关序列化形式的文档页上。 如果类用来被多个线程共享,那么你就要注意了,自定义的序列化方法也要考虑同步问题。 不要忘记显示地声明序列化版本号UID,现在IDE都可以一键生成,有了这样一个值,序列化的类升级版本后才能安全地废除老的版本,而且扩展性也能得到保证。

第七十六条 保护性地编写readObject方法

实现序列化接口会极大地降低类的安全性,尤其是会令一个不可变的类成为一个可变的类,会破坏一个类的约束条件。 为了弥补安全性,必须保护性地编写readObject方法,即使你接受默认的序列化形式,也要这么干。 下面是书中的一个例子:

public final class Period implements Serializable {
    private Date start;
    private Date end;

    /**
     * @param start
     *            the beginning of the period
     * @param end
     *            the end of the period; must not precede start
     * @throws IllegalArgumentException
     *             if start is after end
     * @throws NullPointerException
     *             if start or end is null
     */
    public Period(Date start, Date end) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if (this.start.compareTo(this.end) > 0)
            throw new IllegalArgumentException(start + " after " + end);
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    public String toString() {
        return start + " - " + end;
    }
}

考虑上面这个类,原本构造器中的约束条件想要保证开始时间总是在结束时间之前,然而,当你实现了序列化接口,一切都是泡影,攻击者可以很轻松地使用反序列化方法给你创造出一个结束时间在开始时间之前的对象出来。一旦约束条件不再有效,这样的对象有可能造成整个系统的奔溃,更严重的情况,将导致黑客彻底控制系统。 因此,为了防止这种情况出现,你必须显式地实现readObject方法,如下:

    // readObject method with validity checking - Page 304
    // This will defend against BogusPeriod attack but not MutablePeriod.
    private void readObject(ObjectInputStream s)
    throws IOException, ClassNotFoundException {
        s.defaultReadObject();

        // Check that our invariants are satisfied
        if (start.compareTo(end) > 0)
            throw new InvalidObjectException(start +" after "+ end);
    }

你以为这样就完事大吉了?问题是,本来例子中的类是个不可变的类,它使用了保护性拷贝确保其内部域不被人修改,现在呢?现在攻击者可以很容易地获取到内部域的引用,进而在反序列化结束之后,修改内部域,破坏约束条件,如下:

public class MutablePeriod {
    // A period instance
    public final Period period;

    // period's start field, to which we shouldn't have access
    public final Date start;

    // period's end field, to which we shouldn't have access
    public final Date end;

    public MutablePeriod() {
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(bos);

            // Serialize a valid Period instance
            out.writeObject(new Period(new Date(), new Date()));

            /*
             * Append rogue "previous object refs" for internal Date fields in
             * Period. For details, see "Java Object Serialization
             * Specification," Section 6.4.
             */
            byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // Ref #5
            bos.write(ref); // The start field
            ref[4] = 4; // Ref # 4
            bos.write(ref); // The end field

            // Deserialize Period and "stolen" Date references
            ObjectInputStream in = new ObjectInputStream(
                    new ByteArrayInputStream(bos.toByteArray()));
            period = (Period) in.readObject();
            start = (Date) in.readObject();
            end = (Date) in.readObject();
        } catch (Exception e) {
            throw new AssertionError(e);
        }
    }

    public static void main(String[] args) {
        MutablePeriod mp = new MutablePeriod();
        Period p = mp.period;
        Date pEnd = mp.end;

        // Let's turn back the clock
        pEnd.setYear(78);
        System.out.println(p);

        // Bring back the 60s!
        pEnd.setYear(69);
        System.out.println(p);
    }
}

可以看到,攻击者轻而易举地使用额外的两个字段将要攻击的域的引用保存下来,并随后改变了它们的值,不可变的类变成了可以改变的类。 防止这种情况发生只能使用下面的方法:

    // readObject method with defensive copying and validity checking - Page 306
    // This will defend against BogusPeriod and MutablePeriod attacks.
    private void readObject(ObjectInputStream s)
    throws IOException, ClassNotFoundException {
        s.defaultReadObject();

        // Defensively copy our mutable components
        start = new Date(start.getTime());
        end = new Date(end.getTime());

        // Check that our invariants are satisfied
        if (start.compareTo(end) > 0)
            throw new InvalidObjectException(start +" after "+ end);
    }

即针对每个内部域,使用保护性拷贝。注意一定要在有效性验证之前做这个事情。否则有效性验证可能失效。另外由于保护性拷贝的缘故,无法使实例域声明为final的,这也是无奈之举,后面要介绍的序列化代理类可以解决这个问题。 另外要注意的是,不要使用ObjectOutputStream中的writeUnsharedreadUnshared方法,书中说这两个方法并不安全,有漏洞,只要进行一些复杂的攻击,就可以搞定它们。 不要在readObject方法中直接或者间接调用任何可被覆盖的方法,那也是不安全的。

第七十七条 对于实例控制(单例模式),枚举优先于readResolve