![[Effective Java]第十一章 序列化 [Effective Java]第十一章 序列化](https://image.shishitao.com:8440/aHR0cDovL2ltYWdlcy5jbmJsb2dzLmNvbS9jbmJsb2dzX2NvbS9qaWFuZ3poZW5nanVuLzY1NTYxNC9vXyVFNyVCQiVCRjIuZ2lm.gif?w=700&webp=1)
第十一章 序列化
74、 谨慎地实现Serializable接口
实现Serializable接口而付出的最大代价是,一旦一个类被发布,就大降低了“改变这个类的实现”的灵活性。如采用默认的序列化方式时(仅实现Serializable),且没有在一个名为serialVersionUID的私有静态final的long域显示地指定该标识号,则如果类改变了,则会导致不兼容。
实现Serializable的第二个代价是,它增加了出现Bug和安全漏洞的可能性。反序列化过程不是调用原有的构造器,所以你很容易忘记要确保:反序列化过程必须也要保证所有“由真正的构造器建立起来约束关系”,并且不允许攻击者访问正在构造过程上的对象的内部信息,依靠默认的反序列化机制,很容易对象的约束关系遭到破坏,以及遭受非法访问(第76条)。
实现Serializable的第三个代价是,随着类发行新的版本,相关的测试负担也增加了。
实现Serializable接口并不是一个很轻松就可以做出的决定。根据经验,如Data和BigInteger这样的的值类应该实现Serializable,大多数的集合类也是应该如此。代表活动实体的类,如线程池,一般不应该实现Serializable。
为了继承而设计的类,应该尽可能少地去实现Serializable接口,用户的接口也应该尽可能少地继承Serializable接口。如果违反了这条规则,扩展这个类或者实现这个接口的程序员就会背上沉重的负担。然后在有些情况下是合适的,如,这个类或接口主要是为了参与到某个框架中,而该框架要求所有参与者都实现Serializable接口,则实现Serializable是有意义的。
对于为继承而设计的不可序列化的类,你应该考虑提供一个无参构造器,因为他的子类可能是可序列化的,一旦子类实现Serializable接口,则在反序列化时会调用调用父类的无参构造器。最好在所有约束关系都已经建立的情况下再创建对象。下面是一个父类不可序列化,而子类可序列化的建议做法:
与78条详细地讨论这个问题。
注,虽然lastName、firstName和middleInitial域是私有的,但它们依然有相应的文档注释。这是因为,这些私有域定义了一个公有的API,即这个类的序列化形式,并且该公有的API必须建立文档。
下面与Name不同,它是一个极端例子,该类表示了一个字符串列表:
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;
}
... // Remainder omitted
}
从逻辑上讲,它表示一个字符序列,但从物理上看,它把字符串序列表示成了双向链表。如果你采用默认的序列化,它会将链表中的所有项都序列化。
当一个对象的物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下缺点:
1、 它使这个类的导出API永远地束缚在该类的内部表示法上。上面例子中私有的StringList.Entry类变成了公有API的一部分。如果将来版本中内部表示法变化了,StringList仍将需接受链表形式的输入,并产生链表的输出。这个类永远也摆脱不掉维护链表项所需要的代码,即使不再使用链作为内部数据结构了,也仍需要这些代码。因为原来已序列化的二进对象在默认恢复过程中与当前版本类不兼容而导致反序列化失败,比如当前版本中少了某个域。
2、 它会消耗过多的空间与时间。上面的链表中的项的next、previouse是链表的实现实节,不用关心链接的物理信息,在恢复时是可以构造这些关系,不需要将它们一起序列化。
3、 它会引起栈溢出。默认的序列化过程要对对象图进行一次递归遍历,如果对象图层次很深的话很易容就会引起栈的溢出。
对于上面的StringList,我们只需要对size,与date逻辑数据进行序列化即可:
,布尔false。如果这些值不是你期望的,则需要在readObject方法中重新手动初始化。
无论你是否使用默认的序列化形式,如果在读取对象任何状态的地方使用到了同步,则也必须在对象序列化上强制这种同步。如果你使用的是默认序列化方式,也得要这样做:
条中的不可变Period日期范围类做成可序列化的,你可能认为使用默认序列化方式较合适,因为Period的物理表示法正好是逻辑数据内容。但这样会有很大的安全隐患。问题在于,readObject方法实际上相当于另一个公有的构造器,如同其他的构造器一样,它也需要注意同样的所有注意事项:一是构造器必须检查其参数的有效性,二是在必要的时候对参数进行保护性拷贝(见39),同样的,readObject方法也需要这样做。如果readObject方法没有做到这两点,对于攻击都来说就很容易破坏这个类的约束条件了。
>>>伪字节流的攻击法<<<
因为readObject方法是字节流作为参数的,因此我们可以伪造这样的对象流后传递给它进行反序列化。假设现在Period类采用默认的序列化方式,下面这个程序将反序列化产生一个Period实例,而它的结束时间比开始时间要早:
)。同时原Period类中的start与end域为final类型是行不通了的,因为如果这样将不能进行拷贝。这是很遗憾的,但这还算相对好的做法,不过我们可以加上volatile关键字加强并发的可见性。经过上面修改后我们再次执行MutablePeriod类,结果正常:
Mon May 24 14:19:26 CST 2010 - Mon May 24 14:19:26 CST 2010
在1.4中,为了阻止恶意的对象引用攻击,同时节省保护性拷贝的开销,在ObjectOutputStream中增加了wirteUnshared和readUnshared方法。但遗憾的是,这些方法都很容易受到复杂的攻击,即本质上与第77条中所述ElvisStealer攻击相似的攻击。所以不要使用这两个方法,虽然它们通常比保护性拷贝更快,但是它们还是不很安全。
readObject其实就相当于公共的构造器,所以对于readObject额外要注意的是,不要在readObject方法中调用可能被覆盖的方法,因为这与在构造函数中调用被覆盖方法是一样错误的。
默认的readObject 方法是否可以被接受,我们只需做一个简单的测试:增加一个公有的构造器,其参数对就于该对象每个非transient的域,并且无论参数的值是什么,都是不进行检查就可以保存到相应域中,如果对就这个做法不赞同,就必须提供一个显示的readObject方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝,另一种方法是使用序列化代理模式(见79条)
77、 对于实例控制,枚举类型优先于readResolve
对于实现了Serializable接口的单实例类,只要反序列化就一定会产生一个不同于现VM中实例的新对象,这是肯定的。
readResolve方法允许你用另一个实例去替代readObject方法创建的实例,如果可序列化类中有readResolve的方法,则在readObject调用之后再调用readResolve方法,然后,readResolve方法返回的对象引用将被返回,取代新建的对象,这样readObject新建的对象不再被引用,立即成为垃圾回收的对象。具体做法如下:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
该方法忽略了被反序列化的对象,只返回该类被初始化时创建的那个特殊的Elvis实例,因此,Elvis实例的序列化形式并不需要包含任何实际的数据(因为真真反序列化得到的实例被readResolve方法给替换成了当前VM中正在运行的原有的单实例,所以单例模式在序列化成字节码流后对反序列化根本没有用,所以不需要将任何域序列化)。单例中的所有的实例域都应该被声明为transient,事实上,如果依赖readResolve进行实例控制,带有对象引用类型的所有实例域则都必须声明为transient的,否则,攻击者们可能使用readResolve方法被运行前,通过一个引用指向反序列化出来的对象,从而系统中会同时存在两个实例。基本原理就是非transient引用域会默认序列化,并且这个域的readResolve方法会在Singleton的readResolve方法前调用。当这个域反序列化时,就可以使用精心制作的该域的“反序列化替代对象”即“盗用者”来代替这个域默认的反序列化过程。以下是具体工作原理:先编写一个“盗用者”类,它既有readResolve方法,又有实例域,实例域指向被序列化的Singleton的实例,在序列化流中,用“盗用者”类的实例代替Singleton的非transient域。现在Singleton序列化流中包含“盗用者”类实例,而“盗用者”类实例则引用Singleton实例。当反序列化时这个“盗用者”类的readResolve会先于Singleton类的readResolve方法运行,因此,当“盗用者”的readResolve方法运行时,它的实例域是可以引用到被部分反序列化的Singleton实例,最后“盗用者”将引用到的Singleton实例赋值给“盗用者”的静态域,供外界引用,最终导致系统同时存在两个实例。具体做法请看下面几个类:
条)进行修正。自从1.5后使用readResolve就不是个好做法了。下面使用单一的枚举类型来修正:
中的方案:
条
条中的第二种攻击方法来进行攻击,这其实则不然,即使在外界修
条中的
中的问题,因为序
)不同的是,这种方案允许Period的域为final的,为了确保Period类真正不可变是必须的。
总之,每当你发现自己必须在一个不能被客户端扩展的类上编写readObject或者writeObject方法的时候,就应该考虑使用序列化代理模式,对具有约束条件的对象序列化是否不错的选择,但性能不如保护性拷贝(见76)。