深入理解Java序列化机制

4,000 阅读12分钟

1、Java序列化介绍

序列化是指对象通过写出描述自己状态的数值来记录自己的过程,即将对象表示成一系列有序字节,Java提供了将对象写入流和从流中恢复对象的方法。对象能包含其它的对象,而其它的对象又可以包含另外的对象。Java序列化能够自动的处理嵌套的对象。对于一个对象的简单域,writeObject()直接将其值写入流中。当遇到一个对象域时,writeObject()被再次调用,如果这个对象内嵌另一个对象,那么,writeObject()又被调用,直到对象能被直接写入流为止。程序员所需要做的是将对象传入ObjectOutputStream的writeObject()方法,剩下的将有系统自动完成。

要实现序列化的类必须实现的java.io.Serializable或java.io.Externalizable接口,否则将产生一个NotSerializableException。该接口内部并没有任何方法,它只是一个"tagging interface",仅仅"tags"它自己的对象是一个特殊的类型。类通过实现 java.io.Serializable接口以启用其序列化功能。未实现此接口的类将无法使其任何状态序列化或反序列化。可序列化类的所有子类型本身都是可序列化的。序列化接口没有方法或字段,仅用于标识可序列化的语义。Java的"对象序列化"能让你将一个实现了Serializable接口的对象转换成一组byte,这样日后要用这个对象时候,你就能把这些byte数据恢复出来,并据此重新构建那个对象了。

2、序列化必要性及目的

Java中,一切都是对象,在分布式环境中经常需要将Object从这一端网络或设备传递到另一端。这就需要有一种可以在两端传输数据的协议。Java序列化机制就是为了解决这个问题而产生。

Java序列化支持的两种主要特性:

  • Java 的RMI使本来存在于其他机器的对象可以表现出就象本地机器上的行为。
  • 将消息发给远程对象时,需要通过对象序列化来传输参数和返回值

Java序列化的目的(我目前能理解的):

  • 支持运行在不同虚拟机上不同版本类之间的双向通讯;
  • 提供对持久性和RMI的序列化;

3、关于序列化的一些例子

下面我们通过一个简单的例子来看下Java默认支持的序列化。我们先定义一个类,然后将其序列化到文件中,最后读取文件重新构建出这个对象。在序列化一个对象的时候,有几点需要注意下:

  • 当一个对象被序列化时,只序列化对象的非静态成员变量,不能序列化任何成员方法和静态成员变量。
  • 如果一个对象的成员变量是一个对象,那么这个对象的数据成员也会被保存。
  • 如果一个可序列化的对象包含对某个不可序列化的对象的引用,那么整个序列化操作将会失败,并且会抛出一个NotSerializableException。可以通过将这个引用标记为transient,那么对象仍然可以序列化。对于一些比较敏感的不想序列化的数据,也可以采用该标识进行修饰。
    下面我们先通过一个简单的例子来看一下Java内置的序列化过程。
class SuperClass implements Serializable{
    private String name;
    private int age;
    private String email;
    
    public String getName() {
    	return name;
    }
    
    public int getAge() {
    	return age;
    }
    
    public String getEmail() {
    	return email;
    }
    
    public SuperClass(String name,int age,String email) {
    	this.name=name;
    	this.age=age;
    	this.email=email;
    }
}

下面我们来看下main方法里面的序列化过程,代码如下:

public static void main(String[] args) throws IOException,ClassNotFoundException {
    	System.out.println("序列化对象开始!");
    	SuperClass superClass=new SuperClass("gong",27, "1301334028@qq.com");
    	File rootfile=new File("C:/data");
    	if(!rootfile.exists()) {
    		rootfile.mkdirs();
    	}
    	File file=new File("C:/data/data.txt");
    	if(!file.exists()) {
    		file.createNewFile();
    	}
    	FileOutputStream fileOutputStream=new FileOutputStream(file);
    	ObjectOutputStream objectOutputStream=new ObjectOutputStream(fileOutputStream);
    	objectOutputStream.writeObject(superClass);
    	objectOutputStream.flush();
    	objectOutputStream.close();
    	System.out.println("序列化对象完成!");
    	
    	System.out.println("反序列化对象开始!");
    	FileInputStream fileInputStream=new FileInputStream(new File("C:\\data\\data.txt"));
    	ObjectInputStream objectInputStream=new ObjectInputStream(fileInputStream);
    	SuperClass getObject=(SuperClass) objectInputStream.readObject();
    	System.out.println("反序列化对象数据:");
    	
    	System.out.println("name:"+getObject.getName()+"\nage:"+getObject.getAge()+"\nemail:"+getObject.getEmail());
}

代码运行结果如下:

序列化对象开始!
序列化对象完成!
反序列化对象开始!
反序列化对象数据:
name:gong
age:27
email:1301334028@qq.com

通过上面的例子,我们看到Java默认提供了序列化与反序列化机制,对于单个实体类来说,整个过程都是自动完成的,无需程序员进行额外的干预。如果我们想让某些关键的域不参与序列化过程呢?Java提供了方法,接着往下看。

transient关键字与序列化

如果我们现在想让上面SuperClass类走age和email不参与序列化过程,那么只需要在其定义前面加上transient关键字即可:

private transient int age;
private transient String email;

这样我们在进行序列化的时候,字节流中不不包含age和email的数据的,反序列的时候会赋予这两个变量默认值。还是运行刚才的工程,这时候我们结果如下:

序列化对象开始!
序列化对象完成!
反序列化对象开始!
反序列化对象数据:
name:gong
age:0
email:null

自定义序列化过程

如果默认的序列化过程不能满足需求,我们也可以自定义整个序列化过程。这时候我们只需要在需要序列化的类中定义writeObject方法和readObject方法即可。我们还是以SuperClass为例,现在我们添加自定义的序列化过程,transient关键字让Java内置的序列化过程忽略修饰的变量,我们通过自定义序列化过程,还是序列化age和email,我们来看看改动后的结果:

private String name;
private transient int age;
private transient String email;

public String getName() {
	return name;
}

public int getAge() {
	return age;
}

public String getEmail() {
	return email;
}

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

private void writeObject(ObjectOutputStream objectOutputStream) 
		throws IOException {
	objectOutputStream.defaultWriteObject();
	objectOutputStream.writeInt(age);
	objectOutputStream.writeObject(email);
}


private void readObject(ObjectInputStream objectInputStream) 
		throws ClassNotFoundException,IOException {
	objectInputStream.defaultReadObject();
	age=objectInputStream.readInt();
	email=(String)objectInputStream.readObject();
}

运行结果如下:

反序列化对象数据:
name:gong
age:27
email:1301334028@qq.com

我们看到,执行结果和默认的结果是一致的,我们通过自定义序列化机制,修改了默认的序列化过程(让transient关键字失去了作用)。
注意:
细心的同学可能发现了我们在自定义序列化的过程中调用了defaultWriteObject()和defaultReadObject()方法。这两个方法是默认的序列化过程调用的方法。如果我们自定义序列化过程仅仅调用了这两个方法而没有任何额外的操作,这其实和默认的序列化过程没任何区别,大家可以试一下。

4、存在继承关系下的序列化

子类支持序列化,超类不支持序列化

默认情况下是这样的
子类实现了Serializable接口,父类没有,父类中的属性不能序列化(不报错,数据丢失),但是在子类中属性仍能正确序列化。
如果我们想在序列化的时候保存父类的域,那么在序列化子类实例的时候必须显式的保存父类的状态。我们将前面的例子稍作修改:

    class SuperClass{
    protected String name;
    protected int age;
    
    public String getName() {
    	return name;
    }
    
    public int getAge() {
    	return age;
    }
    
    public SuperClass(String name,int age) {
    	this.name=name;
    	this.age=age;
    }
    }
    
    class DeriveClass extends SuperClass implements Serializable{
    private String email;
    private String address;
    
    public DeriveClass(String name,int age,String email,String address) {
    	super(name,age);
    	this.email=email;
    	this.address=address;
    }
    
    public String getEmail() {
    	return email;
    }
    
    public String getAddress() {
    	return address;
    }
    
    private void writeObject(ObjectOutputStream out) throws IOException {  
        out.defaultWriteObject();  
        out.writeObject(name);
        out.writeInt(age);
    }  
    
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {  
        in.defaultReadObject();  
        name=(String)in.readObject();
        age=in.readInt();
    }   
    
    @Override
    public String toString() {
    	return "name:"+getName()+"\nage:"+getAge()+"\nemail:"+getEmail()+"\naddress"+getAddress();
    }
}

main方法我们修改为序列化子类对象即可:

DeriveClass superClass=new DeriveClass("gong",27,"1301334028@qq.com","NJ");
DeriveClass getObject=(DeriveClass) objectInputStream.readObject();
System.out.println("反序列化对象数据:");
System.out.println(getObject);

运行代码发现报错了,报错如下:

Exception in thread "main" java.io.InvalidClassException: com.learn.example.DeriveClass; no valid constructor
	at java.io.ObjectStreamClass$ExceptionInfo.newInvalidClassException(Unknown Source)
	at java.io.ObjectStreamClass.checkDeserialize(Unknown Source)
	at java.io.ObjectInputStream.readOrdinaryObject(Unknown Source)
	at java.io.ObjectInputStream.readObject0(Unknown Source)
	at java.io.ObjectInputStream.readObject(Unknown Source)
	at com.learn.example.RunMain.main(RunMain.java:88)

我们来仔细分析下,为什么会这样。DeriveClass支持序列化,其父类不支持序列化,所以这种情况下,子类在序列化的时候需要额外的序列化父类的域(如果有这个需要的话)。那么在反序列的时候,由于构建DeriveClass实例的时候需要先调用父类的构造函数,然后才是自己的构造函数。反序列化时,为了构造父对象,只能调用父类的无参构造函数作为默认的父对象,因此当我们取父对象的变量值时,它的值是调用父类无参构造函数后的值。如果你考虑到这种序列化的情况,在父类无参构造函数中对变量进行初始化。或者在readObject方法中进行赋值。 我们只需要在SuperClass中添加一个空的构造函数即可:

public SuperClass() {}

父类支持序列化

这种情况下,子类也支持序列化操作的。一般情况下,无需做特殊的操作即可。

5、序列化与serialVersionUID

上面的例子,我们都没有看到这个serialVersionUID这个字段,为什么我们也能正常的序列化也反序列化呢?这是因为Eclipse默认为我们生成了一个序列化ID。
Eclipse下提供了两种生成策略,一个是固定的1L,一个是随机生成一个不重复的long类型数据(实际上是使用JDK工具生成),在这里有一个建议,如果没有特殊需求,就是用默认的1L就可以,这样可以确保代码一致时反序列化成功。
注意:虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化ID是否一致(就是 privatestatic final long serialVersionUID = 1L)虽然两个类的功能代码完全一致,但是序列化ID不同,他们无法相互序列化和反序列化(这种情况特别是在网络传输后,远程建立对象的时候需要注意)

6、序列化存储

通过前面的例子,我们将数据序列化到data.txt文件中,下面我们通过二进制查看工具来看下Java序列化后的字节流是如何存储到文件中的,它的格式是怎么样的?我们将上面的SuperClass类改造下:

class SuperClass implements Serializable{
	
	private static final int serialVersionUID=1;
	
	protected String name;
	protected int age;
	
	public SuperClass() {}
	
	public String getName() {
		return name;
	}
	
	public int getAge() {
		return age;
	}
	
	public SuperClass(String name,int age) {
		this.name=name;
		this.age=age;
	}
	
	 @Override
    public String toString() {
    	return "name:"+getName()+"\nage:"+getAge();
    }
}

写入的数据如下:

SuperClass superClass=new SuperClass("gong",27);

下面我们打开data.txt来看下存储的内容:具体的存储内容如图所示:

下面我们就来详细解释每一步的内容。

第1部分是序列化文件头

  • AC ED:STREAM_MAGIC序列化协议
  • 00 05:STREAM_VERSION序列化协议版本
  • 73:TC_OBJECT声明这是一个新的对象

第2部分是要序列化的类的描述,在这里是SerializableObject类

  • 72:TC_CLASSDESC声明这里开始一个新的class
  • 00 1C:十进制的28,表示class名字的长度是28个字节
  • 63 6F 6D ... 61 73 73:表示的是“com.learn.example.SuperClass”这一串字符,可以数一下确实是28个字节
  • 00 00 00 00 00 00 00 01:SerialVersion,我们在这个类里面设置的值是1,如果我们不设置的话,Eclipse会为我们自动设置一个。
  • 02:标记号,声明该对象支持序列化
  • 00 02:该类所包含的域的个数为2个

第3部分是对象中各个属性项的描述

  • 4C:字符"L",表示该属性是一个对象类型而不是一个基本类型
  • 00 03十进制的3,表示属性名的长度
  • 61 67 65:字符串“age”,属性名
  • 4C:字符"L",表示该属性是一个对象类型而不是一个基本类型
  • 00 04十进制的4,表示属性名的长度
  • 6E 61 6D 65:字符串“name”,属性名
  • 74:TC_STRING,代表一个new String,用String来引用对象

第4部分是该对象父类的信息,如果没有父类就没有这部分。有父类和第2部分差不多

  • 00 12:十进制的18,表示父类的长度
  • 4C 6A 61 ... 6E 67 3B:“L/java/lang/String;”表示的是父类属性
  • 78:TC_ENDBLOCKDATA,对象块结束的标志
  • 70:TC_NULL,说明没有其他超类的标志

第5部分输出对象的属性项的实际值,如果属性项是一个对象,这里还将序列化这个对象,规则和第2部分一样

  • 00 00 00 1B:属性值 age=27
  • 74:TC_STRING,代表一个new String,用String来引用对象
  • 00 04十进制的4,表示属性名的长度
  • 67 6F 6E 67 name属性的值gong
    从以上对于序列化后的二进制文件的解析,我们可以得出以下几个关键的结论:
  • 1、序列化之后保存的是对象的信息
  • 2、被声明为transient的属性不会被序列化,这就是transient关键字的作用
  • 3、被声明为static的属性不会被序列化,这个问题可以这么理解,序列化保存的是对象的状态,但是static修饰的变量是属于类的而不是属于对象的,因此序列化的时候不会序列化它