Java序列化系列教程(下)

 

一引言

将 Java 对象序列化为二进制文件的 Java 序列化技术是 Java 系列技术中一个较为重要的技术点,在大部分情况下,开发人员只需要了解被序列化的类需要实现 Serializable 接口,使用 ObjectInputStream 和 ObjectOutputStream 进行对象的读写。然而在有些情况下,光知道这些还远远不够,文章列举了笔者遇到的一些真实情境,它们与 Java 序列化相关,通过分析情境出现的原因,使读者轻松牢记 Java 序列化中的一些高级认识。

1.1serialVersionUID问题

情境

两个客户端 A 和 B 试图通过网络传递对象数据,A 端将对象 C 序列化为二进制数据再传给 B,B 反序列化得到 C。

这里要顺便说一句如果不进行序列化的话,我们都知道在java中对于封装类型来说传递是引用传递,假设如果不序列化,对象C的引用指向客户端A所在的jvm内的某一块内存区域,这时如果只是把这个引用传递个B客户端的话,显然B客户端所在的jvm的内存当中是没有这个对象的。

问题

C 对象的全类路径假设为 com.inout.Test,在 A 和 B 端都有这么一个类文件,功能代码完全一致。也都实现了 Serializable 接口,但是反序列化时总是提示不成功。

解决

虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化 ID 是否一致(就是 private static final long serialVersionUID = 1L)。清单 1 中,虽然两个类的功能代码完全一致,但是序列化 ID 不同,他们无法相互序列化和反序列化,serialVersionUID 是和类的名称,所包含的属性名称,类型,数量,方法签名有关系的,如果加一个属性,或者加一个方法,所生成的serialVersionUID 是不一样的,但是如果之前类的一个属性是private String name;,但是后来仅仅把这个属性改成了private String name="join";那生成的serialVersionUID 也是不变的。

序列化 ID 在 Eclipse 下提供了两种生成策略,一个是固定的 1L,一个是随机生成一个不重复的 long 类型数据(实际上是使用 JDK 工具生成),在这里有一个建议,如果没有特殊需求,就是用默认的 1L 就可以,这样可以确保代码一致时反序列化成功。那么随机生成的序列化 ID 有什么作用呢,有些时候,通过改变序列化 ID 可以用来限制某些用户的使用。

特性使用案例:

读者应该听过 Façade 模式,它是为应用程序提供统一的访问接口,案例程序中的 Client 客户端使用了该模式,案例程序结构图如图 1 所示。

图 1. 案例程序结构

1.2静态变量序列化

类的静态属性在序列化的时候是不被序列化的

 1public class Test implements Serializable {
2    private static final long serialVersionUID = 1L;
3    public static int staticVar = 5;
4    public static void main(String[] args) {
5        try {
6            //初始时staticVar为5
7            ObjectOutputStream out = new ObjectOutputStream(
8                    new FileOutputStream("result.obj"));
9            out.writeObject(new Test());
10            out.close();
11            //序列化后修改为10
12            Test.staticVar = 10;
13            ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
14                    "result.obj"));
15            Test t = (Test) oin.readObject();
16            oin.close();
17            //再读取,通过t.staticVar打印新的值
18            System.out.println(t.staticVar);
19        } catch (FileNotFoundException e) {
20            e.printStackTrace();
21        } catch (IOException e) {
22            e.printStackTrace();
23        } catch (ClassNotFoundException e) {
24            e.printStackTrace();
25        }
26    }
27}

清单 2 中的 main 方法,将对象序列化后,修改静态变量的数值,再将序列化对象读取出来,然后通过读取出来的对象获得静态变量的数值并打印出来。依照清单 2,这个 System.out.println(t.staticVar) 语句输出的是 10 还是 5 呢?

最后的输出是 10,对于无法理解的读者认为,打印的 staticVar 是从读取的对象里获得的,应该是保存时的状态才对。之所以打印 10 的原因在于序列化时,并不保存静态变量,这其实比较容易理解,序列化保存的是对象的状态,静态变量属于类的状态,因此 序列化并不保存静态变量。

1.3父类的序列化与 Transient 关键字

情境:

一个子类实现了 Serializable 接口,它的父类都没有实现 Serializable 接口,序列化该子类对象,然后反序列化后输出父类定义的某变量的数值,该变量数值与序列化时的数值不同。

解决:

要想将父类对象也序列化,就需要让父类也实现Serializable 接口。如果父类不实现的话的,就 需要有默认的无参的构造函数。在父类没有实现 Serializable 接口时,虚拟机是不会序列化父对象的,而一个 Java 对象的构造必须先有父对象,才有子对象,反序列化也不例外。所以反序列化时,为了构造父对象,只能调用父类的无参构造函数作为默认的父对象。因此当我们取父对象的变量值时,它的值是调用父类无参构造函数后的值。如果你考虑到这种序列化的情况,在父类无参构造函数中对变量进行初始化,否则的话,父类变量值都是默认声明的值,如 int 型的默认是 0,string 型的默认是 null。

Transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null。

特性使用案例

我们熟悉使用 Transient 关键字可以使得字段不被序列化,那么还有别的方法吗?根据父类对象序列化的规则,我们可以将不需要被序列化的字段抽取出来放到父类中,子类实现 Serializable 接口,父类不实现,根据父类序列化规则,父类的字段数据将不被序列化,形成类图如图 2 所示。

图 2. 案例程序类图

上图中可以看出,attr1、attr2、attr3、attr5 都不会被序列化,放在父类中的好处在于当有另外一个 Child 类时,attr1、attr2、attr3 依然不会被序列化,不用重复抒写 transient,代码简洁。

1.4对敏感字段加密

情境:

服务器端给客户端发送序列化对象数据,对象中有一些数据是敏感的,比如密码字符串等,希望对该密码字段在序列化时,进行加密,而客户端如果拥有解密的密钥,只有在客户端进行反序列化时,才可以对密码进行读取,这样可以一定程度保证序列化对象的数据安全。

解决:

在序列化过程中,虚拟机会试图调用对象类里的 writeObject 和 readObject 方法,进行用户自定义的序列化和反序列化,如果没有这样的方法,则默认调用是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。用户自定义的 writeObject 和 readObject 方法可以允许用户控制序列化的过程,比如可以在序列化的过程中动态改变序列化的数值。基于这个原理,可以在实际应用中得到使用,用于敏感字段的加密工作,清单 3 展示了这个过程。

 1private static final long serialVersionUID = 1L;
2   private String password = "pass";
3   public String getPassword() {
4       return password;
5   }
6   public void setPassword(String password) {
7       this.password = password;
8   }
9   private void writeObject(ObjectOutputStream out) {
10       try {
11           PutField putFields = out.putFields();
12           System.out.println("原密码:" + password);
13           password = "encryption";//模拟加密
14           putFields.put("password", password);
15           System.out.println("加密后的密码" + password);
16           out.writeFields();
17       } catch (IOException e) {
18           e.printStackTrace();
19       }
20   }
21   private void readObject(ObjectInputStream in) {
22       try {
23           GetField readFields = in.readFields();
24           Object object = readFields.get("password", "");
25           System.out.println("要解密的字符串:" + object.toString());
26           password = "pass";//模拟解密,需要获得本地的密钥
27       } catch (IOException e) {
28           e.printStackTrace();
29       } catch (ClassNotFoundException e) {
30           e.printStackTrace();
31       }
32   }
33   public static void main(String[] args) {
34       try {
35           ObjectOutputStream out = new ObjectOutputStream(
36                   new FileOutputStream("result.obj"));
37           out.writeObject(new Test());
38           out.close();
39           ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
40                   "result.obj"));
41           Test t = (Test) oin.readObject();
42           System.out.println("解密后的字符串:" + t.getPassword());
43           oin.close();
44       } catch (FileNotFoundException e) {
45           e.printStackTrace();
46       } catch (IOException e) {
47           e.printStackTrace();
48       } catch (ClassNotFoundException e) {
49           e.printStackTrace();
50       }
51   }

在清单 3 的 writeObject 方法中,对密码进行了加密,在 readObject 中则对 password 进行解密,只有拥有密钥的客户端,才可以正确的解析出密码,确保了数据的安全。执行清单 3 后控制台输出如图 3 所示。

图 3. 数据加密演示

特性使用案例

RMI 技术是完全基于 Java 序列化技术的,服务器端接口调用所需要的参数对象来至于客户端,它们通过网络相互传输。这就涉及 RMI 的安全传输的问题。一些敏感的字段,如用户名密码(用户登录时需要对密码进行传输),我们希望对其进行加密,这时,就可以采用本节介绍的方法在客户端对密码进行加密,服务器端进行解密,确保数据传输的安全性。

1.5序列化存储规则

先看代码后解释

 1ObjectOutputStream out = new ObjectOutputStream(
2                   new FileOutputStream("result.obj"));
3   Test test = new Test();
4   //试图将对象两次写入文件
5   out.writeObject(test);
6   out.flush();
7   System.out.println(new File("result.obj").length());
8   out.writeObject(test);
9   out.close();
10   System.out.println(new File("result.obj").length());
11   ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
12           "result.obj"));
13   //从文件依次读出两个文件
14   Test t1 = (Test) oin.readObject();
15   Test t2 = (Test) oin.readObject();
16   oin.close();
17   //判断两个引用是否指向同一个对象
18   System.out.println(t1 == t2);

清单 3 中对同一对象两次写入文件,打印出写入一次对象后的存储大小和写入两次后的存储大小,然后从文件中反序列化出两个对象,比较这两个对象是否为同一对象。一般的思维是,两次写入对象,文件大小会变为两倍的大小,反序列化时,由于从文件读取,生成了两个对象,判断相等时应该是输入 false 才对,但是最后结果输出如图 4 所示。

图 4. 示例程序输出

特性案例分析

 1ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("result.obj"));
2Test test = new Test();
3test.i = 1;
4out.writeObject(test);
5out.flush();
6test.i = 2;
7out.writeObject(test);
8out.close();
9ObjectInputStream oin = new ObjectInputStream(new FileInputStream(
10                    "result.obj"));
11Test t1 = (Test) oin.readObject();
12Test t2 = (Test) oin.readObject();
13System.out.println(t1.i);
14System.out.println(t2.i);

代码的目的是希望将 test 对象两次保存到 result.obj 文件中,写入一次以后修改对象属性值再次保存第二次,然后从 result.obj 中再依次读出两个对象,输出这两个对象的 i 属性值。案例代码的目的原本是希望一次性传输对象修改前后的状态。

结果两个输出的都是 1, 原因就是第一次写入对象以后,第二次再试图写的时候,虚拟机根据引用关系知道已经有一个相同对象已经写入文件,因此只保存第二次写的引用,所以读取时,都是第一次保存的对象。读者在使用一个文件多次 writeObject 需要特别注意这个问题。

1.6ArrayList的序列化

在介绍ArrayList序列化之前,先来考虑一个问题:如何自定义的序列化和反序列化策略带着这个问题,我们来看java.util.ArrayList的源码

1public class ArrayList<E> extends AbstractList<E>
2        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
3{
4    private static final long serialVersionUID = 8683452581122892189L;
5    transient Object[] elementData; // non-private to simplify nested class access
6    private int size;
7}

笔者省略了其他成员变量,从上面的代码中可以知道ArrayList实现了java.io.Serializable接口,那么我们就可以对它进行序列化及反序列化。因为elementData是transient的,所以我们认为这个成员变量不会被序列化而保留下来。我们写一个Demo,验证一下我们的想法:

 1public static void main(String[] args) throws IOException, ClassNotFoundException {
2        List<String> stringList = new ArrayList<String>();
3        stringList.add("hello");
4        stringList.add("world");
5        stringList.add("duomeng");
6        stringList.add("king");
7        System.out.println("init StringList" + stringList);
8        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("stringlist"));
9        objectOutputStream.writeObject(stringList);
10        IOUtils.close(objectOutputStream);
11        File file = new File("stringlist");
12        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
13        List<String> newStringList = (List<String>)objectInputStream.readObject();
14        IOUtils.close(objectInputStream);
15        if(file.exists()){
16            file.delete();
17        }
18        System.out.println("new StringList" + newStringList);
19    }
20//init StringList[hello, world, duomeng, king]

了解ArrayList的人都知道,ArrayList底层是通过数组实现的。那么数组elementData其实就是用来保存列表中的元素的。通过该属性的声明方式我们知道,他是无法通过序列化持久化下来的。那么为什么code 4的结果却通过序列化和反序列化把List中的元素保留下来了呢?

同样上上面所说的那样ArrayList定义了 writeObject和readObject如果没有这样的方法,则默认调用是 ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。来看一下源码。

 1private void readObject(java.io.ObjectInputStream s)
2        throws java.io.IOException, ClassNotFoundException {
3        elementData = EMPTY_ELEMENTDATA;
4        // Read in size, and any hidden stuff
5        s.defaultReadObject();
6        // Read in capacity
7        s.readInt(); // ignored
8        if (size > 0) {
9            // be like clone(), allocate array based upon size not capacity
10            ensureCapacityInternal(size);
11            Object[] a = elementData;
12            // Read in all elements in the proper order.
13            for (int i=0; i<size; i++) {
14                a[i] = s.readObject();
15            }
16        }
17    }

上面的是readObject的源码,下面看一下writeObject的源码

 1private void writeObject(java.io.ObjectOutputStream s)
2        throws java.io.IOException{
3        // Write out element count, and any hidden stuff
4        int expectedModCount = modCount;
5        s.defaultWriteObject();
6        // Write out size as capacity for behavioural compatibility with clone()
7        s.writeInt(size);
8        // Write out all elements in the proper order.
9        for (int i=0; i<size; i++) {
10            s.writeObject(elementData[i]);
11        }
12        if (modCount != expectedModCount) {
13            throw new ConcurrentModificationException();
14        }
15    }

这么做的原因是ArrayList实际上是动态数组,每次在放满以后自动增长设定的长度值,如果数组自动增长长度设为100,而实际只放了少量的元素,那就会序列化多个null元素。为了保证在序列化的时候不会将这么多null同时进行序列化,ArrayList把元素数组设置为transient。为了防止一个包含大量空对象的数组被序列化,为了优化存储,所以ArrayList使用transient来声明elementData。 但是,作为一个集合,在序列化过程中还必须保证其中的元素可以被持久化下来,所以,通过重写writeObject 和 readObject方法的方式把其中的元素保留下来。writeObject方法把elementData数组中的元素遍历的保存到输出流(ObjectOutputStream)中。readObject方法从输入流(ObjectInputStream)中读出对象并保存赋值到elementData数组中。至此,我们先试着来回答刚刚提出的问题。

1.7序列化对单例的破坏

单例模式,是设计模式中最简单的一种。通过单例模式可以保证系统中一个类只有一个实例而且该实例易于外界访问,从而方便对实例个数的控制并节约系统资源。如果希望在系统中某个类的对象只能存在一个,单例模式是最好的解决方案。

可是单例模式真的能够实现实例的唯一性吗?其实不然,很多人知道通过反射是可以破坏这种单例的,同样通过序列化也是可以破坏的。

首先写一个单例的实例

 1package com.duomeng;
2import java.io.Serializable;
3public class Singleton implements Serializable{
4    private volatile static Singleton singleton;
5    private Singleton (){}
6    public static Singleton getSingleton() {
7        if (singleton == null) {
8            synchronized (Singleton.class) {
9                if (singleton == null) {
10                    singleton = new Singleton();
11                }
12            }
13        }
14        return singleton;
15    }
16}

接下来通过序列化来破坏单例

 1package com.duomeng;
2import java.io.*;
3public class SerializableDemo1 {
4    public static void main(String[] args) throws IOException, ClassNotFoundException {
5        //Write Obj to file
6        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("tempFile"));
7        oos.writeObject(Singleton.getSingleton());
8        //Read Obj from file
9        File file = new File("tempFile");
10        ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file));
11        Singleton newInstance = (Singleton) ois.readObject();
12        //判断是否是同一个对象
13        System.out.println(newInstance == Singleton.getSingleton());
14    }
15}
16输出
17//false

证明序列化是可以破坏单例的,但是有没有办法来防止这样的事情发生呢?同样是有办法的。实例如下

 1package com.duomen;
2import java.io.Serializable;
3public class Singleton implements Serializable{
4    private volatile static Singleton singleton;
5    private Singleton (){}
6    public static Singleton getSingleton() {
7        if (singleton == null) {
8            synchronized (Singleton.class) {
9                if (singleton == null) {
10                    singleton = new Singleton();
11                }
12            }
13        }
14        return singleton;
15    }
16    private Object readResolve() {
17        return singleton;
18    }
19}

同样用上面写的测试类来验证一下,不出所料的话会输出true,说明还是之前的那个对象。所以只要在Singleton类中定义readResolve就可以解决该问题。

二总结

本文通过收集在序列化和反序列化过程中遇到的几个常见问题来让大家更进一步的了解序列化和反序列化长度问题和原理,从而可以在以后的工作和学习当中避免采坑,当然文中有些是参考的别人的,有的是自己踩过的坑,如果有描素的不准备,不恰当的,欢迎拍砖指正。

原文地址:https://www.cnblogs.com/cyl048/p/9107936.html