Java 通信优化
一、网络通信优化之I/O模型:如何解决高并发下I/O瓶颈?
1、什么是 I/O
I/O 是机器获取和交换信息的主要渠道,而流是完成 I/O 操作的主要方式。
在计算机中,流是一种信息的转换。流是有序的,因此相对于某一机器或者应用程序而言,我们通常把机器或者应用程序接收外界的信息称为输入流(InputStream),从机器或者应用程序向外输出的信息称为输出流(OutputStream),合称为输入 / 输出流(I/O Streams)。
机器间或程序间在进行信息交换或者数据交换时,总是先将对象或数据转换为某种形式的流,再通过流的传输,到达指定机器或程序后,再将流转换为对象数据。因此,流就可以被看作是一种数据的载体,通过它可以实现数据交换和传输。
Java 的 I/O 操作类在包 java.io 下,其中 InputStream、OutputStream 以及 Reader、Writer 类是 I/O 包中的 4 个基本类,它们分别处理字节流和字符流。如下图所示:
1.字节流
InputStream/OutputStream 是字节流的抽象类,这两个抽象类又派生出了若干子类,不同的子类分别处理不同的操作类型。如果是文件的读写操作,就使用 FileInputStream/FileOutputStream;如果是数组的读写操作,就使用ByteArrayInputStream/ByteArrayOutputStream;如果是普通字符串的读写操作,就使用 BufferedInputStream/BufferedOutputStream。具体内容如下图所示:
2.字符流
Reader/Writer 是字符流的抽象类,这两个抽象类也派生出了若干子类,不同的子类分别处理不同的操作类型,具体内容如下图所示:
2、传统 I/O 的性能问题
我们知道,I/O 操作分为磁盘 I/O 操作和网络 I/O 操作。前者是从磁盘中读取数据源输入到内存中,之后将读取的信息持久化输出在物理磁盘上;后者是从网络中读取信息输入到内存, 终将信息输出到网络中。但不管是磁盘 I/O 还是网络 I/O,在传统 I/O 中都存在严重的性能问题。
1.多次内存复制
在传统 I/O 中,我们可以通过 InputStream 从源数据中读取数据流输入到缓冲区里,通过OutputStream 将数据输出到外部设备(包括磁盘、网络)。你可以先看下输入操作在操作系统中的具体流程,如下图所示:
在这个过程中,数据先从外部设备复制到内核空间,再从内核空间复制到用户空间,这就发生了两次内存复制操作。这种操作会导致不必要的数据拷贝和上下文切换,从而降低 I/O 的性能。
2.阻塞
在传统 I/O 中,InputStream 的 read() 是一个 while 循环操作,它会一直等待数据读取,直到数据就绪才会返回。这就意味着如果没有数据就绪,这个读取操作将会一直被挂起,用户线程将会处于阻塞状态。
在少量连接请求的情况下,使用这种方式没有问题,响应速度也很高。但在发生大量连接请求时,就需要创建大量监听线程,这时如果线程没有数据就绪就会被挂起,然后进入阻塞状态。一旦发生线程阻塞,这些线程将会不断地抢夺 CPU 资源,从而导致大量的 CPU 上下文切换,增加系统的性能开销。
3、如何优化 I/O 操作
1.使用缓冲区优化读写流操作
在传统 I/O 中,提供了基于流的 I/O 实现,即 InputStream 和 OutputStream,这种基于流的实现以字节为单位处理数据。
NIO 与传统 I/O 不同,它是基于块(Block)的,它以块为基本单位处理数据。在 NIO 中, 为重要的两个组件是缓冲区(Buffer)和通道(Channel)。Buffer 是一块连续的内存块,是 NIO 读写数据的中转地。Channel 表示缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是访问缓冲的接口。
传统 I/O 和 NIO 的最大区别就是传统 I/O 是面向流,NIO 是面向 Buffer。Buffer 可以将文件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据。虽然传统 I/O 后面也使用了缓冲块,例如 BufferedInputStream,但仍然不能和 NIO 相媲美。使用NIO 替代传统 I/O 操作,可以提升系统的整体性能,效果立竿见影。
2.使用 DirectBuffer 减少内存复制
NIO 的 Buffer 除了做了缓冲块优化之外,还提供了一个可以直接访问物理内存的类DirectBuffer。普通的 Buffer 分配的是 JVM 堆内存,而 DirectBuffer 是直接分配物理内存。
我们知道数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而 DirectBuffer 则是直接将步骤简化为从内核空间复制到外部设备,减少了数据拷贝。
由于 DirectBuffer 申请的是非 JVM 的物理内存,所以创建和销毁的代价很高。DirectBuffer 申请的内存并不是直接由 JVM 负责垃圾回收,但在 DirectBuffer 包装类被回收时,会通过 Java Reference 机制来释放该内存块。
3.避免阻塞,优化 I/O 操作
传统的 I/O 即使使用了缓冲块,依然存在阻塞问题。由于线程池线程数量有限,一旦发生大量并发请求,超过最大数量的线程就只能等待,直到线程池中有空闲的线程可以被复用。
通道(Channel)
传统 I/O 的数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
开始,在应用程序调用操作系统 I/O 接口时,是由 CPU 完成分配,这种方式最大的问题是“发生大量 I/O 请求时,非常消耗 CPU“;之后,操作系统引入了 DMA(直接存储器存储),内核空间与磁盘之间的存取完全由 DMA 负责,但这种方式依然需要向 CPU 申请权限,且需要借助 DMA 总线来完成数据的复制操作,如果 DMA 总线过多,就会造成总线冲突。
通道的出现解决了以上问题,Channel 有自己的处理器,可以完成内核空间和磁盘之间的I/O 操作。在 NIO 中,我们读取和写入数据都要通过 Channel,由于 Channel 是双向的,所以读、写可以同时进行。
多路复用器(Selector)
Selector 是 Java NIO 编程的基础。用于检查一个或多个 NIO Channel 的状态是否处于可读、可写。
Selector 是基于事件驱动实现的,我们可以在 Selector 中注册 accpet、read 监听事件,Selector 会不断轮询注册在其上的 Channel,如果某个 Channel 上面发生监听事件,这个Channel 就处于就绪状态,然后进行 I/O 操作。
一个线程使用一个 Selector,通过轮询的方式,可以监听多个 Channel 上的事件。我们可以在注册 Channel 时设置该通道为非阻塞,当 Channel 上没有 I/O 操作时,该线程就不会一直等待了,而是会不断轮询所有 Channel,从而避免发生阻塞。
目前操作系统的 I/O 多路复用机制都使用了 epoll,相比传统的 select 机制,epoll 没有大连接句柄 1024 的限制。所以 Selector 在理论上可以轮询成千上万的客户端。
总结
Java 的传统 I/O 开始是基于 InputStream 和 OutputStream 两个操作流实现的,这种流操作是以字节为单位,如果在高并发、大数据场景中,很容易导致阻塞,因此这种操作的性能是非常差的。还有,输出数据从用户空间复制到内核空间,再复制到输出设备,这样的操作会增加系统的性能开销。
传统 I/O 后来使用了 Buffer 优化了“阻塞”这个性能问题,以缓冲块作为最小单位,但相比整体性能来说依然不尽人意。
于是 NIO 发布,它是基于缓冲块为单位的流操作,在 Buffer 的基础上,新增了两个组件“管道和多路复用器”,实现了非阻塞 I/O,NIO 适用于发生大量 I/O 连接请求的场景,这三个组件共同提升了 I/O 的整体性能。
二、网络通信优化之序列化:避免使用Java序列化
微服务架构将一个服务按照业务划分实现了服务的解偶,但同时也带来了新的问题,不同业务之间通信需要通过接口实现调用。两个服务之间要共享一个数据对象,就需要从对象转换成二进制流,通过网络传输,传送到对方服务,再转换回对象,供服务方法调用。这个编码和解码过程我们称之为序列化与反序列化。
在大量并发请求的情况下,如果序列化的速度慢,会导致请求响应时间增加;而序列化后的传输数据体积大,就会导致网络吞吐量下降。所以一个优秀的序列化框架可以提高系统的整体性能。
Java 提供了 RMI 框架可以实现服务与服务之间的接口暴露和调用,RMI 中对数据对象的序列化采用的是 Java 序列化。而目前主流的微服务框架却几乎没有用到 Java 序列化,SpringCloud 用的是 Json 序列化,Dubbo 虽然兼容了 Java 序列化,但默认使用的是 Hessian 序列化。这是为什么呢?通过对比近两年比较火的 Protobuf 序列化,看看Protobuf 是如何实现最优序列化的。
1、Java 序列化
Java 提供了一种序列化机制,这种机制能够将一个对象序列化为二进制形式(字节数组),用于写入磁盘或输出到网络,同时也能从网络或磁盘中读取字节数组,反序列化成对象,在程序中使用。
- JDK 提供的两个输入、输出流对象 ObjectInputStream 和 ObjectOutputStream,它们只能对实现了 Serializable 接口的类的对象进行反序列化和序列化。
- ObjectOutputStream 的默认序列化方式,仅对对象的非 transient 的实例变量进行序列化,而不会序列化对象的 transient 的实例变量,也不会序列化静态变量。
在实现了 Serializable 接口的类的对象中,会生成一个 serialVersionUID 的版本号,这个版本号有什么用呢?它会在反序列化过程中来验证序列化对象是否加载了反序列化的类,如果是具有相同类名的不同版本号的类,在反序列化中是无法获取对象的。
- 具体实现序列化的是 writeObject 和 readObject,通常这两个方法是默认的,当然我们也可以在实现 Serializable 接口的类中对其进行重写,定制一套属于自己的序列化与反序列化机制。
- 另外,Java 序列化的类中还定义了两个重写方法:writeReplace() 和 readResolve(),前者是用来在序列化之前替换序列化对象的,后者是用来在反序列化之后对返回对象进行处理的。
2、Java 序列化的缺陷
如果你用过一些 RPC 通信框架,你就会发现这些框架很少使用 JDK 提供的序列化。其实不用和不好用多半是挂钩的,下面我们就一起来看看 JDK 默认的序列化到底存在着哪些缺陷。
1.无法跨语言
现在的系统设计越来越多元化,很多系统都使用了多种语言来编写应用程序。比如,开发的一些大型游戏就使用了多种语言,C++ 写游戏服务,Java/Go 写周边服务,Python 写一些监控应用。
而 Java 序列化目前只适用基于 Java 语言实现的框架,其它语言大部分都没有使用 Java 的序列化框架,也没有实现 Java 序列化这套协议。因此,如果是两个基于不同语言编写的应用程序相互通信,则无法实现两个应用服务之间传输对象的序列化与反序列化。
2.易被攻击
Java 官网安全编码指导方针中说明:“对不信任数据的反序列化,从本质上来说是危险的,应该予以避免”。可见 Java 序列化是不安全的。
我们知道对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,这个方法其实是一个神奇的构造器,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。
2015 年 FoxGlove Security 安全团队的 breenmachine 发布过一篇长博客,主要内容是:通过 Apache Commons Collections,Java 反序列化漏洞可以实现攻击。一度横扫了 WebLogic、WebSphere、JBoss、Jenkins、OpenNMS 的 新版,各大 Java Web Server 纷纷躺枪。
其实,Apache Commons Collections 就是一个第三方基础库,它扩展了 Java 标准库里的 Collection 结构,提供了很多强有力的数据结构类型,并且实现了各种集合工具类。
实现攻击的原理就是:Apache Commons Collections 允许链式的任意的类函数反射调用,攻击者通过“实现了 Java 序列化协议”的端口,把攻击代码上传到服务器上,再由Apache Commons Collections 里的 TransformedMap 来执行。
那么后来是如何解决这个漏洞的呢?
- 很多序列化协议都制定了一套数据结构来保存和获取对象。例如,JSON 序列化、 ProtocolBuf 等,它们只支持一些基本类型和数组数据类型,这样可以避免反序列化创建一些不确定的实例。虽然它们的设计简单,但足以满足当前大部分系统的数据传输需求。
- 我们也可以通过反序列化对象白名单来控制反序列化对象,可以重写 resolveClass 方法,并在该方法中校验对象名字。代码如下所示:
@Override
protected Class resolveClass(ObjectStreamClass desc) throws IOException,ClassNotFoundExc {
if (!desc.getName().equals(Bicycle.class.getName())) {
throw new InvalidClassException(
"Unauthorized deserialization attempt", desc.getName());
}
return super.resolveClass(desc);
}
3.序列化后的流太大
序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。
Java 序列化中使用了 ObjectOutputStream 来实现对象转二进制编码,那么这种序列化机制实现的二进制编码完成的二进制数组大小,相比于 NIO 中的 ByteBuffer 实现的二进制编码完成的数组大小,有没有区别呢?
ObjectOutputStream 字节编码长度:99
ByteBuffer 字节编码长度:16
4.序列化性能太差
序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间。我们再来对比下 Java 序列化与NIO 中的 ByteBuffer 编码的性能:
ObjectOutputStream 序列化时间:29
ByteBuffer 序列化时间:6
总结
无论是网路传输还是磁盘持久化数据,我们都需要将数据编码成字节码,而我们平时在程序中使用的数据都是基于内存的数据类型或者对象,我们需要通过编码将这些数据转化成二进制字节流;如果需要接收或者再使用时,又需要通过解码将二进制字节流转换成内存数据。我们通常将这两个过程称为序列化与反序列化。
Java 默认的序列化是通过 Serializable 接口实现的,只要类实现了该接口,同时生成一个默认的版本号,我们无需手动设置,该类就会自动实现序列化与反序列化。
Java 默认的序列化虽然实现方便,但却存在安全漏洞、不跨语言以及性能差等缺陷,所以我强烈建议你避免使用 Java 序列化。
纵观主流序列化框架,FastJson、Protobuf、Kryo 是比较有特点的,而且性能以及安全方面都得到了业界的认可,我们可以结合自身业务来选择一种适合的序列化框架,来优化系统的序列化性能。
三、网络通信优化之通信协议:如何优化RPC网络通信?
1、RPC 通信是大型服务框架的核心
微服务的核心是远程通信和服务治理。远程通信提供了服务之间通信的桥梁,服务治理则提供了服务的后勤保障。所以,我们在做技术选型时,更多要考虑的是这两个核心的需求。
服务的拆分增加了通信的成本,特别是在一些抢购或者促销的业务场景中,如果服务之间存在方法调用,比如,抢购成功之后需要调用订单系统、支付系统、券包系统等,这种远程通信就很容易成为系统的瓶颈。所以,在满足一定的服务治理需求的前提下,对远程通信的性能需求就是技术选型的主要影响因素。
目前,很多微服务框架中的服务通信是基于 RPC 通信实现的:
- SpringCloud 是基于 Feign 组件实现的 RPC 通信(基于 Http+Json 序列化实现);
- Dubbo 是基于 SPI 扩展了很多 RPC 通信框架,包括 RMI、Dubbo、Hessian 等 RPC 通信框架(默认是 Dubbo+Hessian 序列化)。
不同的业务场景下,RPC 通信的选择和优化标准也不同。
1.什么是 RPC 通信
无论是微服务、SOA、还是 RPC 架构,它们都是分布式服务架构,都需要实现服务之间的互相通信,我们通常把这种通信统称为 RPC 通信。
RPC(Remote Process Call),即远程服务调用,是通过网络请求远程计算机程序服务的通信技术。RPC 框架封装好了底层网络通信、序列化等技术,我们只需要在项目中引入各个服务的接口包,就可以实现在代码中调用 RPC 服务同调用本地方法一样。正因为这种方便、透明的远程调用,RPC 被广泛应用于当下企业级以及互联网项目中,是实现分布式系统的核心。
RMI(Remote Method Invocation)是 JDK 中最先实现了 RPC 通信的框架之一,RMI 的实现对建立分布式 Java 应用程序至关重要,是 Java 体系非常重要的底层技术,很多开源的 RPC 通信框架也是基于 RMI 实现原理设计出来的,包括 Dubbo 框架中也接入了RMI 框架。
2.RMI:JDK 自带的 RPC 通信框架
目前 RMI 已经很成熟地应用在了 EJB 以及 Spring 框架中,是纯 Java 网络分布式应用系统的核心解决方案。RMI 实现了一台虚拟机应用对远程方法的调用可以同对本地方法的调用一样,RMI 帮我们封装好了其中关于远程通信的内容。
3.RMI 的实现原理
RMI 远程代理对象是 RMI 中最核心的组件,除了对象本身所在的虚拟机,其它虚拟机也可以调用此对象的方法。而且这些虚拟机可以不在同一个主机上,通过远程代理对象,远程应用可以用网络协议与服务进行通信。
4.RMI 在高并发场景下的性能瓶颈
- Java 默认序列化:RMI 的序列化采用的是 Java 默认的序列化方式,它的性能并不是很好,而且其它语言框架也暂时不支持 Java 序列化。
- TCP 短连接:由于 RMI 是基于 TCP 短连接实现,在高并发情况下,大量请求会带来大量连接的创建和销毁,这对于系统来说无疑是非常消耗性能的。
- 阻塞式网络 I/O:网络通信存在 I/O 瓶颈,如果在 Socket 编程中使用传统的 I/O 模型,在高并发场景下基于短连接实现的网络通信就很容易产生 I/O 阻塞,性能将会大打折扣。
2、一个高并发场景下的 RPC 通信优化路径
SpringCloud 的 RPC 通信和 RMI 通信的性能瓶颈就非常相似。SpringCloud 是基于 Http 通信协议(短连接)和 Json 序列化实现的,在高并发场景下并没有优势。 那么,在瞬时高并发的场景下,我们又该如何去优化一个 RPC 通信呢?
RPC 通信包括了建立通信、实现报文、传输协议以及传输数据编解码等操作,接下来我们就从每一层的优化出发,逐步实现整体的性能优化。
1. 选择合适的通信协议
要实现不同机器间的网络通信,我们先要了解计算机系统网络通信的基本原理。网络通信是两台设备之间实现数据流交换的过程,是基于网络传输协议和传输数据的编解码来实现的。
其中网络传输协议有 TCP、UDP 协议,这两个协议都是基于 Socket 编程接口之上,为某类应用场景而扩展出的传输协议。通过以下两张图,我们可以大概了解到基于 TCP 和 UDP 协议实现的 Socket 网络通信是怎样的一个流程。
- 基于 TCP 协议实现的 Socket 通信是有连接的,而传输数据是要通过三次握手来实现数据传输的可靠性,且传输数据是没有边界的,采用的是字节流模式。
- 基于 UDP 协议实现的 Socket 通信,客户端不需要建立连接,只需要创建一个套接字发送数据报给服务端,这样就不能保证数据报一定会达到服务端,所以在传输数据方面,基于UDP 协议实现的 Socket 通信具有不可靠性。UDP 发送的数据采用的是数据报模式,每个UDP 的数据报都有一个长度,该长度将与数据一起发送到服务端。
通过对比,我们可以得出优化方法:为了保证数据传输的可靠性,通常情况下我们会采用 TCP 协议。如果在局域网且对数据传输的可靠性没有要求的情况下,我们也可以考虑使用UDP 协议,毕竟这种协议的效率要比 TCP 协议高。
2.使用单一长连接
如果是基于 TCP 协议实现 Socket 通信,我们还能做哪些优化呢?
- 服务之间的通信不同于客户端与服务端之间的通信。客户端与服务端由于客户端数量多,基于短连接实现请求可以避免长时间地占用连接,导致系统资源浪费;
- 但服务之间的通信,连接的消费端不会像客户端那么多,但消费端向服务端请求的数量却一样多,我们基于长连接实现,就可以省去大量的 TCP 建立和关闭连接的操作,从而减少系统的性能消耗,节省时间。
3.优化 Socket 通信
建立两台机器的网络通信,我们一般使用 Java 的 Socket 编程实现一个 TCP 连接。传统的 Socket 通信主要存在 I/O 阻塞、线程模型缺陷以及内存拷贝等问题。我们可以使用比较成熟的通信框架,比如 Netty。Netty4 对 Socket 通信编程做了很多方面的优化,具体如下:
- 实现非阻塞 I/O: 使用多路复用器 Selector 实现了非阻塞 I/O 通信。
- 高效的 Reactor 线程模型: Netty 使用了主从 Reactor 多线程模型,服务端接收客户端请求连接是用了一个主线程(只负责监听,交给工作线程处理),这个主线程用于客户端的连接请求操作,一旦连接建立成功,将会监听 I/O 事件,监听到事件后会创建一个链路请求。
链路请求将会注册到负责 I/O 操作的 I/O 工作线程上,由 I/O 工作线程负责后续的 I/O 操作。利用这种线程模型,可以解决在高负载、高并发的情况下,由于单个 NIO 线程无法监听海量客户端和满足大量 I/O 操作造成的问题。
-
串行设计: 服务端在接收消息之后,存在着编码、解码、读取和发送等链路操作。如果这些操作都是基于并行去实现,无疑会导致严重的锁竞争,进而导致系统的性能下降。为了提升性能,Netty 采用了串行无锁化完成链路操作,Netty 提供了 Pipeline 实现链路的各个操作在运行期间不进行线程切换。 -
零拷贝: 一个数据从内存发送到网络中,存在着两次拷贝动作,先是从用户空间拷贝到内核空间,再是从内核空间拷贝到网络 I/O 中。而 NIO 提供的ByteBuffer 可以使用 Direct Buffers 模式,直接开辟一个非堆物理内存,不需要进行字节缓冲区的二次拷贝,可以直接将数据写入到内核空间。
4.量身定做报文格式
接下来就是实现报文,我们需要设计一套报文,用于描述具体的校验、操作、传输数据等内容。为了提高传输的效率,我们可以根据自己的业务和架构来考虑设计,尽量实现报体小、满足功能、易解析等特性。我们可以参考下面的数据格式:
5.编码、解码
序列化编码和解码的过程,对于实现一个好的网络通信协议来说,兼容优秀的序列化框架是非常重要的。如果只是单纯的数据对象传输,我们可以选择性能相对较好的 Protobuf 序列化,有利于提高网络通信的性能。
6.调整 Linux 的 TCP 参数设置选项
如果 RPC 是基于 TCP 短连接实现的,我们可以通过修改 Linux TCP 配置项来优化网络通信。
通过 sysctl -a | grep net.xxx 命令运行查看 Linux 系统默认的的 TCP 参数设置,如果需要修改某项配置,可以通过编辑 vim/etc/sysctl.conf,加入需要修改的配置项, 并通过 sysctl -p 命令运行生效修改后的配置项设置。通常我们会通过修改以下几个配置项来提高网络吞吐量和降低延时。
总结
在现在的分布式系统中,特别是系统走向微服务化的今天,服务间的通信就显得尤为频繁,掌握服务间的通信原理和通信协议优化,是你的一项的必备技能。
在一些并发场景比较多的系统中,更偏向使用 Dubbo 实现的这一套 RPC 通信协议。Dubbo 协议是建立单一长连接通信,网络 I/O 为 NIO 非阻塞读写操作,更兼容了Kryo、FST、Protobuf 等性能出众的序列化框架,在高并发、小对象传输的业务场景中非常实用。
在企业级系统中,业务往往要比普通的互联网产品复杂,服务与服务之间可能不仅仅是数据传输,还有图片以及文件的传输,所以 RPC 的通信协议设计考虑更多是功能性需求,在性能方面不追求极致。
|