Java 序列化
“持久化”意味着对象的“生存时间”并不取决于程序是否正在执行——它存活于程序的每一次调用之间。通过序列化一个对象,将其写入磁盘,以后在程序再次调用时,通过反序列化,重新恢复那个对象,就能圆满实现一种“持久”效果。
什么是序列化和反序列化(这里仅指 java 自身提供的序列化功能)?
- 把对象转换为字节序列的过程称为对象的序列化。
- 把字节序列恢复为对象的过程称为对象的反序列化。
通过对象流进行序列化
序列化中对象则必须实现Serializable
接口或是Externalizable
接口。
1 | public class User implements Serializable { |
Java IO 包中为我们提供了 ObjectInputStream 和 ObjectOutputStream 两个类。java.io.ObjectOutputStream 类实现object的序列化功能。java.io.ObjectInputStream 类实现了object的反序列化功能。
1 | // 这是对上面 User 序列化的例子 |
ObjectOutputStream.writeObject(Object object)
方法,提供了JAVA默认的序列化方案:序列化传入参数,并以递归的方式遍历对象依赖图中的其他对象。用于创建完整的序列化表示。ObjectOutputStream.readObject()
方法反序列化流中的对象,并以递归的方式遍历对其他对象的引用,用于创建完整的对象。
在上述的遍历图的过程中,如果出现一个对象没有实现序列化接口,将会触发NotSerializableException。
序列化和继承
若父类实现序列化,则子类自动实现序列化。可序列化类的所有子类型本身可序列化,序列化接口没有方法或字段并且仅用于标识可序列化的语义。为了允许序列化序列化类的非序列化父类型,父类型必须要有无参构造器,方便子类处理父类属性(使用父类属性的空值)。如果不是这样,会在运行时抛出这个错误。
1 | Exception in thread "main" java.io.InvalidClassException: XXXX; no valid constructor |
oracle doc
由于多种原因,强烈建议不要对内部类(即非静态成员类的嵌套类)(包括本地类和匿名类)进行序列化。因为在非静态上下文中声明的内部类包含对封闭类实例的隐式非瞬态引用,所以序列化这样的内部类实例也将导致其关联的外部类实例的序列化。由javac(或其他Java 编译器)生成的用于实现内部类的合成字段是依赖于实现的,并且可能在编译器之间变化; 这些字段的差异可能会破坏兼容性,并导致违约冲突serialVersionUID值。分配给本地和匿名内部类的名称也依赖于实现,并且编译器之间可能不同。由于内部类不能声明除编译时常量字段之外的静态成员,因此它们不能使用该 serialPersistentFields机制来指定可序列化字段。最后,因为与外部实例关联的内部类没有零参数构造函数(此类内部类的构造函数隐式接受封闭实例作为前置参数),所以它们无法实现 Externalizable。但是,上面列出的所有问题都不适用于静态成员类。
序列化版本
每个序列化类都有一个版本标识,这个标识用于判断流中的对象是否是当前类的序列化结果,以及是否可以反序列化。如果没有为类声明serialVersionUID,则该值默认为该类的哈希值,是类名,接口类名,方法和字段的64位散列。
1 | private static final long serialVersionUID = 3487495895819393L; |
当我们新增了User类的属性 private String addr;
,那么如果我们没有指定serialVersionUID,在反序列化前一个版本生成的序列化文件时,会使用默认生成的serialVersionUID,从而发生异常:
1 | Exception in thread "main" java.io.InvalidClassException: XXXX; local class incompatible: stream classdesc serialVersionUID = 3487495895819393, local class serialVersionUID = 2144810805526923675 |
在上面的异常中,明确指出异常在于对象流中的serialVersionUID是3487495895819393,而本地serialVersionUID是2144810805526923675,这会导致验证不过。当然,如果我们指定serialVersionUID是-1,那么就不会产生异常,但是这样做,会导致版本管理失去作用。例如,添加字段,和删除字段,会丢失属性和初始化null 属性。
oracle doc
强烈建议所有可序列化类显式声明 serialVersionUID值,因为默认 serialVersionUID计算对类详细信息高度敏感,这些详细信息可能因编译器实现而异,因此可能在serialVersionUID 反序列化期间导致意外冲突,从而导致反序列化失败。
序列化字段管理
如果考虑安全问题,我们不想把密码序列化进行保存,那么该怎么做呢?这里有两个解决方法。
第一种方法是使用transient 关键字,当一个属性被声明为transient后,默认序列化机制就会忽略该字段。
1 | public class User implements Serializable { |
在将 password 字段设为 transient后,可以看到序列化的结果中是不含password值的。
第二种方法是为类设置静态字段serialPersistentFields,必须使用ObjectStreamField列出可序列化字段的名称和类型,并把它写入对象数组serialPersistentFields。该字段的修饰符必须是private,static和final。如果字段的值为null或者不是实例 ObjectStreamField[],或者字段没有所需的修饰符,则等同于没有设置。
1 | public class User implements Serializable { |
假设同时使用了 transient 和serialPersistentFields,会发生什么?
transient会被忽略,只使用serialPersistentFields的设置。
1 | public class User implements Serializable { |
序列化的扩展方法
假设,序列化需要保存密码,同时需要加密,那么该怎么办?
Serializable 对象
Java 提供了在序列化期间的自定义实现,对于Serializable对象,writeObject方法允许类控制其自己字段的序列化。readObject方法允许类控制其自己字段的反序列化。
1 | private void writeObject(ObjectOutputStream stream) throws IOException; |
使用时有几点需要注意下:
- writeObject,readObject 是Serializable子类自己实现的方法,如果没有实现这些方法,默认是使用ObjectOutputStream的defaultWriteObject和ObjectInputStream的defaultReadObject方法来提供默认的序列化和反序列化。以writeObject为例:
1 | ObjectOutputStream.writeObject() |
- 如果实现了这两个方法,那么类的状态将由该方法托管,需要注意的是方法仅负责编写类自己的字段,而不是其超类型或子类型的字段。下面是ArrayList的例子(注意write/readObject中的顺序)
1 | //ArrayList |
在使用writeObject和readObject的时候,常常还会使用到ObjectOutputStream.PutField和ObjectOutputStream.GetField,用于按照Key-value的方式写入字段,优势是可以忽略字段顺序。
1 | // StringBuffer.java |
除了自定义扩展方法外,对于Serializable对象还可以使用writeReplace和readResolve方法来实现拦截。
- 当对象声明并实现了writeReplace后,序列化的对象其实是writeReplace的返回值。
- 当对象声明并实现了readResolve后,反序列化的对象其实是readResolve的返回值。
Externalizable 对象
Externalizable继承自Serializable。
1 | public interface Externalizable extends Serializable |
- 使用Externalizable接口需要实现writeExternal以及readExternal方法。
- Externalizable接口的实现方式一定要有默认的无参构造函数(见ObjectStreamClass的构造器)
1 | if (externalizable) { |
- 采用Externalizable无需产生序列化ID(serialVersionUID)
反序列化后的认证
JDK 提供了ObjectInputValidation接口,用于反序列化后检验对象的有效性。
1 | public interface ObjectInputValidation |
枚举类的序列化
枚举常量的序列化与普通的可序列化或可外部化的对象不同。枚举常量的序列化形式仅由其名称组成;常量的字段值不存在于表单中。要序列化枚举常量,请ObjectOutputStream 写入由枚举常量的name方法返回的值 。要反序列化枚举常量,请 ObjectInputStream从流中读取常量名称。然后通过调用该java.lang.Enum.valueOf方法,将常量的枚举类型与接收到的常量名称作为参数传递,来获得反序列化的常量 。
枚举常数被序列过程中不能被定制:任何由枚举类型定义的方法writeObject,readObject,readObjectNoData,writeReplace和readResolve被序列化和反序列化期间被忽略。同样,任何 serialPersistentFields或 serialVersionUID字段声明也将被忽略,所有枚举类型都有一个固定serialVersionUID,0L。不需要为枚举类型记录可序列化的字段和数据,因为发送的数据类型没有变化。
序列化的文档注解
javadoc 提供了文档注解(@serial
,@serialField
和@serialData
)来标注序列化的相关信息。
@serial
用于标注被序列化的字段, 注解后面可以加上对字段的描述,例如:@serial FIELD_DESC
@serialField
用于标注serialPersistentFields 数组中的一个ObjectStreamField,用法:c@serialField field-name field-type field-description
@serialData
用于标注被writeObject
或Externalizable.writeExternal
方法序列化的数据的序列和类型,用法:@serialData data-description
ObjectStreamClass
ObjectStreamClass 是存储在序列化流中的类的描述器,提供了序列化类的信息,例如class的全名,序列化版本UID,和类的字段信息。
ObjectStreamClass描述符还用于提供有关保存在序列化流中的动态代理类(例如,通过调用java.lang.reflect.Proxy的getProxyClass方法获得的类)的信息。动态代理类本身没有可序列化的字段,并且serialVersionUID为0L。换句话说,将动态代理类的Class对象传递给ObjectStreamClass的静态查找方法时,返回的ObjectStreamClass实例将具有以下属性:
- 调用其getSerialVersionUID方法将返回0L。
- 调用其getFields方法将返回长度为零的数组。
- 使用任何String参数调用其getField方法将返回null。
ObjectStreamClass实例的序列化形式取决于它表示的Class对象是可序列化的,可外部化的还是动态代理类。当将ObjectStreamClass不代表动态代理类的实例写入流中时,它将写入类名称和serialVersionUID,标志以及字段数。根据类的不同,可能会编写其他信息:
- 对于不可序列化的类,字段数始终为零。标志位
SC_SERIALIZABLE
,SC_EXTERNALIZABLE
不会被设置 - 对于可序列化的类,标志位
SC_SERIALIZABLE
会被设置,字段数是可序列化字段的数,后面是每个可序列化字段的描述符。描述符以一定规范顺序编写。首先写入按字段名称排序原始类型字段的描述符,然后是对象类型字段的描述符(按字段名称排序)。名称使用String.compareTo进行排序。 - 对于可外部化的类,标志SC_EXTERNALIZABLE,并且字段数始终为零。
- 对于枚举类型,标志SC_ENUM,并且字段数始终为零。
- 当ObjectOutputStream序列化动态代理类的ObjectStreamClass描述符(通过将其Class对象传递给java.lang.reflect.Proxy的isProxyClass方法确定)时,它将写入动态代理类实现的接口数,然后是接口名称。通过在动态代理类的Class对象上调用getInterfaces方法以返回接口的顺序列出它们。动态代理类和非动态代理类的ObjectStreamClass描述符的序列化表示形式通过使用不同的类型代码(分别为TC_PROXYCLASSDESC和TC_CLASSDESC)来区分。
流唯一标识符(serialVersionUID )
默认流唯一标识符是类名称,接口类名称,方法和字段的64位哈希值。如果未为某个类声明SUID,则该值默认为该类的哈希。动态代理类和枚举类型总是有值0L。
强烈建议所有可序列化的类显式声明 serialVersionUID值,因为默认 serialVersionUID计算对类详细信息高度敏感,而类详细信息可能会根据编译器的实现而变化,因此可能在serialVersionUID 反序列化期间导致意外冲突,从而导致反序列化失败。
二进制协议
语法文件如下:
1 | stream: |
参考资料:
- oracle java 8 serialization