目录导航
前言
这一节,我们主要对计算机网络协议以及序列化做一个知识整理。
另外,有兴趣的同学可以看一下这几本书
链接:推荐PDF
Tips:
1.通信协议在分布式架构中的核心应用
2.深入了解 TCP/IP 和 UDP/IP 通信协议
3.TCP 流量整形
4.基于 Java 自身技术实现系统通信
5.多任务处理及优化
6.了解什么是 NIO
7.组播协议 Multicast
网络领域的知识
a)协议:tcp、udp、multicast
b)IO(BIO、NIO、AIO)
c)Socket
d)NIO(Netty/Mina)
e)序列化和反序列化
一个 http 请求,在整个网络中的请求过程
当应用程序用TCP传送数据时,数据被送入协议栈中,然后逐个通过每一层直到被当作一串比特流送入网络。其中每一层对收到的数据都要增加一些首部信息
当目的主机收到一个以太网数据帧时,数据就开始从协议栈中由底向上升,同时去掉各层协议加上的报文首部。每层协议盒都要去检查报文首部中的协议标识,以确定接收数据的上层协议。这个过程称作分用
为什么有了 MAC 层还要走 IP 层呢?
mac 地址就好像个人的身份证号,人的身份证号和人户口所在的城市,出生的日期有关,但是和人所在的位置没有关系,人是会移动的,知道一个人的身份证号,并不能找到它这个人,mac 地址类似,它是和设备的生产者,批次,日期之类的关联起来,知道一个设备的 mac,并不能在网络中将数据发送给它,除非它和发送方的在同一个网络内。所以要实现机器之间的通信,还需要有 ip 地址的概念,ip 地址表达的是当前机器在网络中的位置,类似于城市名+道路号+门牌号的概念。通过 ip 层的寻址,我们能知道按何种路径在全世界任意两台 Internet 上的的机器间传输数据。
IP 协议和 TCP/UDP 协议
什么是协议
协议相当于两个需要通过网络通信的程序达成的一种约定,它规定了报文的交换方式和包含的意义。比如(HTTP)为了解决在服务器之间传递超文本对象的问题,这些超文本对象在服务器中创建和存储,并由 Web 浏览器进行可视化,完成用户对远程内容的感知和体验
什么是 IP 协议
T C P 和 U D P 是两种最为著名的传输层协议,他们都是使用 I P 作为网络层协议。IP 协议提供了一组数据报文服务,每组分组报文都是由网络独立处理和分发,就像寄送快递包裹一样,为了实现这个功能,每个 IP 报文必须包含一个目的地址的字段;就像我们寄送快递都需要写明收件人信息,但是和我们寄送快递一样,也可能会出现包裹丢失问题,所以 IP 协议只是一个“尽力而为”的协议,在网络传输过程中,可能会发生报文丢失、报文顺序打乱,重复发送的情况。IP 协议层之上的传输层,提供了两种可以选择的协议,TCP、UPD。这两种协议都是建立在 IP 层所提供的服务基础上,根据应用程序的不同需求选择不同方式的传输;
TCP/IP
TCP 协议能够检测和恢复 IP 层提供的主机到主机的通信中可能发生的报文丢失、重复及其他错误。TCP 提供了一个可信赖的字节流通道,这样应用程序就不需要考虑这些问题。同时,TCP 协议是一种面向连接的协议,在使用 TCP 进行通信之前,两个应用程序之间需要建立一个 TCP 连接,而这个连接又涉及到两台电脑需要完成握手消息的交换。
UDP/IP
UDP 协议不会对 IP 层产生的错误进行修复,而是简单的扩展了 IP 协议“尽力而为”的数据报文服务,使他能够在应用程序之间工作,而不是在主机之间工作,因此使用 UDP 协议必须要考虑到报文丢失,顺序混乱的问题
TCP 是如何做到可靠传输的?
建立可靠的链接
由于 TCP 协议是一种可信的传输协议,所以在传输之前,需要通过三次握手建立一个连接,所谓的三次握手,就是在建立 TCP 链接时,需要客户端和服务端总共发送 3 个包来确认连接的建立
TCP 四次挥手协议
四次挥手表示 TCP 断开连接的时候,需要客户端和服务端总共发送 4 个包以确认连接的断开;客户端或服务器均可主动发起挥手动作(因为 TCP 是一个全双工协议),在 socket 编程中,任何一方执行 close() 操作即可产生挥手操作。
tips:为什么连接的时候是三次握手,关闭的时候却是四次握手?
三次握手是因为因为当 Server 端收到 Client 端的 SYN 连接请求报文后,可以直接发送 SYN+ACK 报文。其中 ACK报文是用来应答的,SYN 报文是用来同步的。但是关闭连接时,当 Server 端收到 FIN 报文时,很可能并不会立即关闭 SOCKET(因为可能还有消息没处理完),所以只能先回复一个 ACK 报文,告诉 Client 端,“你发的 FIN 报文我收到了”。只有等到我 Server 端所有的报文都发送完了,我才能发送 FIN 报文,因此不能一起发送。故需要四步握手。
数据传输过程的流量控制和确认机制
建立可靠连接以后,就开始进行数据传输了。在通信过程中,最重要的是数据包,也就是协议传输的数据。如果数据的传送与接收过程当中出现收方来不及接收的情况,这时就需要对发方进行控制以免数据丢失。利用滑动窗口机制可以很方便的在 TCP 连接上实现对发送方的流量控制。TCP 的窗口单位是字节,不是报文段,发送方的发送窗口不能超过接收方给出的接收窗口的数值。
滑动窗口协议
滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题;发送和接受方都会维护一个数据帧的序列,这个序列被称作窗口
简单解释下,发送和接受方都会维护一个数据帧的序列,这个序列被称作窗口。发送方的窗口大小由接受方确定,目的在于控制发送速度,以免接受方的缓存不够大,而导致溢出,同时控制流量也可以避免网络拥塞。上面图中的 4,5,6 号数据帧已经被发送出去,但是未收到关联的ACK,7,8,9 帧则是等待发送。可以看出发送端的窗口大小为 6,这是由接受端告知的。此时如果发送端收到 4 号ACK,则窗口的左边缘向右收缩,窗口的右边缘则向右扩展,此时窗口就向前“滑动了”,即数据帧 10 也可以被发送。
发送窗口
就是发送端允许连续发送的幀的序号表。
发送端可以不等待应答而连续发送的最大幀数称为发送窗口的尺寸。
接收窗口
接收方允许接收的幀的序号表,凡落在 接收窗口内的幀,接收方都必须处理,落在接收窗口外的幀被丢弃。
接收方每次允许接收的幀数称为接收窗口的尺寸。在线滑动窗口演示功能
滑动窗口地址
应用层是如何使用 tcp/udp 进行通信的
基于 socket 和 DatagramSocket 进行通信,请详见代码
通信的性能问题?
正常的通信过程如下(BIO)
我们发现 TCP 响应服务器一次只能处理一个客户端请求,当一个客户端向一个已经被其他客户端占用的服务器发送连接请求时,虽然在连接建立后可以向服务端发送数据,但是在服务端处理完之前的请求之前,却不会对新的客户端做出响应,这种类型的服务器称为“迭代服务器”。迭代服务器是按照顺序处理客户端请求,也就是服务端必须要处理完前一个请求才能对下一个客户端的请求进行响应。但是在实际应用中,我们不能接收这样的处理方式。所以我们需要一种方法可以独立处理每一个连接,并且他们之间不会相互干扰。而 Java 提供的多线程技术刚好满足这个需求,这个机制使得服务器能够方便处理多个客户端的请求。
如何提高性能
TCP 协议的通信过程
对于 TCP 通信来说,每个 TCP Socket 的内核中都有一个发送缓冲区和一个接收缓冲区,TCP 的全双工的工作模式及 TCP 的滑动窗口就是依赖于这两个独立的 Buffer 和该 Buffer 的填充状态。
接收缓冲区把数据缓存到内核,若应用进程一直没有调用 Socket 的 read 方法进行读取,那么该数据会一直被缓存在接收缓冲区内。不管进程是否读取 Socket,对端发来的数据都会经过内核接收并缓存到 Socket 的内核接收缓冲区。
read 所要做的工作,就是把内核接收缓冲区中的数据复制到应用层用户的 Buffer 里。
进程调用 Socket 的 send 发送数据的时候,一般情况下是将数据从应用层用户的 Buffer 里复制到 Socket 的内核发送缓冲区,然后 send 就会在上层返回。换句话说,send 返回时,数据不一定会被发送到对端。
Socket 的接收缓冲区被 TCP 用来缓存网络上收到的数据,一直保存到应用进程读走为止。如果应用进程一直没有读取,那么 Buffer 满了以后,出现的情况是:通知对端 TCP 协议中的窗口关闭,保证 TCP 接收缓冲区不会移除,保证了 TCP 是可靠传输的。如果对方无视窗口大小发出了超过窗口大小的数据,那么接收方会把这些数据丢弃。
如何使用非阻塞提高性能?
非阻塞要解决的就是 I/O 线程与 Socket 解耦的问题,因此,它引入了事件机制来达到解耦的目的。我们可以认为 NIO 底层中存在一个 I/O 调度线程,它不断的扫描每个 Socket 的缓冲区,当发现写入缓冲区为空的时候,它会产生一个 Socket 可写事件,此时程序就可以把数据写入到 Socket 中。如果一次写不完,就等待下一次的可写事件通知;反之,当发现缓冲区里有数据的时候,它会产生一个 Socket 可读事件,程序收到这个通知事件就可以从 Socket 读取数据了。
关于 NIO
实际上基于上面讲的传统的 BIO 模型,一个请求一个线程的方式,如果要涉及到上千个客户端访问时,会产生很多的问题,比如扩展性、系统资源开销等等。所以我们需要一种方法来轮询一组客户端,来查找哪个连接需要提供服务,这个就是我们讲的“NIO”;
缓冲区
在 NIO 中,所有数据都是用缓冲区处理,在读取数据的时候,它是直接读到缓冲区中,在写如数据的时候,也是写到缓冲区。任何时候访问 NIO 中的数据,都是通过缓冲区进行的操作
通道
Channel 通道,就像一个自来水管一样,可以通过它读取和写入数据,Channel 是全双工的,所以数据是双向流动。
多路复用
多路复用器 Selector,是 NIO 的基础,多路复用器提供选择已经就绪的任务的能力,简单来说,Selector 会不断轮询注册上的 Channel,如果某个 Channel 上面有新的 TCP 连接接入、读、写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来,然后通过 SelectionKey 可以获取就绪的 Channel 进行 I/O 操作;一个多路复用器可以同时轮询多个 Channel。通过这个机制可以接入成千上万的客户端。
组播协议 Multicast
对于某些信息,多个接受者都可能感兴趣的时候,那么我们应该怎么解决呢?我们可以向每个接受者单播一个数据副本,但是如果这样的话,效率会低;而且同样的数据发送多次,浪费带宽。
解决方案是,我们可以把复制数据包的工作交给网络来做,而不是由发送者负责。这样无论是多少客户端,都没问题有两种分发类型,广播(broadcast)和多播(multicast);
-
广播:网络中的所有主机都会接收到一份数据副本
-
多播:消息只发送给一个多播地址,网络只是将数据分发给哪些想要接收发送到该多播地址的数据的主机。
总的来说,要实现这个功能,只有 UDP 是最合适的
广播
广播是主机向子网内所有主机发送消息,子网内所有主机都能收到来自某台主机的广播信息,属于点对所有点的通信。广播意味着网络向子网每一个主机都投递一份数据包,不论这些主机是否乐意接收该数据包;
多播
多播是主机向一组主机发送信息,存在于某个组的所有主机都可以接收到消息,属于点对多点的通信。
了解序列化的意义
Java 平台允许我们在内存中创建可复用的 Java 对象,但一般情况下,只有当 JVM 处于运行时,这些对象才可能存在,即,这些对象的生命周期不会比 JVM 的生命周期更长。但在现实应用中,就可能要求在 JVM 停止运行之后能够保存(持久化)指定的对象,并在将来重新读取被保存的对象。Java 对象序列化就能够帮助我们实现该功能
简单来说
序列化是把对象的状态信息转化为可存储或传输的形式过程,也就是把对象转化为字节序列的过程称为对象的序列化
反序列化是序列化的逆向过程,把字节数组反序列化为对象,把字节序列恢复为对象的过程成为对象的反序列化
序列化面临的挑战
评价一个序列化算法优劣的两个重要指标是:序列化以后的数据大小;序列化操作本身的速度及系统资源开销(CPU、内存);
Java 语言本身提供了对象序列化机制,也是 Java 语言本身最重要的底层机制之一,Java 本身提供的序列化机制存在两个问题
1.序列化的数据比较大,传输效率低
2.其他语言无法识别和对接
如何实现一个序列化操作
在 Java 中,只要一个类实现了 java.io.Serializable 接口,那么它就可以被序列化
- 定义接口
public interface ISerializer {
<T> byte[] serializer(T obj);
<T> T deSerializer(byte[] data,Class<T> clazz);
}
- 基于 JDK 序列化方式实现
JDK 提 供 了 Java 对 象 的 序 列 化 方 式 , 主 要 通 过 输 出 流 java.io.ObjectOutputStream 和对象输入流 java.io.ObjectInputStream 来实现。其中,被序列化的对象需要实现 java.io.Serializable 接口
public class JavaSerializer implements ISerializer {
@Override
public <T> byte[] serializer(T obj) {
ObjectOutputStream objectOutputStream=null;
try {
objectOutputStream=new ObjectOutputStream(new FileOutputStream(new File("user")));
objectOutputStream.writeObject(obj);
} catch (IOException e) {
e.printStackTrace();
}finally {
if(objectOutputStream!=null){
try {
objectOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
@Override
public <T> T deSerializer(byte[] data, Class<T> clazz) {
ObjectInputStream objectInputStream=null;
try {
objectInputStream=new ObjectInputStream(new FileInputStream(new File("user")));
return (T)objectInputStream.readObject();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
if(objectInputStream!=null){
try {
objectInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
}
- 具体实现
通过对一个 user 对象进行序列化操作
public class User extends SuperClass {
public static int num=5;
private String name;
private int age;
private transient String hobby;
//序列化对象
private void writeObject(ObjectOutputStream objectOutputStream) throws IOException {
objectOutputStream.defaultWriteObject();
objectOutputStream.writeObject(hobby);
}
//反序列化
private void readObject(ObjectInputStream objectInputStream) throws IOException, ClassNotFoundException {
objectInputStream.defaultReadObject();
hobby=(String)objectInputStream.readObject();
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getHobby() {
return hobby;
}
public void setHobby(String hobby) {
this.hobby = hobby;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
", hobby='" + hobby + '\'' +
'}';
}
}
测试:
public static void main( String[] args ) {
ISerializer iSerializer=new FastjsonSerializer();
User user=new User();
user.setAge(18);
user.setName("Mic");
user.setHobby("菲菲");
user.setSex("男");
byte[] rs=iSerializer.serializer(user);
System.out.println(new String(rs));
User user1=iSerializer.deSerializer(rs,User.class);
System.out.println(user1+"->"+user1.getSex());
}
序列化的高阶认识
serialVersionUID 的作用
Java 的序列化机制是通过判断类的 serialVersionUID 来验证版本一致性的。在进行反序列化时,JVM 会把传来的字节流中的 serialVersionUID 与本地相应实体类的 serialVersionUID 进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是 InvalidCastException
如果没有为指定的 class 配置 serialVersionUID,那么 java 编译器会自动给这个 class 进行一个摘要算法,类似于指纹算法,只要这个文件有任何改动,得到的 UID 就会截然不同的,可以保证在这么多类中,这个编号是唯一的
serialVersionUID 有两种显示的生成方式:
一是默认的 1L,比如:private static final long serialVersionUID = 1L; 二是根据类名、接口名、成员方法及属性等来生成一个 64 位的哈希字段
当 实 现 java.io.Serializable 接 口 的 类 没 有 显 式 地 定 义 一 个 serialVersionUID 变量时候,Java 序列化机制会根据编译的 Class 自动生成一个 serialVersionUID 作序列化版本比较用,这种情况下,如果 Class 文件(类名,方法明等)没有发生变化(增加空格,换行,增加注释等等),就算再编译多次,serialVersionUID 也不会变化的。
静态变量序列化
在 User 中添加一个全局的静态变量 num , 在执行序列化以后修改 num 的值为 10, 然后通过反序列化以后得到的对象去输出 num 的值
测试:
public static void main( String[] args ) {
ISerializer iSerializer=new FastjsonSerializer();
User user=new User();
user.setAge(18);
user.setName("Mic");
user.setHobby("菲菲");
user.setSex("男");
User.num = 10;
byte[] rs=iSerializer.serializer(user);
System.out.println(new String(rs));
User user1=iSerializer.deSerializer(rs,User.class);
System.out.println(user1+"->"+user1.getSex());
System.out.println(user1+":"+User.num);
}
最后的输出是 10,理论上打印的 num 是从读取的对象里获得的,应该是保存时的状态才对。之所以打印 10 的原因在于序列化时,并不保存静态变量,这其实比较容易理解,序列化保存的是对象的状态,静态变量属于类的状态,因此 序列化并不保存静态变量。
父类的序列化
一个子类实现了 Serializable 接口,它的父类都没有实现 Serializable接口,在子类中设置父类的成员变量的值,接着序列化该子类对象。再反序列化出来以后输出父类属性的值。结果应该是什么?
发现父类的 sex 字段的值为 null。也就是父类没有实现序列化
结论:
1.当一个父类没有实现序列化时,子类继承该父类并且实现了序列化。在反序列化该子类后,是没办法获取到父类的属性值的
2.当一个父类实现序列化,子类自动实现序列化,不需要再显示实现
Serializable 接口
3.当一个对象的实例变量引用了其他对象,序列化该对象时也会把引用对象进行序列化,但是前提是该引用对象必须实现序列化接口
Transient 关键字
Transient 关键字的作用是控制变量的序列化,在变量声明前加上该关
键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient
变量的值被设为初始值,如 int 型的是 0,对象型的是 null
绕开 transient 机制的办法
//序列化对象
private void writeObject(ObjectOutputStream objectOutputStream) throws IOException {
objectOutputStream.defaultWriteObject();
objectOutputStream.writeObject(hobby);
}
//反序列化
private void readObject(ObjectInputStream objectInputStream) throws IOException, ClassNotFoundException {
objectInputStream.defaultReadObject();
hobby=(String)objectInputStream.readObject();
}
Q: writeObject和 readObject 这两个私有的方法,既不属于 Object、也不是 Serializable,为什么能够在序列化的时候被调用呢?
A: ObjectOutputStream使用了反射来寻找是否声明了这两个方法。因为 ObjectOutputStream 使用 getPrivateMethod,所以这些方法必须声明为 priate 以至于供 ObjectOutputStream 来使用
序列化的存储规则
public static void main(String[] args) throws IOException {
ObjectOutputStream outputStream=
new ObjectOutputStream(new FileOutputStream(new File("user")));
User user=new User();
user.setAge(18);
user.setName("Mic");
user.setHobby("菲菲");
user.setSex("男");
outputStream.flush();
outputStream.writeObject(user);
System.out.println(new File("user").length());
outputStream.writeObject(user);
outputStream.flush();
outputStream.close();
System.out.println(new File("user").length());
}
同一对象两次(开始写入文件到最终关闭流这个过程算一次,上面的演示效果是不关闭流的情况才能演示出效果)写入文件,打印出写入一次对象后的存储大小和写入两次后的存储大小,第二次写入对象时文件只增加了 5 字节
Java 序列化机制为了节省磁盘空间,具有特定的存储规则,当写入文件的为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用,上面增加的 5 字节的存储空间就是新增引用和一些控制信息的空间。反序列化时,恢复引用关系.该存储规则极大的节省了存储空间。
序列化实现深克隆
在 Java 中存在一个 Cloneable 接口,通过实现这个接口的类都会具备 clone 的能力,同时 clone 是在内存中进行,在性能方面会比我们直接
通过 new 生成对象要高一些,特别是一些大的对象的生成,性能提升相对比较明显。那么在 Java 领域中,克隆分为深度克隆和浅克隆
浅克隆
被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。
实现一个邮件通知功能,告诉每个人今天晚上的上课时间,通过浅克隆实现如下
public class Email implements Serializable {
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
Person:
@Data
public class Person implements Cloneable,Serializable {
private String name;
private Email email;
@Override
protected Person clone() throws CloneNotSupportedException {
return (Person)super.clone();
}
public Person deepClone() throws IOException, ClassNotFoundException {
ByteArrayOutputStream bos=new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream=
new ObjectOutputStream(bos);
objectOutputStream.writeObject(this);
ByteArrayInputStream bis=new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream objectInputStream=new ObjectInputStream(bis);
return (Person) objectInputStream.readObject();
}
}
测试:
public static void main(String[] args) throws CloneNotSupportedException, IOException, ClassNotFoundException {
Email email=new Email();
email.setContent("今天晚上20:00有课程");
Person p1=new Person();
p1.setName("Mic");
p1.setEmail(email);
// Person p2=p1.clone();
Person p2=p1.deepClone();
p2.setName("黑白");
p2.getEmail().setContent("今天晚上是20:30上课");
System.out.println(p1.getName()+"->"+p1.getEmail().getContent());
System.out.println(p2.getName()+"->"+p2.getEmail().getContent());
}
但是,当我们只希望,修改“黑白”的上课时间,调整为 20:30 分。通过结果发现,所有人的通知消息都发生了改变。这是因为 p2 克隆的这个对象的 Email 引用地址指向的是同一个。这就是浅克隆
深克隆
被复制对象的所有变量都含有与原来的对象相同的值,除去那些引用其他对象的变量。那些引用其他对象的变量将指向被复制过的新对象,而不再是原有的那些被引用的对象。换言之,深拷贝把要复制的对象所引用的对象都复制了一遍
测试:
这样就能实现深克隆效果,原理是把对象序列化输出到一个流中,然后在把对象从序列化流中读取出来,这个对象就不是原来的对象了。
常见的序列化技术
使用 JAVA 进行序列化有他的优点,也有他的缺点
优点:JAVA 语言本身提供,使用比较方便和简单
缺点:不支持跨语言处理、 性能相对不是很好,序列化以后产生的数据
相对较大
XML 序列化框架
XML 序列化的好处在于可读性好,方便阅读和调试。但是序列化以后的
字节码文件比较大,而且效率不高,适用于对性能不高,而且 QPS 较
低的企业级内部系统之间的数据交换的场景,同时 XML 又具有语言无
关性,所以还可以用于异构系统之间的数据交换和协议。比如我们熟知
的 Webservice,就是采用 XML 格式对数据进行序列化的
JSON 序列化框架
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,相
对于 XML 来说,JSON 的字节流更小,而且可读性也非常好。现在 JSON
数据格式在企业运用是最普遍的
JSON 序列化常用的开源工具有很多
1.Jackson (https://github.com/FasterXML/jackson)
2.阿里开源的 FastJson (https://github.com/alibaba/fastjon)
3.Google 的 GSON (https://github.com/google/gson)
这几种 json 序列化工具中,Jackson 与 fastjson 要比 GSON 的性能要好,但是 Jackson、GSON 的稳定性要比 Fastjson 好。而 fastjson 的优势在于提供的 api 非常容易使用
Hessian 序列化框架
Hessian 是一个支持跨语言传输的二进制序列化协议,相对于 Java 默认的序列化机制来说,Hessian 具有更好的性能和易用性,而且支持多种不同的语言
实际上 Dubbo 采用的就是 Hessian 序列化来实现,只不过 Dubbo 对 Hessian 进行了重构,性能更高
Protobuf 序列化框架
Protobuf 是 Google 的一种数据交换格式,它独立于语言、独立于平台。
Google 提供了多种语言来实现,比如 Java、C、Go、Python,每一种实现都包含了相应语言的编译器和库文件Protobuf 使用比较广泛,主要是空间开销小和性能比较好,非常适合用于公司内部对性能要求高的 RPC 调用。 另外由于解析性能比较高,序列化以后数据量相对较少,所以也可以应用在对象的持久化场景中
但是但是要使用 Protobuf 会相对来说麻烦些,因为他有自己的语法,有自己的编译器
下载 protobuf 工具
https://github.com/google/protobuf/releases 找到 protoc-3.5.1-win32.zip
编写 proto 文件
syntax="proto2";
package com.test.serial;
option java_package = "com.test.serial"; option java_outer_classname="UserProtos";
message User {
required string name=1;
required int32 age=2;
}
1.包名
2.option 选项
3.消息模型(消息对象、字段(字段修饰符-required/optional/repeated)
字段类型(基本数据类型、枚举、消息对象)、字段名、标识号)
生成实体类
在 protoc.exe 安装目录下执行如下命令
.\protoc.exe --java_out=./ ./user.proto
运行查看结果
将生成以后的 UserProto.java 拷贝到项目中
Protobuf 原理分析
核心原理: protobuf 使用 varint(zigzag)作为编码方式, 使用 T-L-
V作为存储方式
varint 编码方式
varint 是一种数据压缩算法,其核心思想是利用 bit 位来实现数据压缩。比如:对于 int32 类型的数字,一般需要 4 个字节 表示;若采用 Varint 编码,对于很小的 int32 类型 数字,则可以用 1 个字节假设我们定义了一个 int32 字段值=296.
第一步,转化为 2 进制编码
第二步,提取字节
规则: 按照从字节串末尾选取 7 位,并在最高位补 1,构成一个字节
第三步,继续提取字节
整体右移 7 位,继续截取 7 个比特位,并且在最高位补 0 。因为这个是最后一个有意义的字节了。补 0 不影响结果
varint 编码对于小于 127 的数,可以最大化的压缩
varint 压缩小数据
比如我们压缩一个 var32 = 104 的数据
第一步,转换为 2 进制编码
第二步,提取字节
从末尾开始提取 7 个字节
并且在最高位最高位补 0,因为这个是最后的 7 位。
第三步,形成新的字节
也就是通过 varint 对于小于 127 以下的数字编码,只需要占用 1 个字节。
zigzag 编码方式
对于负数的处理,protobuf 使用 zigzag 的形式来存储。为什么负数需要用 zigzag 算法?
计算机语言中如何表示负整数?
在计算机中,定义了原码、反码和补码。来实现负数的表示。我们以一个字节 8 个 bit 来演示这几个概念数字 8 的二进制表示为 0000 1000
原码
通过第一个位表示符号(0 表示非负数、1 表示负数)
(+8) = {0000 1000}
(-8) = {1000 1000}
反码
因为第一位表示符号位,保持不变。剩下的位,非负数保持不变、负数按位取反。那对于上面的原码按照这个规则得到的结果
(+8) = {0000 1000}原 ={0000 1000}反 非负数,剩下的位不变。所以和原码是保持一致
(-8) = {1000 1000}原 ={1111 0111}反 负数,符号位不动,剩下为取
反
但是通过原码和反码方式来表示二进制,还存在一些问题。
第一个问题:
0这个数字,按照上面的反码计算,会存在两种表示
(+0) ={0000 0000}原= {0000 0000}反
(-0) ={1000 0000}原= {1111 1111}反
第二个问题:
符号位参与运算,会得到一个错误的结果,比如
1 + (-1)=
{0000 0001}原 +{1 0000 0001}原 ={1000 0010}原 =-2
{0000 0001}反+ {1111 1110}反 = {1111 1111}反 =-0
不管是原码计算还是反码计算。得到的结果都是错误的。所以为了解决
这个问题,引入了补码的概念。
补码
补码的概念:第一位符号位保持不变,剩下的位非负数保持不变,负数按位取反且末位加 1
(+8) = {0000 1000}原 = {0000 1000}原 ={0000 1000}补 (-8) = {1000 1000}原 ={1111 0111}反={1111 1000}末位加一(补码)
8+(-8)= {0000 1000}补 +{1111 1000}末位加一(补码) ={0000 0000}=0 通过补码的方式,在进行符号运算的时候,计算机就不需要关心符号的
问题,统一按照这个规则来计算。就没问题没问题
zigzag 原理
有了前面这块的基础以后,我们再来了解下 zigzag 的实现原理
比如我们存储一个 int32 = -2 按照上面提到的负数表现形式如下
原码{1 000 0010} ->取反 {1111 1101} ->整体加 1 {111 1110}->{1111 1110}
zigzag 的核心思想是去掉无意义的 0,最大可能性的压缩数据。但是对于负数。第一位表示符号位,如果补码的话,前面只能补 1. 就会导致陷入一个很尴尬的地步,负数似乎没办法压缩。
所以 zigzag 提供了一个方法,既然第一位是符号位,那么干脆把这个符号位放到补码的最后。整体右移。
所以上面这个-2,将符号位移到最末尾,也就是右移 31 位。得到如下结果**(对于负数形式,整体右移 31 位,把符号位移动到最后边; 为什么要移动到最后呢,因为对于负数形式,补码位永远是 1,那么如果他站在最高位,就永远没办法压缩。所以做了一个移动)**
但是对于上面这个操作,并不能解决压缩的问题,因为值越小,那么前导的 1 越多。所以 zigzag 算法考虑到是否能够将符号位不变,整体取反呢?
那这样就能够实现压缩的需求了?(这里如果是单纯的这么实现,是没办法实现反序列化的。)所以还需要下面这个过程。
所以对于同样(-2)的正数形式(2),在二进制中的表现为 {00000010} 那 zigzag 算法定义了对于非负数形式,则把符号位移动到最后,其他整体往左移动一位。得到如下的效果
(对于非负数形式 2,按照整体左移 1 位,右边补零的形式来表示如下)
这样一来,对于(2)这个数字,正负数都有表示的方法了。那么 zigzag 结合了两种表示方法,来进行计算。计算规则是将正数形式和负数形式进行异或运算。按照上面的两种表现形式的异或运算结果是
而在 zigzag 中的计算规则是
将-2 的二进制形式{1111 1110}按照正数的算法,左移一位,右边补零得到{11111100},如下图左边。 按照负数的形式,讲符号位移动到最右边,右移 31 位,得到下面右图。再将两者取异或算法。实现最终的压缩。
然后再将两个结果进行 “异或” 运算
异或运算是
0异或 0 =0
1异或 1 =0
1异或 0 =1
0异或 1 =1
得到:
最后,-2 在的结果是 3. 占用一个比特位存储。
存储方式
经过编码以后的数据,大大减少了字段值的占用字节数,然后基于 T-L-
V的方式进行存储
tag 的取值为 field_number(字段数) << 3 | wire_type 296 被 varint 编码后的字节为 10101000 00000010
总结
Protocol Buffer 的性能好,主要体现在 序列化后的数据体积小 & 序列化速度快,最终使得传输效率高,其原因如下:序列化速度快的原因:
a. 编码 / 解码 方式简单(只需要简单的数学运算 = 位移等等) b. 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成序列化后的数据量体积小(即数据压缩效果好)的原因:
a.采用了独特的编码方式,如 Varint、Zigzag 编码方式等等
b.采用 T - L - V 的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑
各个序列化技术的性能比较
这 个 地 址 有 针 对 不 同 序 列 化 技 术 进 行 性 能 比 较 :
https://github.com/eishay/jvm-serializers/wiki
序列化技术的选型
技术层面
1.序列化空间开销,也就是序列化产生的结果大小,这个影响到传输的性能
2.序列化过程中消耗的时长,序列化消耗时间过长影响到业务的响应时间
3.序列化协议是否支持跨平台,跨语言。因为现在的架构更加灵活,如果存在异构系统通信需求,那么这个是必须要考虑的
4.可扩展性/兼容性,在实际业务开发中,系统往往需要随着需求的快速迭代来实现快速更新,这就要求我们采用的序列化协议基于良好
的可扩展性/兼容性,比如在现有的序列化数据结构中新增一个业务字段,不会影响到现有的服务
5.技术的流行程度,越流行的技术意味着使用的公司多,那么很多坑都已经淌过并且得到了解决,技术解决方案也相对成熟
6.学习难度和易用性
选型建议
1.对性能要求不高的场景,可以采用基于 XML 的 SOAP 协议
2.对性能和间接性有比较高要求的场景,那么 Hessian、Protobuf、Thrift、 Avro 都可以。
3.基于前后端分离,或者独立的对外的 api 服务,选用 JSON 是比较好的,对于调试、可读性都很不错
4.Avro 设计理念偏于动态类型语言,那么这类的场景使用 Avro 是可以的
后记
代码地址: