Netty知识笔记

[TOC]算法

1、简介

Netty是一个异步事件驱动的网络应用框架,用于快速开发可维护的高性能服务器和客户端。 编程

kcnmJ1.png

Netty是典型的Reactor模型结构,在实现上,Netty中的Boss类充当mainReactor,NioWorker类充当subReactor(默认NioWorker的个数是当前服务器的可用核数)。后端

在处理新来的请求时,NioWorker读完已收到的数据到ChannelBuffer中,以后触发ChannelPipeline中的ChannelHandler流。数组

Netty是事件驱动的,能够经过ChannelHandler链来控制执行流向。由于ChannelHandler链的执行过程是在subReactor中同步的,因此若是业务处理handler耗时长,将严重影响可支持的并发数。缓存

kcn8dH.png

2、NIO基础知识点

一、阻塞与非阻塞

阻塞与非阻塞是描述进程在访问某个资源时,数据是否准备就绪的的一种处理方式。安全

  • 阻塞 :当数据没有准备就绪时,线程持续等待资源中数据准备完成,直到返回响应结果。
  • 非阻塞: 线程直接返回结果,不会持续等待资源准备数据结束后才响应结果。

二、同步与异步

  • 同步:通常指主动请求并等待IO操做完成的方式
  • 异步:指主动请求数据后即可以继续处理其它任务,随后等待IO操做完毕的通知。

3、IO模型

一、传统BIO模型

传统BIO是一种同步的阻塞IO,IO在进行读写时,该线程将被阻塞,线程没法进行其它操做。bash

二、伪异步IO模型

以传统BIO模型为基础,经过线程池的方式维护全部的IO线程,实现相对高效的线程开销及管理。服务器

三、NIO模型

NIO模型是一种同步非阻塞IO,主要有三大核心部分:Channel(通道),Buffer(缓冲区),Selector网络

传统IO基于字节流和字符流进行操做,而NIO基于Channel和Buffer(缓冲区)进行操做,数据老是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择区)用于监听多个通道的事件(好比:链接打开,数据到达)。所以,单个线程能够监听多个数据通道。多线程

NIO和传统IO(一下简称IO)之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。

Java IO面向流意味着每次从流中读一个或多个字节,直至读取全部字节,它们没有被缓存在任何地方。此外,它不能先后移动流中的数据。若是须要先后移动从流中读取的数据,须要先将它缓存到一个缓冲区。NIO的缓冲导向方法略有不一样。数据读取到一个它稍后处理的缓冲区,须要时可在缓冲区中先后移动。这就增长了处理过程当中的灵活性。可是,还须要检查是否该缓冲区中包含全部您须要处理的数据。并且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里还没有处理的数据。

IO的各类流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据彻底写入。该线程在此期间不能再干任何事情了。 NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,可是它仅能获得目前可用的数据,若是目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,因此直至数据变的能够读取以前,该线程能够继续作其余的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不须要等待它彻底写入,这个线程同时能够去作别的事情。 线程一般将非阻塞IO的空闲时间用于在其它通道上执行IO操做,因此一个单独的线程如今能够管理多个输入和输出通道(channel)。

4、NIO模型要点

一、Channel(通道)

传统IO操做对read()或write()方法的调用,可能会由于没有数据可读/可写而阻塞,直到有数据响应。也就是说读写数据的IO调用,可能会无限期的阻塞等待,效率依赖网络传输的速度。最重要的是在调用一个方法前,没法直到是否会被阻塞。

NIO的Channel抽象了一个重要特征就是能够经过配置它的阻塞行为,来实现非阻塞式的通道。

Channel是一个双向通道,与传统IO操做之容许单向的读写不一样的是,NIO的Channel容许在一个通道上进行读和写的操做。

二、Buffer(缓冲区)

Bufer顾名思义,它是一个缓冲区,其实是一个容器,一个连续数组。Channel提供从文件、网络读取数据的渠道,可是读写的数据都必须通过Buffer。

kcuaN9.jpg

Buffer缓冲区本质上是一块能够写入数据,而后能够从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该模块内存。为了理解Buffer的工做原理,须要熟悉它的三个属性:capacity、position和limit。

position和limit的含义取决于Buffer处在读模式仍是写模式。无论Buffer处在什么模式,capacity的含义老是同样的.

kcuB1x.png

  • capacity

做为一个内存块,Buffer有固定的大小值,也叫做“capacity”,只能往其中写入capacity个byte、long、char等类型。一旦Buffer满了,须要将其清空(经过读数据或者清楚数据)才能继续写数据。

  • position

当你写数据到Buffer中时,position表示当前的位置。初始的position值为0,当写入一个字节数据到Buffer中后,position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity-1。当读取数据时,也是从某个特定位置读,讲Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取一个字节数据后,position向前移动到下一个可读的位置。

  • limit

在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。

当切换Buffer到读模式时, limit表示你最多能读到多少数据。所以,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到以前写入的全部数据(limit被设置成已写数据的数量,这个值在写模式下就是position)

  • Buffer的分配 对Buffer对象的操做必须首先进行分配,Buffer提供一个allocate(int capacity)方法分配一个指定字节大小的对象。

  • Buffer写数据

    • 方式一: channel写到buffer
    • 方式二:经过Buffer的put()方法写到Buffer

方式1:

int bytes = channel.read(buf); //将channel中的数据读取到buf中
复制代码

方式2:

buf.put(byte); //将数据经过put()方法写入到buf中
复制代码
  • flip()方法

将Buffer从写模式切换到读模式,调用flip()方法会将position设置为0,并将limit设置为以前的position的值。

  • Buffer读数据
    • 从buffer读取到channel
    • 经过buffer的get获取数据

方式1:

int bytes = channel.write(buf); //将buf中的数据读取到channel中
复制代码

方式2:

byte bt = buf.get(); //从buf中读取一个byte
复制代码
  • rewind()方法

Buffer.rewind()方法将position设置为0,使得能够重读Buffer中的全部数据,limit保持不变。

  • clear() 与compact() 方法

一旦读完Buffer中的数据,须要让Buffer准备好再次被写入,能够经过clear()或compact()方法完成。

若是调用的是clear()方法,position将被设置为0,limit设置为capacity的值。可是Buffer并未被清空,只是经过这些标记告诉咱们能够从哪里开始往Buffer中写入多少数据。若是Buffer中还有一些未读的数据,调用clear()方法将被"遗忘 "。compact()方法将全部未读的数据拷贝到Buffer起始处,而后将position设置到最后一个未读元素的后面,limit属性依然设置为capacity。可使得Buffer中的未读数据还能够在后续中被使用。

  • mark() 与reset()方法

经过调用Buffer.mark()方法能够标记一个特定的position,以后能够经过调用Buffer.reset()恢复到这个position上。

4.Selector(多路复用器)

Selector与Channel是相互配合使用的,将Channel注册在Selector上以后,才能够正确的使用Selector,但此时Channel必须为非阻塞模式。Selector能够监听Channel的四种状态(Connect、Accept、Read、Write),当监听到某一Channel的某个状态时,才容许对Channel进行相应的操做。

5、NIO开发的问题

一、NIO类库和API复杂,使用麻烦。
二、须要具有Java多线程编程能力(涉及到Reactor模式)。
三、客户端断线重连、网络不稳定、半包读写、失败缓存、网络阻塞和异常码流等问题处理难度很是大
四、存在部分BUG
复制代码

NIO进行服务器开发的步骤:

一、建立ServerSocketChannel,配置为非阻塞模式;
	二、绑定监听,配置TCP参数;
	三、建立一个独立的IO线程,用于轮询多路复用器Selector;
	四、建立Selector,将以前建立的ServerSocketChannel注册到Selector上,监听Accept事件;
	五、启动IO线程,在循环中执行Select.select()方法,轮询就绪的Channel;
	六、当轮询处处于就绪状态的Channel时,须要对其进行判断,若是是OP_ACCEPT状态,说明有新的客户端接入,则调用ServerSocketChannel.accept()方法接受新的客户端;
	七、设置新接入的客户端链路SocketChannel为非阻塞模式,配置TCP参数;
	八、将SocketChannel注册到Selector上,监听READ事件;
	九、若是轮询的Channel为OP_READ,则说明SocketChannel中有新的准备就绪的数据包须要读取,则构造ByteBuffer对象,读取数据包;
	十、若是轮询的Channel为OP_WRITE,则说明还有数据没有发送完成,须要继续发送。
复制代码

6、Netty的有点

一、API使用简单,开发门槛低;
	二、功能强大,预置了多种编解码功能,支持多种主流协议;
	三、定制功能强,能够经过ChannelHandler对通讯框架进行灵活的扩展;
	四、性能高,经过与其余业界主流的NIO框架对比,Netty综合性能最优;
	五、成熟、稳定,Netty修复了已经发现的NIO全部BUG;
	六、社区活跃;
	七、经历了不少商用项目的考验。
复制代码

7、粘包/拆包问题

TCP是一个“流”协议,所谓流,就是没有界限的一串数据。能够想象为河流中的水,并无分界线。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际状况进行包的划分,因此在业务上认为,一个完整的包可能会被TCP拆分红多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。

k7zyaF.png

假设客户端分别发送了两个数据包D1和D2给服务端,因为服务端一次读取到的字节数是不肯定的,可能存在如下4中状况。

1.服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;
 2.服务端一次接收到了两个数据包,D1和D2粘合在一块儿,被称为TCP粘包;
 3.服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部份内容,第二次读取到了D2包的剩余部份内容,这被称为TCP拆包;
 4.服务端分两次读取到了两个数据包,第一次读取到了D1包的部份内容D1_1,第二次读取到了D1包的剩余内容D1_1和D2包的完整内容;
复制代码

若是此时服务器TCP接收滑窗很是小,而数据包D1和D2比较大,颇有可能发生第五种状况,既服务端分屡次才能将D1和D2包接收彻底,期间发生屡次拆包;

问题的解决策略

因为底层的TCP没法理解上层的业务数据,因此在底层是没法保证数据包不被拆分和重组的,这个问题只能经过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案可概括以下:

1.消息定长,例如每一个报文的大小为固定长度200字节,若是不够,空位补空格;
 2.在包尾增长回车换行符进行分割,例如FTP协议;
 3.将消息分为消息头和消息体,消息头中包含消息总长度(或消息体总长度)的字段,一般设计思路为消息头的第一个字段使用int32来表示消息的总程度;
 4.更复杂的应用层协议;
复制代码
  • LineBasedFrameDecoder
为了解决TCP粘包/拆包致使的半包读写问题,Netty默认提供了多种编解码器用于处理半包。

LinkeBasedFrameDecoder的工做原理是它一次遍历ByteBuf中的可读字节,判断看是否有“\n”、“\r\n”,若是有,就一次位置为结束位置,从可读索引到结束位置区间的字节就组成一行。它是以换行符为结束标志的编解码,支持携带结束符或者不携带结束符两种解码方式,同时支持配置单行的最大长度。若是连续读取到最大长度后任然没有发现换行符,就会抛出异常,同时忽略掉以前读到的异常码流。
复制代码
  • DelimiterBasedFrameDecoder
实现自定义分隔符做为消息的结束标志,完成解码。
复制代码
  • FixedLengthFrameDecoder
是固定长度解码器,可以按照指定的长度对消息进行自动解码,开发者不须要考虑TCP的粘包/拆包问题。
复制代码

8、Netty的高性能之道

1.异步非阻塞通讯

在IO编程过程当中,当须要同时处理多个客户端接入请求时,能够利用多线程或者IO多路复用技术进行处理。IO多路复用技术经过把多个IO的阻塞复用到同一个Selector的阻塞上,从而使得系统在单线程的状况下能够同时处理多个客户端请求。与传统的多线程/多进程模型相比,IO多路复用的最大优点是系统开销小,系统不须要建立新的额外进程或者线程,也不须要维护这些进程和线程的运行,下降了系统的维护工做量,节省了系统资源。

Netty的IO线程NioEventLoop因为聚合了多路复用器Selector,能够同时并发处理成百上千个客户端SocketChannel。因为读写操做都是非阻塞的,这就能够充分提高IO线程的运行效率,避免由频繁的IO阻塞致使的线程挂起。另外,因为Netty采用了异步通讯模式,一个IO线程能够并发处理N个客户端链接和读写操做,这从根本上解决了传统同步阻塞IO中 一链接一线程模型,架构的性能、弹性伸缩能力和可靠性都获得了极大的提高。

二、高效的Reactor线程模型

经常使用的Reactor线程模型有三种,分别以下:

  • C一、Reactor单线程模型

kOzIhT.png

Reactor单线程模型,指的是全部的IO操做都在同一个NIO线程上面完成,NIO线程职责以下:

一、做为NIO服务端,接收客户端的TCP链接;

二、做为NIO客户端,向服务端发起TCP链接;

三、读取通讯对端的请求或者应答消息;

四、向通讯对端发送请求消息或者应答消息;

因为Reactor模式使用的是异步非阻塞IO,全部的IO操做都不会致使阻塞,理论上一个线程能够独立处理全部IO相关操做。从架构层面看,一个NIO线程确实能够完成其承担的职责。例如,经过Acceptor接收客户端的TCP链接请求消息,链路创建成功以后,经过Dispatch将对应的ByteBuffer派发到指定的Handler上进行消息编码。用户Handler能够经过NIO线程将消息发送给客户端。

对于一些小容量应用场景,可使用单线程模型,可是对于高负载、大并发的应用却不合适,主要缘由以下:

①、一个NIO线程同时处理成百上千的链路,性能上没法支撑。即使NIO线程的CPU负荷达到100%,也没法知足海量消息的编码、解码、读取和发送;

②、当NIO线程负载太重后,处理速度将变慢,这会致使大量客户端链接超时,超时以后每每会进行重发,这更加剧了NIO线程的负载,最终会致使大量消息积压和处理超时,NIO线程会成为系统的性能瓶颈;

③、可靠性问题。一旦NIO线程意外进入死循环,会致使整个系统通讯模块不可用,不能接收和处理外部消息,形成节点故障。

为了解决这些问题,从而演进出了Reactor多线程模型。

  • C二、Reactor多线程模型
    kXpteA.png

Reactor多线程模型与单线程模型最大的区别就是有一组NIO线程处理IO操做,特色以下:

①有一个专门的NIO线程——Acceptor线程用于监听服务端,接收客户端TCP链接请求;

②网络IO操做——读、写等由一个NIO线程池负责,线程池能够采用标准的JDK线程池实现,它包含一个任务队列和N个可用的线程,由这些NIO线程负责消息的读取、编码、解码和发送;

③1个NIO线程能够同时处理N条链路,可是1个链路只对应1个NIO线程,防止发生并发操做问题。

在绝大多数场景下,Reactor多线程模型均可以知足性能需求;可是,在极特殊应用场景中,一个NIO线程负责监听和处理全部的客户端链接可能会存在性能问题。例如百万客户端并发链接,或者服务端须要对客户端的握手消息进行安全认证,认证自己很是损耗性能。在这类场景下,单独一个Acceptor线程可能会存在性能不足问题,为了解决性能问题,产生了第三种Reactor线程模型——主从Reactor多线程模型。

  • C三、主从Reactor多线程模型
    kXpyLj.png

主从Reactor线程模型的特色是:

服务端用于接收客户端链接的再也不是一个单独的NIO线程,而是一个独立的NIO线程池。Acceptor接收到客户端TCP链接请求处理完成后(可能包含接入认证等),将新建立的SocketChannel注册到IO线程池(subReactor线程池)的某个IO线程上,由它负责SocketChannel的读写和编解码工做。Acceptor线程池只用于客户端的登陆、握手和安全认证,一旦链路创建成功,就将链路注册到后端subReactor线程池的IO线程上,由IO线程负责后续的IO操做。
复制代码

利用主从NIO线程模型,能够解决1个服务端监听线程没法有效处理全部客户端链接的性能不足问题。Netty官方推荐使用该线程模型。它的工做流程总结以下:

①从主线程池中随机选择一个Reactor线程做为Acceptor线程,用于绑定监听端口,接收客户端链接;

②Acceptor线程接收客户端链接请求以后,建立新的SocketChannel,将其注册到主线程池的其余Reactor线程上,由其负责接入认证、IP黑白名单过滤、握手等操做;

③而后也业务层的链路正式创建成功,将SocketChannel从主线程池的Reactor线程的多路复用器上摘除,从新注册到Sub线程池的线程上,用于处理IO的读写操做。

三、无锁化的串行设计

在大多数场景下,并行多线程处理能够提高系统的并发性能。可是,若是对于共享资源的并发访问处理不当,会带来严重的锁竞争,这最终会致使性能的降低。为了尽量地避免锁竞争带来的性能损耗,能够经过串行化设计,既消息的处理尽量在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。

为了尽量提高性能,Netty采用了串行无锁化设计,在IO线程内部进行串行操做,避免多线程竞争致使的性能降低。表面上看,串行化设计彷佛CPU利用率不高,并发程度不够。可是,经过调整NIO线程池的线程参数,能够同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列——多个工做线程模型性能更优。

kjGsf0.png

Netty的NioEventLoop读取到消息后,直接调用ChannelPipeline的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由NioEventLoop调用到用户的Handler,期间不进行线程切换。这种串行化处理方式避免了多线程致使的锁竞争,从性能角度看是最优的

四、高效的并发编程

Netty中高效并发编程主要体现:

  • volatile的大量、正确使用
  • CAS和原子类的普遍使用
  • 线程安全容器的使用
  • 经过读写锁提高并发性能

五、高效的序列化框架

影响序列化性能的关键因素总结以下:

  • 序列化后的码流大小(网络宽带的占用)
  • 序列化与反序列化的性能(CPU资源占用)
  • 是否支持跨语言(异构系统的对接和开发语言切换) Netty默认提供了对GoogleProtobuf的支持,经过扩展Netty的编解码接口,用户能够实现其余的高性能序列化框架

六、零拷贝

Netty的“零拷贝”主要体如今三个方面:

  • Direct buffers

Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不须要进行字节缓冲区的二次拷贝。若是使用传统的堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,而后才写入Socket中。相比于堆外直接内存,消息在发送过程当中多了一次缓冲区的内存拷贝

  • CompositeByteBuf

第二种“零拷贝 ”的实现CompositeByteBuf,它对外将多个ByteBuf封装成一个ByteBuf,对外提供统一封装后的ByteBuf接口

  • 文件传输

第三种“零拷贝”就是文件传输,Netty文件传输类DefaultFileRegion经过transferTo方法将文件发送到目标Channel中。不少操做系统直接将文件缓冲区的内容发送到目标Channel中,而不须要经过循环拷贝的方式,这是一种更加高效的传输方式,提高了传输性能,下降了CPU和内存占用,实现了文件传输的“零拷贝”。

七、内存池

随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是个很是轻量级的工做。可是对于缓冲区Buffer,状况却稍有不一样,特别是对于堆外直接内存的分配和回收,是一件耗时的操做。为了尽可能重用缓冲区,Netty提供了基于内存池的缓冲区重用机制。

八、灵活的TCP参数配置能力

Netty在启动辅助类中能够灵活的配置TCP参数,知足不一样的用户场景。合理设置TCP参数在某些场景下对于性能的提高能够起到的显著的效果,总结一下对性能影响比较大的几个配置项:

1)、SO_RCVBUF和SO_SNDBUF:一般建议值为128KB或者256KB;
    2)、TCP_NODELAY:NAGLE算法经过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提升网络应用效率。可是对于时延敏感的应用场景须要关闭该优化算法;
    3)、软中断:若是Linux内核版本支持RPS(2.6.35以上版本),开启RPS后能够实现软中断,提高网络吞吐量。RPS根据数据包的源地址,目的地址以及目的和源端口,计算出一个hash值,而后根据这个hash值来选择软中断运行的CPU。从上层来看,也就是说将每一个链接和CPU绑定,并经过这个hash值,来均衡软中断在多个CPU上,提高网络并行处理性能。

复制代码

9、项目实践

相关文章
相关标签/搜索