首页 > 技术文章 > Effective java笔记(十),序列化

wangyingli 2016-09-24 18:24 原文

将一个对象编码成字节流称作将该对象「序列化」。相反,从字节流编码中重新构建对象被称作「反序列化」。一旦对象被「序列化」后,它的编码就可以从一台虚拟机传递到另一台虚拟机,或被存储到磁盘上,供以后「反序列化」使用。序列化技术为JavaBean组件结构提供了标准的持久化数据格式。

74、谨慎的实现Serializable接口

一个类实现Serializable接口需要付出的代价:

  • 一旦一个类被发布,就大大降低了「改变这个类的实现」的灵活性。若一个类实现了Serializable接口,它就成了这个类导出API的一部分。
  • 增加了出现Bug和安全漏洞的可能性。序列化机制是一种语言之外的对象创建机制,反序列化是一个「隐藏的构造器」,具备与其他构造器相同的特点。因此,反序列化过程必须要保证所有的约束关系。
  • 随着发行新的版本,相关的测试负担也增加了。

每个可序列化的类都有一个唯一的名为serialVersionUID的标识号与它相关联。若类在私有的静态final的long域中没有显式的指定这个标识号,系统就会自动的为该类产生一个标识号,这时类的兼容性将会遭到破坏,在运行时导致InvalidClassException异常。

为了继承而设计的类应该尽可能少的去实现Serializable接口,用户自定义的接口也应该尽可能少的继承Serializable接口。例外,Throwable、Component和HttpServlet抽象类。

内部类不应该实现Serializable即可。静态成员类可以实现Serializable接口。

75、考虑使用自定义的序列化形式

对于一个对象来说,理想的序列化形式应该只包含该对象所表示的逻辑数据,而逻辑数据与物理表示法(存储结构)应该是独立的。如果一个对象的物理表示法等同于它的逻辑内容,就适用于使用默认的序列化形式。如:

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;

	private final String middleName;

	....
}

在这段代码中,Name类的实例域精确的反应了它的逻辑内容,可以使用默认的序列化形式。注意:虽然lastName、firstName和middleName域是私有的,但它们仍然需要有注释文档。因为,这些私有域定义了一个公有的API,即这个类的序列化形式。@serial标签用来告知Javadoc工具,把这些文档信息放在有关序列化形式的特殊文档页中。

当一个对象的物理表示法与它的逻辑内容之间有实质性的不同时,使用默认序列化形式有如下缺点:

  • 它将这个类的导出API永远束缚在了该类的内部表示法上。如,私有内部类变成公有API的一部分。
  • 会消耗过多的空间和时间
  • 会引起栈溢出
  • 其约束关系可能遭到严重破坏,如散列表

如:

//默认序列化形式
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;
	}
	....
}

自定义序列化:


public final class StringList implements Serializable {

	private static final long serialVersionUID = ...;
	private transient int size = 0; //不会被序列化
	private transient Entry head = null;

	private static class Entry {
		String data;
		Entry next;
		Entry previous;
	}
	
	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);
		for(Entry e = head; e != null; e = e.next ) {
			s.writeObject(e.data);
		}
	}

	private void readObject(ObjectInputStream s) 
		throws IOException, ClassNotFoundException {
		s.defaultReadObject();
		int num = s.readInt();
		for(int i=0; i < num; i++) {
			add((String)s.readObject());
		}
	}
	.....
}

注意:尽管StringList的所有域都是transient,但writeObject和readObject的首要任务仍是调用defaultXxxObject方法,这样可以极大的增强灵活性。另外尽管writeObject是私有的,仍然需要文档注释。

无论自定义序列化还是默认序列化,对于一个线程安全的对象,必须在序列化方法上强制同步。如:

private synchronized void writeObject(ObjectOutputStream s) 
	throws IOException {
	s.defaultWriteObject();
}

总之,当要将一个类序列化时,应该仔细考虑采用默认序列化还是自定义序列化。选择错误的序列化形式对于一个类的复杂性和性能都会有永久的负面影响。

76、保护性编写readObject方法

readObject方法实际上相当于一个公有的构造器,如同其它构造器一样,readObject方法必须检查其参数的有效性,并且在必要的时候进行保护性拷贝。readObject是一个「用字节流作为唯一参数」的构造器,当面对一个人工仿造的字节流时,readObject产生的对象可能会违反它所属类的约束条件,所以必须在readObject中增加约束性检查,若有效性检查失败,抛出InvalidObjectException异常。如:

public final class Period {
	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 getStart() {
		return new Date(start.getTime());
	}

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

	//反序列化时增加约束条件
	private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
		s.defaultReadObject();
		if(start.compareTo(end) > 0) {
			throw new InvalidObjectException(start + " after " + end);
		}
	}
}

在这段代码中,尽管readObject中增加了有效性检查,但通过伪造字节流创建可变的Period实例仍是可能的。做法是:字节流以Period实例开头,然后附加上两个额外的引用执行Period实例中两个私有的Date域。攻击者从ObjectInputStream中读取Period实例,然后读取其后的「恶意引用」,通过这个引用攻击者就可以修改Period中私有的Date域。如:

ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
Period period = new Period(new Date(), new Date());
out.writeObject(period);
byte[] ref = {0x71, 0, 0x7e, 0, 5}; //指向period中私有域start的字节
bos.write(ref);
ref[4] = 4;	//指向period中私有域end的字节
bos.write(ref);

//反序列化
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
Period period1 = (Period)in.readObject();
//ref1指向period1中私有域start指向的对象,可通过这个引用修改不可变对象
Date ref1 = (Date)in.readObject(); 
Date ref2 = (Date)in.readObject();

因此,对于每个可序列化的不可变类,若它包含了私有的可变组件(对象的引用),那么在它的readObject方法中,必须对这些组件进行保护性拷贝。否则,它内部的约束条件可能遭受破坏。如:

private void readObject(ObjectInputStream s) {
	s.defaultReadObject();
	start = new Date(start.getTime());
	end = new Date(end.getTime());
	if(start.compareTo(end) > 0) {
		throw new InvalidObjectException(start + " after " + end);
	}
}

注意:final域必须在对象构造时初始化,为了使用readObject方法,必须将start和end域做成非final的。

编写readObject方法的指导原则:

  • 对于对象引用域必须保持为私有的类,要保护性的拷贝这些域中的每个对象。
  • 对于任何约束条件,若检查失败,则抛出一个InvalidObjectException异常。检查应在保护性拷贝之后。
  • 无论直接方式还是间接方式,都不要调用类中任何可被覆盖的方法,否则反序列时可能会失败。

77、对于实例控制,枚举类型优先于readResolve

public class Elvis implements Serializable {
	public static final Elvis INSTANCE = new Elvis();
	private Elvis() { } 
	....
}

如上所示,若这个Singleton类的声明上加上「implements Serializable」,它就不再是一个Singleton。无论该类使用的是默认的序列化形式还是自定义的序列化形式。因为任何一个readObject方法,它都会返回一个新建的实例

对于一个正在被反序列化的对象,若它的类定义了一个readResolve方法,那么在反序列化后,新建对象上的readResolve方法就会被调用。然后该方法返回的对象引用将被返回,取代新建的对象,而新建的对象将被垃圾回收。

public class Elvis implements Serializable {
	public static final transient Elvis INSTANCE = new Elvis();
	private Elvis() { } 
	....

	private Object readResolve() {
		//Return the one true Elvis
		return INSTANCE;
	}
}

该方法忽略了被反序列化的对象,只返回该类初始化时创建的Elvis实例。若依赖readResolve进行实例控制,带有对象引用类型的所有实例域则都必须声明为transient的。,否则能被人工仿造的字节流攻击。静态成员不属于对象,不参与序列化。

通过将一个可序列化的实例受控的类编写成枚举,可以绝对保证除了所声明的常量外,不会有别的实例。如:

public enum Elvis {
	INSTANCE;
	....
}

另外,readResolve的可访问性很重要。若把readResolve方法放在一个final类上,它就应该是私有的。若readResolve方法是受保护的或共有的,并且子类没有覆盖它,对序列化过的子类实例进行反序列化,就会产生一个超类实例,这可能导致ClassCastException异常。

总之,应该尽可能的使用枚举类型来实施实例控制的约束条件,若做不到,就必须提供一个readResolve方法,并将引用类型的域声明为transient的。

78、考虑用序列化代理代替序列化实例

序列化代理模式能够极大的减少实现Serializable接口所带来的风险。

实现序列化代理模式的步骤:

  • 首先为可序列化的类设计一个私有的静态嵌套类,精确的表示外围类实例的逻辑状态。它有一个单独的构造器,其参数类型为外围类。外围类及其序列化代理都必须实现Serializable接口。
  • 将writeReplace方法添加到外围类中。
  • 在SerializableProxy类中提供readResolve方法,它返回逻辑上相等的外围类的实例。

如:

//外围类不需要serialVersionUID
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 getStart() {
		return new Date(start.getTime());
	}

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

	//在序列化之前,将外围类的实例转变成它的序列化代理
	private Object writeReplace(){
		return new SerializationProxy(this);
	}

	//防止被攻击者使用
	private void readObject(ObjectInputStream stream) 
		throws InvalidObjectException{
		throw new InvalidObjectException("Proxy required");
	}

	private static class SerializationProxy implements Serializable {
		private static final long serialVersionUID = ...;
		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);
		}
	}
}

正如保护性拷贝一样,序列化代理可以阻止伪字节流的攻击及内部域的盗用攻击。与使用保护性拷贝不同,使用序列化代理允许Period的域为final的,这可以保证Period类真正不可变。序列化代理模式更容易实现,它不必考虑哪些域会被序列化攻击,也不必显示的执行有效性检查。

序列化代理的局限性:不能与可以被客户端扩展的类兼容,也不能与对象图中包含循环的类兼容,比保护性拷贝性能低。

推荐阅读