Java 对于实例控制,枚举类型优于 readResolve详解

  第 3 条讲述了 Singletion(单例)模式,并且给出了以下这个 Singletion 示例。这个类限制了对其构造器的访问,以确保永远只创建一个实例。

public class Elvis {
    public static final Elvis INSTANCE = new Elvis();

    private Elvis() { ... }
    public void leaveTheBuilding() { ... }
}

  正如在第 3 条中所提到的,如果在这个类上面增加 implements Serializable 的字样,它就不是一个单例。无论该类使用了默认的序列化形式,还是自定义的序列化形式(详见 87 条),都没有关系;也跟它是否使用了显式的 readObject(详见 88 条)无关。任何一个 readObject 方法,不管是显式的还是默认的,都会返回一个新建的实例,这个新建的实例不同于类初始化时创建的实例。

  readResolve 特性允许你用 readObject 创建的实例代替另外一个实例[Serialization, 3.7]。对于一个正在被反序列化的对象,如果它的类定义了一个 readResolve 方法,并且具备正确的声明,那么在反序列化之后,新建对象上的 readResolve 方法就会被调用。然后,该方法返回的对象引用将会被回收,取代新建的对象。这个特性在绝大多数用法中,指向新建对象的引用不会再被保留,因此成为垃圾回收的对象。

  如果 Elvis 类要实现 Serializable 接口,下面的 readResolve 方法就足以保证它的单例属性:

// readResolve for instance control - you can do better!
private Object readResolve() {
    // Return the one true Elvis and let the garbage collector
    // take care of the Elvis impersonator.
    return INSTANCE;
}

  该方法忽略了被反序列化的对象,只返回类初始化创建的那个特殊的 Elvis 实例。因此 Elvis 实例的序列化形式不应该包含任何实际的数据;所有的实例字段都应该被声明为 transient。事实上,如果依赖 readResolve 进行实例控制,带有对象引用类型的所有实例字段都必须声明为 transient 否则,那种破釜沉舟式的攻击者,就有可能在 readResolve 方法运行之前,保护指向反序列化对象的引用,采用的方式类似于在第 88 条中提到的 MutablePeriod 攻击。

  这种攻击有点复杂,但是背后的思想十分简单。如果单例包含一个非 transient 的对象引用字段,这个字段的内容就可以在单例的 readResolve 方法之前被反序列化。当对象引用字段的内容被反序列化时,它就允许一个精心制作的流「盗用」指向最初被反序列化的单例对象引用。

  以下是它更详细的工作原理。首先编写一个「盗用者」类,它既有 readResolve 方法,又有实例字段,实例字段指向被序列化的单例的引用,「盗用者」就「潜伏」在其中。在序列化流中,用「盗用者」的 readResolve 方法运行时,它的实例字段仍然引用部分反序列化(并且还没有被解析)的 Singletion。

  「盗用者」的 readResolve 方法从它的实例字段中将引用复制到静态字段中,以便该引用可以在 readResolve 方法运行之后被访问到。然后这个方法为它所藏身的那个域返回一个正确的类型值。如果没有这么做,当序列化系统试着将「盗用者」引用保存到这个字段时,虚拟机就会抛出 ClassCastException

  为了更具体的说明这一点,我们以如下这个单例模式为例:

// Broken singleton - has nontransient object reference field!
public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { }

    private String[] favoriteSongs = 
        { "Hound Dog", "Heartbreak Hotel" };
    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }

    private Object readResolve() {
        return INSTANCE;
    }
}

  如下「盗用者」类,是根据以上描述构造的:

public class ElvisStealer implements Serializable {
    static Elvis impersonator;
    private static final long serialVersionUID = 0;
    private Elvis payload;

    private Object readResolve() {
        // Save a reference to the "unresolved" Elvis instance
        impersonator = payload;

        // Return object of correct type for favoriteSongs field
        return new String[] { "A Fool Such as I" };
    }
}

  下面是一个不完整的程序,它反序列化一个手工制作的流,为那个有缺陷的单例产生两个截然不同的实例。这个程序省略了反序列化方法,因为它与第 88 条一样。

public class ElvisImpersonator {
    // Byte stream couldn't have come from a real Elvis instance!
    private static final byte[] serializedForm = {
            (byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05, 0x45,
            0x6c, 0x76, 0x69, 0x73, (byte) 0x84, (byte) 0xe6, (byte) 0x93, 0x33,
            (byte) 0xc3, (byte) 0xf4, (byte) 0x8b, 0x32, 0x02, 0x00, 0x01, 0x4c,
            0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x53,
            0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61, 0x76,
            0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a, 0x65,
            0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45, 0x6c,
            0x76, 0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72, 0x00,
            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x4c,
            0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74, 0x00,
            0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b, 0x78, 0x70, 0x71,
            0x00, 0x7e, 0x00, 0x02
        };

    public static void main(String[] args) {
        // Initializes ElvisStealer.impersonator and returns
        // the real Elvis (which is Elvis.INSTANCE)
        Elvis elvis = (Elvis) deserialize(serializedForm);
        Elvis impersonator = ElvisStealer.impersonator;
        elvis.printFavorites();
        impersonator.printFavorites();
    }
}

  这个程序会产生如下的输出,最终证明可以创建两个截然不同的 Elvis 实例(包含两种不同的音乐品味):

[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]

  通过将 favoriteSongs 字段声明为 transient,可以修复这个问题,但是最好把 Elvis 做成一个单元素的枚举类型(详见第 3 条)。就如 ElvisStealer 攻击所示范的,用 readResolve 方法防止“临时”被反序列化的实例收到攻击者的访问,这种方法十分脆弱需要万分谨慎。

  如果将一个可序列化的实例受控的类编写为枚举,Java 就可以绝对保证除了所声明的常量之外,不会有其他实例,除非攻击者恶意的使用了享受特权的方法。如 AccessibleObject.setAccessible。能够做到这一点的任何一位攻击者,已经拥有了足够的特权来执行任意的本地代码,后果不堪设想。将 Elvis 写成枚举的例子如下所示:

// Enum singleton - the preferred approach
public enum Elvis {
    INSTANCE;

    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };

    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

  用 readResolve 进行实例控制并不过时。如果必须编写可序列化的实力受控的类,在编译时还不知道它的实例,你就无法将类表示成为一个枚举类型。

  readResolve 的可访问性(accessibility)十分重要。 如果把 readResolve 方法放在一个 final 类上面,它应该是私有的。如果把 readResolve 方法放在一个非 final 类上,就必须认真考虑它的的访问性。如果它是私有的,就不适用于任何一个子类。如果它是包级私有的,就适用于同一个包内的子类。如果它是受保护的或者是公开的,并且子类没有覆盖它,对序列化的子类进行反序列化,就会产生一个超类实例,这样可能会导致 ClassCastException 异常。

  总而言之,应该尽可能的使用枚举类型来实施实例控制的约束条件。如果做不到,同时又需要一个即可序列化又可以实例受控的类,就必须提供一个 readResolve 方法,并确保该类的所有实例化字段都被基本类型,或者是 transient 的。

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

Service Mesh实践指南 -〔周晶〕

Elasticsearch核心技术与实战 -〔阮一鸣〕

从0打造音视频直播系统 -〔李超〕

分布式技术原理与算法解析 -〔聂鹏程〕

后端技术面试 38 讲 -〔李智慧〕

架构实战案例解析 -〔王庆友〕

Web 3.0入局攻略 -〔郭大治〕

超级访谈:对话毕玄 -〔毕玄〕

PPT设计进阶 · 从基础操作到高级创意 -〔李金宝(Bobbie)〕