发掘Java Serialization API中的秘密
作者 Todd Greanier
翻译 Yurii
原文地址 http://java.sun.com/developer/technicalArticles/Programming/serialization/
转载请注明来源 http://www.luanxiang.org/tech/archives/43.html
我们都知道,在Java中,我们能在内存里创建可重用的对象。然而,这些对象只有在Java虚拟机运行时才能存在。如果我们创建的对象能摆脱虚拟机的限制就好了,对吗?对了,依靠对象的序列化(serialization),你就能把对象固化(flatten),用各种神奇的方式重用。
序列化一个对象,就是把这个对象的状态存储到一系列字节中的过程,将来可以从这些字节还原出这个对象。Java Serialization API为处理对象序列化的开发人员提供了一套标准机制。如果你理解了这些类和方法,就会发现API并不复杂,而且方便使用。
本文介绍如何持久化自己的Java对象,从基础开始,前进到更高级的概念。我们会学到不同的序列化方法——使用缺省协议,定制缺省协议,编制自己的协议——我们会详细检查从对象缓存、版本控制以及性能因素等等持久化方案中产生的问题。
看完本文,你会清晰认识这套强大的Java API,尽管你之前可能并没什么了解。
开始:缺省的序列化机制
从基础开始。要序列化一个Java对象,我们必须有一个能持久的对象。如果一个对象实现了java.io.Serializable接口,它就是可以序列化的;这个接口向下层API说明,此对象可以转换为一系列字节固化下来(flatten),将来再从中取出。
下面这个类能够持久化,我们用它来说明序列化机制
10 import java.io.Serializable;
20 import java.util.Date;
30 import java.util.Calendar;
40 public class PersistentTime implements Serializable
50 {
60 private Date time;
70
80 public PersistentTime()
90 {
100 time = Calendar.getInstance().getTime();
110 }
120
130 public Date getTime()
140 {
150 return time;
160 }
170 }
我们看到,与通常使用的类的唯一区别就在于第40行实现了java.io.Serializable接口。Serializable接口仅仅是是一个用于标记的接口,不需要执行任何方法——序列化机制依靠它来验证类是否能持久化。这样,我们得到了序列化的第一条规则:
规则1:欲持久化的对象必须实现Serializable接口,或者继承实现了此接口的类。
下一步就是真正来序列化这个对象了。它是由java.io.ObjectOutputStream这个类完成的。这个类是一个过滤流——它是包装好的低端字节流(也就是节点流node stream),为我们处理序列化协议。节点流可以写到文件,也可以通过Socket传输。也就是说,我们可以很容易地把固化的对象通过网络传输,在网络的另一端恢复出来。
来看看存储PersistentTime对象的代码:
10 import java.io.ObjectOutputStream;
20 import java.io.FileOutputStream;
30 import java.io.IOException;
40 public class FlattenTime
50 {
60 public static void main(String [] args)
70 {
80 String filename = “time.ser”;
90 if(args.length > 0)
100 {
110 filename = args[0];
120 }
130 PersistentTime time = new PersistentTime();
140 FileOutputStream fos = null;
150 ObjectOutputStream out = null;
160 try
170 {
180 fos = new FileOutputStream(filename);
190 out = new ObjectOutputStream(fos);
200 out.writeObject(time);
210 out.close();
220 }
230 catch(IOException ex)
240 {
250 ex.printStackTrace();
260 }
270 }
280 }
真正的步骤是在第200行,我们调用了ObjectOutputStream.writeObject()方法,它启动了序列化机制,对象被固化了(存到了文件里)
下面的代码用来从文件中恢复对象:
10 import java.io.ObjectInputStream;
20 import java.io.FileInputStream;
30 import java.io.IOException;
40 import java.util.Calendar;
50 public class InflateTime
60 {
70 public static void main(String [] args)
80 {
90 String filename = “time.ser”;
100 if(args.length > 0)
110 {
120 filename = args[0];
130 }
140 PersistentTime time = null;
150 FileInputStream fis = null;
160 ObjectInputStream in = null;
170 try
180 {
190 fis = new FileInputStream(filename);
200 in = new ObjectInputStream(fis);
210 time = (PersistentTime)in.readObject();
220 in.close();
230 }
240 catch(IOException ex)
250 {
260 ex.printStackTrace();
270 }
280 catch(ClassNotFoundException ex)
290 {
300 ex.printStackTrace();
310 }
320 // print out restored time
330 System.out.println(”Flattened time: ” + time.getTime());
340 System.out.println();
350 // print out the current time
360 System.out.println(”Current time: ” + Calendar.getInstance().getTime());
370 }
380}
在上面的代码中,对象的重建是在第210行进行的,通过调用ObjectInputStream.readObject()方法。它读入我们之前序列化时存储的一系列字节,重建与之前一样的对象。因为readObject()可以读取任何序列化的对象,所以必须进行类型转换。因此,进行重建的系统必须能够访问到类的文件。也就是说,对象所属的类的文件,以及对象的方法,都没有保存下来,保存的只是对象的状态。
之后,在第360行,我们调用了getTime()方法来得到被序列化对象被固化的时间。对比两个时间,就可以证明序列化机制确实如期望的工作了。
不可序列化的对象
Java序列化的基本机制很容易使用的,但是只了解这一点是不够的。上面提到过,只有标记了Serializable的对象才可以持久化。java.lang.Object对象没有实现这个接口。所以,并不是所有的Java对象都可以自动序列化的。好消息是,大多数对象——譬如AWT和Swing GUI组件,字符串和数组——都可以序列化。
另一方面,诸如Thread、OutputStream及其子类和Socket等系统级别的类,是无法序列化的。实际上,序列化它们也没有意义。例如,在这台电脑的JVM中运行的线程,会使用这台电脑的内存。把它持久化,再到另一个JVM中运行,毫无意义。关于java.lang.Object没有实现Serializable接口的另一点重要的是,你所创建的任何仅仅继承Object(而不是其它可序列化的类)的类,都是不能序列化的,除非你自己(就像上面的例子一样)实现这个接口。
这样就产生了一个问题:如果我们的类包含Thread实例怎么办?我们能不能持久化这样的对象?答案是肯定的,前提是,我们把自己的意图明确告诉序列化机制,把这个类的Thread对象标记为临时的(transient)。
假设我们要创建表征动物的类。下面省略了关于动物的代码,只给出了我们使用的类:
10 import java.io.Serializable;
20 public class PersistentAnimation implements Serializable, Runnable
30 {
40 transient private Thread animator;
50 private int animationSpeed;
60 public PersistentAnimation(int animationSpeed)
70 {
80 this.animationSpeed = animationSpeed;
90 animator = new Thread(this);
100 animator.start();
110 }
120 public void run()
130 {
140 while(true)
150 {
160 // do animation here
170 }
180 }
190 }
创建PersistentAnimation类的实例,同时也就创立animator线程(Yurii注:这种方法是不值得推荐的,具体请参考Java Concurrency In Practice一书),线程会如预想的工作。在第40行,我们标记了这个线程,告诉序列化机制,这个字段(field)不应该与对象的其它字段(此处是animationSpeed)同时保存下来。底线是:任何字段,如果不能序列化,或者不希望序列化,都应该标记为transient。序列化不关心private之类的访问限定——只要字段没有标记为transient,就会作为对象的持久状态,都会被持久化。
因此,我们得到另一条规则。下面是关于持久化对象的两条规则:
* 规则1:要持久化的对象必须实现Serializable接口,或者继承实现了此接口的类
* 规则2:要持久化的类必须把所有不用序列化的字段标记为transient。
定制缺省协议
现在来看进行序列化的第二种办法:定制缺省协议。尽管上面的代码说明了怎样序列化包含线程的对象,但我们重建这个对象时,仍然会遇到问题。要记住,如果我们创建了一个新的对象,对象的构造函数只有在这个类的实例创建时调用。记住了这一点,我们再来看看animation的代码。首先,我们创建一个PersistentAnimation对象,它启动了animation线程。然后,我们序列化这个对象:
PersistentAnimation animation = new PersistentAnimation(10);
FileOutputStream fos = …
ObjectOutputStream out = new ObjectOutputStream(fos);
out.writeObject(animation);
看来没问题,直到我们调用readObject()方法重建这个对象为止。记得吗?只有创建新的实例,才会调用构造函数。这里并没有创建新的实例,只是恢复之前持久化的对象。这样,animation对象只在第一次创建的时候运行了。这样看来,持久化似乎没什么用,对吗?
嘿,不过有好消息。我们有办法让对象照期望的办法来工作:我们可以让animation在对象恢复之后重新启动。这样,我们可以创建一个辅助方法startAnimation()来完成现有构造函数的工作。我们可以在构造函数中调用这个方法,也可以在读入对象之后调用。这不错,但是把事情搞复杂了。现在,任何希望使用animation对象的人都必须记得,必须在正常的反序列化(deserialization)过程之后调用这个方法。Java Serialization API希望给开发人员提供无缝的方案,但这个办法做不到。
不过,这里倒是有个不错的办法。通过使用序列化机制内建的特性,开发人员必须在类文件内部提供两个办法,为普通的序列化过程中多加一些处理。这两个方法是:
* private void writeObject(ObjectOutputStream out) throws IOException;
* private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException;
请注意,因为两个方法不是继承(inherited),也不是重用(overridden),也不是重载(overloaded),(而且必须)被声明为private。奥妙就在于,虚拟机在调用相应的方法时,会自动检查是否声明了这两个方法。只要需要,虚拟机就能调用private方法,哪怕其它对象都不能。这样就保存了类的完整性,序列化协议也可以正常工作。序列化协议通常就是用这种办法工作的,调用ObjectOutputStream.writeObject()或者是ObjectInputStream.readObject()。因而,即使某个类提供了这些特殊的专用的private方法,其对象的序列化也和其它普通对象的序列化没什么不同。
综上,我们来看看改进的PersistentAnimation,它包含了这两个方法,容许我们控制反序列化(deserialization)过程,下面是构造函数伪代码:
10 import java.io.Serializable;
20 public class PersistentAnimation implements Serializable, Runnable
30 {
40 transient private Thread animator;
50 private int animationSpeed;
60 public PersistentAnimation(int animationSpeed)
70 {
80 this.animationSpeed = animationSpeed;
90 startAnimation();
100 }
110 public void run()
120 {
130 while(true)
140 {
150 // do animation here
160 }
170 }
180 private void writeObject(ObjectOutputStream out) throws IOException
190 {
200 out.defaultWriteObject();
220 }
230 private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
240 {
250 // our “pseudo-constructor”
260 in.defaultReadObject();
270 // now we are a “live” object again, so let’s run rebuild and start
280 startAnimation();
290
300 }
310 private void startAnimation()
320 {
330 animator = new Thread(this);
340 animator.start();
350 }
360 }
请注意两个新的private方法的第一行。它们的任务正如它们的名字——对固化的字段执行缺省的读写操作,这两点很重要,因为我们没有替换缺省的程序,只是向其中增添了内容。调用ObjectOutputStream.writeObject()就会启动缺省的序列化协议。首先检查对象,确保它实现了Serializable接口,然后检查是否提供了这些缺省的private方法。如果是,stream类就被作为参数传递过去,根据用途,交出代码的控制权。
这些private方法可以用来定制在序列化过程中进行任何需要的步骤。加密应该加到输出中,而解密应该加到输入(请注意,字节是直接读写的,没有进行任何处理)。也可以用来向流中加入其它的数据,可能是公司的验证代码。这里具备无限的可能。
停止这样的序列化!
OK,关于序列化过程,我们已经了解了不少,现在再多看一点。如果你希望创建这样的类:父类可以序列化,但是子类不能序列化?你不能取消对某个接口的实现,所以如果父类实现了Serializable接口,子类就会实现(假设满足了上面两条规则)。
10 private void writeObject(ObjectOutputStream out) throws IOException
20 {
30 throw new NotSerializableException(”Not today!”);
40 }
50 private void readObject(ObjectInputStream in) throws IOException
60 {
70 throw new NotSerializableException(”Not today!”);
80 }
读写这个对象的任何尝试,都会抛出异常。请记住,因为这些方法是private的,除非能接触到源代码,否则没法修改它们——Java不容许重写(override)这些方法。
创建自己的协议:Externalizable接口
如果错过了序列化的第三个选项,我们的讨论就不够完整:通过Externalizable接口创建自己的协议。我们可以实现Externalizable,而不是Serializable,Externalizable包含两个方法:
* public void writeExternal(ObjectOutput out) throws IOException;
* public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
重写这两个方法就可以提供自己的协议。它与前面看到的序列化做法不同,这里一切都得自己来。也就是说,整个协议都得自己动手。尽管这办法更麻烦,但也是操控程度最高的办法。举个这样序列化的例子:通过Java程序读写PDF文件。如果你知道该怎么读写PDF(也就是那一系列字节),就可以通过writeExternal和readExternal方法,提供专门针对PDF的协议。
和以前一样,无论一个类是如何实现Externalizable的,使用上都没有差别。只需要调用writeObject()和readObject(),然后,就会自动调用这些方法了。
陷阱
不熟悉序列化机制的开发人员可能觉得有些问题非常奇怪。当然,本文就是为此而作——让你明白。所以,我们来看看这些陷阱,看我们是否能明白,它们为什么会出现,如何解决。
在流中缓存对象
首先来看这样的情况,把对象写如流中,然后再写一次。缺省情况下,ObjectOutputStream会保存写入它的对象的引用。也就是说,如果写入对象的状态被多次写入,新的状态就不会保存!下面的代码说明了这个问题:
10 ObjectOutputStream out = new ObjectOutputStream(…);
20 MyObject obj = new MyObject(); // must be Serializable
30 obj.setState(100);
40 out.writeObject(obj); // saves object with state = 100
50 obj.setState(200);
60 out.writeObject(obj); // does not save new object state
有两种办法控制这个情况。第一,你可以确定,在一个写入调用之后关闭整个流,确保新的对象每次只写入一次。第二,你可以调用ObjectOutputStream.reset()方法,它会告诉流清理其所保存的对引用的缓存,这样所有的新的写入调用都会实际写入进去。但也要小心,reset会刷新整个object的缓存,因此所有写入的对象都可以重新写入。
版本控制
现在来看第二个陷阱,设想,你创建了一个类,实例化了,然后写入到对象流。固化的对象在文件系统中保存了一段时间。之后,你更新了类文件,可能增加了新的字段。这时候再读入之前固化的对象,会发生什么?
坏消息是,会抛出异常——具体说就是java.io.InvalidClassException——因为所有能够持久化的类都自动赋予了一个独特的标识符。如果标识符不等于固化对象的标识符,就会抛出异常。但是,如果你真正思考这个问题,我仅仅加了一个字段,为什么就要抛出异常?这个字段不能被设定为缺省值,下次序列化时再写入吗?
确实,我们可以做到,但是需要一点代码来实现。所有类的标识符,都保存在一个叫serialVersionUID的字段中。如果你希望控制版本,你就可以自己提供serialVersionUID字段,确保你修改类文件时它不会发生变化。你可以使用JDK发布版带的一个工具来检查缺省的值(缺省情况下,就是object的hash值)。
下面的例子说明了使用serialver来查看Baz类的缺省值。
> serialver Baz
> Baz: static final long serialVersionUID = 10275539472837495L;
把返回的数值拷贝出来,贴到自己的代码里(在Windows窗口里,你可以用–show参数运行这个工具,简化拷贝-粘贴流程)现在,如果你对Baz的类文件进行了任何修改,只要确保version ID没有发生变化,程序就可以正常运行。
只要对源代码的更改保证了兼容性,版本控制就能带来巨大的好处。兼容的修改包括添加或去除方法或者字段。不兼容的改变包含更改对象的层级结构,或者是去掉对Serializable接口的实现。在Java Serialization Specification中,给出了兼容和不兼容更改的完整列表。
性能问题
第三个陷阱:缺省的机制尽管易于使用,性能却不是最好的。我把Date对象写入到一个文件1000次,重复这个过程100次。写入一个Date对象的平均时间是115ms。然后我手动输出Date对象,通过标准的I/O,运行同样多的次数,平均时间是52ms。几乎节省了一半的时间!通常,会在易用性和性能之间进行权衡,序列化也不例外。如果速度是你的应用程序的主要目标,你可能希望自己定制一个协议。
另一个考虑是关于之前提到的事实,在输出流中缓存的对象引用。这样,如果流没有关闭,系统可能不能对写入流的对象进行垃圾收集。故而,最好的办法仍然与其它I/O操作一样,就是在流操作完成之后,尽快关闭这些流。
结论
Java中的序列化很有吸引力,而且同样易于使用。了解实现序列化的三种不同方式,有助于按照自己的意愿整理API。在本文中,我们看到了许多序列化机制,我希望它有助于澄清事实,而不是相反。给出所有代码的底线,就是在熟悉API的前提下维持常识。本文为理解Java序列化API提供了坚实的基础,不过我仍然推荐读者细读规范,发现更多详尽的细节。