序列化和反序列化
序列化和反序列化几乎是工程师们每天都要面对的事情,但是要精确掌握这两个概念并不容易:一方面,它们往往作为框架的一部分出现而湮没在框架之中;另一方面,它们会以其他更容易理解的概念出现,例如加密、持久化。恰当的序列化协议不仅可以提高系统的通用性、强健性、安全性、优化系统性能,而且会让系统更加易于调试、便于扩展。
概念
- 序列化:将数据结构或对象转换成二进制串的过程,在 Java 中对应把对象转换为字节序列的过程。
- 反序列化:将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程,在 Java 中对应把字节序列恢复为对象的过程。
用途
把对象的字节序列永久地保存到硬盘上,通常存放在一个文件中。
在网络上传送对象的字节序列。
JDK 类库中的序列化API
在序列化过程中,如果被序列化的类中定义了 writeObject() 和 readObject() 方法,虚拟机会试图调用对象类里的 writeObject() 和 readObject() 方法,进行用户自定义的序列化和反序列化。如果没有这样的方法,则默认调用是 ObjectOutputStream 的 defaultWriteObject() 方法以及 ObjectInputStream 的 defaultReadObject() 方法。
用户自定义的 writeObject 和 readObject 方法可以允许用户控制序列化的过程,比如可以在序列化的过程中动态改变序列化的数值。
ObjectOutputStream
代表对象输出流,它的 writeObject(Object obj)
方法可对参数指定的obj对象进行序列化,把得到的字节序列写到一个目标输出流中。
ObjectInputStream
代表对象输入流,它的 readObject()
方法从一个源输入流中读取字节序列,再把它们反序列化为一个对象,并将其返回。
只有实现了 Serializable
和 Externalizable
接口的类的对象才能被序列化。Externalizable
接口继承自 Serializable
接口,实现 Externalizable
接口的类完全由自身来控制序列化的行为,而仅实现 Serializable
接口的类可以采用默认的序列化方式 。
1 | public interface Externalizable extends java.io.Serializable |
对象序列化步骤
- 创建一个对象输出流,它可以包装一个其他类型的目标输出流,如文件输出流;
- 通过对象输出流的writeObject()方法写对象。
对象反序列化步骤
- 创建一个对象输入流,它可以包装一个其他类型的源输入流,如文件输入流;
- 通过对象输入流的readObject()方法读取对象。
1 | import org.apache.commons.io.IOUtils; |
序列化版本号
凡是实现Serializable接口的类都有一个表示序列化版本标识符的静态变量 serialVersionUID
。serialVersionUID
的取值是 Java 运行时环境根据类的内部细节自动生成的。如果对类的源代码作了修改,再重新编译,新生成的类文件的 serialVersionUID
的取值有可能也会发生变化。
1 | private static final long serialVersionUID |
如果没有指定类的 serialVersionUID
,Java 编译器会自动给这个 class 进行一个摘要算法,类似于指纹算法,只要这个文件多一个空格,得到的 serialVersionUID
就会截然不同的,可以保证在这么多类中,这个编号是唯一的。所以,添加了一个字段后,由于没有显指定 serialVersionUID
,编译器又为我们生成了一个 serialVersionUID
,当然和前面保存在文件中的那个不会一样了,于是就会出现了2个序列化版本号不一致的错误。因此,只要我们自己指定了 serialVersionUID
,就可以在序列化后,去添加一个字段,或者方法,而不会影响到后期的还原。
类的 serialVersionUID
的默认值完全依赖于Java编译器的实现,对于同一个类,用不同的Java编译器编译,有可能会导致不同的 serialVersionUID,也有可能相同。为了提高serialVersionUID的独立性和确定性,强烈建议在一个可序列化类中显示的定义serialVersionUID,为它赋予明确的值。
显式地定义serialVersionUID有两种用途:
在某些场合,希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有相同的
serialVersionUID
;在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的
serialVersionUID
。
总结
Serializable
只是一个接口,本身没有任何实现。如果一个类想被序列化,需要实现Serializable接口。否则将抛出NotSerializableException
异常。因为在序列化操作过程中会对类型进行检查,要求被序列化的类必须属于Enum、String和Serializable类型其中的任何一种。(当一个父类实现序列化,子类自动实现序列化,不需要显式实现Serializable接口。)对象的反序列化并没有调用对象的任何构造方法。序列化时,只对对象的状态进行保存,而不管对象的方法。
当一个对象的实例变量引用其他对象,序列化该对象时也把引用对象进行序列化。
并非所有的对象都可以序列化,比如:
- 安全方面的原因,比如一个对象拥有 private,public 等field,对于一个要传输的对象,比如写到文件,或者进行RMI传输等等,在序列化进行传输的过程中,这个对象的private等域是不受保护的;
- 资源分配方面的原因,比如socket,thread类,如果可以序列化,进行传输或者保存,也无法对他们进行重新的资源分配,而且,也是没有必要这样实现;
serialVersionUID 是用于记录文件版本信息的,最好能够自定义。否则,系统会自动生成 serialVersionUID,文件或者对象的任何改变,都会改变 serialVersionUID,导致反序列化的失败,如果自定义就没有这个问题
声明为
static
和transient
类型的成员数据不能被序列化。因为static
代表类的状态,transient
代表对象的临时数据。Serializable
的系统实现是采用ObjectInputStream
和ObjectOutputStream
实现的,调用ObjectInputStream
和ObjectOutputStream
时,需要对应的类实现Serializable
接口。服务器端给客户端发送序列化对象数据,对象中有一些数据是敏感的,比如密码字符串等,希望对该密码字段在序列化时,进行加密,而客户端如果拥有解密的密钥,只有在客户端进行反序列化时,才可以对密码进行读取,这样可以一定程度保证序列化对象的数据安全。
Java 序列化机制为了节省磁盘空间,具有特定的存储规则,当写入文件的为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用。反序列化时,恢复引用关系。该存储规则极大的节省了存储空间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("tempFile"));
User user = new User();
//试图将对象两次写入文件
out.writeObject(user);
out.flush();
System.out.println(new File("tempFile").length());
out.writeObject(user);
out.close();
System.out.println(new File("tempFile").length());
ObjectInputStream oin = new ObjectInputStream(new FileInputStream("tempFile"));
//从文件依次读出两个文件
User t1 = (User) oin.readObject();
User t2 = (User) oin.readObject();
oin.close();
//判断两个引用是否指向同一个对象
System.out.println(t1 == t2);
扩展
序列化与单例模式
序列化对单例的破坏
使用反射可以破坏单例模式,除了反射以外,使用序列化与反序列化也同样会破坏单例。
单例示例:
1 | public class Singleton implements Serializable{ |
测试序列化对单例模式的破坏:
1 | public class SingletonSerializableTest{ |
通过对 Singleton 的序列化与反序列化得到的对象是一个新的对象,这就破坏了 Singleton 的单例性。这是因为序列化会通过反射调用无参数的构造方法创建一个新的对象。
防止序列化破坏单例模式
在 Singleton 类中定义 readResolve
就可以解决该问题。
1 | public class Singleton implements Serializable{ |
在 Singleton 类中定义 readResolve
可以防止序列化破坏单例模式,实现如下:
ObjectOutputStream
的writeObject()
调用writeObject0()
,writeObject0()
里会调用writeOrdinaryObject()
。- 在
writeOrdinaryObject()
中会通过hasReadResolveMethod
进行判断,如果实现了Serializable 或者 Externalizable 接口的类中包含readResolve
则返回 true。从而调用invokeReadResolve
,通过反射的方式调用要被反序列化的类的readResolve()
方法。
1 | if (obj != null && handles.lookupException(passHandle) == null |
序列化与对象的深拷贝
实现对象的深拷贝有以下几种方法:
- 实现 Clonable 接口,重写 clone() 方法,这种方法没有通用性,优点在于实现简单,并且可以实现定制化。
- 基于反射,BeanUtil、Spring 核心包提供的一个工具类,基本原理就是获取 class 实例化,再通过反射实现对象的深拷贝。
- 基于Serialize、Deserialize 实现,这种办法比较多,本质上和反射类似,反射相当于 JVM 提供,而 Serialize 是基于上层协议。具体实现可以参考 RMI、thrift、protobuf 序列化方式。
- 基于 Unsafe 内存,这种方法极不推荐使用,直接复制对象内存空间,容易造成内存泄露。
在内存中通过字节流的拷贝是比较容易实现的。把母对象写入到一个字节流中,再从字节流中将其读出来,这样就可以创建一个新的对象了,并且该新对象与母对象之间并不存在引用共享的问题,真正实现对象的深拷贝。
1 | public static <T extends Serializable> T clone(T obj){ |
也可以使用 Apache 推出的 SerializationUtils 序列化工具类
SerializationUtils 功能
- 使用序列化进行深度克隆
- 序列化对象
- 反序列化对象
SerializationUtils 优缺点
- 深度拷贝实现比较简单,不用实现Cloneable接口。
- 深度拷贝效率不如实现Cloneable接口高。
- 序列化和反序列化,是基于jdk自带的序列化,速度慢,占空间。效率不如Protostuff、Hessian、Kryo等专业序列化工具高。
更深入的序列化知识可以参考: 序列化和反序列化