第二十二章:Java序列化

基本概念

什么是序列化

Java序列化(Serialization),即将Java对象转化为二进制的字节数据,反之就是反序列化。

为什么要序列化

序列化后Java对象变成了字节数据,可以更方便地存储和传输。

  • 当你想要持久化存储数据时(包括文件、数据库、缓存等)
  • 当你需要通过网络传输对象时(包括RMI、RPC等)

如何比较序列化

  1. 序列化后的码流大小:占用网络带宽、存储空间
  2. 序列化的性能:占用CPU、内存
  3. 是否支持跨语言:异构系统的对接和开发语言切换
  4. API使用的难易度:开发和维护的成本

JDK序列化

Java自带的序列化方式,通过 ObjectOutputStream 和 ObjectInputStream 实现。需要使用JDK序列化的类,要在定义时声明 java.io.Serializable 接口或 java.io.Externalizable 接口并生成序列化ID。

优点:

这种序列化的主要优点是JDK原生支持,使用起来非常简便。

缺点:

  1. 无法跨语言。这是Java内置的协议,其他语言并不了解其编码方式。
  2. 序列化后的数据太长。数据中冗余记录了许多的类型信息,导致有效数据占比很低。
  3. 序列化效率低。由于Java序列化采用同步阻塞IO,相对于目前主流的序列化协议,它的效率非常差。

Serializable

声明

首先定义一个类,声明实现 java.io.Serializable 接口。

package foo.bar;

import java.io.Serializable;

public class Foo implements Serializable {

    private static final long serialVersionUID = -1L;

    private int age;

    private String 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;
    }

}

如果类的成员指向了另一个类的对象,被指向的类也要实现 Serializable 接口,否则就无法序列化。

package foo.bar;

import java.io.Serializable;

public class Foo implements Serializable {

    private static final long serialVersionUID = -1L;

    private int age;

    private Bar bar;// Foo 中引用了 Bar,Bar也得实现 Serializable 接口

}

关键字

使用 transient 关键字来标记成员,表示该成员不参与序列化。由于反序列化会忽略 transient 成员,使用时要注意对成员的正确初始化。

使用 final 关键字标识的对象成员,无论是否使用 transient 关键字,都会参与序列化。final 表示只初始化赋值一次,反序列化相当于一个特殊的构造方法,如果此时不给 final 成员赋值,以后就没有机会赋值了。

使用 static 关键字标识的成员,属于类的成员,不属于对象的成员,因此不参与对象的序列化。

例如,下面的 gender、SCORE 就不会被序列化:

package foo.bar;

import java.io.Serializable;

public class Foo implements Serializable {

    private static final long serialVersionUID = -1L;

    private int age;

    private String name;

    private transient int gender;// transient 标识不参与序列化

    private final int bornYear = 2000;// final 标识的成员会参与序列化

    public final static int SCORE = 10;// static 标识不参与序列化
}

serialVersionUID

serialVersionUID 是一个特殊的成员,用于判断序列化文件是否已经失效(过期)。

序列化时这个ID会被编码二进制数据中,反序列化时用来和类中的ID作比较。如果两者不一致,就说明代码被修改过,不可以反序列化。(会抛出 java.io.InvalidClassException 异常)。

实现了 Serializable 接口的类都要定义一个 serialVersionUID 成员,你可以自己给它赋值,可以让IDE自动计算。

如果不赋值的话,JVM会根据类的相关信息(包括:类的全名、类中参与序列化的成员、类的方法)自动计算一个ID。每次修改代码,JVM自动计算的ID都会发生变化。

若是对象序列化后再修改类的代码,有极大几率造成反序列化失败。只加一个 transient 字段是不会有问题的,因为这个成员不参与序列化。但增加方法、减少方法、改属性名、改属性类型都会造成ID变化。

一般来说,假设代码只是做增量更新,最好不要修改 serialVersionUID 的值。

注:JDK 中有一个 serialver 工具,可以用来查看类的 serialVersionUID。

serialver [-classpath 类路径] [-show] [类名称...]

序列化与反序列化

序列化

利用ObjectOutputStream对其序列化,然后保存到文件中:

package foo.bar;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

public class TestSerial {

    private final static String FILENAME = "foo";

    public static void main(String[] args) throws IOException {
        // 创建对象
        Foo obj = new Foo();
        obj.setAge(10);
        obj.setName("tom");

        // 打开文件
        FileOutputStream out = new FileOutputStream(FILENAME);
        ObjectOutputStream oos = new ObjectOutputStream(out);

        // 将对象序列化,并写入文件中
        oos.writeObject(obj);
        oos.flush();

        // 关闭文件
        oos.close();
        out.close();
    }

}

利用 ObjectInputStream 执行反序列化:

package foo.bar;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class TestDeserial {

    private final static String FILENAME = "foo";

    public static void main(String[] args) throws IOException {

        FileInputStream in = new FileInputStream(FILENAME);
        ObjectInputStream ois = new ObjectInputStream(in);

        try {
            Foo obj = (Foo) ois.readObject();
            System.out.printf("age: %d\nname: %s\n", obj.getAge(), obj.getName());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

        ois.close();
        in.close();
    }

}

反序列化时,如果找不到 foo.bar.Foo 这个类会抛出 ClassNotFoundException,如果 serialVersionUID 不一致会抛出 InvalidClassException。

文件结构分析

文件中的二进制数据如下:

aced 0005 7372 000b 666f 6f2e 6261 722e
466f 6fff ffff ffff ffff ff02 0002 4900
0361 6765 4c00 046e 616d 6574 0012 4c6a
6176 612f 6c61 6e67 2f53 7472 696e 673b
7870 0000 000a 7400 0374 6f6d 

上述的内容分为以下几部分:

第一部分是JDK序列化的魔数

前4个字节是固定的,表示这是通过JDK序列化的数据。

  • aced STREAM_MAGIC,声明使用了序列化协议
  • 0005 STREAM_VERSION,声明JDK序列化协议的版本

注意:JDK序列化时,数字的编码采用大端格式(Big Endian)。版本号 “5” 编码为 “0005”,而不是 “0500”。

第二部分用于描述对象的类型信息

  • 73 TC_OBJECT 表示序列化的是一个Java对象
  • 72 TC_CLASSDESC 表示后面是对象的类型信息
  • 000b 表示类名的长度,即 11 字节
  • 66 6f6f 2e62 6172 2e46 6f 接下来11字节是类名,即 "foo.bar.Foo"
  • ff ffff ffff ffff ff 类名后的8字节是一个长整数,即 serialVersionUID = -1L。
  • 02 SC_SERIALIZABLE 标识位,说明这个类实现了Serializable接口。

第三部分是对象的字段表

  • 0002 表示这个对象中有2个属性。
  • 49 即 I 表示 int,说明这是一个32位整数。
  • 00 03 表示属性名的长度,即 3 字节
  • 61 6765 即 "age"
  • 4c 即 L,表示引用类型,说明这个属性是某个类型的引用。
  • 00 04 表示属性名的长度,即 4 字节
  • 6e 616d 65 即 "name"
  • 74 TC_STRING 表示后面是个字符串
  • 0012 表示字符串长度,即 18 字节
  • 4c6a 6176 612f 6c61 6e67 2f53 7472 696e 673b 即"Ljava/lang/String;"

第四部分是父类的描述信息

  • 78 TC_ENDBLOCKDATA 表示这个数据块到此结束。
  • 70 TC_NULL 表示这个类没有父类

如果 foo.bar.Foo 有父类,那么第四部分将重复第二部分的结构,描述父类的信息。

第五部分是对象的属性值

根据第三部分的字段表,可以知道该如何识别属性的值。

第一个属性age,根据字段表知道它是一个32位的整数,

  • 0000 000a 即 10。

第二个属性name,根据类型知道它是一个字符串

  • 74 TC_STRING 表示后面是个字符串
  • 00 03 表示字符串长度为3
  • 74 6f6d 即 "tom"

Externalizable

java.io.Externalizable 继承于 java.io.Serializable 接口。它不会记录对象的字段表,允许程序员自定义对象的编码方式。

package foo.bar;
import java.io.Externalizable;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectOutput;

public class Bar implements Externalizable {

    private static final long serialVersionUID = -1L;

    private int age;

    private String 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;
    }

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeInt(age);
        out.writeUTF(name);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        age = in.readInt();
        name = in.readUTF();
    }

}

数据结构

二进制文件中的数据内容:

aced 0005 7372 000b 666f 6f2e 6261 722e
4261 72ff ffff ffff ffff ff0c 0000 7870
7709 0000 000a 0003 746f 6d78 

第一部分是JDK序列化的魔数

前4个字节是固定的,表示这是通过JDK序列化的数据。

  • aced STREAM_MAGIC,声明使用了序列化协议
  • 0005 STREAM_VERSION,声明JDK序列化协议的版本

第二部分用于描述对象的类型信息

  • 73 TC_OBJECT 表示序列化的是一个Java对象
  • 72 TC_CLASSDESC 表示后面是对象的类型信息
  • 000b 表示类名的长度,即 11 字节
  • 666f 6f2e 6261 722e 4261 72 接下来11字节是类名,即 "foo.bar.Bar"
  • ff ffff ffff ffff ff 类名后的8字节是一个长整数,即 serialVersionUID = -1L。
  • 0c SC_EXTERNALIZABLE | SC_BLOCK_DATA 标识位,说明这个类实现了Externalizable接口,并且使用块数据编码。

第三部分字段表

  • 0000 字段表长度为0,字段表被省掉了。

第四部分父类信息

  • 78 TC_ENDBLOCKDATA 表示这个数据块到此结束。
  • 70 TC_NULL 表示这个类没有父类

第五部分数据值

由于第三部分的字段表没有了,因此二进制数据的字段顺序、字段的数据类型、字段的长度,完全靠代码中的 writeExternalreadExternal 方法来指定。

  • 0000 000a 这是一个整数,即10。
  • 0003 746f 6d78 这是一个长度3字节的UTF8字符串,即"tom"。

XML

XML(Extensible Markup Language)是一种常用的序列化和反序列化协议, 它历史悠久,从1998年的1.0版本被广泛使用至今。

优点

  1. 人机可读性好
  2. 可指定元素或特性的名称

缺点

  1. 序列化数据只包含数据本身以及类的结构,不包括类型标识和程序集信息。
  2. 只能序列化公共属性和字段
  3. 不能序列化方法/接口
  4. 文件庞大,文件格式复杂,传输占带宽

使用场景

  1. 当做配置文件存储数据
  2. WebService,基于 SOAP 协议。

Jackson

下面使用 Jackson 的 jackson-dataformat-xml 模块作为序列化工具。

<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.dataformat/jackson-dataformat-xml -->
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.9.9</version>
</dependency>

序列化

package foo.bar;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;

import java.io.FileOutputStream;
import java.io.IOException;

public class TestSerXml {

    private final static String FILENAME = "foo.xml";

    public static void main(String[] args) throws IOException {
        // 创建对象
        Foo obj = new Foo();
        obj.setAge(10);
        obj.setName("tom");

        // 打开文件
        FileOutputStream out = new FileOutputStream(FILENAME);

        // 序列化
        XmlMapper xmlMapper = new XmlMapper();
        xmlMapper.writeValue(out, obj);

        // 关闭文件
        out.close();
    }

}

反序列化

package foo.bar;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;

import java.io.FileInputStream;
import java.io.IOException;

public class TestDeserXml {

    private final static String FILENAME = "foo.xml";

    public static void main(String[] args) throws IOException {

        // 打开文件
        FileInputStream in = new FileInputStream(FILENAME);

        // 反序列化
        XmlMapper xmlMapper = new XmlMapper();
        Foo obj = xmlMapper.readValue(in, Foo.class);
        System.out.printf("age: %d\nname: %s\n", obj.getAge(), obj.getName());

        // 关闭文件
        in.close();
    }

}

文件中的数据如下:

<Foo><age>10</age><name>tom</name></Foo>

JSON

JSON(JavaScript Object Notation, JS 对象标记) 是一种轻量级的数据交换格式。它基于 ECMAScript (w3c制定的js规范)的一个子集, JSON采用与编程语言无关的文本格式,但是也使用了类C语言(包括C, C++, C#, Java, JavaScript, Perl, Python等)的习惯,简洁和清晰的层次结构使得 JSON 成为理想的数据交换语言。

优点:

  1. 前后兼容性高
  2. 数据格式比较简单,易于读写
  3. 序列化后数据较小,可扩展性好,兼容性好
  4. 与XML相比,其协议比较简单,解析速度比较快

缺点:

  1. 数据的描述性比XML差
  2. 不适合性能要求为ms级别的情况
  3. 额外空间开销比较大

使用场景(可替代XML)

  1. 跨防火墙访问
  2. 可调式性要求高的情况
  3. 基于Web browser的Ajax请求,以及Mobile app与服务端之间的通讯,
  4. 传输数据量相对小,实时性要求相对低(例如秒级别)的服务
  5. 动态类型语言为主的系统

Fastjson

Jackson也常用于对json进行序列化。但本文选择使用alibaba的fastjson作为序列化工具。

<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.58</version>
</dependency>

序列化

package foo.bar;
import com.alibaba.fastjson.JSON;

import java.io.FileOutputStream;
import java.io.IOException;

public class TestSerJson {

    private final static String FILENAME = "foo.json";

    public static void main(String[] args) throws IOException {
        // 创建对象
        Foo obj = new Foo();
        obj.setAge(10);
        obj.setName("tom");

        // 打开文件
        FileOutputStream out = new FileOutputStream(FILENAME);

        // 序列化
        JSON.writeJSONString(out, obj);

        // 关闭文件
        out.close();
    }

}

反序列化

package foo.bar;
import com.alibaba.fastjson.JSON;

import java.io.FileInputStream;
import java.io.IOException;

public class TestDeserJson {

    private final static String FILENAME = "foo.json";

    public static void main(String[] args) throws IOException {

        // 打开文件
        FileInputStream in = new FileInputStream(FILENAME);

        // 反序列化
        Foo obj = JSON.parseObject(in, Foo.class);
        System.out.printf("age: %d\nname: %s\n", obj.getAge(), obj.getName());

        // 关闭文件
        in.close();
    }

}

文件中的数据如下:

{"age":10,"name":"tom"}

protobuf

Protobuf是google开源的项目,全称 Google Protocol Buffers。它将数据结构以.proto文件进行描述,通过代码生成工具可以生成对应数据结构的POJO对象和Protobuf相关的方法和属性。

protostuff 基于protobuf协议,但不需要配置proto文件,直接导包即可使用。

优点:

  1. 结构化数据存储格式(如同xml,json等)
  2. 高性能编解码技术
  3. 语言和平台无关,扩展性好
  4. 支持C++,C#,Dart,Go,Java,JavaScript,Objective-C,PHP,Python,Ruby语言。
  5. 通过标识字段的顺序,可以实现协议的前向兼容

缺点:

  • 需要额外定义 .proto 文件来描述数据结构,使用较为复杂
  • 需要依赖于工具生成代码

适用场景:

  • 对性能要求高的RPC调用
  • 具有良好的跨防火墙的访问属性
  • 适合应用层对象的持久化
  • 静态类型语言

例子

首先创建Foo.proto文件,定义Foo的数据结构。

syntax = "proto3";
package foo.bar;

message Foo {
    int32 age = 1;
    string name = 2;
}

使用 protoc 工具生成Java代码。这个工具需要去 Google 的开发者平台下载,或者下载源码编译。

生成代码的指令如下:

protoc --java_out src/mava/java Foo.proto

protoc 编译器会生成了一个.java文件,以及一个特殊的Builder类(该类是用来创建消息类接口的),用于对Foo类进行序列化和反序列化。

这个代码很长,就不在文章中展示了。默认生成的类名是 FooOuterClass,可以在 Foo.proto 文件中配置生成类的包名和类名。

syntax = "proto3";
package foo.bar;

option java_package = "foo.bar.builder";
option java_outer_classname = "FooBuilder";

message Foo {
    int32 age = 1;
    string name = 2;
}

在Java中使用protobuff,需要依赖protobuf-java模块。

<!-- https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java -->
<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.9.0</version>
</dependency>

序列化

package foo.bar;
import java.io.FileOutputStream;
import java.io.IOException;

import foo.bar.FooOuterClass.Foo;

public class TestSerProto {
    
    private final static String FILENAME = "foo.pb";

    public static void main(String[] args) throws IOException {
        // 创建对象
        Foo obj = Foo.newBuilder()
            .setAge(10)
            .setName("tom")
            .build();

        // 打开文件
        FileOutputStream out = new FileOutputStream(FILENAME);

        // 序列化
        obj.writeTo(out);

        // 关闭文件
        out.close();
    }

}

反序列化

package foo.bar;
import java.io.FileInputStream;
import java.io.IOException;

import foo.bar.FooOuterClass.Foo;

public class TestDeserProto {

    private final static String FILENAME = "foo.pb";

    public static void main(String[] args) throws IOException {

        // 打开文件
        FileInputStream in = new FileInputStream(FILENAME);

        // 反序列化
        Foo obj = Foo.parseFrom(in);
        System.out.printf("age: %d\nname: %s\n", obj.getAge(), obj.getName());

        // 关闭文件
        in.close();
    }

}

文件中的数据如下:

080a 1203 746f 6d
  • 080a 表示整数 10
  • 1203 746f 6d 表示长度为3字节的字符串,即 "tom"

结束

还有很多中序列化协议,本文没有涉及。不同序列化方式的对比,可参考 Github:jvm-serilizers项目的可视化分析。

https://github.com/eishay/jvm-serializers/wiki