0%

Java对象序列化

<< Java高级软件工程师知识结构

  1. 掌握InputStream、OutputStream、Reader、Writer的继承体系.
  2. 掌握字节流(FileInputStream、DataInputStream、BufferedInputStream、FileOutputSteam、DataOutputStream、BufferedOutputStream)和 字符流(BufferedReader、InputStreamReader、FileReader、BufferedWriter、OutputStreamWriter、PrintWriter、FileWriter), 并熟练运用.
  3. 掌握NIO实现原理及使用方法.

Java IO包括:

序列化

所谓序列化是将内存中Java对象转化为二进制字节数据, 反序列化就是从二进制字节数据转换为内存中的对象.

  • 对象保存到文件: Java序列化将对象转换为二进制字节数据, 可以用于将内存中的对象保存到文件中.
  • 复制对象:由于Java中的对象变量仅仅是栈中的一个指针, 而对象保存在堆内存中. 当clone复制对象时, 只是将对象中的对象变量复制, 而没有复制对象本身. 这被称为**浅复制**;相比之下, 使用序列化的方法, 可以将对象的值转换为二进制字节数组, 再反序列化便可以复制对象的所有内容, 这就是深复制.
  • 自定义序列化

实现的样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Person implements Serializable {
private static final long serialVersionUID = 123l;
private String name;
private int age;
private long birthday;

@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
", birthday=" + birthday +
'}';
}

public Person(String name, int age, long birthday) {
this.name = name;
this.age = age;
this.birthday = birthday;
}
....// getter setter
}

实现要点

Serializable接口

Java是通过实现Serializable接口, 实现的序列化, Serializable接口里面没有任何的方法, 只是个标示接口.

SerialVersionUID

1
private static final long serialVersionUID = ...;

serialVersionUID变量用于标记该类的版本UID.

Java使用一种hash值来快速的区分序列化流中的对象与要转换为的对象是否相同. 该 hash值 是根据给定源文件中几乎所有东西 — 方法名称、字段名称、字段类型、访问修改方法等 — 计算出来的, 序列化将该 hash 值与序列化流中的 hash 值相比较. 这个hash值就是serialVersionUIDserialVersionUID变量可以手动指定, 如果不指定, Java将使用 JDKserialver 命令计算出.

ObjectOutputStream

实际的序列化和反序列化工作是通过ObjectOuputStream和ObjectInputStream来完成的。ObjectOutputStream的writeObject方法可以把一个Java对象写入到流中,ObjectInputStream的readObject方法可以从流中读取一个Java对象。在写入和读取的时候,虽然用的参数或返回值是单个对象,但实际上操纵的是一个对象图,包括该对象所引用的其它对象,以及这些对象所引用的另外的对象。Java会自动帮你遍历对象图并逐个序列化。除了对象之外,Java中的基本类型和数组也是可以通过 ObjectOutputStream和ObjectInputStream来序列化的。
下面的深复制是将内存中的二进制数组作为媒介来复制对象的. 关于ObjectOutputStream详情请看

深复制与浅复制

基础变量和对象的引用变量都是保存在JVM栈内存(stack)之中的, 而对象保存在JVM堆(Heap)内存中.^JVM结构

实现Clonable接口, 并调用对象上的clone方法, 只会将JVM栈内存(stack)复制到新对象之中. 对象的引用变量所引用的对象实体不能使用. 这就是浅复制.

为了可以复制对象的所有的内容, 可以采用将目标序列化到内存中, 转换为二进制数据, 然后再反序列化回对象. 这就是”深复制“.

上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ShallowCopy implements Cloneable, Serializable {
/**
* 浅复制
*/
@Override
protected ShallowCopy clone() throws CloneNotSupportedException {
return (ShallowCopy) super.clone();
}

/**
* 深复制
*/
public ShallowCopy deepClone() throws IOException, ClassNotFoundException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(this);

ByteArrayInputStream bis = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
ShallowCopy shallowCopy = (ShallowCopy) ois.readObject();
return shallowCopy;
}
}

序列化多个目标

如上文所述, 序列化是将对象转换为字节数组, 需要ObjectOutputStreamObjectInputStream实现. 因此, 可以向ObjectOutputStream输出多个对象, 也可以从ObjectInputStream中输入多个对象.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public static void writeObjects(List os, String fileName) {
FileOutputStream outputStream = null;
try {
outputStream = new FileOutputStream(fileName);
ObjectOutputStream stream = new ObjectOutputStream(outputStream);
for (Object o : os) {
stream.writeObject(o);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

public static <T> List<T> readObjects(String fileName, Class<T> clasz) {
FileInputStream inputStream = null;
T result = null;
List<T> list = new ArrayList<T>();
try {
inputStream = new FileInputStream(fileName);
ObjectInputStream in = new ObjectInputStream(inputStream);
Object object = null;
while ((object = in.readObject()) != null) {
result = (T) object;
list.add(result);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return list;
}

序列化允许重构

序列化在保存对象的同时, 也就保存了数据. 序列化也成为保存数据的方式之一. 与数据库、XML或JSON等方式不同, 序列化依赖于对象体. 当变更数据项时, 需要修改类. 序列化是允许重构的.

序列化允许一定数量的类变种, 甚至重构之后也是如此, ObjectInputStream 仍可以很好地将其读出来.
Java Object Serialization 规范可以自动管理的关键任务是:

  • 将新字段添加到类中
  • 将字段从 static 改为非 static
  • 将字段从 transient 改为非 transient

取决于所需的向后兼容程度, 转换字段形式(从非 static 转换为 static 或从非 transient 转换为 transient)或者删除字段需要额外的消息传递.

假设要在上面的Person类增加gender

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Person implements Serializable {
private static final long serialVersionUID = 123l;
private String name;
private int age;
private long birthday;
private int gender;

@Override
public String toString() {
return "PersonV1{" +
"name='" + name + '\'' +
", age=" + age +
", birthday=" + birthday +
", gender=" + gender +
'}';
}

public Person(String name, int age, long birthday, int gender) {
this.name = name;
this.age = age;
this.birthday = birthday;
this.gender = gender;
}
// setter getter .....
}

重构, 需要满足两个条件:

  • serialVersionUID必须相同
  • 类名必须与原类相同

为了使 Java 运行时相信两种类型实际上是一样的, 第二版和随后版本的 Person 必须与第一版有相同的序列化版本 hash(存储为 private static final serialVersionUID 字段). 因此, 我们需要 serialVersionUID 字段, 它是通过对原始(或 V1)版本的 Person 类运行 JDKserialver 命令计算出的.

一旦有了 Person 的 serialVersionUID, 不仅可以从原始对象 Person 的序列化数据创建 PersonV2 对象(当出现新字段时, 新字段被设为缺省值, 最常见的是“null”), 还可以反过来做:即从 PersonV2 的数据通过反序列化得到 Person, 这毫不奇怪.

安全性

Java对象序列化之后的内容格式是公开的。所以可以很容易的从中提取出各种信息。从实现的角度来说,可以从不同的层次来加强序列化的安全性。
对序列化之后的流进行加密。这可以通过CipherOutputStream来实现。实现自己的writeObject和readObject方法,在调用defaultWriteObject之前,先对要序列化的域的值进行加密处理。使用一个SignedObject或SealedObject来封装当前对象,用SignedObject或SealedObject进行序列化。在从流中进行反序列化的时候,可以通过ObjectInputStream的registerValidation方法添加ObjectInputValidation接口的实现,用来验证反序列化之后得到的对象是否合法。

自定义序列化,提高安全性

为了避免保存的数据被恶意利用, 可以通过模糊化需要保密的字段. 此处, hookPerson类中的age字段, 在上面的Person类中添加两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 自定义将对象写入输出流的方法<br/>
* 此处, 判断性别为女, 将年龄左移2个单位
* <code>stream.defaultWriteObject();</code>接下来将所有的对象写入.
*/
private void writeObject(java.io.ObjectOutputStream stream) throws java.io.IOException {
if (gender == 1) {
age = age << 2;
}
stream.defaultWriteObject();
}
/**
* 自定义从输入流读入对象的方法:
* 与写对应, 在读入对象后, 女士的年龄右移2个单位
*/
private void readObject(java.io.ObjectInputStream stream)
throws java.io.IOException, ClassNotFoundException {
stream.defaultReadObject();
if (gender == 1) {
age = age >> 2;
}
}

当调用ObjectInputStream读入对象时, 首先判断是否Entity实现了readObject方法, 如果实现了将调用该方法.

签名与密封

//TODO

如果需要对整个对象进行加密和签名, 最简单的是将它放在一个 javax.crypto.SealedObject 和/或 java.security.SignedObject 包装器中. 两者都是可序列化的, 所以将对象包装在 SealedObject 中可以围绕原对象创建一种 “包装盒”. 必须有对称密钥才能解密, 而且密钥必须单独管理. 同样, 也可以将 SignedObject 用于数据验证, 并且对称密钥也必须单独管理.
结合使用这两种对象, 便可以轻松地对序列化数据进行密封和签名, 而不必强调关于数字签名验证或加密的细节. 很简洁, 是吧?

将代理放在流中

很多情况下, 类中包含一个核心数据元素, 通过它可以派生或找到类中的其他字段. 在此情况下, 没有必要序列化整个对象. 可以将字段标记为transient, 但是每当有方法访问一个字段时, 类仍然必须显式地产生代码来检查它是否被初始化.

如果首要问题是序列化, 那么最好指定一个 flyweight 或代理放在流中. 为原始 Person 提供一个 writeReplace 方法, 可以序列化不同类型的对象来代替它. 类似地, 如果反序列化期间发现一个 readResolve 方法, 那么将调用该方法, 将替代对象提供给调用者.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class PersonProxy implements java.io.Serializable{
public PersonProxy(Person orig){
data = orig.getFirstName() + "," + orig.getLastName() + "," + orig.getAge();
if (orig.getSpouse() != null)
{
Person spouse = orig.getSpouse();
data = data + "," + spouse.getFirstName() + "," + spouse.getLastName() + ","
+ spouse.getAge();
}
}

public String data;
private Object readResolve() throws java.io.ObjectStreamException{
String[] pieces = data.split(",");
Person result = new Person(pieces[0], pieces[1], Integer.parseInt(pieces[2]));
if (pieces.length > 3)
{
result.setSpouse(new Person(pieces[3], pieces[4], Integer.parseInt
(pieces[5])));
result.getSpouse().setSpouse(result);
}
return result;
}
}

public class Person implements java.io.Serializable{
public Person(String fn, String ln, int a){
this.firstName = fn; this.lastName = ln; this.age = a;
}

public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }

private Object writeReplace()
throws java.io.ObjectStreamException{
return new PersonProxy(this);
}

public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }

public String toString(){
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" age=" + age +
" spouse=" + spouse.getFirstName() +
"]";
}

private String firstName;
private String lastName;
private int age;
private Person spouse;
}

注意, PersonProxy 必须跟踪 Person 的所有数据. 这通常意味着代理需要是 Person 的一个内部类, 以便能访问 private 字段. 有时候, 代理还需要追踪其他对象引用并手动序列化它们, 例如 Person 的 spouse.

这种技巧是少数几种不需要读/写平衡的技巧之一. 例如, 一个类被重构成另一种类型后的版本可以提供一个 readResolve 方法, 以便静默地将被序列化的对象转换成新类型. 类似地, 它可以采用 writeReplace 方法将旧类序列化成新版本.

信任但要验证

认为序列化流中的数据总是与最初写到流中的数据一致, 这没有问题. 但是, 正如一位美国前总统所说的, “信任, 但要验证”.

对于序列化的对象, 这意味着验证字段, 以确保在反序列化之后它们仍具有正确的值, “以防万一”. 为此, 可以实现 ObjectInputValidation接口, 并覆盖 validateObject() 方法. 如果调用该方法时发现某处有错误, 则抛出一个 InvalidObjectException.

声明某个字段不序列化

在默认的序列化实现中,Java对象中的非静态和非瞬时域都会被包括进来,而与域的可见性声明没有关系。这可能会导致某些不应该出现的域被包含在序列化之后的字节数组中,比如密码等隐私信息。由于Java对象序列化之后的格式是固定的,其它人可以很容易的从中分析出其中的各种信息。对于这种情况,一种解决办法是把域声明为瞬时的,即使用transient关键词。另外一种做法是添加一个serialPersistentFields?

域来声明序列化时要包含的域。从这里可以看到在Java序列化机制中的这种仅在书面层次上定义的契约。声明序列化的域必须使用固定的名称和类型。在后面还可以看到其它类似这样的契约。虽然Serializable只是一个标记接口,但它其实是包含有不少隐含的要求。下面的代码给出了
serialPersistentFields的声明示例,即只有firstName这个域是要被序列化的。

1
2
3
private static final ObjectStreamField[] serialPersistentFields = {
new ObjectStreamField("firstName", String.class)
};

定制化序列化Externalizable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* 序列化和反序列化的操作
* */
public class ExternalizableDemo{
public static void main(String[] args) throws Exception{
ser(); // 序列化
dser(); // 反序列话
}

public static void ser() throws Exception{
File file = new File("d:" + File.separator + "hello.txt");
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(
file));
out.writeObject(new Person("rollen", 20));
out.close();
}

public static void dser() throws Exception{
File file = new File("d:" + File.separator + "hello.txt");
ObjectInputStream input = new ObjectInputStream(new FileInputStream(
file));
Object obj = input.readObject();
input.close();
System.out.println(obj);
}
}

class Person implements Externalizable{
public Person(){

}

public Person(String name, int age){
this.name = name;
this.age = age;
}

@Override
public String toString(){
return "姓名:" + name + " 年龄:" + age;
}

// 复写这个方法, 根据需要可以保存的属性或者具体内容, 在序列化的时候使用
@Override
public void writeExternal(ObjectOutput out) throws IOException{
out.writeObject(this.name);
out.writeInt(age);
}

// 复写这个方法, 根据需要读取内容 反序列话的时候需要
@Override
public void readExternal(ObjectInput in) throws IOException,
ClassNotFoundException{
this.name = (String) in.readObject();
this.age = in.readInt();
}

private String name;
private int age;
}

疑问:

  1. 转换成的二进制数据是什么样子?
  2. Enum类型的变量是否能正确的序列化和反序列化?

其他序列化框架

kryo


[参考文献]:

  1. Think in Java
  2. 关于 Java 对象序列化您不知道的 5 件事
  3. Java深度历险(十)——Java对象序列化与RMI