Java基础(016):序列化和反序列化

  注:官方最正宗的长篇大论般的详细介绍,请移步:[1]Java Object Serialization 。这次偷懒就没仔细研究了,本文只针对Java自带的序列化机制进行介绍分析,这是Java本身的基础知识。序列化这块涉及面比较广,也有很多跨语言的序列化协议,当前可用的序列化协议也挺多的,例如 XML 、 JSON 、 Hession 、 Protobuf 、 kryo 、avro 、 ProtoStuff 、 Thrift 、 FST(Fast Serialization) 、 MsgPack(MessagePack) 等,有兴趣的可自行搜索学习。大家只要记住,很多问题都很难有通用而完美的解决方案,因此很多方案的出现其实都只是为了解决存在的某些问题而提出来的,而具体是什么问题、为什么会有这些问题、如何解决这些问题、可以从哪些方面着手、会不会又导致其他问题等等,只有了解清楚了,你才会明白为什么可以这样处理。笔者后续会再安排相关文章对常用的序列化协议专门进行介绍,待办中 ^_^
 
  本文目录结构如下:

0、前言

  我们知道, Java 应用中创建的所有对象,最终都会随着 GC 或者应用(线程/方法调用等)的终止或者 JVM 的关闭而消失。如果此时需要保存某些对象的状态信息,例如存入文件系统、数据库等进行持久化,这样应用或者 JVM (可以是不同的)在再次需要的时候或者下次启用时就能够继续使用之前的对象状态信息;又或者说,不同的 JVM 之间如果想要使用对方的某些对象信息,例如常见的 RMI(Remote Method Invocation,远程方法调用),则需要(通过网络)相互传输对象,而只有二进制的字节序列流才能够保存到硬盘或者在网络上进行传输,这时候就需要通过某种机制,实现对象与字节序列之间的转换,这种机制就是 Java 的序列化机制,序列化针对的是对象(的状态),Java的序列化机制包括对象的序列化 Serialization 和反序列化 Deserialization ,它是对象与字节序列的相互转换机制。
  此外,既然可以保存对象信息,那利用这个信息就进行对象的复制和克隆,从而拥有对象的一份拷贝,也是可以和有一定应用场景的。

1、什么是序列化

  将 Java 实例对象的瞬时状态转换成字节序列,便于保存(重新/重复利用、持久化存储)或者进行网络传输,之后再将之转换成对应状态的对象,从而在不同的 JVM 中共享实例对象(也可以是不同方法、线程、进程、应用等等),这就是Java的序列化机制,而对象与字节序列之间的转换包括序列化和反序列化:
  • 序列化 Serialization : 将 Java 对象转换成字节序列的过程称为对象的序列化。
  • 反序列化 Deserialization : 将字节序列恢复成 Java 对象的过程称为对象的反序列化。
  总而言之,序列化机制实现了 Java 对象与字节序列的相互转换,序列化和反序列化是一组逆操作,而且序列化的是对象的状态,也即是对象的属性,而不是方法、类变量等,这些是类定义中的,无需序列化。

2、Java序列化的应用

  为什么需要序列化?或者说序列化有什么意义?序列化有什么作用?有什么应用场景?
  可以这么说,很多接口都是有使用价值才存在的、才一直被用的。序列化也是因为有相关的应用场景才会被提出来和一直在使用。
  当对象只需要在 JVM (准确来说是实例化对象的线程)内部使用,确实可以不用考虑到序列化,而当对象需要考虑到持久化存储或者进行网络传输时,就需要通过序列化方式转换成可以存储或者进行网络传输的字节序列。根据前面的介绍,序列化有几个重要的应用场景:
  • ① 持久化对象:将对象状态信息持久化,即把对象的字节序列永久地保存到硬盘上(通常存放在文件里,或者数据库中),此时其他有需要的应用或者 JVM 是可以共用的;
    • 一种很常见的就是,大量数据进行处理且内存不足的时候(排序等),可以把中间过程数据先序列化存储在文件中,等需要用到再重新还原,继续使用。
  • ② 网络传输对象:在网络上传输对象信息,跨进程共享对象,实现进程间的远程通信【RMI(远程方法调用 Remote Method Invocation)、RPC协议,JMS,Socket通信】
    • 当两个进程在进行远程通信时,彼此可以发送各种类型的数据信息,例如文本、图片、音频、视频等。无论是何种类型的数据,都会以二进制序列的形式进行网络传输。发送方需要通过序列化把这个Java对象转换为字节序列,才能在网络上传送;而接收方则需要把字节序列反序列化恢复为 Java 对象才能使用。
  • ③ 深度拷贝对象:通过序列化和反序列化实现对象的深度克隆拷贝
    • 既然序列化之后的字节序列可以保存对象信息,反序列化则恢复对象状态,那利用这个就可以进行对象的复制和克隆,从而拥有对象的一份深拷贝,这对于需要使用拷贝对象的场景来说也是一种可选方案。
    • 不过这里要注意的是,这种对象会受Java序列化机制限制,它本身及其相关属性等都需要实现 Serializable 接口,都可序列化才行。可用于普通数据对象的深度复制拷贝。

3、Java序列化的实现方式和实现步骤

  在 Java 中,实现序列化主要有2种方式:
  • 1、实现 Serializable 接口,然后通过 ObjectOutputStream 和 ObjectInputStream 将对象进行默认的序列化和反序列化。
  • 2、实现 Externalizable 接口,重写 writeExternal 和 readExternal 方法,实现自定义的序列化和反序列化操作。
  只有实现了 Serializable 或 Externalizable 接口的类的对象才能被序列化。Externalizable 接口继承自 Serializable 接口,实现 Externalizable 接口的类完全由自身来控制序列化的行为,而仅实现 Serializable 接口的类可以采用默认的序列化方式 。
  对象序列化包括如下步骤:
  • 1) 创建一个对象输出流,它可以包装一个其他类型的目标输出流,如文件输出流;
  • 2) 通过对象输出流的 writeObject() 方法写对象。
  • java.io.ObjectOutputStream 代表对象输出流,它的 writeObject(Object obj) 方法可对参数指定的 obj 对象进行序列化,把得到的字节序列写到一个目标输出流中。
  对象反序列化的步骤如下:
  • 1) 创建一个对象输入流,它可以包装一个其他类型的源输入流,如文件输入流;
  • 2) 通过对象输入流的 readObject() 方法读取对象。
  • java.io.ObjectInputStream 代表对象输入流,它的 readObject() 方法从一个源输入流中读取字节序列,再把它们反序列化为一个对象,并将其返回。
  下面通过案例进行说明。

3.1、Serializable

  Serializable 是个空接口,它是一个比较常用的标记接口,用于表示实现该接口的类可以按照默认规则被序列化,实际上如果看 ObjectOutputStream.writeObject 源码的话,可以看到会有 obj instanceof Serializable 的判断,这就是使用到的地方。实现 Serializable 接口进行序列化的案例如下:
  首先是实现 Serializable 接口的待序列化的 Data 类:
public class Data implements Serializable{

    private static final long serialVersionUID = 123L;

    // 静态变量/类变量,都不会参与序列化
    public static String one;
    public static String two = "2";
    public static transient String three;
    public static transient String four = "4";
    // 静态常量需要在声明时直接初始化或者在静态块中初始化,类加载时完成
    static final String five;
    static final transient String six;
    
    // 静态语句块,类加载时执行
    static {
        System.out.println("Data static block executes");
        three = "3";
        five = "5";
        six = "6";
    }
    
    public static void printStaticField() {
        System.out.println(one);
        System.out.println(two);
        System.out.println(three);
        System.out.println(four);
        System.out.println(five);
        System.out.println(six);
    }
    
    // 普通的实例常量,可以序列化
    private int n;
    private String msg;
    // final 实例变量需要在语句块/构造器中初始化
    final String seven;
    // final transient 变量,没有序列化
    final transient String eight;
    final transient String eightx = "8x";
    // 普通的transient变量,没有序列化
    public transient String night;
    
    // 语句块,实例化时执行
    {
        System.out.println("Data instance block executes");
        seven = "7";
        eight = "8";
    }
    
    public void printInstanceField() {
        System.out.println(seven);
        System.out.println(eight);
        System.out.println(eightx);
        System.out.println(night);
    }
    
    public Data(){
        System.out.println("Data non-arg Constructor executes");
    }
    
    public Data(int n, String msg) {
        super();
        this.n = n;
        this.msg = msg;
        System.out.println("Data Constructor executes");
    }
    // getter / setter / toString 。。。
}

   然后就是测试类进行序列化和反序列化的测试验证:

public class DataTest {
    
    public static void main(String[] args)
            throws FileNotFoundException, ClassNotFoundException, IOException {
        // 序列化
        serialize();
        // 反序列化
//        deserialize();
    }
    
    private static final String DATA_FILE = "DataTest.txt";
    
    /**
     * 序列化
     * @throws FileNotFoundException
     * @throws IOException
     */
    public static void serialize() throws FileNotFoundException, IOException {
        Data data = new Data(123, "data-serialization");
        // 静态变量不参与序列化,反序列化后,已加载的类变量值是啥还是啥
        Data.one = "11";    // 反序列化后是 null ,因为类加载时没有初始化,其他则是2/3/4/5/6
        Data.three = "33";
        Data.four = "44";
        // transient变量不参与序列化
        data.night = "99";
        Data.printStaticField();
        data.printInstanceField();
        // 输出结果是  11 2 33 44 5 6 7 8 8x 99
        System.out.println(data);
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(DATA_FILE));
        oos.writeObject(data);
        oos.close();
        System.out.println("Data has been serialized successfully");
    }
    
    /**
     * 反序列化
     * @throws FileNotFoundException
     * @throws IOException
     * @throws ClassNotFoundException
     */
    public static void deserialize() throws FileNotFoundException, IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(DATA_FILE));
        Data data = (Data) ois.readObject();
        ois.close();
        System.out.println("Data has been deserialized successfully! The result is ");
        Data.printStaticField();
        data.printInstanceField();
        // 输出结果是  null 2 3 4 5 6 7 null 8x null
        // 反序列化后语句块和构造器没有执行,说明单独的 final 变量(这里是7)可以序列化
        System.out.println(data);
    }
}

3.2、Externalizable

  Externalizable 接口继承了 Serializable 接口,替我们封装了两个方法,一个用于序列化,一个用于反序列化。这种方式可以自定义属性序列化的顺序和是否序列化,注意这种方式transient 修饰词将失去作用,也就是说被 transient 修饰的属性,只要你在 writeExternal 方法中序列化了该属性,照样也会得到序列化,因此可以用于自定义序列化。
  需要注意的是,Externalizable 实现类在反序列化时,需要调用类的无参构造函数,因此需要有默认的 public 无参构造函数
  这里就简单看下案例就行了,实现 Externalizable 接口的 DataExt 类: 
public class DataExt implements Externalizable{

    private int n;
    private String msg;
    
    // 反序列化需要使用,如果注释掉会报错 InvalidClassException : no valid constructor
    public DataExt(){}
    
    public DataExt(int n, String msg) {
        super();
        this.n = n;
        this.msg = msg;
    }

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeInt(n);
        out.writeObject(msg);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        this.n = in.readInt();
        this.msg = (String) in.readObject();
    }
    
    // getter / setter / toString 。。。
}

  测试类:

public class DataExtTest {
    
    public static void main(String[] args)
            throws FileNotFoundException, IOException, ClassNotFoundException {
        // 序列化
        DataExtTest.serialize();
        
        // 反序列化
        DataExtTest.deserialize();
    }
    
    private static final String DATAEXT_FILE = "DataExtTest.txt";
    
    /**
     * DataExt 序列化
     * @throws FileNotFoundException
     * @throws IOException
     */
    public static void serialize()
            throws FileNotFoundException, IOException {

        DataExt dataExt = new DataExt(123, "dataExt-serialization");
        System.out.println(dataExt);
        ObjectOutputStream oos =
                new ObjectOutputStream(new FileOutputStream(DATAEXT_FILE));
        oos.writeObject(dataExt);
        oos.close();
        System.out.println("DataExt has been serialized successfully");
    }
    
    /**
     * DataExt 反序列化
     * @throws FileNotFoundException
     * @throws IOException
     * @throws ClassNotFoundException
     */
    public static void deserialize()
            throws FileNotFoundException, IOException, ClassNotFoundException {

        ObjectInputStream ois =
                new ObjectInputStream(new FileInputStream(DATAEXT_FILE));
        DataExt dataExt = (DataExt) ois.readObject();
        ois.close();
        System.out.println("DataExt has been deserialized successfully! The result is ");
        System.out.println(dataExt);
    }
}

3.3、Serializable和Externalizable的区别比较

通过实现 Serializable 和 Externalizable 接口的序列化方式的对比如下:
Serializable
Externalizable
由Java底层支持,自动存储必要的信息
需要自定义序列化和反序列化操作
所有对象信息由Java统一保存,性能不高
自定义需要保存的对象信息,可以提升转换速度
序列化后字节流占用空间大,性能差
自定义存储,占用空间相对会较少,性能相对较高

4、Java序列化特性

  实际应用中,有时候需要忽略某些敏感数据或者进行脱敏,不能使用默认的序列化机制,这个时候就需要用到一些相关特性了,例如指定属性为 transient ,Java 默认不会将 transient 属性序列化。 

4.1、transient关键字

当一个字段被声明为 transient 时,Java默认的序列化机制会自动忽略该字段的内容,不会保存相关的信息。这时候如果需要序列化,则需要自定义序列化规则。这也是很多类内部属性定义为 transient 又重写了 writeObject(java.io.ObjectOutputStream s) 方法的原因,例如集合框架,包括 ArrayList 等。 

4.2、static关键字

静态变量不会被序列化。序列化仅针对的是对象状态,也即是实例变量,而且是非瞬时 transient 的实例变量,静态变量不在此范围内,不在序列化的范畴。 

4.3、serialVersionUID

根据 Serializable 接口的 API-docs 说明(这里简单翻译下,具体参考 [4]java.io.Serializable 的说明或者直接看源码的注释):
序列化运行时会将每个可序列化的类与版本号关联在一起,这个版本号称为 serialVersionUID 。在反序列化过程中使用 serialVersionUID 来验证序列化对象的发送方和接收方是否为该对象加载了与序列化兼容的类。如果接收方为该对象加载的类的 serialVersionUID 与发送方的不一致,则反序列化将导致 InvalidClassException 。可序列化类可以通过显式声明一个 serialVersionUID 属性作为版本号,而且必须声明为 static final long ,例如: 
ANY-ACCESS-MODIFIER static final long serialVersionUID = 42L;

   如果没有显式声明 serialVersionUID ,则序列化运行时将根据类的各个方面为该类计算默认的 serialVersionUID 值,具体参考 [2]4.6 Stream Unique Identifiers 。强烈建议所有 Serializable 类都显式定义自己的 serialVersionUID ,因为默认的 serialVersionUID 计算对类细节高度敏感,可能会因编译器实现而异,存在兼容性问题,导致在反序列化过程中可能出现意外的 InvalidClassException 。因此,要保证不同的Java编译器实现中保持一致的 serialVersionUID ,就需要显式声明 serialVersionUID 。另外还强烈建议尽可能将 serialVersionUID 声明为 private ,因为这个只适用于直接声明它的类,不适合作为继承成员。数组类不能声明显式的 serialVersionUID ,因此他们使用默认计算值,但是数组类不需要匹配 serialVersionUID 。

  注:序列化版本号 serialVersionUID 其实就是为了实现(可能是不同的)Java编译器中序列化与反序列化所使用的类的兼容性,一个是序列化类本身的兼容性、一个是不同Java编译器实现之间的兼容性。类的升级改动、不同的编译器实现都可能导致默认计算的版本号不兼容,从而导致反序列化失败,而显式定义的 serialVersionUID 则是固定的。类本身的兼容性有以下两种用途:
  • 某些场景下,希望类的不同版本对序列化兼容,需要确保类的不同版本由相同的 serialVersionUID ,实现兼容。例如增加字段等,但仍然保持兼容。
  • 某些场景下,不希望类的不同版本对序列化兼容,则可以定义不同的 serialVersionUID 。
  注:有提到在Class类文件中默认会有一个serialNo作为序列化对象的版本号来进行验证,暂未研究 ^_^ 

4.4、序列化规则及注意事项

  • 1、序列化只针对对象状态,即实例属性,而且是非瞬时 transient 的实例属性。
  • 2、transient 标记的变量在默认的序列化过程中会被自动忽略。
    • 反序列化后是默认值,基本类型是 0 或者 false ,引用类型则是 null
    • 为什么需要使用 transient 变量?
      • 不希望被序列化,例如密码信息等
      • 希望自定义序列化,例如 ArrayList 的内部元素就是 transient 标记的,自定义了序列化方式,它只需要序列化实际已初始化的元素就行,并非所有。
      • 注:默认序列化机制下,transient 变量被完全隔离了。
  • 3、static 静态变量不会序列化
    • static 变量如果声明时已经进行了初始化,无论后面如何修改,在相同的类加载器中,反序列化后其实都是共同的引用值,因为这是类层面的,不属于对象的状态。
    • 如果是不同的类加载器或者JVM,比如在 main 函数中修改了静态变量,序列化结束退出,然后另一个 main 函数反序列化,则反序列化后还是类加载时的初值,因为同样是类层次的,只是重新加载了。
    • 对于 static transient 变量同样适用,这本身是类变量,类加载后就被初始化,不需要依靠序列化。 transient 声明其实多余了。
    • 对于 static final transient 变量同样适用。 transient 声明其实多余了。
  • 4、普通的 final 变量可以被序列化,而 final transient 变量不参与序列化
    • final transient 变量如果声明时进行初始化,则反序列化后都是一样的初始化值,猜测因为这是类定义中存储的,实例化时默认值。
    • final transient 变量如果声明时没有初始化,而是在语句块或者构造器中初始化,则反序列化后会使用默认值,因为反序列化不会执行语句块和构造器。
  • 5、默认的反序列化没有调用类的构造方法
    • 可序列化类本身可以不需要无参构造器,但是,假如父类没有实现 Serializable 接口,也不会序列化,但是要提供默认的无参构造器,因为反序列化时需要先构造父对象,否则会抛出异常。
    • 如果父类实现了 Serializable 接口,则父类相关属性会跟着序列化。
  • 6、父类可序列化,子类自动可序列化,不需要再显式实现 Serializable 接口。
  • 7、如果某个属性不是基本数据类型,也没有标记为可序列化,会导致整个对象无法实例化,抛出 NotSerializableException 异常。
  • 8、当一个对象的实例变量引用其他对象,序列化该对象时也会把引用对象进行序列化,这就是可以解决深度拷贝的重要原因。
  • 9、存储规则:对同个实例对象序列化之后,再改变其属性,再次序列化,得到的都是同个实例对象的引用。并不会因为对象属性的改变而变化,后面的序列化内容只是增加了对前面已经序列化过的信息的引用。
    • 由于Java序列化算法不会重复序列化同一个对象,只会记录已序列化对象的序列化编号。而当一个可变的对象中的内容发生改变时,此时进行序列化,却不会重新将此对象转换为字节序列,而是保存序列化编号。
    • 同一个对象两次(开始写入文件到最终关闭流的这个过程算一次),如果不关闭流写入文件两次,则第二次写入对象时文件只增加5字节,即对前面一个的引用。
    • Java序列化机制为了节省磁盘空间,具有特定的存储规则,当写入文件为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用,上面增加的5字节的存储空间就是新增引用和一些控制信息的空间。反序列化时,恢复引用关系,该存储规则极大的节省了存储空间。
  • 10、为什么有些对象不需要序列化?
    • 安全方面的原因考虑:序列化过程中所有可写的状态都会进行处理,即便是 private 域,没有声明为 transient ,也会进行序列化,不受保护。
      • 此时建议实现 Externalizable 接口,定义序列化的属性,也可以对敏感信息进行加密处理。
    • 资源分配方面的原因:例如 socket、thread、connection 等,如果可以序列化,进行传输或者保存,也无法对他们重新进行资源分配,而且,也是没有必要这样实现。 

5、序列化的底层原理

  其实,像序列化和反序列化,无论这些可逆操作是什么机制,都会有对应的处理和解析协议,例如加密和解密,TCP的粘包和拆包,序列化机制是通过序列化协议来进行处理的,和 class 文件类似,它其实是定义了序列化后的字节流格式,然后对此格式进行操作,生成符合格式的字节流或者将字节流解析成对象。
  Java序列化机制中的字节码定义主要参考 java.io.ObjectStreamConstants 中的相关定义(下面已列出来部分说明):
常量
API docs
 
序列化字节流的魔数和版本号
STREAM_MAGIC
Magic number that is written to the stream header.
魔数,序列化字节流的头部标识
0xaced
STREAM_VERSION
Version number that is written to the stream header.
字节流的版本号
5
Each item in the stream is preceded by a tag 流中的每个项目前面都有一个标记
TC_BASE
First tag value.
第一个标记值
0x70
TC_NULL
Null object reference.
null 对象引用
0x70
TC_REFERENCE
Reference to an object already written into the stream.
已写入流的对象引用
0x71
TC_CLASSDESC
new Class Descriptor.
Class 描述符,表示当前的对象的类型信息
0x72
TC_OBJECT
new Object.
表示序列化的是一个普通的 Object 对象
0x73
TC_STRING
new String.
表示序列化的是一个普通的 String 对象
0x74
TC_ARRAY
new Array.
表示序列化的是一个普通的数组 Array 对象
0x75
TC_CLASS
Reference to Class.
 
0x76
TC_BLOCKDATA
Block of optional data. Byte following tag indicates number of bytes in this block data.
 
0x77
TC_ENDBLOCKDATA
End of optional block data blocks for an object.
标志所有的字段类型信息描述结束
0x78
TC_RESET
Reset stream context. All handles written into stream are reset.
 
0x79
TC_BLOCKDATALONG
long Block data. The long following the tag indicates the number of bytes in this block data.
 
0x7A
TC_EXCEPTION
Exception during write.
 
0x7B
TC_LONGSTRING
Long string.
 
0x7C
TC_PROXYCLASSDESC
new Proxy Class Descriptor.
 
0x7D
TC_ENUM
new Enum constant.
 
0x7E
TC_MAX
Last tag value.
 
0x7E
baseWireHandle
First wire handle to be assigned.
 
0x7e0000
Bit masks for ObjectStreamClass flag.
SC_WRITE_METHOD
Bit mask for ObjectStreamClass flag. Indicates a Serializable class defines its own writeObject method.
 
0x01
SC_BLOCK_DATA
Bit mask for ObjectStreamClass flag. Indicates Externalizable data written in Block Data mode.
Added for PROTOCOL_VERSION_2.
 
0x08
SC_SERIALIZABLE
Bit mask for ObjectStreamClass flag. Indicates class is Serializable.
标识位,说明这个类实现了 Serializable 接口
0x02
SC_EXTERNALIZABLE
Bit mask for ObjectStreamClass flag. Indicates class is Externalizable.
标识位,说明这个类实现了 Externalizable 接口
0x04
SC_ENUM
Bit mask for ObjectStreamClass flag. Indicates class is an enum type.
标识位,说明这个类是枚举类型
0x10
Security permissions
SUBSTITUTION_PERMISSION
Enable substitution of one object for another during serialization/deserialization.
   
SUBCLASS_IMPLEMENTATION_PERMISSION
Enable overriding of readObject and writeObject.
   
PROTOCOL_VERSION_1
A Stream Protocol Version.
All externalizable data is written in JDK 1.1 external data format after calling this method.
This version is needed to write streams containing Externalizable data that can be read by pre-JDK 1.1.6 JVMs.
 
1
PROTOCOL_VERSION_2
A Stream Protocol Version.
This protocol is written by JVM 1.2. Externalizable data is written in block data mode and is terminated with TC_ENDBLOCKDATA.
Externalizable class descriptor flags has SC_BLOCK_DATA enabled. JVM 1.1.6 and greater can read this format change.
Enables writing a nonSerializable class descriptor into the stream. The serialVersionUID of a nonSerializable class is set to 0L.
 
2
  下面这个是前面案例 Data 序列化的二进制流的十六进制显式格式,下面简单翻译前面一些,有兴趣可以自行对照着进行翻译。
  • aced :STREAM_MAGIC ,魔数,序列化字节流的头部标识
  • 0005 :STREAM_VERSION ,字节流的版本号
  • 73 :TC_OBJECT 表示序列化的是一个Java对象
  • 72 :TC_CLASSDESC 表示后面是对象的类型信息
  • 0026 :表示类型的长度,即38个字节,也就是 63 6e 2e 77 70 62 78 69 6e 2e 6a 61 76 61 62 61 73 69 73 2e 73 65 72 69 61 6c 69 7a 61 74 69 6f 6e 2e 44 61 74 61
  • 20 20 20 20 20 20 20 7b :这个就是8字节的长整数,即 Data 中定义的 serialVersionUID = 123L
  • 02 :SC_SERIALIZABLE 标识位,说明这个类实现了Serializable接口
  • 00 03 :表示这个对象有3个可序列化的实例属性
  • 。。。。
  具体的格式可以参考(或者自行搜索), API 和 Docs 都有相关说明,这里就不再具体说明了:

6、Java序列化的缺陷

注:参考[13]
  • 无法跨平台:异构系统的对接
    • 现在的系统设计越来越多元化,项目里可能会用多种语言来编写应用程序,比如 Java、C++、Python、Go 同时配合使用。而 Java 序列化是Java内置的协议,只适用于基于 Java 语言实现的框架,其他语言大部分没有使用 Java 的序列化框架。如果两个基于不同语言编写的应用程序相互通信,那么就无法实现两个应用服务之间的序列化与反序列化。
  • 容易被攻击
    • 对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。这意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。
    • 对于需要长时间进行反序列化的对象,不需要执行任何代码,也可以发起一次攻击。攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致 hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。
Set root = new HashSet();
Set s1 = root;
Set s2 = new HashSet();
for (int i = 0; i < 100; i++) {
Set t1 = new HashSet();
Set t2 = new HashSet();
    t1.add("foo"); //使t2不等于t1
    s1.add(t1);
    s1.add(t2);
    s2.add(t1);
    s2.add(t2);
    s1 = t1;
    s2 = t2;
}
    • 字节流可以被恶意修改
      • 序列化和反序列化中间过程中,如果字节流被恶意修改,反序列化时可能会有一定风险,可以自定义 private void readObject(ObjectInputStream objectInputStream) 进行控制,例如加属性校验。
      • 对序列化的流数据进行加密
      • 在传输过程中使用 TLS 加密传输
      • 对序列化数据进行完整性校验
  • 序列化后的流太大:占用网络带宽影响传输、存储空间
    • 数据中冗余记录了许多的类型信息,导致有效数据占比很低。
    • 序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。
  • 序列化的性能太差:占用CPU、内存、耗时长
    • 由于Java序列化采用同步阻塞IO,Java 的序列化耗时比较大。序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间。
  • 单例模式与序列化
    • 首先抛出一个问题,单例模式真的能够实现实例的唯一性吗?
    • 答案是否定的,很多人都知道反射可以恶意破坏单例模式。其实除了反射以外,使用序列化与反序列化也同样会破坏掉单例。前提是单例可序列化,比如下面这个单例:

public class Singleton implements Serializable{
    private static final Singleton singleInstance = new Singleton();
    
    private Singleton() {}
    
    public static final Singleton getInstance(){return singleInstance; }
}
  • 上边这种情况,其实已经破坏掉单例。因为序列化会直接分配内存并通过二进制流保存的属性复制然后返回一个新的对象,从而破坏了单例模式,一个比较折中的处理办法就是添加 readResolve() 方法,返回指定的对象。通过JDK源码可以看出,虽然 readResolve 方法返回实例解决了单例模式被破坏的问题, 但实际上还是实例化了两次,只不过新创建的对象没有被返回而已。如果创建对象的动作发生频率加快,就意味着内存分配开销也会随之增大,应该使用注册式单例来解决这个问题,包括枚举式单例、容器缓存式单例,有兴趣的可自行搜索。 
public class Singleton implements Serializable{
    
    public static void main(String[] args) throws FileNotFoundException, IOException, ClassNotFoundException {
        ObjectOutputStream oos = new ObjectOutputStream(
                new FileOutputStream("Singleton.out"));
        oos.writeObject(Singleton.getInstance());
        oos.close();
        
        ObjectInputStream ooi = new ObjectInputStream(
                new FileInputStream("Singleton.out"));
        Singleton singleton2 = (Singleton) ooi.readObject();
        ooi.close();
        System.out.println(singleton2 == Singleton.getInstance());
    }
    
    private static final long serialVersionUID = 124L;
    
    private static final Singleton singleInstance = new Singleton();
    
    private Singleton() {}
    
    public static final Singleton getInstance(){return singleInstance; }
    
    private Object readResolve() {
        return Singleton.singleInstance;
    }
}
  •  注:观察上面这个单例输出的二进制流文件,其实可以发现根本就没有存任何东西。8字节的 0x000000000000007c 就是序列号 124L ,而 02 表示实现了 Serializable 接口, 接下来的 0000 则表示没有可序列化的属性。毕竟这是个没有任何实例属性的单例。

注:单例本身就是一个对象,将这个对象序列化之后,再进行反序列化,其实就是在直接再创建一个同样的对象实例,因此破坏了单例模式。
注:反射可以通过修改私有构造函数的可见性,再次构造实例。

7、序列化技术选型考虑和常见的序列化协议

选型考虑:
  • 序列化后的数据可读性:定位和验证问题
  • 序列化和反序列化的性能、速度:占用CPU、内存等
  • 序列化后的信息密度,即占用空间大小:占用网络带宽、存储空间
  • 是否支持跨语言:异构系统的对接和开发语言切换
  • 实现序列化的复杂度和API使用难易度:开发和维护成本
常见的序列化协议:
  • XML
  • JSON
  • Hession
  • Protobuf
  • kryo
  • avro
  • ProtoStuff
  • Thrift
  • FST(Fast Serialization)
  • MsgPack(MessagePack
 
注:本文只介绍Java序列化和反序列化的基础知识,这里就不再对此进行展开了。 

8、其他

序列化协议选型、遇到的坑等,可先自行搜索 ^_^ 

9、参考

 
其他:
 

 

posted @ 2021-04-01 00:05  心明谭  阅读(514)  评论(0编辑  收藏  举报