Java序列化深入理解
1 序列化
1.1 基本概念理解
Java 对象序列化用于作为一种将 Java 对象的状态转换为字节数组,以便存储或传输的机制,以后,仍可以将字节数组转换回 Java 对象原有的状态。
序列化就是一种用来处理对象流的机制,所谓对象流也就是将对象的内容进行流化。可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。序列化是为了解决在对对象流进行读写操作时所引发的问题。
实际上,序列化的思想是 冻结 对象状态,传输对象状态(写到磁盘、通过网络传输等等),然后 解冻 状态,重新获得可用的 Java 对象。所有这些事情的发生有点像是魔术,这要归功于 ObjectInputStream/ObjectOutputStream 类、完全保真的元数据以及程序员愿意用Serializable 标识接口标记他们的类,从而 参与 这个过程
序列化的实现:将需要被序列化的类实现Serializable接口,然后使用一个输出流(如:FileOutputStream)来构造一个ObjectOutputStream(对象流)对象,接着,使用ObjectOutputStream对象的writeObject(Object obj)方法就可以将参数为obj的对象写出(即保存其状态),要恢复的话则用输入流
Serialization(序列化)是一种将对象以一连串的字节描述的过程;deserialization(反序列化)是一种将这些字节重建成一个对象的过程
1.2 串行序列化特点
1.2.1 序列化允许重构
序列化允许一定数量的类变种,甚至重构之后也是如此,ObjectInputStream 仍可以很好地将其读出来。
Java Object Serialization 规范可以自动管理的关键任务是:
- 将新字段添加到类中,将字段从
static改为非static - 将字段从
transient改为非transient
取决于所需的向后兼容程度,转换字段形式(从非static 转换为 static 或从非 transient 转换为 transient)或者删除字段需要额外的消息传递。
1.2.2 序列化并不安全
让 Java 开发人员诧异并感到不快的是,序列化二进制格式完全编写在文档中,并且完全可逆。实际上,只需将二进制序列化流的内容转储到控制台,就足以看清类是什么样子,以及它包含什么内容。这对于安全性有着不良影响。
例如,当通过 RMI 进行远程方法调用时,通过连接发送的对象中的任何 private 字段几乎都是以明文的方式出现在套接字流中,这显然容易招致哪怕最简单的安全问题。幸运的是,序列化允许 hook 序列化过程,并在序列化之前和反序列化之后保护(或模糊化)字段数据。可以通过在 Serializable对象上提供一个writeObject方法来做到这一点。
假设 类中的敏感数据是 age 字段。我们可以在序列化之前模糊化该数据,将数位循环左移一位,然后在反序列化之后复位。(您可以开发更安全的算法,当前这个算法只是作为一个例子。)
为了 hook 序列化过程,我们将在类上实现一个 writeObject 方法;为了 hook 反序列化过程,我们将在同一个类上实现一个readObject 方法。
public class SerialEnty implements Serializable {
private static final long serialVersionUID = 1L;
private int age;
private String name;
public SerialEnty(int age, String name) {
this.age = age;
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
private void writeObject(java.io.ObjectOutputStream stream)throws java.io.IOException {
// "Encrypt"/obscure the sensitive data
age = age << 2;
stream.defaultWriteObject();
}
private void readObject(java.io.ObjectInputStream stream) throws java.io.IOException, ClassNotFoundException {
stream.defaultReadObject();
// "Decrypt"/de-obscure the sensitive data
age = age >> 2;
}
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
SerialEnty hello = new SerialEnty(2, "hello");
System.out.println(hello);
ByteArrayOutputStream bos = new ByteArrayOutputStream ();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(hello);
oos.flush();
bos.flush();
InputStream is = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(is);
SerialEnty se = (SerialEnty)ois.readObject();
System.out.println(se);
System.out.println(JSON.toJSON(se));
ois.close();
is.close();
oos.close();
bos.close();
}
1.2.3 序列化的数据可以被签名和密封
上一个技巧假设想模糊化序列化数据,而不是对其加密或者确保它不被修改。当然,通过使用 writeObject 和 readObject 可以实现密码加密和签名管理,但其实还有更好的方式。
如果需要对整个对象进行加密和签名,最简单的是将它放在一个 javax.crypto.SealedObject 或 java.security.SignedObject 包装器中。两者都是可序列化的,所以将对象包装在 SealedObject 中可以围绕原对象创建一种 “包装盒”。必须有对称密钥才能解密,而且密钥必须单独管理。同样,也可以将 SignedObject 用于数据验证,并且对称密钥也必须单独管理。
结合使用这两种对象,便可以轻松地对序列化数据进行密封和签名,而不必强调关于数字签名验证或加密的细节。
1.2.4 序列化允许将代理放在流中
很多情况下,类中包含一个核心数据元素,通过它可以派生或找到类中的其他字段。在此情况下,没有必要序列化整个对象。可以将字段标记为 transient,但是每当有方法访问一个字段时,类仍然必须显式地产生代码来检查它是否被初始化。
打包和解包代理
writeReplace 和 readResolve 方法使 类可以将它的所有数据(或其中的核心数据)打包到一个 PersonProxy 中,将它放入到一个流中,然后在反序列化时再进行解包。
1.2.5 串行化的继承
如果某个类能够被串行化,其子类也可以被串行化。
如果该类有父类,则分两种情况来考虑,如果该父类已经实现了可串行化接口。则其父类的相应字段及属性的处理和该类相同,即:父类实现序列化,子类自动实现序列化,不需要显式实现Serializable接口;
如果该类的父类没有实现可串行化接口,则该类的父类所有的字段属性将不会串行化。
对于父类的处理,如果父类没有实现串行化接口,则其必须有默认的构造函数(即没有参数的构造函数,如果只声明有参构造会报错),否则编译的时候就会报错。在反串行化的时候,默认构造函数会被调用。但是若把父类标记为可以串行化,则在反串行化的时候,其默认构造函数不会被调用。这是为什么呢?这是因为Java 对串行化的对象进行反串行化的时候,直接从流里获取其对象数据来生成一个对象实例,而不是通过其构造函数来完成
注意 :当一个对象的实例变量引用其他对象,序列化该对象时也把引用对象进行序列化
1.2.6 static和transient
声明为static和transient类型的成员数据不能被串行化。因为static代表类的状态, transient代表对象的临时数据
序列化会忽略静态变量,即序列化不保存静态变量的状态。静态成员属于类级别的,所以不能序列化。即 序列化的是对象的状态不是类的状态 。
这里的不能序列化的意思,是序列化信息中不包含这个静态成员域
1.2.7 序列化前和序列化后的对象的关系
是 ==还是equal? or 是浅复制还是深复制?
答案:深复制,反序列化还原后的对象地址与原来的的地址不同
序列化前后对象的地址不同了,但是内容是一样的,而且对象中包含的引用也相同。换句话说,通过序列化操作,我们可以实现对任何可Serializable对象的深度复制(deep copy)——这意味着我们复制的是整个对象网,而不仅仅是基本对象及其引用。对于同一流的对象,他们的地址是相同,说明他们是同一个对象,但是与其他流的对象地址却不相同。也就说,只要将对象序列化到单一流中,就可以恢复出与我们写出时一样的对象网,而且只要在同一流中,对象都是同一个
1.3 相关类和方法
1.3.1 简介
在java.io包中提供的涉及对象的串行化的类与接口有ObjectOutput接口、ObjectOutputStream类、ObjectInput接口、ObjectInputStream类。
ObjectOutput接口:它继承DataOutput接口并且支持对象的串行化,其内的writeObject()方法实现存储一个对象。
ObjectInput接口:它继承DataInput接口并且支持对象的串行化,其内的readObject()方法实现读取一个对象。
ObjectOutputStream类:它继承OutputStream类并且实现ObjectOutput接口。利用该类来实现将对象存储(调用ObjectOutput接口中的writeObject()方法)。ObjectInputStream类:它继承InputStream类并且实现ObjectInput接口。利用该类来实现读取一个对象(调用ObjectInput接口中的readObject()方法)。
1.3.2 SerializedLambda
SerializedLambda 是序列化过程中的一个特殊产物。它是 Lambda表达式(实现序列化接口)调用 writeReplace 方法后返回的那个替身对象。它自身也是一个标准的、可序列化的对象,里面装满了Lambda的元数据。
如果Lambda表达式没有实现序列化接口,则不会有 writeReplace 方法和SerializedLambda
使用示例:
@FunctionalInterface
interface MyFunc<T> extends Serializable {
T get(T input);
}
public class LambdaSerializeExample {
public static void main(String[] args) throws Exception {
// 1. 创建一个可序列化的Lambda
MyFunc<String> func = (s) -> s.toUpperCase();
// 2. 使用反射获取其SerializedLambda
Method writeReplace = func.getClass().getDeclaredMethod("writeReplace");
writeReplace.setAccessible(true);
SerializedLambda serializedLambda = (SerializedLambda) writeReplace.invoke(func);
// 3. 查看Lambda的“指纹信息”
System.out.println("实现类: " + serializedLambda.getImplClass());
System.out.println("实现方法名: " + serializedLambda.getImplMethodName());
System.out.println("函数接口方法名: " + serializedLambda.getCapturingClass());
}
}
1.3.3 主要方法
Java对象序列化涉及多个层次的方法,可以大致分为三类:
| 类别 | 方法名 | 所属接口/类 | 调用时机与目的 | 关键特性 |
|---|---|---|---|---|
| 序列化“后门”方法 | writeReplace | 在被序列化的类中定义 | 序列化之前被调用,用于返回一个替换原对象进行序列化的“替身”对象。 | 优先级最高,完全控制替换逻辑。 |
| readResolve | 在被序列化的类中定义 | 反序列化之后被调用,用于替换从流中读取到的对象(常用于实现单例模式) | 最后一道工序,可以修改或替换反序列化结果 | |
| 自定义序列化方法 | writeObject | 在被序列化的类中定义 | 当类实现此私有方法时,JVM会调用它来写入对象的非默认字段 | 细粒度控制如何写数据,但仍是写自己 |
| readObject | 在被序列化的类中定义 | 当类实现此私有方法时,JVM会调用它来读取并还原对象的非默认字段 | 细粒度控制如何读数据,与writeObject配对 | |
| 外部化控制方法 | writeExternal | java.io.Externalizable 接口 | 由Externalizable对象调用,完全接管序列化的写入过程 | 比Serializable更彻底的控制,必须自己写所有数据 |
| readExternal | java.io.Externalizable 接口 | 由Externalizable对象调用,完全接管反序列化的读取和重构过程 | 比Serializable更彻底的控制,必须自己读所有数据 |
但是Externalizable 要求手动读写每个字段,容易遗漏或顺序错误导致数据混乱且不常用,若未正确维护版本兼容性,极易引发反序列化失败
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeObject(name);
out.writeInt(age); // 注意顺序必须一致
}
@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
name = (String) in.readObject();
age = in.readInt(); // 顺序必须匹配
}
2 serialVersionUID
2.1 serialVersionUID作用
serialVersionUID适用于Java的序列化机制。简单来说,Java的序列化机制是通过判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidCastException
2.2 添加方式
serialVersionUID有两种显示的生成方式:
- 一是默认的
1L,比如:private static final long serialVersionUID = 1L; - 二是根据类名、接口名、成员方法及属性等来生成一个
64位的哈希字段,比如:private static final long serialVersionUID = xxxxL;
当一个类实现了Serializable接口,如果没有显示的定义serialVersionUID
- Eclipse:
Eclipse会提供相应的提醒。面对这种情况,我们只需要在Eclipse中点击类中warning图标一下,Eclipse就会自动给定两种生成的方式。如果不想定义,在Eclipse的设置中也可以把它关掉的,设置如下:Window ==> Preferences ==> Java ==> Compiler ==> Error/Warnings ==> Potential programming problems
将Serializable class without serialVersionUID的warning改成ignore即可 - IntellijIdea:
IntellijIdea中没有相关提示,就需要相关设置:File ==> Settings ==> Editor ==> Inspections ==> Java ==> Serialization issus或者搜索框中输入serialVersionUID关键字 ==> 勾选Serializable class without serialVersionUID,就完成了IntellijIdea设置
使用时把光标放在类名上,按Alt+Enter键,这个时候可以看到Add serialVersionUID field提示信息,选中后就可以生成了
当实现java.io.Serializable接口的类没有显式地定义一个serialVersionUID变量时候,Java序列化机制会根据编译的Class自动生成一个serialVersionUID作序列化版本比较用,这种情况下,如果Class文件(类名,方法明等)没有发生变化(增加空格,换行,增加注释等等),就算再编译多次,serialVersionUID也不会变化的。
如果我们不希望通过编译来强制划分软件版本,即实现序列化接口的实体能够 兼容先前版本 ,就需要显式地定义一个名为serialVersionUID,类型为long的变量,不修改这个变量值的序列化实体都可以相互进行串行化和反串行化
2.3 相关案例说明
下面用代码说明一下serialVersionUID在应用中常见的几种情况。
序列化实体类
import java.io.Serializable;
public class Person implements Serializable
{
private static final long serialVersionUID = 1234567890L;
public int id;
public String name;
public Person(int id, String name)
{
this.id = id;
this.name = name;
}
public String toString()
{
return "Person: " + id + " " + name;
}
}
序列化功能:
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
public class SerialTest
{
public static void main(String[] args) throws IOException
{
Person person = new Person(1234, "wang");
System.out.println("Person Serial" + person);
FileOutputStream fos = new FileOutputStream("Person.txt");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(person);
oos.flush();
oos.close();
}
}
反序列化功能:
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
public class DeserialTest
{
public static void main(String[] args) throws IOException, ClassNotFoundException
{
Person person;
FileInputStream fis = new FileInputStream("Person.txt");
ObjectInputStream ois = new ObjectInputStream(fis);
person = (Person) ois.readObject();
ois.close();
System.out.println("Person Deserial" + person);
}
}
情况一:假设Person类序列化之后,从A端传输到B端,然后在B端进行反序列化。在序列化Person和反序列化Person的时候,A端和B端都需要存在一个相同的类。如果两处的serialVersionUID不一致,会产生什么错误呢?
【答案】可以利用上面的代码做个试验来验证:
先执行测试类SerialTest,生成序列化文件,代表A端序列化后的文件,然后修改serialVersion值,再执行测试类DeserialTest,代表B端使用不同serialVersion的类去反序列化,结果报错:
Exception in thread "main" java.io.InvalidClassException: test.Person; local class incompatible: stream classdesc serialVersionUID = 1234567890, local class serialVersionUID = 123456789
at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:560)
at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1580)
at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1493)
at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1729)
at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1326)
at java.io.ObjectInputStream.readObject(ObjectInputStream.java:348)
at test.DeserialTest.main(DeserialTest.java:15)
情况二:假设两处serialVersionUID一致,如果A端增加一个字段,然后序列化,而B端不变,然后反序列化,会是什么情况呢?
【答案】新增public int age;执行SerialTest,生成序列化文件,代表A端。删除public int age,反序列化,代表B端,最后的结果为:执行序列化,反序列化正常,但是A端增加的字段丢失(被B端忽略)。情况三:假设两处serialVersionUID一致,如果B端减少一个字段,A端不变,会是什么情况呢?
【答案】序列化,反序列化正常,B端字段少于A端,A端多的字段值丢失(被B端忽略)。情况四:假设两处serialVersionUID一致,如果B端增加一个字段,A端不变,会是什么情况呢?
验证过程如下:
先执行SerialTest,然后在实体类Person增加一个字段age,如下所示,再执行测试类DeserialTest.
【答案】序列化,反序列化正常,B端新增加的int字段被赋予了默认值0。
最后通过下面的图片,总结一下上面的几种情况
![在这里插入图片描述]()


浙公网安备 33010602011771号