Java 网络IO模型(BIO NIO AIO)
BIO同步并阻塞(传统阻塞型):一个连接一个线程,客户端有连接请求时服务器端就需要启动一个线程进行处理。线程开销大。
NIO同步非阻塞:一个请求一个线程,但客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有 I/O 请求时才启动一个线程进行处理。
AIO异步非阻塞:一个有效请求一个线程,客户端的 I/O 请求都是由 OS 先完成了再通知服务器应用去
启动线程进行处理。
BIO与NIO的区别
BIO 是面向流的,NIO 是面向缓冲区的或者面向块的;
BIO 的各种流是阻塞的。而 NIO 是非阻塞的;
BIO的 Stream 是单向的,而 NIO 的 channel 是双向的。
阻塞和非阻塞
阻塞和非阻塞指的是执行一个操作是等操作结束再返回,还是马上返回。
比如餐馆的服务员为用户点菜,当有用户点完菜后,服务员将菜单给后台厨师,此时有两种方式:
第一种:就在出菜窗口等待,直到厨师炒完菜后将菜送到窗口,然后服务员再将菜送到用户手中;
第二种:等一会再到窗口来问厨师,某个菜好了没?如果没有先处理其他事情,等会再去问一次;
第一种就是阻塞方式,第二种则是非阻塞的。
同步和异步
同步和异步又是另外一个概念,它是事件本身的一个属性。还拿前面点菜为例,服务员直接跟厨师打交道,菜出来没出来,服务员直接指导,但只有当厨师将菜送到服务员手上,这个过程才算正常完成,这就是同步的事件。同样是点菜,有些餐馆有专门的传菜人员,当厨师炒好菜后,传菜员将菜送到传菜窗口,并通知服务员,这就变成异步的了。其实异步还可以分为两种:带通知的和不带通知的。前面说的那种属于带通知的。有些传菜员干活可能主动性不是很够,不会主动通知你,你就需要时不时的去关注一下状态。这种就是不带通知的异步。
对于同步的事件,你只能以阻塞的方式去做。而对于异步的事件,阻塞和非阻塞都是可以的。非阻塞又有两种方式:主动查询和被动接收消息。被动不意味着一定不好,在这里它恰恰是效率更高的,因为在主动查询里绝大部分的查询是在做无用功。对于带通知的异步事件,两者皆可。而对于不带通知的,则只能用主动查询。
但是对于非阻塞和异步的概念有点混淆,非阻塞只是意味着方法调用不阻塞,就是说作为服务员的你不用一直在窗口等,非阻塞的逻辑是"等可以读(写)了告诉你",但是完成读(写)工作的还是调用者(线程)服务员的你等菜到窗口了还是要你亲自去拿。而异步意味这你可以不用亲自去做读(写)这件事,你的工作让别人(别的线程)来做,你只需要发起调用,别人把工作做完以后,或许再通知你,它的逻辑是“我做完了 告诉/不告诉 你”,他和非阻塞的区别在于一个是"已经做完"另一个是"可以去做"。
阻塞IO/非阻塞IO
阻塞IO是:拷贝-知道所有数据拷贝到发送缓冲区。
非阻塞IO是拷贝-返回-再拷贝-再返回
read总是在接受缓存区有数据的时候直接返回,而不是等到应用程序哥顶的数据充满才返回。如果此时缓冲区是空的,那么阻塞模式会等待,非阻塞则会返回-1并有EWOULDBLOCK或EAGAIN错误
和read不太一样的是,在阻塞模式下,write只有在发送缓冲区足矣容纳应用程序的输出字节时才会返回。在非阻塞的模式下,能写入多少则写入多少,并返回实际写入的字节数
I/O多路复用机制
I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
select运行机制
当使用select函数的时候,先通知内核挂起进程,一旦一个或者多个IO事情发生,控制权将返回给应用程序,然后由应用程序进行IO处理。
select()的机制中提供一种fd_set的数据结构,实际上是一个long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读。
从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
select机制的问题
- 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大时,那这个开销也很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd_set,如果fd_set集合很大时,那这个开销也很大
- 为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)
Poll运行机制
鉴于select所支持的描述符有限,随后提出poll解决这个问题
poll和select不同之处在于,在select中,文件描述符个数随着fd_set的实现而固定,而在poll函数中,我们可以通过控制pollfd数组的大小来改变描述符的个数
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。也就是说,poll只解决了上面的问题3,并没有解决问题1,2的性能开销问题。
poll改变了文件描述符集合的描述方式,使用了链表结构而不是select的数组结构,使得poll支持的文件描述符集合限制远大于select的1024。
Epoll运行机制
epoll 通过监控注册的多个描述字,来进行 I/O 事件的分发处理。不同于 poll 的是,epoll 不仅提供了默认的 level-triggered(条件触发)机制,还提供了性能更为强劲的edge triggered(边缘触发)机制
epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式,相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
当我们使用epoll_fd增加一个fd的时候,内核会为我们创建一个epitem实例,讲这个实例作为红黑树的节点,随后查找的每一个fd是否有事件发生就是通过红黑树的epitem来操作
epoll维护一个链表来记录就绪事件,内核会当每个文件有事件发生的时候将自己登记到这个就绪列表,然后通过内核自身的文件file-eventpoll之间的回调和唤醒机制,减少对内核描述字的遍历,大俗事件通知和检测的效率
elect、poll、epoll 区别总结:
综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。
1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。
NIO模型
BIO是基于字节流或者字符流进行操作,而NIO基于channel通道和buffer缓冲区进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector选择器用于监听多个通道的事件(比如:连接请求,数据到达等),因此就可以使用单个线程监听多个客户端通道。
NIO(JDK1.4提供的新API)
NIO三大核心部分:Channel(通道)、Buffer(缓冲区)、Selector(选择器)
NIO面向缓冲区,或者面向 块 编程的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络。
Java NIO的非阻塞模式(单线程处理多任务),使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程可以去做其他事情。
NIO 的特点:事件驱动模型、单线程处理多任务、非阻塞 I/O,I/O 读写不再阻塞,而是返
回 0、基于 block 的传输比基于流的传输更高效、更高级的 IO 函数 zero-copy、IO 多路复用大大提高了 Java 网络应用的可伸缩性和实用性。基于 Reactor 线程模型。
在 Reactor 模式中,事件分发器等待某个事件或者可应用或个操作的状态发生,事件分发
器就把这个事件传给事先注册的事件处理函数或者回调函数,由后者来做实际的读写操
作。如在 Reactor 中实现读:注册读就绪事件和相应的事件处理器、事件分发器等待事
件、事件到来,激活分发器,分发器调用事件对应的处理器、事件处理器完成实际的读操
作,处理读到的数据,注册新的事件,然后返还控制权。
NIO的组成
Buffer:与 Channel 进行交互,数据是从 Channel 读入缓冲区,从缓冲区写入 Channel 中的DirectByteBuffer 可减少一次系统空间到用户空间的拷贝。但 Buffer 创建和销毁的成本更
高,不可控,通常会用内存池来提高性能。直接缓冲区主要分配给那些易受基础系统的本
机 I/O 操作影响的大型、持久的缓冲区。如果数据量比较小的中小应用情况下,可以考虑
使用 heapBuffer,由 JVM 进行管理。
Channel:表示 IO 源与目标打开的连接,是双向的,但不能直接访问数据,只能与 Buffer
进行交互。通过源码可知,FileChannel 的 read 方法和 write 方法都导致数据复制了两次!
Selector 可使一个单独的线程管理多个 Channel,open 方法可创建 Selector,register 方法向多路复用器器注册通道,可以监听的事件类型:读、写、连接、accept。注册事件后会产
生一个 SelectionKey:它表示 SelectableChannel 和 Selector 之间的注册关系,wakeup 方
法:使尚未返回的第一个选择操作立即返回,唤醒的原因是:注册了新的 channel 或者事
件;channel 关闭,取消注册;优先级更高的事件触发(如定时器事件),希望及时处理。
Selector 在 Linux 的实现类是 EPollSelectorImpl,委托给 EPollArrayWrapper 实现,其中三个native 方法是对 epoll 的封装,而 EPollSelectorImpl. implRegister 方法,通过调用 epoll_ctl向 epoll 实例中注册事件,还将注册的文件描述符(fd)与 SelectionKey 的对应关系添加到fdToKey 中,这个 map 维护了文件描述符与 SelectionKey 的映射。
fdToKey 有时会变得非常大,因为注册到 Selector 上的 Channel 非常多(百万连接);过期或失效的 Channel 没有及时关闭。fdToKey 总是串行读取的,而读取是在 select 方法中进行的,该方法是非线程安全的。
Pipe:两个线程之间的单向数据连接,数据会被写到 sink 通道,从 source 通道读取
NIO 的服务端建立过程:Selector.open():打开一个 Selector;ServerSocketChannel.open():创建服务端的 Channel;bind():绑定到某个端口上。并配置非阻塞模式;register():注册Channel 和关注的事件到 Selector 上;select()轮询拿到已经就绪的事件
NIO与零拷贝
零拷贝是服务器网络编程的关键,任何性能优化都离不开。在 Java 程序员的世界,常用的零拷贝有 mmap 和 sendFile。
每一次的用户态和内核态的上下文切换就相当于一次80中断(耗时)
传统IO模型
上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作
步骤:
1.read 调用导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU) 引擎从磁盘读取 index.html 文件,并将数据放入到内核缓冲区。
2.发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。
3.发生第三次数据拷贝,我们调用 write 方法,系统将用户缓冲区的数据拷贝到 Socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。
4.第四次拷贝,数据异步的从 Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。
5.write 方法返回,再次从内核态切换到用户态。
mmap 优化
mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。如下图:
只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但不减少上下文切换次数。
sendFile
数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。
如上图,我们进行 sendFile 系统调用时,数据被 DMA 引擎从文件复制到内核缓冲区,然后调用,然后掉一共 write 方法时,从内核缓冲区进入到 Socket,这时,是没有上下文切换的,因为在一个用户空间。最后,数据从 Socket 缓冲区进入到协议栈。
此时,数据经过了 3 次拷贝,3 次上下文切换。
那么,还能不能再继续优化呢? 例如直接从内核缓冲区拷贝到网络协议栈?
实际上,Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。具体如下图:
现在,进入到网络协议栈,只需 2 次拷贝:第一次使用 DMA 引擎从文件拷贝到内核缓冲区,第二次从内核缓冲区将数据拷贝到网络协议栈;内核缓存区只会拷贝一些 offset 和 length 信息到 SocketBuffer,基本无消耗。
不是说零拷贝吗?为什么还是要 2 次拷贝?
答:首先我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据,sendFile 2.1 版本实际上有 2 份数据,算不上零拷贝)。例如我们刚开始的例子,内核缓存区和 Socket 缓冲区的数据就是重复的。
而零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。
mmap 和 sendFile 的区别
1.mmap 适合小数据量读写,sendFile 适合大文件传输。
2.mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
3.sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
在这个选择上:rocketMQ 在消费消息时,使用了 mmap。kafka 使用了 sendFile。
Netty 的零拷贝
零拷贝的目的是为了减少IO流程中不必要的拷贝,以及减少用户进程地址空间和内核地址空间之间因为上下文切换而带来的开销。由于虚拟机不能直接操作内核,因此它的实现需要操作系统OS的支持,也就是需要kernel内核暴漏API
- Direct Buffers:Netty的接收和发送ByteBuffer采用直接缓冲区(Direct Buffer)实现零拷贝,直接在内存区域分配空间,避免了读写数据的二次内存拷贝,这就实现了读写Socket的零拷贝。
如果使用传统的堆内存缓冲区(Heap Buffer)进行Socket读写,JVM会将堆内存Buffer拷贝到直接内存中,然后才写入Socket中。相比堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
- CompositeByteBuf:它可以将多个ByteBuf封装成ByteBuf,对外提供统一封装后的ByteBuf接口。CompositeByteBuf并没有真正将多个Buffer组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝
传统的ByteBuffer,如果需要将两个ByteBuffer中的数据组合到一起,我们需要首先创建一个size=size1+size2大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用Netty提供的组合ByteBuf,就可以避免这样的操作。
- Netty的文件传输类DefaultFileRegion通过调用FileChannel.transferTo()方法实现零拷贝,文件缓冲区的数据会直接发送给目标Channel。底层调用Linux操作系统中的sendfile()实现的,数据从文件由DMA引擎拷贝到内核read缓冲区,;DMA从内核read缓冲区将数据拷贝到网卡接口(硬件)的缓冲区,由网卡进行网络传输。
Netty
Netty是一个异步的、基于事件驱动的网络应用框架,用以快速开发高性能、高可靠性的网络IO程序。
Netty主要针对在TCP协议下,面向Clinets端的高并发应用,或者Peer-to-Peer场景下的大量数据持续传输的应用。
Netty本质是一个NIO框架,适用于服务器通讯相关的多种应用场景。
Netty 的特点
- 一个高性能、异步事件驱动的 NIO 框架,它提供了对 TCP、UDP 和文件传输的支持
- 使用更高效的 socket 底层,对 epoll 空轮询引起的 cpu 占用飙升在内部进行了处理,避免了直接使用 NIO 的陷阱,简化了 NIO 的处理方式。
- 采用多种 decoder/encoder 支持,对 TCP 粘包/分包进行自动化处理
- 可使用接受/处理线程池,提高连接效率,对重连、心跳检测的简单支持
- 可配置 IO 线程数、TCP 参数, TCP 接收和发送缓冲区使用直接内存代替堆内存,通过内存池的方式循环利用 ByteBuf
- 通过引用计数器及时申请释放不再引用的对象,降低了 GC 频率
- 使用单线程串行化的方式,高效的 Reactor 线程模型
- 大量使用了 volitale、使用了 CAS 和原子类、线程安全类的使用、读写锁的使用
Netty 线程模型
传统阻塞IO服务模型
特点:1)采用阻塞IO模式获取输入数据
2)每个连接都需要独立的线程完成数据的输入,业务处理,数据返回
问题:1)当并发数很大,就会创建大量的线程,占用很大系统资源
2)连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在read操作,造成线程资源浪费
Reactor模式:
针对传统阻塞IO模型的2个缺点的解决方案:
1)基于IO复用模型:多个连接共用一个线程,应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接。当某个连接有新数据需处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
2)基于线程池复用线程资源:不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个连接可以处理多个连接的业务。
1)Reactor模式,通过一个或者多个输入同时传递给服务处理器的模式(基于事件驱动)
2)服务器端程序处理传入的多个请求,并将它们同步分派到相应的处理线程,因此Reactor模式也叫dispatcher模式(分发者模式)
3)Reactor模式使用IO复用监听事件,收到事件后,分发给某个线程(进程),这是网络服务器高并发处理关键
Reactor模式组成
1)Reactor:Reactor在一个单独线程中运行,负责监听和分发事件,分发给适当的处理程序来对IO事件做出反应
2)Handlers:处理程序执行IO事件要完成的实际事件,Reactor通过适度调度适当处理程序来响应IO事件,处理程序执行非阻塞操作。
Reactor模式分类
- 单Reactor单线程
所有 I/O 操作都由一个线程完成,即多路复用、事件分发和处理都是在一个Reactor 线程上完成的。既要接收客户端的连接请求,向服务端发起连接,又要发送/读取请求或应答/响应消息。一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,速度慢,若线程进入死循环,整个程序不可用,对于高负载、大并发的应用场景不合适
1)Select是前面IO复用模型介绍的标准网络编程API,可以实现应用程序通过一个阻塞对象监听多路连接请求
2)Reactor对象是通过Select监控客户端请求事件,收到事件后通过Dispatch分发
3)如果是建立连接请求事件,则由Acceptor通过Accept处理连接请求,然后创建一个Handler对象处理连接完成后的后续业务处理
4)如果不是建立连接请求事件,则Reactor会分发调用连接对应的Handler来响应
5)Handler会完成Read->业务处理->Send的完整业务流程
- 单Reactor多线程
有一个 NIO 线程(Acceptor) 只负责监听服务端,接收客户端的 TCP 连接请求;NIO 线程池负责网络 IO 的操作,即消息的读取、解码、编码和发送;1 个 NIO 线程可以同时处理 N 条链路,但是 1 个链路只对应 1 个 NIO 线程,这是为了防止发生并发操作问题。但在并发百万客户端连接或需要安全认证时,一个 Acceptor 线程可能会存在性能不足问题。
1)Select是前面IO复用模型介绍的标准网络编程API,可以实现应用程序通过一个阻塞对象监听多路连接请求
2)Reactor对象是通过Select监控客户端请求事件,收到事件后通过Dispatch分发
3)如果是建立连接请求事件,则由Acceptor通过Accept处理连接请求,然后创建一个Handler对象处理连接完成后的后续业务处理
4)Handler只负责响应事件,不做具体的业务处理,通过read读取数据后,会分发给后面的worker线程池的某个线程处理业务
5)worker线程池会分配独立线程完成真正的业务,并将结果返回给handler
6)handler收到响应后,通过send将结果返回给client
- 主从Reactor多线程
Acceptor 线程用于绑定监听端口,接收客户端连接,将 SocketChannel从主线程池的 Reactor 线程的多路复用器上移除,重新注册到 Sub 线程池的线程上,用于处理 I/O 的读写等操作,从而保证 mainReactor 只负责接入认证、握手等操作;
1)Reactor主线程MainReactor对象通过select监听连接事件,收到事件后,通过Acceptor处理连接事件
2)当Acceptor处理连接事件后,MainReactor将连接分配给SubReactor
3)SubReactor将连接加入到连接队列进行监听,并创建handler进行各种事件的处理
4)当有新事件发生时,SubReactor就会调用对应的handler处理
5)Handler通过read读取数据后,会分发给后面的worker线程处理
6)worker线程池会分配独立线程完成真正的业务,并将返回结果
7)handler收到响应后,通过send将结果返回给client
8)Reactor主线程可以对应多个Reactor子线程,即MainReactor可以关联多个SubReactor
Netty模型
Netty线程模式主要基于主从Reactor多线程做了一定的改进,主从Reactor多线程模型有多个Reactor
Netty 通过 Reactor 模型基于多路复用器接收并处理用户请求,内部实现了两个线程池,
boss 线程池和 work 线程池,其中 boss 线程池的线程负责处理请求的 accept 事件,当接收到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 work线程池,其中 work 线程池负责请求的 read 和 write 事件,由对应的 Handler 处理。
1)Netty抽象出两组线程池BossGroup专门负责接收客户端连接,WorkerGroup专门负责网络的读写
2)BossGroup和WorkerGroup类型都是NioEventLoopGroup
3)NioEventLoopGroup相当于一个事件循环组,这个组中含有多个事件循环,每一个循环是NioEventLoop
4)NioEventLoop表示一个不断循环的执行处理任务的线程,每个NioEventLoop都有一个selector,用于监听绑定在其上的socket的网络通讯
5)NioEventLoopGroup可以有多个线程,即可以含有多个NioEventLoop
6)每个Boos NioEventLoop循环执行步骤有3:
- 轮询accept事件
- 处理accept事件,与client建立连接,生成NioScoketChannel,并将其注册到某个worker NIOEventLoop上的selector
- 处理任务队列的任务,即runAllTasks
7)每个Worker NioEventLoop循环执行步骤:
- 轮询read、write事件
- 处理IO事件,即read、write事件,在对应NioScoketChannel处理
- 处理任务队列的任务,即runAllTasks
8)每个Worker NioEventLoop处理任务时,会使用pipeline(管道),pipeline包含了channel,即通过pipeline可以获取对应通道,管道中维护了很多的处理器
Selector BUG:若 Selector 的轮询结果为空,也没有 wakeup 或新消息处理,则发生空轮询,CPU 使用率 100%,
Netty 的解决办法:对 Selector 的 select 操作周期进行统计,每完成一次空的 select 操作进行一次计数,若在某个周期内连续发生 N 次空轮询,则触发了 epoll 死循环 bug。重建
Selector,判断是否是其他线程发起的重建请求,若不是则将原 SocketChannel 从旧的
Selector 上去除注册,重新注册到新的 Selector 上,并将原来的 Selector 关闭。
异步模型
1)与同步相对,当一个异步过程调用发出后,调用者不能立刻得到结果,实际上处理这个调用的组件在完成后,通过状态、通知和回调来通知调用者。
2)Netty中的IO操作是异步的,包括bind、write、connect 等操作会简单返回一个channelFuture
3)调用者不能立刻获得结果,而是通过Future-Listener机制,用户可以方便的主动获取或者通过通知机制获得IO操作的结果
4)Netty的异步模型是建立在future和callback的之上的,callback是回调,重点说future,它的核心思想是:假设一个方法fun,计算过程可能非常耗时,等待fun返回显然不合适。那么可以在调用fun的时候,立马返回一个future,后续可以通过future去监控方法fun的处理过程(即Future-Listener机制)
Netty 的高性能
-
心跳,对服务端:会定时清除闲置会话 inactive(netty5),对客户端:用来检测会话是否断开,是否重来,检测网络延迟,其中 idleStateHandler 类 用来检测会话状态
-
串行无锁化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。
-
可靠性,链路有效性检测:链路空闲检测机制,读/写空闲超时机制;内存保护机制:通过内存池重用 ByteBuf;ByteBuf 的解码保护;优雅停机:不再接收新消息、退出前的预处理操作、资源的释放操作。
-
Netty 安全性:支持的安全协议:SSL V2 和 V3,TLS,SSL 单向认证、双向认证和第三方 CA认证。
-
高效并发编程的体现:volatile 的大量、正确使用;CAS 和原子类的广泛使用;线程安全容器的使用;通过读写锁提升并发性能。IO 通信性能三原则:传输(AIO)、协议(Http)、线程(主从多线程)
-
流量整型的作用(变压器):防止由于上下游网元性能不均衡导致下游网元被压垮,业务流中断;防止由于通信模块接受消息过快,后端业务线程处理不及时导致撑死问题。
-
TCP 参数配置:SO_RCVBUF 和 SO_SNDBUF:通常建议值为 128K 或者 256K;
SO_TCPNODELAY:NAGLE 算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提高网络应用效率。但是对于时延敏感的应用场景需要关闭该优化算法;
序列化
了解哪几种序列化协议?
序列化(编码)是将对象序列化为二进制形式(字节数组),主要用于网络传输、数据持久
化等;而反序列化(解码)则是将从网络、磁盘等读取的字节数组还原成原始对象,主要
用于网络传输对象的解码,以便完成远程调用。
影响序列化性能的关键因素:序列化后的码流大小(网络带宽的占用)、序列化的性能
(CPU 资源占用);是否支持跨语言(异构系统的对接和开发语言切换)。
Java 默认提供的序列化:无法跨语言、序列化后的码流太大、序列化的性能差
XML,优点:人机可读性好,可指定元素或特性的名称。缺点:序列化数据只包含数据本
身以及类的结构,不包括类型标识和程序集信息;只能序列化公共属性和字段;不能序列
化方法;文件庞大,文件格式复杂,传输占带宽。适用场景:当做配置文件存储数据,实
时数据转换。
JSON,是一种轻量级的数据交换格式,优点:兼容性高、数据格式比较简单,易于读写、
序列化后数据较小,可扩展性好,兼容性好、与 XML 相比,其协议比较简单,解析速度比
较快。缺点:数据的描述性比 XML 差、不适合性能要求为 ms 级别的情况、额外空间开销
比较大。适用场景(可替代XML):跨防火墙访问、可调式性要求高、基于 Web
browser 的 Ajax 请求、传输数据量相对小,实时性要求相对低(例如秒级别)的服务。
Fastjson,采用一种“假定有序快速匹配”的算法。优点:接口简单易用、目前 java 语言中
最快的 json 库。缺点:过于注重快,而偏离了“标准”及功能性、代码质量不高,文档不
全。适用场景:协议交互、Web 输出、Android 客户端
Thrift,不仅是序列化协议,还是一个 RPC 框架。优点:序列化后的体积小, 速度快、支持
多种语言和丰富的数据类型、对于数据字段的增删具有较强的兼容性、支持二进制压缩编
码。缺点:使用者较少、跨防火墙访问时,不安全、不具有可读性,调试代码时相对困
难、不能与其他传输层协议共同使用(例如 HTTP)、无法支持向持久层直接读写数据,即
不适合做数据持久化序列化协议。适用场景:分布式系统的 RPC 解决方案
Avro,Hadoop 的一个子项目,解决了 JSON 的冗长和没有 IDL 的问题。优点:支持丰富的数据类型、简单的动态语言结合功能、具有自我描述属性、提高了数据解析速度、快速可
压缩的二进制数据形式、可以实现远程过程调用 RPC、支持跨编程语言实现。缺点:对于
习惯于静态类型语言的用户不直观。适用场景:在 Hadoop 中做 Hive、Pig 和 MapReduce
的持久化数据格式。
Protobuf,将数据结构以.proto 文件进行描述,通过代码生成工具可以生成对应数据结构的
POJO 对象和 Protobuf 相关的方法和属性。优点:序列化后码流小,性能高、结构化数据存储格式(XML JSON 等)、通过标识字段的顺序,可以实现协议的前向兼容、结构化的文档更容易管理和维护。缺点:需要依赖于工具生成代码、支持的语言相对较少,官方只支持Java 、C++ 、python。适用场景:对性能要求高的 RPC 调用、具有良好的跨防火墙的访问属性、适合应用层对象的持久化
其它
protostuff 基于 protobuf 协议,但不需要配置 proto 文件,直接导包即可
Jboss marshaling 可以直接序列化 java 类, 无须实 java.io.Serializable 接口
Message pack 一个高效的二进制序列化格式
Hessian 采用二进制协议的轻量级 remoting onhttp 工具
kryo 基于 protobuf 协议,只支持 java 语言,需要注册(Registration),然后序列化(Output),反序列化(Input)
如何选择序列化协议?
具体场景
对于公司间的系统调用,如果性能要求在 100ms 以上的服务,基于 XML 的 SOAP 协议是一个值得考虑的方案。
基于 Web browser 的 Ajax,以及 Mobile app 与服务端之间的通讯,JSON 协议是首选。对于性能要求不太高,或者以动态类型语言为主,或者传输数据载荷很小的的运用场景,JSON也是非常不错的选择。
对于调试环境比较恶劣的场景,采用 JSON 或 XML 能够极大的提高调试效率,降低系统开
发成本。
当对性能和简洁性有极高要求的场景,Protobuf,Thrift,Avro 之间具有一定的竞争关系。
对于 T 级别的数据的持久化应用场景,Protobuf 和 Avro 是首要选择。如果持久化后的数据
存储在 hadoop 子项目里,Avro 会是更好的选择。
对于持久层非 Hadoop 项目,以静态类型语言为主的应用场景,Protobuf 会更符合静态类
型语言工程师的开发习惯。由于 Avro 的设计理念偏向于动态类型语言,对于动态语言为主
的应用场景,Avro 是更好的选择。
如果需要提供一个完整的 RPC 解决方案,Thrift 是一个好的选择。
如果序列化之后需要支持不同的传输层协议,或者需要跨防火墙访问的高性能场景,
Protobuf 可以优先考虑。
protobuf 的数据类型有多种:bool、double、float、int32、int64、string、bytes、enum、
message。protobuf 的限定符:required: 必须赋值,不能为空、optional:字段可以赋值,也
可以不赋值、repeated: 该字段可以重复任意次数(包括 0 次)、枚举;只能用指定的常量
集中的一个值作为其值;
protobuf 的基本规则:每个消息中必须至少留有一个 required 类型的字段、包含 0 个或多
个 optional 类型的字段;repeated 表示的字段可以包含 0 个或多个数据;[1,15]之内的标识
号在编码的时候会占用一个字节(常用),[16,2047]之内的标识号则占用 2 个字节,标识号一定不能重复、使用消息类型,也可以将消息嵌套任意多层,可用嵌套消息类型来代替组。
protobuf 的消息升级原则:不要更改任何已有的字段的数值标识;不能移除已经存在的
required 字段,optional 和 repeated 类型的字段可以被移除,但要保留标号不能被重用。
新添加的字段必须是 optional 或 repeated。因为旧版本程序无法读取或写入新增的required 限定符的字段。
编译器为每一个消息类型生成了一个.java 文件,以及一个特殊的 Builder 类(该类是用来创
建消息类接口的)。如:UserProto.User.Builder builder =UserProto.User.newBuilder();builder.build();
Netty 中的使用:ProtobufVarint32FrameDecoder 是用于处理半包消息的解码类;
ProtobufDecoder(UserProto.User.getDefaultInstance())这是创建的 UserProto.java 文件中的解码类;ProtobufVarint32LengthFieldPrepender 对 protobuf 协议的消息头上加上一个长度为32 的整形字段,用于标志这个消息的长度的类;ProtobufEncoder 是编码类将 StringBuilder 转换为 ByteBuf 类型:copiedBuffer()方法