89. 对于实例控制,枚举类型优于 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
的。
No Comments