网络传输 | 序列化与反序列化

1,984 阅读20分钟

改不完的 Bug,写不完的矫情。公众号 杨正友 现在专注移动基础平台开发 ,涵盖音视频, APM和信息安全等各个知识领域;只做全网最 Geek 的公众号,欢迎您的关注!精彩内容不容错过~

一. 定义以及相关概念

  1. 由于在系统底层,数据的传输形式是简单的字节序列形式传递,即在底层,系统不认识对象,只认识字节序列,而为了达到进程通讯的目的,需要先将数据序列化,而序列化就是将对象转化字节序列的过程。相反地,当字节序列被运到相应的进程的时候,进程为了识别这些数据,就要将其反序列化,即把字节序列转化为对象

  2. 无论是在进程间通信、本地数据存储又或者是网络数据传输都离不开序列化的支持。而针对不同场景选择合适的序列化方案对于应用的性能有着极大的影响。

  3. 从广义上讲,数据序列化就是将数据结构或者是对象转换成我们可以存储或者传输的数据格式的一个过程,在序列化的过程中,数据结构或者对象将其状态信息写入到临时或者持久性的存储区中,而在对应的反序列化过程中,则可以说是生成的数据被还原成数据结构或对象的过程。

  4. 这样来说,数据序列化相当于是将我们原先的对象序列化概念做出了扩展,在对象序列化和反序列化中,我们熟知的有两种方法,其一是 Java 语言中提供的 Serializable 接口,其二是 Android 提供的 Parcelable 接口。而在这里,因为我们对这个概念做出了扩展,因此也需要考虑几种专门针对数据结构进行序列化的方法,如现在那些个开放 API 一般返回的数据都是 JSON 格式的,又或者是我们 Android 原生的 SQLite 数据库来实现数据的本地存储,从广义上来说,这些都可以算做是数据的序列化

1.1 序列化

将数据结构或对象转换成二进制串的过程。

1.2 反序列化

将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程

1.3 数据结构、对象与二进制串

不同的计算机语言中,数据结构,对象以及二进制串的表示方式并不相同。

数据结构和对象:对于类似 Java 这种完全面向对象的语言,工程师所操作的一切都是对象(Object),来自于类的实例化。在 Java 语言中最接近数据结构的概念,就是 POJO(Plain Old Java Object)或者 Javabean--那些只有 setter/getter 方法的类。而在 C 二进制串:序列化所生成的二进制串指的是存储在内存中的一块数据。C 语言的字符串可以直接被传输层使用,因为其本质上就是以'0'结尾的存储在内存中的二进制串。在 Java 语言里面,二进制串的概念容易和 String 混淆。实际上 String 是 Java 的一等公民,是一种特殊对象(Object)。对于跨语言间的通讯,序列化后的数据当然不能是某种语言的特殊数据类型。二进制串在 Java 里面所指的是 byte[],byte 是 Java 的 8 中原生数据类型之一(Primitive data types)。

1.4 序列化/反序列化的目的

简单的概括

  • 序列化: 主要用于网络传输,数据持久化,一般序列化也称为编码(Encode)
  • 反序列化: 主要用于从网络,磁盘上读取字节数组还原成原始对象,一般反序列化也称为解码(Decode)

具体的讲:

  • 永久的保存对象数据(将对象数据保存在文件当中,或者是磁盘中)
  • 通过序列化操作将对象数据在网络上进行传输(由于网络传输是以字节流的方式对数据进行传输的.因此序列化的目的是将对象数据转换成字节流的形式)
  • 将对象数据在进程之间进行传递(Activity 之间传递对象数据时,需要在当前的 Activity 中对对象数据进行序列化操作.在另一个 Activity 中需要进行反序列化操作讲数据取出)
  • Java 平台允许我们在内存中创建可复用的 Java 对象,但一般情况下,只有当 JVM 处于运行时,这些对象才可能存在,即,这些对象的生命周期不会比 JVM 的生命周期更长(即每个对象都在 JVM 中)但在现实应用中,就可能要停止 JVM 运行,但有要保存某些指定的对象,并在将来重新读取被保存的对象。这是 Java 对象序列化就能够实现该功能。(可选择入数据库、或文件的形式保存)
  • 序列化对象的时候只是针对变量进行序列化,不针对方法进行序列化.
  • 在 Intent 之间,基本的数据类型直接进行相关传递即可,但是一旦数据类型比较复杂的时候,就需要进行序列化操作了.

二. 序列化协议特性

2.1 通用性

  • 技术层面,序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就大大降低了。
  • 流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。

2.2 强健性 / 鲁棒性

  • 成熟度不够
  • 语言 / 平台的不公平性

2.3 可调试性 / 可读性

  • 支持不到位
  • 访问限制

2.4 性能

性能包括两个方面,时间复杂度和空间复杂度。

  • 空间开销(Verbosity), 序列化需要在原有的数据上加上描述字段,以为反序列化解析之用。如果序列化过程引入的额外开销过高,可能会导致过大的网络,磁盘等各方面的压力。对于海量分布式存储系统,数据量往往以 TB 为单位,巨大的的额外空间开销意味着高昂的成本。
  • 时间开销(Complexity),复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。

2.5 可扩展性 / 兼容性

移动互联时代,业务系统需求的更新周期变得更快,新的需求不断涌现,而老的系统还是需要继续维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提供系统的灵活度。

2.6 安全性 / 访问限制

在序列化选型的过程中,安全性的考虑往往发生在跨局域网访问的场景。当通讯发生在公司之间或者跨机房的时候,出于安全的考虑,对于跨局域网的访问往往被限制为基于 HTTP/HTTPS 的 80 和 443 端口。如果使用的序列化协议没有兼容而成熟的 HTTP 传输层框架支持,可能会导致以下三种结果之一:

  • 因为访问限制而降低服务可用性;
  • 被迫重新实现安全协议而导致实施成本大大提高;
  • 开放更多的防火墙端口和协议访问,而牺牲安全性
  • 注意点:Android 的 Parcelable 也有安全漏洞

    最近几个月,Android 安全公告公布了一系列系统框架层的高危提权漏洞,如下表所示。

参考 www.anquanke.com/post/id/103…

三. 几种常见的序列化和反序列化协议

3.1 XML&SOAP

XML 是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点,SOAP(Simple Object Access protocol) 是一种被广泛应用的,基于 XML 为序列化和反序列化协议的结构化消息传递协议

3.2 JSON(Javascript Object Notation)

JSON 起源于弱类型语言 Javascript, 它的产生来自于一种称之为"Associative array"的概念,其本质是就是采用"Attribute-value"的方式来描述对象。实际上在 Javascript 和 PHP 等弱类型语言中,类的描述方式就是 Associative array。JSON 的如下优点,使得它快速成为最广泛使用的序列化协议之一。

  • 这种 Associative array 格式非常符合工程师对对象的理解。
  • 它保持了 XML 的人眼可读(Human-readable)的优点。
  • 相对于 XML 而言,序列化后的数据更加简洁。 来自于的以下链接的研究表明:XML 所产生序列化之后文件的大小接近 JSON 的两倍
  • 它具备 Javascript 的先天性支持,所以被广泛应用于 Web browser 的应用常景中,是 Ajax 的事实标准协议。
  • 与 XML 相比,其协议比较简单,解析速度比较快。
  • 松散的 Associative array 使得其具有良好的可扩展性和兼容性

3.3 Protobuf

Protobuf 具备了优秀的序列化协议的所需的众多典型特征。

  • 标准的 IDL 和 IDL 编译器,这使得其对工程师非常友好。
  • 序列化数据非常简洁,紧凑,与 XML 相比,其序列化之后的数据量约为 1/3 到 1/10。
  • 解析速度非常快,比对应的 XML 快约 20-100 倍。
  • 提供了非常友好的动态库,使用非常简介,反序列化只需要一行代码。

四. Android 程序员该如何选择序列化方案

五. Serializable 接口

是 Java 提供的序列化接口,它是一个空接口:

Serializable 用来标识当前类可以被 ObjectOutputStream 序列化,以及被 ObjectInputStream 反序列化。

5.1 Serializable 入门

Serializable 有以下几个特点:

  • 可序列化类中,未实现 Serializable 的属性状态无法被序列化/反序列化
  • 也就是说,反序列化一个类的过程中,它的非可序列化的属性将会调用无参构造函数重新创建
  • 因此这个属性的无参构造函数必须可以访问,否者运行时会报错
  • 一个实现序列化的类,它的子类也是可序列化的

5.2 serialVersionUID 与兼容性

  • serialVersionUID 的作用 serialVersionUID 用来表明类的不同版本间的兼容性。如果你修改了此类, 要修改此值。否则以前用老版本的类序列化的类恢复时会报错: InvalidClassException
  • 设置方式 在 JDK 中,可以利用 JDK 的 bin 目录下的 serialver.exe 工具产生这个 serialVersionUID,对于 Test.class,执行命令:serialver Test
  • 兼容性问题 为了在反序列化时,确保类版本的兼容性,最好在每个要序列化的类中加入 private static final long serialVersionUID 这个属性,具体数值自己定义。这样,即使某个类在与之对应的对象 已经序列化出去后做了修改,该对象依然可以被正确反序列化。否则,如果不显式定义该属性,这个属性值将由 JVM 根据类的相关信息计算,而修改后的类的计算 结果与修改前的类的计算结果往往不同,从而造成对象的反序列化因为类版本不兼容而失败。 不显式定义这个属性值的另一个坏处是,不利于程序在不同的 JVM 之间的移植。因为不同的编译器实现该属性值的计算策略可能不同,从而造成虽然类没有改变,但是因为 JVM 不同,出现因类版本不兼容而无法正确反序列化的现象出现

因此 JVM 规范强烈 建议我们手动声明一个版本号,这个数字可以是随机的,只要固定不变就可以。同时最好是 private 和 final 的,尽量保证不变。

5.3 Externalizable 接口

简单使用

5.4 序列化与反序列化 Serializable

Serializable 的序列化与反序列化分别通过 ObjectOutputStream 和 ObjectInputStream 进行

5.5 Java 的序列化步骤与数据结构分析

序列化算法一般会按步骤做如下事情:

  • 将对象实例相关的类元数据输出。
  • 递归地输出类的超类描述直到不再有超类。
  • 类元数据完了以后,开始从最顶层的超类开始输出对象实例的实际数据值。
  • 从上至下递归输出实例的数据

格式化后以二进制打开

  • AC ED: STREAM_MAGIC. 声明使用了序列化协议.
  • 00 05: STREAM_VERSION. 序列化协议版本.
  • 0x73: TC_OBJECT. 声明这是一个新的对象.
  • 0x72: TC_CLASSDESC. 声明这里开始一个新 Class。
  • 00 2e: Class 名字的长度.

5.6 readObject/writeObject 原理分析

oos.writeObject(obj)为例分析

  1. ObjectOutputStream 的构造函数设置 enableOverride = false

  1. 所以 writeObject 方法执行的是 writeObject0(obj, false);

  1. 在 writeObject0 方法中,代码非常多,看重点

  2. 在 writeOrdinaryObject(obj, desc, unshared)方法中

  1. writeSerialData 方法,主要执行方法:defaultWriteFields(obj, slotDesc)

  1. 在 ObjectStreamClass 中,ObjectOutputStream(ObjectInputStream)会寻找目标类中的私有的 writeObject(readObject)方法,赋值给变量 writeObjectMethod(readObjectMethod)

5.7 Serializable 需要注意的坑

  • 多引用写入

执行结果:

在默认情况下, 对于一个实例的多个引用,为了节省空间,只会写入一次,后面会追加几个字节代表某个实例的引用。

  • 子类实现序列化,父类不实现序列化/ 对象引用

在 readObject 时抛出 java.io.NotSerializableException 异常。

  • 类的演化

执行结果:

可以看出反序列化之后,并没有报错,只是 height 实赋成了默认值。类似的其它对象也会赋值为默认值。 还有 相反,如果写入的多一个字段,读出的少一个字段,也是不会报错的 其它演化,比如更改类型等,这种演化本身就有问题,没必再探讨

  • 枚举类型

执行结果:

可以看到 ONE 的值变成了 0. 事实上序列化 Enum 对象时,并不会保存元素的值,只会保存元素的 name。这样,在不依赖元素值的前提下,ENUM 对象如何更改都会保持兼容性。

5.8 重写 readObject,writeObject

“只有当你自行设计的自定义序列化形式与默认的序列化形式基本相同时,才能接受默认的序列化> 形式”.“当一个对象的物理表示方法与它的逻辑数据内容有实质性差别时,使用默认序列化形式有> > N 种缺陷”.其实从 effective java 的角度来讲,是强烈建议我们重写的,这样有助于我们更好地把控> > 序列化过程,防范未知风险

执行结果:

  • writeReplace 先于 writeObject
  • readResolve 后于 readObject

5.9 单例模式的序列化问题/反射问题

六. Parcelable 接口

介绍 Parcelable 不得不先提一下 Serializable 接口,Serializable 是 Java 为我们提供的一个标准化的序列化接口,那什么是序列化呢? ---- 简单来说就是将对象转换为可以传输的二进制流(二进制序列)的过程,这样我们就可以通过序列化,转化为可以在网络传输或者保存到本地的流(序列),从而进行传输数据 ,那反序列化就是从二进制流(序列)转化为对象的过程.

Parcelable 是 Android 为我们提供的序列化的接口,Parcelable 相对于 Serializable 的使用相对复杂一些,但 Parcelable 的效率相对 Serializable 也高很多,这一直是 Google 工程师引以为傲的,有时间的可以看一下 Parcelable 和 Serializable 的效率对比 Parcelable vs Serializable 号称快 10 倍的效率

Parcelable 是 Android SDK 提供的,它是基于内存的,由于内存读写速度高于硬盘,因此 Android 中的跨进程对象的传递一般使用 Parcelable

6.1 Parcelable 入门

6.2 Parcel 的简介

在介绍之前我们需要先了解 Parcel 是什么?Parcel 翻译过来是打包的意思,其实就是包装了我们需要传输的数据,然后在 Binder 中传输,也就是用于跨进程传输数据 简单来说,Parcel 提供了一套机制,可以将序列化之后的数据写入到一个共享内存中,其他进程通过 Parcel 可以从这块共享内存中读出字节流,并反序列化成对象,下图是这个过程的模型。

Parcel 可以包含原始数据类型(用各种对应的方法写入,比如 writeInt(),writeFloat()等),可以包含 Parcelable 对象,它还包含了一个活动的 IBinder 对象的引用,这个引用导致另一端接收到一个指向这个 IBinder 的代理 IBinder。

Parcelable 通过 Parcel 实现了 read 和 write 的方法,从而实现序列化和反序列化,

七. Parcelable 与 Serializable 的性能比较

7.1 Serializable 性能分析

Serializable 是 Java 中的序列化接口,其使用起来简单但开销较大(因为 Serializable 在序列化过程中使用了反射机制,故而会产生大量的临时变量,从而导致频繁的 GC),并且在读写数据过程中,它是通过 IO 流的形式将数据写入到硬盘或者传输到网络上。

7.2 Parcelable 性能分析

Parcelable 则是以 IBinder 作为信息载体,在内存上开销比较小,因此在内存之间进行数据传递时,推荐使用 Parcelable,而 Parcelable 对数据进行持久化或者网络传输时操作复杂,一般这个时候推荐使用 Serializable。

7.3 性能比较总结描述

首先 Parcelable 的性能要强于 Serializable 的原因我需要简单的阐述一下

  • 在内存的使用中,前者在性能方面要强于后者
  • 后者在序列化操作的时候会产生大量的临时变量,(原因是使用了反射机制)从而导致 GC 的频繁调用,因此在性能上会稍微逊色
  • Parcelable 是以 Ibinder 作为信息载体的.在内存上的开销比较小,因此在内存之间进行数据传递的时候,Android 推荐使用 Parcelable,既然是内存方面比价有优势,那么自然就要优先选择.
  • 在读写数据的时候,Parcelable 是在内存中直接进行读写,而 Serializable 是通过使用 IO 流的形式将数据读写入在硬盘上. 但是:虽然 Parcelable 的性能要强于 Serializable,但是仍然有特殊的情况需要使用 Serializable,而不去使用 Parcelable,因为 Parcelable 无法将数据进行持久化,因此在将数据保存在磁盘的时候,仍然需要使用后者,因为前者无法很好的将数据进行持久化.(原因是在不同的 Android 版本当中,Parcelable 可能会不同,因此数据的持久化方面仍然是使用 Serializable)

7.4 性能测试方法分析

    • 通过将一个对象放到一个 bundle 里面然后调用 Bundle#writeToParcel(Parcel, int)方法来模拟传递对象给一个 activity 的过程,然后再把这个对象取出来。
  • 在一个循环里面运行 1000 次。
  • 两种方法分别运行 10 次来减少内存整理,cpu 被其他应用占用等情况的干扰。
  • 参与测试的对象就是上面的相关代码
  • 在多种 Android 软硬件环境上进行测试

7.5 两种如何选择

  • 在使用内存方面,Parcelable 比 Serializable 性能高,所以推荐使用 Parcelable。
  • Serializable 在序列化的时候会产生大量的临时变量,从而引起频繁的 GC。
  • Parcelable 不能使用在要将数据存储在磁盘上的情况,因为 Parcelable 不能很好的保证数据的持续性,在外界有变化的情况下,建议使用 Serializable

7.6 SQLite 与 SharedPreferences

  • SQLite 主要用于存储复杂的关系型数据,Android 支持原生支持 SQLite 数据库相关操作(SQLiteOpenHelper),不过由于原生 API 接口并不友好,所以产生了不少封装了 SQLite 的 ORM 框架。

  • SharedPreferences 是 Android 平台上提供的一个轻量级存储 API,一般用于存储常用的配置信息,其本质是一个键值对存储,支持常用的数据类型如 boolean、float、int、long 以及 String 的存储和读取。

八. 最后附带的几个面试相关的问题

8.1 Android 里面为什么要设计出 Bundle 而不是直接用 Map 结构

Bundle内部是由ArrayMap实现的,ArrayMap的内部实现是两个数组,一个int数组是存储对象数据对应下标,一个对象数组保存key和value,内部使用二分法对key进行排序,所以在添加、删除、查找数据的时候,都会使用二分法查找,只适合于小数据量操作,如果在数据量比较大的情况下,那么它的性能将退化。而HashMap内部则是数组+链表结构,所以在数据量较少的时候,HashMap的Entry Array比ArrayMap占用更多的内存。因为使用Bundle的场景大多数为小数据量,我没见过在两个Activity之间传递10个以上数据的场景,所以相比之下,在这种情况下使用ArrayMap保存数据,在操作速度和内存占用上都具有优势,因此使用Bundle来传递数据,可以保证更快的速度和更少的内存占用。另外一个原因,则是在Android中如果使用Intent来携带数据的话,需要数据是基本类型或者是可序列化类型,HashMap使用Serializable进行序列化,而Bundle则是使用Parcelable进行序列化。而在Android平台中,更推荐使用Parcelable实现序列化,虽然写法复杂,但是开销更小,所以为了更加快速的进行数据的序列化和反序列化,系统封装了Bundle类,方便我们进行数据的传输。

8.2 Android 中 Intent/Bundle 的通信原理及大小限制

Intent 中的 Bundle 是使用 Binder 机制进行数据传送的。能使用的 Binder 的缓冲区是有大小限制的(有些手机是 2 M),而一个进程默认有 16 个 Binder 线程,所以一个线程能占用的缓冲区就更小了( 有人以前做过测试,大约一个线程可以占用 128 KB)。所以当你看到 The Binder transaction failed because it was too large 这类 TransactionTooLargeException 异常时,你应该知道怎么解决了

8.3 为何 Intent 不能直接在组件间传递对象而要通过序列化机制?

Intent在启动其他组件时,会离开当前应用程序进程,进入ActivityManagerService进程(intent.prepareToLeaveProcess()),这也就意味着,Intent所携带的数据要能够在不同进程间传输。首先我们知道,Android是基于Linux系统,不同进程之间的java对象是无法传输,所以我们此处要对对象进行序列化,从而实现对象在 应用程序进程 和 ActivityManagerService进程 之间传输。而 Parcel 或者 Serializable 都可以将对象序列化,其中,Serializable 使用方便,但性能不如 Parcel 容器,后者也是 Android 系统专门推出的用于进程间通信等的接口