http://www.javaworld.com/jw-07-2000/jw-0714-flatten.html
平面化你的对象
揭开JAVA序列化API的神秘面纱
By Todd M. Greanier, JavaWorld.com, 07/14/00
译者:黑狗 Email:blackdog1987@gmail.com
译者注:全篇,序列化和反序列化的说法分别是flatten和inflate,不知道怎么翻译,我翻译成了“平面化”或者“解析“,从字面上,分别就是压扁和使膨胀…可意会不可言传…
这篇文章同样被oracle官方所收录,估计是因为他们的序列化讲得太少了,也说明了这篇文章的含金量…附上链接: http://java.sun.com/developer/technicalArticles/Programming/serialization/
我们知道JAVA允许我们在内存中创建可重复使用的对象。但是,他们的生命仅仅只和虚拟机运行时间一样长。如果我们创建的对象可以超越虚拟机的声明周期的话,定是极好,那么我们能办到么?当然!利用对象序列化你可以使你的对象平面化,这样你就能够用一些更强大的方式来重复使用他们。
对象序列化是一个保存对象的状态到一系列二进制中的过程,也是一个为了在将来重组这些二进制并将真正的信息放入生存着的对象的过程。JAVA序列化API提供给开发者处理对象序列化的标准机制。这个API提供了很简洁,也很易于使用的信息,使我们能够掌握类和方法的信息。
通过本篇文章,我们会循序渐进,用更加先进的概念来检查JAVA对象是如何持久化的。我们可以了解到三种不同的方式来进行序列持久化——使用默认协议,定制默认协议和创建我们自己的协议——我们也会研究一些伴随持久化方案而延展出的问题,例如对象缓存,版本控制和性能问题。
最后,你可以全面的理解这种强大的方式,而有时候仅仅通过JAVA API你的理解可能只是肤浅的。
初窥 第一种:默认机制
让我们从最基本的着手。为了在JAVA中持久化一个对象我们必须拥有这样的对象——一个通过实现java.io.Serializable接口的,被标记为可序列化的对象。在API中说明了他的重要性,必须如此才能将对象解析为字节,在将来再将其解析为对象。
让我们看一下我们即将演示序列化机制的这个持久化类:
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类来完成的。这个类是一个过滤流——他由一个低级的字节流(也被乘坐节点流)所包围,用来替我们处理序列化协议。节点流可以用来写入文件系统,甚至是穿过套接字。这意味着我们可以轻易的将平面化对象在网络线缆上进行传输,在另一头进行重组。
瞧瞧我们用来保存这个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,OutputString及其子类,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 也会被创建并按照我们预料的那样开始执行。在第40行,我们将这个线程标记为transient来告诉序列化机制这个域不会跟随对象的其他状态进行持久化(这种情况可以加快域的效率)。关键是:你必须将所有不能被序列化和不想被序列化的域都标记为transient。序列化并不关心是否可以访问编辑器,比如private——所有的非transient域都被认为是对象状态的一部分,他们都可以被持久化。
因此,我们又有了另外的一条规则。这里的两个规则都涉及到持久化对象:
规则#1:需要被持久化的对象必须实现Serializable接口或者继承一个从对象层次实现了他的类。
规则#2:被持久化的类中,所有非可序列化类都必须被标记为transient。
定制默认协议
接下来让我们看看第二种用于序列化的方式:定制默认协议。尽管上面的演示代码显示了线程可以在将对象序列化时,作为对象的一部分,不过如果我们回想一下JAVA是如何创建对象的话,有一个很大的问题。即,当我们使用关键字new创建对象的时候,只有在对象的一个实例被创建的时候才会调用类的构造函数。记住这个基本的事实,再让我们来回顾一下我们的演示代码。首先,我们实例化了一个PersistentAnimation对象,在演示代码中这是线程序列的开始步骤。接着我们使用下面的代码序列化对象:
PersistentAnimation animation = new PersistentAnimation(10); FileOutputStream fos = ... ObjectOutputStream out = new ObjectOutputStream(fos); out.writeObject(animation); |
一切看起来都很好,直到我们读到调用readObject()方法时,问题来了。记住,构造函数只能在新的对象被创建以后才能被调用。这里我们没有创建一个新的实例,我们重组了一个被持久化的对象。结果是animation对象只能在他第一次被实例化时工作一次。看起来这样做的话,持久化他就显得毫无用处,对么?
这里有一个好消息。我们可以通过我们想要的方式让对象工作;我们可以在恢复这个对象的时候再进行重组。例如,为了完成这件事,我们可以创建一个startAnimation()辅助方法来完成构造函数所做的事情。在阅读代码以后我们发现可以在接下来从构造函数调用这个方法。本来这无可厚非,但他介绍得更为复杂。现在,任何想要使用这个animation对象的时候,都需要按照正规的反序列化过程来执行。这不是一个无缝化的机制,而恰恰JAVA序列化接口却是这样承诺开发者的。
不用过我们有一种灵活的解决方式。通过使用序列化机制的一个内嵌特征,开发者可以通过在类内部提供两个方法来增强正规的过程。这两种方法是:
private void writeObject(ObjectOutputStream out) throws IOException; private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException; |
我们注意到这两个方法都是(也必须是)被声明为private的,使得他们不能被继承,重写或者重载。这里的这个技巧是,虚拟机会自动的检查在相应的方法被调用的时候是否已经被声明。虚拟机可以在任何时候调用类中的private方法,而其他任何对象都无法这样做。因此,类的完整性被很好的维护起来了,同时,序列化协议也可以正常的执行。序列化协议通常是以相同的方式使用的,通过调用以下任何一个方法:ObjectOutputStream. WriteObject()或者ObjectInputStream.readObject()。所以,尽管提供了这些特殊的private方法,在任何相关的对象调用时他们还是按照相同的方式进行。
考虑到所有的这些情况,让我们看一下修订版的PersistentAnimation,他包含了这些private方法来允许我们空值反序列化工程,给我们提供了一个伪构造函数。
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方法。如果提供了,流类被当做参数进行传递,让代码来控制他的使用。
这些private方法可以按照你所需要的任何定制来执行序列化过程。在执行序列化和反序列化的时候可以使用加密技术(注意我们在读写二进制的时候是通过没有经过任何模糊处理的明文形式)。也可以在流中增加额外的数据,可能是公司的版本代码等。这些可延展性都是肯定没有被限制的。
停止序列化!
好了,我们已经看了很多关于序列化的过程,现在让我们看一些其他的东西。如果你创建了一个类,其父类是可序列化的,不过你却不想新的类是可序列化的,会是什么情况呢?你不能取消掉一个接口的实例化,所以,如果你的超类实现了Serializable那么新的子类也实现了他(假设之前所列举的两个规则都满足)。为了停止自动化的序列化,你可以再一次使用这两个private方法来抛出NotSerializableException异常。下面是他的操作方式:
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中不允许能重写这些方法。
创建你自己的协议:可外部化接口
我们的讨论还没有结束,下面看一下序列化的第三种方式:使用Externalizable接口创建你自己的协议。你可以实现Externalizable而不是Serializable 接口,他含有两个方法:
public void writeExternal(ObjectOutput out) throws IOException public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException |
只需要重写这两个方法来提供你自己的协议。和之前的两种序列化变种不一样,他没有提供任何东西。即,整个协议完全掌握在你的手中。尽管这是一个更困难的方案,这也是可控度最高的。例如,解决序列化可以这样:通过JAVA应用来读写PDF文件。如果你知道如何读写PDF(需要字节序列),你可以在writeExternal 和readExternal中提供专门处理PDF的协议。
尽管这样,实现Externalizable 的类的工作并没有什么不同。也需要调用writeObject()和readObject(),而不是自动调用可外部化方法。
Gotchas
对于那些不了解的开发者来说,序列化协议中的一些东西显得非常奇怪。当然,这也是本篇文章的目的——让你了解他!所以让我们讨论一些gotchas(翻译不来这个),看看我们是否能够掌握为什么他们存在同时也知道如何使用他们。
在流中缓存对象
首先考虑一下这种情况,一个对象被写入到了一个流中,后面又重新写了一次。在默认的情况下,在写入操作时,一个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冲洗掉了对象的缓冲区,所以所有已经被写入的对象可能被重新写入。
版本控制
我们第二个需要谈论的技巧是,设想你想创建一个类,实例化,写入和读出他。这个平面化对象在系统中呆上了一段时间。同时,你想更新类文件,也许是加入一个新的域。在你尝试读取这个平面化对象时会发生什么呢?
坏消息是这将抛出一个异常——特别是java.io.InvalidClassException——因为所有的拥有持久化能力的类被自动分配了一个唯一的标示符。如果类的标示符和平面化对象的标示符不相同,则会抛出异常。但如果你细细想一下,为什么我加入一个域就会发生这种情况呢?难道不可以在域被初始化以后下次重新写么?
这是可以的,不过他需要一些代码来操作。这个标示符是被所有类的一个叫做serialVersionUID的域所维护的。如果你想控制版本,你仅仅需要人为提供serialVersionUID并确保他总是相同的,无论你如何改变类文件。你可以使用JDK发布版中的一个工具,他叫做serialver ,以此来观察代码默认是什么样子(其实默认情况下他就是一个对象的哈希码)。
这里是一个使用serialver 的例子,这个类叫做Baz:
> serialver Baz > Baz: static final long serialVersionUID = 10275539472837495L; |
只需要简单的拷贝返回行的版本ID,然后复制到你的代码中(在Windows中,你可以通过- show执行这个工具)现在,如果你改变了Baz类的文件,只需要确保相同的版本ID他就能正常的执行。
如果改变是兼容的话,版本控制能够更好的进行工作。可兼容的改变包括增加或者删除一个方法或者域。非兼容包括改变乐儿对象的层级或者删除了Serializable 接口。一个完整的兼容性列表已经在JAVA序列化规格说明书中给出。(see Resources).
性能考虑
我们的第三个话题是:默认机制并不是最好的选择,尽管他易于使用。我将一个Date对象写入到一个文件,执行1000次,如此反复100次。平均写入时间是115毫秒。然后使用标准的IO操作,用相同的数据进行处理这个Date对象,平均时间为52毫秒。几乎只有他的一半。在便利和性能上往往都有一个平衡点,序列化也不例外。如果在你的应用中速度是主要考虑因素,你应该考虑创建一种定制协议。
另一个需要考虑的是之前所提及的输出流的缓存。因为他,可能在流未关闭的情况下系统并不会回收这些写入流的对象。最好的改进方式——往往是利用I/O——是在写操作后尽快关闭流。
结论
在JAVA中序列化是很容易驾驭的,就跟实现他一样简单。理解序列化的三种不同实现方式有助于按照你的意愿来使用API。本文中我们见到了很多的序列化机制,我希望他让我们的理解更加清晰了,而不是更糟。重点在于,在编码中,我们可以在熟知API的情况下把它当做一种常识。不过我还是建议大家精度规格说明来探求更加细致的内容。
Read more about Core Java in JavaWorld's Core Java section.