当两个进程进行远程通信时,彼此可以发送各种类型的数据,如文本、图片、语音和视频等。无论是何种类型的数据,都会以二进制序列的形式在网络上传送。当两个Java进程进行远程通信时,一个进程能否把一个Java对象发送给另一个进程呢?答案是肯定的。不过,发送方需要把这个Java对象转换为字节序列,才能在网络上传送;接收方则需要把字节序列再恢复为Java对象。把Java对象转换为字节序列的过程称为对象的序列化;把字节序列恢复为Java对象的过程称为对象的反序列化。
可以通过一个比喻来帮助我们形象地理解对象的序列化以及反序列化。假定要把一批新汽车(Car对象)从美国海运到中国。为了便于运输,在美国,先把汽车拆成一个个部件,这个过程相当于对象的序列化。汽车部件到达中国后,再把这些部件组装成汽车,这个过程相当于对象的反序列化。
当程序运行时,程序所创建的各种对象都位于内存中,当程序运行结束,这些对象就结束生命周期。如下图所示,对象的序列化主要有两种用途:
- (1)把对象的字节序列永久地保存到硬盘上,通常存放在一个文件中。
- (2)在网络上传送对象的字节序列。
1、JDK类库中的序列化API
java.io.ObjectOutputStream代表对象输出流,它的writeObject(Object obj)方法对参数指定的obj对象进行序列化,把得到的字节序列写到一个目标输出流中。java.io.ObjectInputStream代表对象输入流,它的readObject()方法从一个源输入流中读取字节序列,再把它们反序列化成一个对象,并将其返回。
只有实现了Serializable或Externalizable接口的类的对象才能被序列化,否则ObjectOutputStream的writeObject(Object obj)方法会抛出IOException。实现Serializable或Externalizable接口的类也被称为可序列化类。Externalizable接口继承自Serializable接口,实现Externalizable接口的类完全由自身来控制序列化的行为,而仅实现Serializable接口的类可以采用默认的序列化方式。JDK类库中的部分类(如String类、包装类和Date类等)都实现了Serializable接口。
假定有一个名为Customer的类,它的对象需要序列化。如果Customer类仅仅实现了Serializable接口的类,那么将按照以下方式序列化以及反序列化Customer对象:
- ObjectOutputStream采用JDK提供的默认的序列化方式,对Customer对象的非transient类型的实例变量进行序列化。
- ObjectInputStream采用JDK提供的默认的反序列化方式,对Customer对象的非transient类型的实例变量进行反序列化。
如果Customer类不仅实现了Serializable接口,并且还定义了readObject(ObjectInputStream in)和writeObject(ObjectOuputStream out)方法,那么将按照以下方式序列化以及反序列化Customer对象:
- ObjectOutputStream调用Customer类的writeObject(ObjectOuputStream out)方法来进行序列化。
- ObjectInputStream调用Customer类的readObject(ObjectInputStream in)方法来进行反序列化。
如果Customer类实现了Externalizable接口,那么Customer类必须实现readExternal(ObjectInput in)和writeExternal(ObjectOutput out)方法。在这种情况下,将按照以下方式序列化以及反序列化Customer对象:
- ObjectOutputStream调用Customer类的writeExternal(ObjectOutput out)方法来进行序列化。
- ObjectInputStream先通过Customer类的不带参数的构造方法创建一个Customer对象,然后调用它的readExternal(ObjectInput int)方法来进行反序列化。
下图是序列化API的类框图:
2、 实现Serializable接口
bjectOuputStream只能对实现了Serializable接口的类的对象进行序列化。在默认情况下,ObjectOuputStream按照默认方式序列化,这种序列化方式仅仅对一个对象的非transient类型的实例变量进行序列化,而不会序列化对象的transient(transient是一个修饰符,用于标记一个实例变量不参与序列化过程)类型的实例变量,也不会序列化静态变量。
下面的Customer1类中,定义了一些静态变量、非transient类型的实例变量,以及transient类型的实例变量。
import java.io.Serializable;
/**
* @title Customer
* @description 测试
* @author: yangyongbing
* @date: 2023/12/8 15:30
*/
public class Customer implements Serializable {
// 用于计算Customer对象的数目
private static int count;
private static final int MAX_COUNT=1000;
private String name;
private transient String password;
static {
System.out.println("调用Customer类的静态代码块");
}
public Customer() {
System.out.println("调用Customer类的不带参数的构造方法");
count++;
}
public Customer(String name, String password) {
System.out.println("调用Customer类的带参数的构造方法");
this.name = name;
this.password = password;
count++;
}
@Override
public String toString() {
return "count=" +count
+" MAX_COUNT="+MAX_COUNT
+" name=" + name
+" password=" + password;
}
public static void main(String[] args) {
Customer customer = new Customer();
System.out.println(customer);
}
}
先运行命令“java SimpleServer Customer”,再运行命令“java SimpleClient”。SimpleServer端的打印结果如下。
SimpleClient端的打印结果如下:
从以上打印结果可以看出,当ObjectOutputStream按照默认方式序列化时,Customer对象的静态变量count,以及transient类型的实例变量password没有被序列化。
当ObjectInputStream按照默认方式反序列化时,有以下特点:
- 如果在内存中对象所属的类还没有被加载,那么会先加载并初始化这个类。如果在classpath中不存在相应的类文件,那么会抛出ClassNotFoundException。从SimpleClient端的打印结果可以看出,客户端加载并初始化了Customer类,在初始化时,把静态常量MAX_COUNT初始化为1000,并且把静态变量count初始化为0,此外还调用了Customer类的静态代码块。
- 在反序列化时不会调用类的任何构造方法。
如果一个实例变量被transient修饰符修饰,那么默认的序列化方式不会对它序列化。根据这一特点,可以用transient修饰符来修饰以下类型的实例变量。
(1)实例变量不代表对象的固有的内部数据,仅仅代表具有一定逻辑含义的临时数据。例如,假定Customer类有firstName、lastName和fullName等属性:
Customer类的fullName实例变量可以不必被序列化,因为知道了firstName和lastName变量的值,就可以由它们推导出fullName实例变量的值。
(2)实例变量表示一些比较敏感的信息(比如银行账户的口令),出于安全方面的原因,不希望对其序列化。
(3)实例变量需要按照用户自定义的方式序列化,比如经过加密后再序列化。在这种情况下,可以把实例变量定义为transient类型,然后在writeObject()方法中对其序列化
2.1、序列化对象图
类与类之间可能存在关联关系。例如下列中的Customer2类与Order2类之间存在一对多的双向关联关系。
SimpleServer类的main()方法中,以下代码创建了一个Customer2对象和两个Order2对象,并且建立了它们的关联关系:
下图显示了在内存中,以上代码创建的3个对象之间的关联关系:
当通过ObjectOutputStream对象的writeObject(customer)方法序列化Customer2对象时,会不会序列化与它关联的Order2对象呢?答案是肯定的。在默认方式下,对象输出流会对整个对象图进行序列化。当程序执行writeObject(customer)方法时,该方法不仅序列化Customer2对象,还会把两个与它关联的Order2对象也序列化。当通过ObjectInputStream对象的readObject()方法反序列化Customer2对象,实际上会对整个对象图反序列化。
先运行命令“java SimpleServer Customer2”,再运行命令“java SimpleClient”。SimpleServer服务器会把由一个Customer2对象和两个Order2对象构成的对象图发送给SimpleClient。SimpleClient端的打印结果如下:
按照默认方式序列化对象A时,到底会序列化由哪些对象构成的对象图呢?如下图所示:
从对象A到对象B之间的箭头表示从对象A到对象B有关联关系,或者说,对象A持有对象B的引用,或者说,在内存中可以从对象A导航到对象B。序列化对象A时,实际上会序列化对象A,以及所有可以从对象A直接或间接导航到的对象。因此序列化对象A时,实际上在对象图中被序列化的对象包括:对象A、对象B、对象C、对象D、对象E、对象F和对象G。
2.2、控制序列化的行为
如果用户希望控制类的序列化行为,那么可以在可序列化类中提供以下形式的writeObject()方法和readObject()方法:
当ObjectOutputStream对一个Customer对象进行序列化时,如果该Customer对象具有writeObject()方法,那么就会执行这一方法,否则就按默认方式序列化。在ObjectOutputStream的defaultWriteObject()方法中指定了默认的序列化操作。
在Customer对象的writeObject()方法中,可以先调用ObjectOutputStream的defaultWriteObject()方法,使得对象输出流先执行默认的序列化操作。
当ObjectInputStream对一个Customer对象进行反序列化时,如果该Customer对象具有readObject()方法,那么就会执行这一方法,否则就按默认方式反序列化。在ObjectInputStream的defaultReadObject()方法中指定了默认的反序列化操作。
在Customer对象的readObject()方法中,可以先调用ObjectInputStream的defaultReadObject()方法,使得对象输入流先执行默认的反序列化操作。
值得注意的是,以上writeObject()方法和readObject()方法并不是在java.io.Serializable接口中被定义的。当一个软件系统希望扩展第三方提供的Java类库(比如JDK类库)的功能时,最常见的方式是实现第三方类库的一些接口,或创建类库中抽象类的子类。但是以上writeObject()方法和readObject()方法并不是在java.io.Serializable接口中被定义的。JDK类库的设计人员没有把这两个方法放在Serializable接口中,这样做的优点如下所述:
- (1)不必公开这两个方法的访问权限,以便封装序列化的细节。如果把这两个方法放在Serializable接口中,就必须定义为public类型。
- (2)不必强迫用户定义的可序列化类实现这两个方法。如果把这两个方法放在Serializable接口中,它的实现类就必须实现这些方法,否则就只能声明为抽象类。
在以下情况下,可以考虑采用用户自定义的序列化方式,从而控制序列化的行为:
- (1)确保序列化的安全性,对敏感的信息加密后再序列化,在反序列化时则需要解密。
- (2)确保对象的成员变量符合正确的约束条件。
- (3)优化序列化的性能。
- (4)便于更好地封装类的内部数据结构,确保类的接口不会被类的内部实现所束缚。
2.3、readResolve()方法在单例类中的运用
单例类指仅有一个实例的类。在系统中具有唯一性的组件可作为单例类,这种类的实例通常会占用较多的内存,或者实例的初始化过程比较冗长,因此随意创建这些类的实例会影响系统的性能。
下面的GlobalConfig类就是个单例类,它用来存放软件系统的配置信息。这些配置信息本来存放在配置文件中,在GlobalConfig类的构造方法中,会从配置文件中读取配置信息,把它存放在properties属性中:
无论是采用默认方式,还是采用用户自定义的方式,反序列化都会创建一个新的对象。在以上GlobalConfig类的main()方法中,in.readObject()方法会返回一个新的GlobalConfig对象,运行以上程序,打印结果如下.
由此可见,反序列化打破了单例类只能有一个实例的约定。为了避免这一问题,可以在GlobalConfig类中再增加一个readResolve()方法:
如果一个类提供了readResolve()方法,那么在执行反序列化操作时,先按照默认方式或者用户自定义的方式进行反序列化,最后再调用readResolve()方法,该方法返回的对象为反序列化的最终结果。
提示:readResolve()方法用来重新指定反序列化得到的对象,与此对应,Java序列化规范还允许在可序列化类中定义一个writeReplace()方法,用来重新指定被序列化的对象。writeReplace()方法返回一个Object类型的对象,这个返回对象才是真正要被序列化的对象。writeReplace()方法的访问权限也可以是private、默认或protected级别。
3、实现Externalizable接口
Externalizable接口继承自Serializable接口。如果一个类实现了Externalizable接口,那么将完全由这个类控制自身的序列化行为。Externalizable接口中声明了两个方法:
writeExternal()方法负责序列化操作,readExternal()方法负责反序列化操作。在对实现了Externalizable接口的类的对象进行反序列化时,会先调用类的不带参数的构造方法,这是有别于默认反序列化方式的。
注意:一个类如果实现了Externalizable接口,那么它必须具有public类型的不带参数的构造方法,否则这个类无法反序列化。
4、可序列化类的不同版本的序列化兼容性
假定Customer5类有两个版本1.0和2.0,如果要把基于1.0的序列化数据反序列化为2.0的Customer5对象,或者把基于2.0的序列化数据反序列化为1.0的Customer5对象,那么会出现什么情况呢?如果可以成功地反序列化,则意味着不同版本之间对序列化兼容,反之,则意味着不同版本之间对序列化不兼容。
凡是实现Serializable接口的类都有一个表示序列化版本标识符的静态常量:
private static final long serialVersionUID;
以上serialVersionUID的取值是Java运行时环境根据类的内部细节自动生成的。如果对类的源代码做了修改,再重新编译,那么新生成的类文件的serialVersionUID的取值有可能也会发生变化。
在Customer5类的1.0版本中,具有name和age属性:
而在Customer5类的2.0版本中,删除了age属性,并且增加了isMarried属性:
分别对以上两个类编译,把它们的类文件分别放在server和client目录下,此外把SimpleServer和SimpleClient的类文件也分别拷贝到server和client目录下,如下图所示:
JDK安装好以后,在它的bin目录下有一个serialver.exe程序,用于查看实现了Serializable接口的类的serialVersionUID。在server目录下运行命令“serialver Customer5”,打印结果如下:
client目录下运行命令“serialver Customer5”,打印结果如下:
由此可见,Customer5类的两个版本有着不同的serialVersionUID。
先在server目录下运行命令“java SimpleServer Customer5”,然后在client目录下运行命令“java SimpleClient”。SimpleServer按照Customer5类的1.0版本对一个Customer5对象进行序列化,而SimpleClient按照Customer5类的2.0版本进行反序列化,由于两个类的版本不一样,SimpleClient在执行反序列化操作时,会抛出以下异常:
类的serialVersionUID的默认值依赖于Java编译器的实现,对于同一个类,用不同的Java编译器编译,有可能会导致不同的serialVersionUID,也有可能相同。为了提高serialVersionUID的独立性和确定性,强烈建议在一个可序列化类中显式地定义serialVersionUID,为它赋予明确的值。显式定义serialVersionUID有两种用途:
- (1)在某些场合,希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有相同的serialVersionUID。
- (2)在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的serialVersionUID。
(2)在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的serialVersionUID。
用serialVersionUID来控制序列化兼容性的能力是很有限的。当一个类的不同版本的serialVersionUID相同时,仍然有可能出现序列化不兼容的情况。因为序列化兼容性不仅取决于serialVersionUID,还取决于类的不同版本的实现细节和序列化细节。所以需要前面介绍的各种方法,来手工控制序列化以及反序列化的行为,从而保证不同版本之间的兼容性。
5、总结
如果采用默认的序列化方式,只要让一个类实现Serializable接口,它的实例就可以被序列化了。尽管让一个类变为可序列化很容易,似乎不会给程序员增加很多编程负担,仍然要谨慎地考虑是否要让一个类实现Serializable接口,因为给可序列化类进行版本升级时,需要测试序列化兼容性,这种测试工作量与“可序列化类的数目”与“版本数”的乘积成正比。通常,专门为继承而设计的类应该尽量不要实现Serializable接口,因为一旦父类实现了Serializable接口,所有子类也都变为可序列化的了,这大大增加了为这些类进行升级时测试序列化兼容性的工作量。
默认的序列化方式尽管方便,但是有以下不足之处:
- (1)直接对对象的不易对外公开的敏感数据进行序列化,这是不安全的。
- (2)不会检查对象的成员变量是否符合正确的约束条件。
- (3)默认的序列化方式需要对对象图进行递归遍历,如果对象图很复杂,会消耗很多空间和时间,甚至引起Java虚拟机的堆栈溢出。
- (4)使类的接口被类的内部实现所束缚,制约类的升级与维护。
为了克服默认序列化方式的不足之处,可以采用以下两种方式控制序列化的行为:
- (1)可序列化类不仅实现Serializable接口,并且提供private类型的writeObject()和readObject()方法,由这两个方法负责序列化和反序列化。
- (2)可序列化类不仅实现Externalizable接口,并且实现writeExternal()和readExternal()方法,由这两个方法负责序列化和反序列化。这种可序列化类必须提供public类型的不带参数的构造方法,因为反序列化操作会先调用类的不带参数的构造方法。