存取程序状态的几种方法——Java I/O应用杂谈

80酷酷网    80kuku.com

  程序
jungleford如是说

    已经有一个多月没有搭理blog了,原因很多,譬如实验室的项目正在收工,巨忙;譬如找工作及其相关的事情;而且二月份大部分时间是陪老爹老妈,家里拨号的速度可想而知……但主要还是没有找到一个合适的topic,或者说这段时间懒了(临毕业前期综合症),净在看《汉武大帝》和历史方面的书,还有其它乱七八糟的闲书,就是没有认真地玩Java,哈哈!现在工作差不多落实了,好在不算太烂,小资青年jungleford的生活又开始步入正轨了!以上是新年里的一些废话。    今天稍微聊一点关于“程序状态保存”方面的问题,我们很容易就会想到“序列化”(Serialization,有的书上又翻译为“顺序化”或者“串行化”,但“串行”一词总是让我联想到通信和硬件接口,所以我更习惯于“序列化”的叫法,何况这种叫法是有来头的,后面我会谈到这个名称的由来),当然,序列化是一种方便有效的数据存取方式,但它还有更加广泛的应用。广义上讲,就是讨论一下I/O的一些应用。

文件I/O:文件流→序列化

★文件流    文件操作是最简单最直接也是最容易想到的一种方式,我们说的文件操作不仅仅是通过FileInputStream/FileOutputStream这么“裸”的方式直接把数据写入到本地文件(像我以前写的一个扫雷的小游戏JavaMine就是这样保存一局的状态的),这样就比较“底层”了。
主要类与方法描述FileInputStream.read()从本地文件读取二进制格式的数据FileReader.read()从本地文件读取字符(文本)数据FileOutputStream.write()保存二进制数据到本地文件FileWriter.write()保存字符数据到本地文件
★XML    和上面的单纯的I/O方式相比,XML就显得“高档”得多,以至于成为一种数据交换的标准。以DOM方式为例,它关心的是首先在内存中构造文档树,数据保存在某个结点上(可以是叶子结点,也可以是标签结点的属性),构造好了以后一次性的写入到外部文件,但我们只需要知道文件的位置,并不知道I/O是怎么操作的,XML操作方式可能多数人也实践过,所以这里也只列出相关的方法,供初学者预先了解一下。主要的包是javax.xml.parsers,org.w3c.dom,javax.xml.transform。
主要类与方法描述DocumentBuilderFactory.newDocumentBuilder().parse()解析一个外部的XML文件,得到一个Document对象的DOM树DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()初始化一棵DOM树Document.getDocumentElement(). appendChild()为一个标签结点添加一个子结点Document.createTextNode()生成一个字符串结点Node.getChildNodes()取得某个结点的所有下一层子结点Node.removeChild() 删除某个结点的子结点Document. getElementsByTagName()查找所有指定名称的标签结点Document.getElementById()查找指定名称的一个标签结点,如果有多个符合,则返回某一个,通常是第一个Element.getAttribute()取得一个标签的某个属性的的值Element.setAttribute()设置一个标签的某个属性的的值Element.removeAttribute()删除一个标签的某个属性TransformerFactory.newInstance().newTransformer().transform()将一棵DOM树写入到外部XML文件
★序列化    使用基本的文件读写方式存取数据,如果我们仅仅保存相同类型的数据,则可以用同一种格式保存,譬如在我的JavaMine中保存一个盘局时,需要保存每一个方格的坐标、是否有地雷,是否被翻开等,这些信息组合成一个“复合类型”;相反,如果有多种不同类型的数据,那我们要么把它分解成若干部分,以相同类型(譬如String)保存,要么我们需要在程序中添加解析不同类型数据格式的逻辑,这就很不方便。于是我们期望用一种比较“高”的层次上处理数据,程序员应该花尽可能少的时间和代码对数据进行解析,事实上,序列化操作为我们提供了这样一条途径。    序列化(Serialization)大家可能都有所接触,它可以把对象以某种特定的编码格式写入或从外部字节流(即ObjectInputStream/ObjectOutputStream)中读取。序列化一个对象非常之简单,仅仅实现一下Serializable接口即可,甚至都不用为它专门添加任何方法:
public class MySerial implements java.io.Serializable{  ...}
但有一个条件:即你要序列化的类当中,它的每个属性都必须是是“可序列化”的。这句话说起来有点拗口,其实所有基本类型(就是int,char,boolean之类的)都是“可序列化”的,而你可以看看JDK文档,会发现很多类其实已经实现了Serializable(即已经是“可序列化”的了),于是这些类的对象以及基本数据类型都可以直接作为你需要序列化的那个类的内部属性。如果碰到了不是“可序列化”的属性怎么办?对不起,那这个属性的类还需要事先实现Serializable接口,如此递归,直到所有属性都是“可序列化”的。
主要类与方法描述ObjectOutputStream.writeObject()将一个对象序列化到外部字节流ObjectInputStream.readObject()从外部字节流读取并重新构造对象
    从实际应用上看来,“Serializable”这个接口并没有定义任何方法,仿佛它只是一个标记(或者说像是Java的关键字)而已,一旦虚拟机看到这个“标记”,就会尝试调用自身预定义的序列化机制,除非你在实现Serializable接口的同时还定义了私有的readObject()或writeObject()方法。这一点很奇怪。不过你要是不愿意让系统使用缺省的方式进行序列化,那就必须定义上面提到的两个方法:
public class MySerial implements java.io.Serializable{  private void writeObject(java.io.ObjectOutputStream out) throws IOException  {    ...  }  private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException  {    ...  }  ...}
    譬如你可以在上面的writeObject()里调用默认的序列化方法ObjectOutputStream.defaultWriteObject();譬如你不愿意将某些敏感的属性和信息序列化,你也可以调用ObjectOutputStream.writeObject()方法明确指定需要序列化那些属性。关于用户可定制的序列化方法,我们将在后面提到。

★Bean    上面的序列化只是一种基本应用,你把一个对象序列化到外部文件以后,用notepad打开那个文件,只能从为数不多的一些可读字符中猜到这是有关这个类的信息文件,这需要你熟悉序列化文件的字节编码方式,那将是比较痛苦的(在《Core Java 2》第一卷里提到了相关编码方式,有兴趣的话可以查看参考资料),某些情况下我们可能需要被序列化的文件具有更好的可读性。另一方面,作为Java组件的核心概念“JavaBeans”,从JDK 1.4开始,其规范里也要求支持文本方式的“长期的持久化”(long-term persistence)。    打开JDK文档,java.beans包里的有一个名为“Encoder”的类,这就是一个可以序列化bean的实用类。和它相关的两个主要类有XMLEcoder和XMLDecoder,显然,这是以XML文件的格式保存和读取bean的工具。他们的用法也很简单,和上面ObjectOutputStream/ObjectInputStream比较类似。
主要类与方法描述XMLEncoder.writeObject()将一个对象序列化到外部字节流XMLDecoder.readObject()从外部字节流读取并重新构造对象
    如果一个bean是如下格式:
public class MyBean{  int i;  char[] c;  String s;  ...(get和set操作省略)...}
那么通过XMLEcoder序列化出来的XML文件具有这样的形式:
<?xml version="1.0" encoding="UTF-8"?><java version="1.4.0" class="java.beans.XMLDecoder">  <object class="MyBean">    <void property="i">      <int>1</int>    </void>    <void property="c">      <array class="char" length="3">        <void index="0">          <int>a</int>        </void>        <void index="1">          <int>b</int>        </void>        <void index="2">          <int>c</int>        </void>      </array>    </void>    <void property="s">      <string>fox jump!</string>     </void>  </object></java>
    像AWT和Swing中很多可视化组件都是bean,当然也是可以用这种方式序列化的,下面就是从JDK文档中摘录的一个JFrame序列化以后的XML文件:
<?xml version="1.0" encoding="UTF-8"?><java version="1.0" class="java.beans.XMLDecoder">  <object class="javax.swing.JFrame">    <void property="name">      <string>frame1</string>    </void>    <void property="bounds">      <object class="java.awt.Rectangle">        <int>0</int>        <int>0</int>        <int>200</int>        <int>200</int>      </object>    </void>    <void property="contentPane">      <void method="add">        <object class="javax.swing.JButton">          <void property="label">            <string>Hello</string>          </void>        </object>      </void>    </void>    <void property="visible">      <boolean>true</boolean>    </void>  </object></java>
    因此但你想要保存的数据是一些不是太复杂的类型的话,把它做成bean再序列化也不失为一种方便的选择。

★Properties    在以前我总结的一篇关于集合框架的小文章里提到过,Properties是历史集合类的一个典型的例子,这里主要不是介绍它的集合特性。大家可能都经常接触一些配置文件,如Windows的ini文件,Apache的conf文件,还有Java里的properties文件等,这些文件当中的数据以“关键字-值”对的方式保存。“环境变量”这个概念都知道吧,它也是一种“key-value”对,以前也常常看到版上问“如何取得系统某某信息”之类的问题,其实很多都保存在环境变量里,只要用一条
System.getProperties().list(System.out);
就能获得全部环境变量的列表:

-- listing properties --java.runtime.name=Java(TM) 2 Runtime Environment, Stand...sun.boot.library.path=C:\Program Files\Java\j2re1.4.2_05\binjava.vm.version=1.4.2_05-b04java.vm.vendor=Sun Microsystems Inc.java.vendor.url=http://java.sun.com/path.separator=;java.vm.name=Java HotSpot(TM) Client VMfile.encoding.pkg=sun.iouser.country=CNsun.os.patch.level=Service Pack 1java.vm.specification.name=Java Virtual Machine Specificationuser.dir=d:\my documents\项目\eclipse WTDemojava.runtime.version=1.4.2_05-b04java.awt.graphicsenv=sun.awt.Win32GraphicsEnvironmentjava.endorsed.dirs=C:\Program Files\Java\j2re1.4.2_05\li...os.arch=x86java.io.tmpdir=C:\DOCUME~1\cn2lx0q0\LOCALS~1\Temp\line.separator=

java.vm.specification.vendor=Sun Microsystems Inc.user.variant=os.name=Windows XPsun.java2d.fontpath=java.library.path=C:\Program Files\Java\j2re1.4.2_05\bi...java.specification.name=Java Platform API Specificationjava.class.version=48.0java.util.prefs.PreferencesFactory=java.util.prefs.WindowsPreferencesFac...os.version=5.1user.home=D:\Users\cn2lx0q0user.timezone=java.awt.printerjob=sun.awt.windows.WPrinterJobfile.encoding=GBKjava.specification.version=1.4user.name=cn2lx0q0java.class.path=d:\my documents\项目\eclipse WTDemo\bi...java.vm.specification.version=1.0sun.arch.data.model=32java.home=C:\Program Files\Java\j2re1.4.2_05java.specification.vendor=Sun Microsystems Inc.user.language=zhawt.toolkit=sun.awt.windows.WToolkitjava.vm.info=mixed modejava.version=1.4.2_05java.ext.dirs=C:\Program Files\Java\j2re1.4.2_05\li...sun.boot.class.path=C:\Program Files\Java\j2re1.4.2_05\li...java.vendor=Sun Microsystems Inc.file.separator=\java.vendor.url.bug=http://java.sun.com/cgi-bin/bugreport...sun.cpu.endian=littlesun.io.unicode.encoding=UnicodeLittlesun.cpu.isalist=pentium i486 i386
主要类与方法描述load()从一个外部流读取属性store()将属性保存到外部流(特别是文件)getProperty()取得一个指定的属性setProperty()设置一个指定的属性list()列出这个Properties对象包含的全部“key-value”对System.getProperties()取得系统当前的环境变量
    你可以这样保存一个properties文件:
Properties prop = new Properties();prop.setProperty("key1", "value1");...FileOutputStream out = new FileOutputStream("config.properties");prop.store(out, "--这里是文件头,可以加入注释--");
★Preferences    如果我说Java里面可以不使用JNI的手段操作Windows的注册表你信不信?很多软件的菜单里都有“Setting”或“Preferences”这样的选项用来设定或修改软件的配置,这些配置信息可以保存到一个像上面所述的配置文件当中,如果是Windows平台下,也可能会保存到系统注册表中。从JDK 1.4开始,Java在java.util下加入了一个专门处理用户和系统配置信息的java.util.prefs包,其中一个类Preferences是一种比较“高级”的玩意。从本质上讲,Preferences本身是一个与平台无关的东西,但不同的OS对它的SPI(Service Provider Interface)的实现却是与平台相关的,因此,在不同的系统中你可能看到首选项保存为本地文件、LDAP目录项、数据库条目等,像在Windows平台下,它就保存到了系统注册表中。不仅如此,你还可以把首选项导出为XML文件或从XML文件导入。
主要类与方法描述systemNodeForPackage()根据指定的Class对象得到一个Preferences对象,这个对象的注册表路径是从“HKEY_LOCAL_MACHINE\”开始的systemRoot()得到以注册表路径HKEY_LOCAL_MACHINE OFTWARE\Javasoft\Prefs 为根结点的Preferences对象userNodeForPackage()根据指定的Class对象得到一个Preferences对象,这个对象的注册表路径是从“HKEY_CURRENT_USER\”开始的userRoot()得到以注册表路径HKEY_CURRENT_USER OFTWARE\Javasoft\Prefs 为根结点的Preferences对象putXXX()设置一个属性的值,这里XXX可以为基本数值型类型,如int、long等,但首字母大写,表示参数为相应的类型,也可以不写而直接用put,参数则为字符串getXXX()得到一个属性的值exportNode()将全部首选项导出为一个XML文件exportSubtree()将部分首选项导出为一个XML文件importPreferences()从XML文件导入首选项
    你可以按如下步骤保存数据:

Preferences myPrefs1 = Preferences.userNodeForPackage(this);// 这种方法是在“HKEY_CURRENT_USER\”下按当前类的路径建立一个注册表项Preferences myPrefs2 = Preferences.systemNodeForPackage(this);// 这种方法是在“HKEY_LOCAL_MACHINE\”下按当前类的路径建立一个注册表项Preferences myPrefs3 = Preferences.userRoot().node("com.jungleford.demo");// 这种方法是在“HKEY_CURRENT_USER OFTWARE\Javasoft\Prefs\”下按“com\jungleford\demo”的路径建立一个注册表项Preferences myPrefs4 = Preferences.systemRoot().node("com.jungleford.demo");// 这种方法是在“HKEY_LOCAL_MACHINE OFTWARE\Javasoft\Prefs\”下按“com\jungleford\demo”的路径建立一个注册表项myPrefs1.putInt("key1", 10);myPrefs1.putDouble("key2", -7.15);myPrefs1.put("key3", "value3");FileOutputStream out = new FileOutputStream("prefs.xml");myPrefs1.exportNode(out);

网络I/O:Socket→RMI

★Socket    Socket编程可能大家都很熟,所以就不多讨论了,只是说通过socket把数据保存到远端服务器或从网络socket读取数据也不失为一种值得考虑的方式。

★RMI    RMI机制其实就是RPC(远程过程调用)的Java版本,它使用socket作为基本传输手段,同时也是序列化最重要的一个应用。现在网络传输从编程的角度来看基本上都是以流的方式操作,socket就是一个例子,将对象转换成字节流的一个重要目标就是为了方便网络传输。    想象一下传统的单机环境下的程序设计,对于Java语言的函数(方法)调用(注意与C语言函数调用的区别)的参数传递,会有两种情况:如果是基本数据类型,这种情况下和C语言是一样的,采用值传递方式;如果是对象,则传递的是对象的引用,包括返回值也是引用,而不是一个完整的对象拷贝!试想一下在不同的虚拟机之间进行方法调用,即使是两个完全同名同类型的对象他们也很可能是不同的引用!此外对于方法调用过程,由于被调用过程的压栈,内存“现场”完全被被调用者占有,当被调用方法返回时,才将调用者的地址写回到程序计数器(PC),恢复调用者的状态,如果是两个虚拟机,根本不可能用简单压栈的方式来保存调用者的状态。因为种种原因,我们才需要建立RMI通信实体之间的“代理”对象,譬如“存根”就相当于远程服务器对象在客户机上的代理,stub就是这么来的,当然这是后话了。    本地对象与远程对象(未必是物理位置上的不同机器,只要不是在同一个虚拟机内皆为“远程”)之间传递参数和返回值,可能有这么几种情形:
值传递:这又包括两种子情形:如果是基本数据类型,那么都是“可序列化”的,统统序列化成可传输的字节流;如果是对象,而且不是“远程对象”(所谓“远程对象”是实现了java.rmi.Remote接口的对象),本来对象传递的应该是引用,但由于上述原因,引用是不足以证明对象身份的,所以传递的仍然是一个序列化的拷贝(当然这个对象也必须满足上述“可序列化”的条件)。 引用传递:可以引用传递的只能是“远程对象”。这里所谓的“引用”不要理解成了真的只是一个符号,它其实是一个留在(客户机)本地stub中的,和远端服务器上那个真实的对象张得一模一样的镜像而已!只是因为它有点“特权”(不需要经过序列化),在本地内存里已经有了一个实例,真正引用的其实是这个“孪生子”。     由此可见,序列化在RMI当中占有多么重要的地位。


数据库I/O:CMP、Hibernate

★什么是“Persistence”    用过VMWare的朋友大概都知道当一个guest OS正在运行的时候点击“Suspend”将虚拟OS挂起,它会把整个虚拟内存的内容保存到磁盘上,譬如你为虚拟OS分配了128M的运行内存,那挂起以后你会在虚拟OS所在的目录下找到一个同样是128M的文件,这就是虚拟OS内存的完整镜像!这种内存的镜像手段其实就是“Persistence”(持久化)概念的由来。

★CMP和Hibernate    因为我对J2EE的东西不是太熟悉,随便找了点材料看看,所以担心说的不到位,这次就不作具体总结了,人要学习……真是一件痛苦的事情 ~~~>_<~~~

序列化再探讨

    从以上技术的讨论中我们不难体会到,序列化是Java之所以能够出色地实现其鼓吹的两大卖点——分布式(distributed)和跨平台(OS independent)的一个重要基础。TIJ(即“Thinking in Java”)谈到I/O系统时,把序列化称为“lightweight persistence”——“轻量级的持久化”,这确实很有意思。

★为什么叫做“序列”化?    开场白里我说更习惯于把“Serialization”称为“序列化”而不是“串行化”,这是有原因的。介绍这个原因之前先回顾一些计算机基本的知识,我们知道现代计算机的内存空间都是线性编址的(什么是“线性”知道吧,就是一个元素只有一个唯一的“前驱”和唯一的“后继”,当然头尾元素是个例外;对于地址来说,它的下一个地址当然不可能有两个,否则就乱套了),“地址”这个概念推广到数据结构,就相当于“指针”,这个在本科低年级大概就知道了。注意了,既然是线性的,那“地址”就可以看作是内存空间的“序号”,说明它的组织是有顺序的,“序号”或者说“序列号”正是“Serialization”机制的一种体现。为什么这么说呢?譬如我们有两个对象a和b,分别是类A和B的实例,它们都是可序列化的,而A和B都有一个类型为C的属性,根据前面我们说过的原则,C当然也必须是可序列化的。

import java.io.*;...class A implements Serializable{  C c;  ...}

class B implements Serializable{  C c;  ...}

class C implements Serializable{  ...}

A a;B b;C c1;...

    注意,这里我们在实例化a和b的时候,有意让他们的c属性使用同一个C类型对象的引用,譬如c1,那么请试想一下,但我们序列化a和b的时候,它们的c属性在外部字节流(当然可以不仅仅是文件)里保存的是一份拷贝还是两份拷贝呢?序列化在这里使用的是一种类似于“指针”的方案:它为每个被序列化的对象标上一个“序列号”(serial number),但序列化一个对象的时候,如果其某个属性对象是已经被序列化的,那么这里只向输出流写入该属性的序列号;从字节流恢复被序列化的对象时,也根据序列号找到对应的流来恢复。这就是“序列化”名称的由来!这里我们看到“序列化”和“指针”是极相似的,只不过“指针”是内存空间的地址链,而序列化用的是外部流中的“序列号链”。    使用“序列号”而不是内存地址来标识一个被序列化的对象,是因为从流中恢复对象到内存,其地址可能就未必是原来的地址了——我们需要的只是这些对象之间的引用关系,而不是死板的原始位置,这在RMI中就更是必要,在两台不同的机器之间传递对象(流),根本就不可能指望它们在两台机器上都具有相同的内存地址。

★更灵活的“序列化”:transient属性和Externalizable    Serializable确实很方便,方便到你几乎不需要做任何额外的工作就可以轻松将内存中的对象保存到外部。但有两个问题使得Serializable的威力收到束缚:    一个是效率问题,《Core Java 2》中指出,Serializable使用系统默认的序列化机制会影响软件的运行速度,因为需要为每个属性的引用编号和查号,再加上I/O操作的时间(I/O和内存读写差的可是一个数量级的大小),其代价当然是可观的。    另一个困扰是“裸”的Serializable不可定制,傻乎乎地什么都给你序列化了,不管你是不是想这么做。其实你可以有至少三种定制序列化的选择。其中一种前面已经提到了,就是在implements Serializable的类里面添加私有的writeObject()和readObject()方法(这种Serializable就不裸了,^_^),在这两个方法里,该序列化什么,不该序列化什么,那就由你说了算了,你当然可以在这两个方法体里面分别调用ObjectOutputStream.defaultWriteObject()和ObjectInputStream.defaultReadObject()仍然执行默认的序列化动作(那你在代码上不就做无用功了?呵呵),也可以用ObjectOutputStream.writeObject()和ObjectInputStream.readObject()方法对你中意的属性进行序列化。但虚拟机一看到你定义了这两个方法,它就不再用默认的机制了。    如果仅仅为了跳过某些属性不让它序列化,上面的动作似乎显得麻烦,更简单的方法是对不想序列化的属性加上transient关键字,说明它是个“暂态变量”,默认序列化的时候就不会把这些属性也塞到外部流里了。当然,你如果定义writeObject()和readObject()方法的化,仍然可以把暂态变量进行序列化。题外话,像transient、violate、finally这样的关键字初学者可能会不太重视,而现在有的公司招聘就偏偏喜欢问这样的问题 :(    再一个方案就是不实现Serializable而改成实现Externalizable接口。我们研究一下这两个接口的源代码,发现它们很类似,甚至容易混淆。我们要记住的是:Externalizable默认并不保存任何对象相关信息!任何保存和恢复对象的动作都是你自己定义的。Externalizable包含两个public的方法:
public void writeExternal(ObjectOutput out) throws IOException;public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
    乍一看这和上面的writeObject()和readObject()几乎差不多,但Serializable和Externalizable走的是两个不同的流程:Serializable在对象不存在的情况下,就可以仅凭外部的字节序列把整个对象重建出来;但Externalizable在重建对象时,先是调用该类的默认构造函数(即不含参数的那个构造函数)使得内存中先有这么一个实例,然后再调用readExternal方法对实例中的属性进行恢复,因此,如果默认构造函数中和readExternal方法中都没有赋值的那些属性,特别他们是非基本类型的话,将会是空(null)。在这里需要注意的是,transient只能用在对Serializable而不是Externalizable的实现里面。

★序列化与克隆    从“可序列化”的递归定义来看,一个序列化的对象貌似对象内存映象的外部克隆,如果没有共享引用的属性的化,那么应该是一个深度克隆。关于克隆的话题有可以谈很多,这里就不细说了,有兴趣的话可以参考IBM developerWorks上的一篇文章:JAVA中的指针,引用及对象的clone

一点启示

    作为一个实际的应用,我在写那个简易的邮件客户端JExp的时候曾经对比过好几种保存Message对象(主要是几个关键属性和邮件的内容)到本地的方法,譬如XML、Properties等,最后还是选择了用序列化的方式,因为这种方法最简单, 大约可算是“学以致用”罢。这里“存取程序状态”其实只是一个引子话题罢了,我想说的是——就如同前面我们讨论的关于logging的话题一样——在Java面前对同一个问题你可以有很多种solution:熟悉文件操作的,你可能会觉得Properties、XML或Bean比较方便,然后又发现了还有Preferences这么一个东东,大概又会感慨“天外有天”了,等到你接触了很多种新方法以后,结果又会“殊途同归”,重新反省Serialization机制本身。这不仅是Java,科学也是同样的道理。

参考资料
Core Java 2. by Cay S. Horstmann, Gary Cornell J2SE进阶. by JavaResearch.org Thinking in Java. by Bruce Eckel J2SE 1.4.2 Documentation. by java.sun.com Java Network Programming. by Elliotte R. Harold Java分布式对象:RMI和CORBA. by IBM developerWorks



分享到
  • 微信分享
  • 新浪微博
  • QQ好友
  • QQ空间
点击: