十、序列化
1. 谨慎地使用Serializable接口
使一个类的实例可被序列化,只需要让它实现Serializable接口即可。因为简单,程序员普遍对序列化存在误解,以为不需要做什么工作就可以实现序列化,实际的情形却复杂的多。
实现Serializable的代价有:
(1)一旦一个类被发布,就大大降低了改变这个类的实现的灵活性
一个类实现了Serializable接口,它的字节流编码就变成了导出API的一部分。一旦这个类被广泛使用,就必须永远支持这种序列化形式。如果不认真设计一种自定义的序列化形式,而仅仅接受默认的序列化形式,这种序列化形式将被永远地束缚在该类最初的内部表示法上。
(2)它增加了出现bug和安全漏洞的可能性
通常是用构造器来构建对象的,序列化机制提供一种语言之外的对象创建机制,反序列化机制是一个隐藏的构造器,具备与其他构造器相同的特点。默认的反序列化机制很容易使对象的约束关系遭到破坏,也易遭受非法访问。
(3)随着类发行新的版本,相关的测试负担也增加了
一旦发布,需要确保各版本的兼容性
每当实现一个类的时候,都需要权衡一下实现序列化所付出的代价和带来的好处。
内部类不应该实现Serializable。
静态成员类可以实现Serializable。
如果一个类是为了继承而设计的,需要倍加留意子类序列化的需求。对于为继承而设计的不可序列化的类,应该考虑提供一个无参构造器,否则子类就无法进行序列化。
2. 考虑使用自定义的序列化形式
如果没有认真考虑默认的序列化形式是否合适,就不要贸然接受。接受默认的序列化形式是一个非常重要的决定,需要从灵活性、性能和正确性多个角度对这种编码形式进行考察。一般来讲,只有当你自行设计的自定义序列化形式与默认的序列化形式基本相同时,才能接受默认的序列化形式。
public class Name implements Serializable {
private final String lastName;
private final String firstName;
private final String middleName;
...
}
即便是确定了默认的序列化形式是合适的,通常还必须提供一个readObject方法以保证约束关系和安全性。对于Name这个类而言,readObject方法必须保证lastName和firstName是非null的。
当一个对象的物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式有以下4个缺点:
(1)它使这个类的导出API永远地束缚在该类的内部表示法上。
序列化字节流也是导出API的一部分,也代表一种约定。
(2)它会消耗过多的空间。
例如链表的序列化,不仅表示链表中的每个项,还要表示所有的连接关系,这是不必要的。
(3)它会消耗过多的时间。
序列化逻辑并不了解对象图的拓扑结构,所以它必须要经过一个昂贵的图遍历过程。
(4)它会引起栈溢出。
默认的序列化过程要对对象图执行一次递归遍历,即使对于中等规模的对象图,这样的操作也可能会引起栈溢出。
选择错误的序列化形式对于一个类的复杂性和性能都会有永久的负面影响。
3. 保护性地编写readObject方法
readObject相当于另一个公有的构造器,如同其他的构造器一样,它也要求注意同样的所有注意事项:必须检查参数的有效性,必要的时候对参数进行保护性拷贝。如果readObject无法做到这两点,就易遭受攻击。
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 (IOException | ClassNotFoundException 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 方法可以确保 Period 类的约束条件不会遭到破坏,以保持它的不可变性:
// readObject method with defensive copying and validity checking
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);
}
编写健壮readObject方法的指导方针:
(1)对于对象引用域必须保持为私有的类,要保护性地拷贝这些域中的每个对象。不可变类的可变组件就属于这一类别。
(2)对于任何约束条件,如果检查失败,则抛出一个InvalidObjectException异常。这些检查应该跟在所有的保护性拷贝之后。
(3)如果整个对象图在被反序列化之后必须进行验证,就应该使用ObjectInputValidation接口。
(4)无论是直接方式还是间接方式,都不要调用类中任何可覆盖的方法。
4. 对于实例控制,枚举类型优先于readResolve
如果单例类的声明加上了“implements Serializable”的字样,他就不再是一个单例。
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
不管是采用默认的序列化方式,还是自定义的序列化方式,都相当于对外部提供了一个公有的构造器。对于可序列化的实例受控类(如可序列化的单例类),readResolve方法提供了一种控制实例构建的方法。
readResolve方法允许你用readObject创建的实例代替另一个实例。对于一个正在被反序列化的对象,如果它的类定义了一个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;
}
如果依赖readResolve进行实例控制,带有对象引用的所有实例域都必须声明为transient的,否则就可能会被攻击。
如有你将你的可序列化的实例受控(instance-controlled)类编写成枚举,Java就可以保证除了所声明的常量之外,不会有别的实例。
// 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方法,并确保该类的所有实例域都为基本类型,或者是transient的。
5. 考虑用序列化代理代替序列化实例
实现Serializable接口,会增加出错和出现安全问题的可能性,因为它是利用语言之外的机制来创建对象的,而不是使用普通的构造器。序列化代理模式可以极大地减少上述风险的发生。
实现序列化代理模式的方法:
- 为可序列化的类设计一个私有的静态嵌套类,精确地表示外围类实例的逻辑状态。
- 将writeReplace方法添加到外围类中。
- 在外围类中添加如下readObject方法。
- 在代理类中提供一个readResolve方法,它返回一个逻辑上相当的外围类实例。
public final class Period implements Serializable {
private final Date start;
private final Date end;
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());
}
private void readObject(ObjectInputStream in) throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}
//将外围类的实例转变成了它的序列化代理
private Object writeReplace() {
return new SerializationProxy(this);
}
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private Object readResolve() {
return new Period(start, end);
}
private static final long serialVersionUID = 234098243823485285L;
}
}