【转】Netty常见面试题
一. 基本概念
1、什么是 Netty?
Netty 是由 JBOSS 提供的一个 Java 开源框架。Netty 提供异步的、基于事件驱动的网络应用程序框架,用以快速开发高性能、高可靠性的网络 IO 程序,是目前最流行的 NIO 框架,Netty 在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,知名的 Elasticsearch 、Dubbo 框架内部都采用了 Netty。
2、Netty 的优势?
使用简单:封闭了 Java 原生 NIO 类库繁琐的 API,使用起来更加高效;
功能强大:预置多种编码能力,支持多种主流协议。同时通过 ChannelHandler 可以进行灵活的拓展,支持很强的定制能力;
高性能:与其它业界主流 NIO 框架相比,Netty 综合更优。主要体现在吞吐量更高、延迟更低、减少资源消耗以及最小化不必要的内存复制;
社区活跃与稳定:版本更新周期短,BUG 修复速度快,让开发者可以专注业务本身。
3、Netty 有什么特点?
高并发:Netty 是一款基于 NIO(Nonblocking I/O,非阻塞IO)开发的网络通信框架。
传输快:Netty 使用零拷贝特性,尽量减少不必要的内存拷贝,实现更快的传输效率。
封装好:Netty 封装了 NIO 操作的很多细节,提供易于使用的 API。
4、Netty 有哪些应用场景?
理论上来说,NIO 可以做的事情,Netty 都可以做并且更好。Netty 主要用来做网络通信:
RPC 框架的网络通信工具;
实现一个 HTTP 服务器;
实现一个即时通讯系统;
实现消息推送系统。
5、Netty 的高性能体现在?
IO 线程模型:同步非阻塞;
零拷贝:尽量做到不必要的内存拷贝:
内存池设计:使用直接内存,并且可重复利用;
串行化处理读写:避免使用锁带来的额外开销;
高性能序列化协议:支持 protobuf 等高性能序列化协议。
6、相比原生 NIO 的优势?
易用性:Netty 在 NIO 基础上封装了更加人性化的 API,大大降低开发人员的学习成本,同时还提供了很多开箱即用的工具。
稳定性:Netty 修复了 Java NIO 较多已知问题,如 select 空转导致 CPU 100%,TCP 断线重连,Keep-alive 检测等问题。
高性能:对象池复用(通过对象复用避免频繁创建和销毁带来的开销)和零拷贝技术。
7、Netty 和 Tomcat 的区别?
Netty 和 Tomcat 最大的区别在于对通信协议的支持:
Tomcat 是基于 Http 协议的,本质是一个基于 http 协议的 web 容器,而 Netty 不仅支持 HTTP,还能通过编程自定义各种协议,通过 codec 自定义编码/解码字节流,完成数据传输。
Tomcat 需要遵循 Servlet 规范(HTTP 协议的请求-响应模型),而 Netty 不需要受到 Servlet 规范约束,可以发挥 NIO 最大特性。
8、BIO. NIO. AIO 分别是什么?
同步与异步
同步: 同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。
异步: 异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。
同步和异步的区别最大在于异步的话调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。
阻塞和非阻塞
阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。
那么同步阻塞、同步非阻塞和异步非阻塞又代表什么意思呢?
举个生活中简单的例子,你妈妈让你烧水,小时候你比较笨啊,在哪里傻等着水开(同步阻塞)。等你稍微再长大一点,你知道每次烧水的空隙可以去干点其他事,然后只需要时不时来看看水开了没有(同步非阻塞)。后来,你们家用上了水开了会发出声音的壶,这样你就只需要听到响声后就知道水开了,在这期间你可以随便干自己的事情,你需要去倒水了(异步非阻塞)。
BIO(同步阻塞 IO)
服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。BIO 方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK 1.4 以前的唯一选择,但程序直观简单易理解。
同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。
采用 BIO 通信模型 的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接。我们一般通过在 while(true) 循环中服务端会调用 accept() 方法等待接收客户端的连接的方式监听请求,请求一旦接收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成, 不过可以通过多线程来支持多个客户端的连接,如上图所示。
如果要让 BIO 通信模型 能够同时处理多个客户端请求,就必须使用多线程(主要原因是 socket.accept()、 socket.read()、 socket.write() 涉及的三个主要函数都是同步阻塞的),也就是说它在接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。这就是典型的 一请求一应答通信模型 。我们可以设想一下如果这个连接不做任何事情的话就会造成不必要的线程开销,不过可以通过 线程池机制 改善,线程池还可以让线程的创建和回收成本相对较低。使用FixedThreadPool 可以有效的控制了线程的最大数量,保证了系统有限的资源的控制,实现了N(客户端请求数量):M(处理客户端请求的线程数量)的伪异步I/O模型(N 可以远远大于 M),下面一节"伪异步 BIO"中会详细介绍到。
伪异步 IO
为了解决同步阻塞I/O面临的一个链路需要一个线程处理的问题,后来有人对它的线程模型进行了优化一一一后端通过一个线程池来处理多个客户端的请求接入,形成客户端个数M:线程池最大线程数N的比例关系,其中M可以远远大于N.通过线程池可以灵活地调配线程资源,设置线程的最大值,防止由于海量并发接入导致线程耗尽。
伪异步IO模型图(图源网络,原出处不明):
采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架,它的模型图如上图所示。当有新的客户端接入时,将客户端的 Socket 封装成一个Task(该任务实现java.lang.Runnable接口)投递到后端的线程池中进行处理,JDK 的线程池维护一个消息队列和 N 个活跃线程,对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。
伪异步I/O通信框架采用了线程池实现,因此避免了为每个请求都创建一个独立线程造成的线程资源耗尽问题。不过因为它的底层任然是同步阻塞的BIO模型,因此无法从根本上解决问题。
NIO(同步非阻塞 IO)
Non-blocking IO(非阻塞IO)
服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有 IO 请求时才启动一个线程进行处理。NIO 方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK 1.4 开始支持。
NIO的特性/NIO与IO区别
如果是在面试中回答这个问题,我觉得首先肯定要从 NIO 流是非阻塞 IO 而 IO 流是阻塞 IO 说起。然后,可以从 NIO 的3个核心组件/特性为 NIO 带来的一些改进来分析。如果,你把这些都回答上了我觉得你对于 NIO 就有了更为深入一点的认识,面试官问到你这个问题,你也能很轻松的回答上来了。
IO流是阻塞的,NIO流是不阻塞的。
Java NIO使我们可以进行非阻塞IO操作。比如说,单线程中从通道读取数据到buffer,同时可以继续做别的事情,当数据读取到buffer中后,线程再继续处理数据。写数据也是一样的。另外,非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
Java IO的各种流是阻塞的。这意味着,当一个线程调用 read() 或 write() 时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了
NIO核心组件
Channel(通道)
Buffer(缓冲区)
Selector(选择器)
Buffer(缓冲区)
IO 面向流(Stream oriented),而 NIO 面向缓冲区(Buffer oriented)。
Buffer是一个对象,它包含一些要写入或者要读出的数据。在NIO类库中加入Buffer对象,体现了新库与原I/O的一个重要区别。在面向流的I/O中·可以将数据直接写入或者将数据直接读到 Stream 对象中。虽然 Stream 中也有 Buffer 开头的扩展类,但只是流的包装类,还是从流读到缓冲区,而 NIO 却是直接读到 Buffer 中进行操作。
在NIO厍中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的; 在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
最常用的缓冲区是 ByteBuffer,一个 ByteBuffer 提供了一组功能用于操作 byte 数组。除了ByteBuffer,还有其他的一些缓冲区,事实上,每一种Java基本类型(除了Boolean类型)都对应有一种缓冲区。
Channel (通道)
NIO 通过Channel(通道) 进行读写。
通道是双向的,可读也可写,而流的读写是单向的。无论读写,通道只能和Buffer交互。因为 Buffer,通道可以异步地读写。
Selectors(选择器)
NIO有选择器,而IO没有。
选择器用于使用单个线程处理多个通道。因此,它需要较少的线程来处理这些通道。线程之间的切换对于操作系统来说是昂贵的。 因此,为了提高系统效率选择器是有用的。
NIO 读数据和写数据方式
通常来说NIO中的所有IO都是从 Channel(通道) 开始的。
从通道进行数据读取 :创建一个缓冲区,然后请求通道读取数据。
从通道进行数据写入 :创建一个缓冲区,填充数据,并要求通道写入数据。
数据读取和写入操作图示:
整个NIO体系包含的类远远不止这三个,只能说这三个是NIO体系的“核心API”。我们上面已经对这三个概念进行了基本的阐述,这里就不多做解释了。
AIO(异步非阻塞 IO)
服务器实现模式为一个有效请求一个线程,客户端的 IO 请求都是由 OS 先完成了再通知服务器应用去启动线程进行处理。AIO 方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用 OS 参与并发操作,编程比较复杂,JDK 1.7 开始支持。
AIO (Asynchronous I/O)
AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的IO模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
AIO 是异步IO的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO操作本身是同步的。(除了 AIO 其他的 IO 类型都是同步的,这一点可以从底层IO线程模型解释,
推荐一篇文章:如何给女朋友解释什么是Linux的五种IO模型?
Java中提供的IO有关的API,在文件处理的时候,其实依赖操作系统层面的IO操作实现的。比如在Linux 2.6以后,Java中NIO和AIO都是通过epoll来实现的,而在Windows上,AIO是通过IOCP来实现的。
可以把Java中的BIO、NIO和AIO理解为是Java语言对操作系统的各种IO模型的封装。程序员在使用这些API的时候,不需要关心操作系统层面的知识,也不需要根据不同操作系统编写不同的代码。只需要使用Java的API就可以了。
在Linux(UNIX)操作系统中,共有五种IO模型,分别是:阻塞IO模型、非阻塞IO模型、IO复用模型、信号驱动IO模型以及异步IO模型。
9、Select、Poll、Epoll 的区别?
select,poll,epoll 都是 IO 多路复用的机制。何为 IO 多路复用的机制?IO 多路复用的本质是通过一种机制,让单个进程可以监视多个描述符,当发现某个描述符就绪之后,能够通知程序进行相应的读写操作。
select,poll,epoll 都是同步 IO。所谓同步 IO,便是读写是阻塞的,需要在读写事件就绪后自己负责读写,而异步 IO 会把数据从内核拷贝到用户空间,并不需要自己负责读写。
select、poll 和 epoll 都是 Linux 提供的 IO 复用方式。
9.1 select
函数定义
我们来看一下 select 函数的定义:
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
select 函数的参数如下:
int maxfdp1:待测试的文件描述字个数
fd_set *readset , fd_set *writeset , fd_set *exceptset:fd_set 是一个集合,里面存放的是文件描述符,三个参数分别表示让内核测试读、写和异常条件的文件描述符集合。若对某一条件不感兴趣,可以将其设置为空指针
const struct timeval *timeout :告诉内核,等待所指定文件描述符集合中的任意一个就绪,一共可以花费多少时间
返回值
返回值类型为 int ,若有就绪描述符,则返回其数目,若超时则返回0,若出错则返回-1。
运行机制
我们上面提到过,传入的参数有一个 fd_set 集合,其实这是一个 long 类型的数组,数组元素能够与已经打开的文件句柄(例如 Socket 句柄,又或者其它文件)建立联系。
当我们调用 select 函数时,内核会根据 IO 状态对 fd_set 的内容进行修改,从而通知执行 select 函数的进程哪一个文件或者 Socket 是可读的。
select 函数与同步阻塞模型并无过多区别,甚至还多出了一部分操作(监视 socket /调用 select 函数),导致更低的效率。
优势
用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后调用 select 函数读取被激活的 socket,从而实现在同一个线程内同时处理多个 IO 请求,在这点上select 函数与同步阻塞模型不同,因为在同步阻塞模型中需要通过多线程才能达到这个目的。
话说回来,为啥我们不直接使用多进程/多线程技术,而是要使用 IO 多路复用技术呢?这是因为,使用 IO 多路复用技术,系统不必创建和维护进程/线程,从而节约了系统的开销。
缺点
调用 select 函数时,需要把 fd_set 集合从用户态拷贝到内核态,当 fd_set 集合很大时,这个开销将会非常巨大
调用 select 函数时,需要在内核遍历传递进来的所有 fd_set,当 fd_set 集合很大时,这个开销将会非常巨大
内核对被监控的 fd_set 集合大小做了限制
9.2 poll
讲解了 select 函数之后,相信各位读者对 poll 的理解便没有多大难度了。poll 的机制与 select 几乎相同,会对管理的描述符进行轮询操作,并根据描述符的状态进行相应的处理。
poll 将用户传入的数组拷贝到内核空间,然后查询每个描述符对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有描述符后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时。
poll 与 select 的区别
select 函数中,内核对 fd_set 集合的大小做出了限制,大小不可变为1024;而 poll 函数中,并没有最大文件描述符数量的限制(基于链表存储)。
函数定义
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数
struct pollfd *fds :存放需要检测状态的 socket 描述符,调用 poll 函数后,fds 数组不会被清空。pollfd 结构体表示一个被监视的文件描述符,poll 函数会通过 fds 参数的传递来监视多个文件描述符
nfds_t nfds : 记录 fds 中描述符的总数量
返回值
返回值类型为 int ,返回 fds 集合中就绪的读、写或出错的描述符数量,若返回0则表示超时,若返回-1则表示出错。
9.3 epoll
epoll 是基于事件驱动的 IO 方式,与 select 相比,epoll 并没有描述符个数限制。
epoll 使用一个文件描述符管理多个描述符,它将文件描述符的事件放入内核的一个事件表中,从而在用户空间和内核空间的复制操作只用实行一次即可。
函数定义
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
各个函数的作用如下:
epoll_create:创建一个 epoll 句柄,其中 size 表示内核要监听的描述符数量
epoll_ctl:注册要监听的事件类型。在每次注册新的事件到 epoll 句柄中时,会把所有的描述符拷贝进内核,而不是在 epoll_wait 的时候重复拷贝。epoll 保证了每个描述符在整个过程中只会拷贝一次
epoll_wait:等待事件的就绪,成功时返回就绪的事件数目
特点
epoll 是 poll 的增强版,在获取事件时,epoll 无需遍历整个被监听的描述符集,而是只需遍历被内核 IO 事件异步唤醒而加入 Ready 队列的描述符集合即可。因此,epoll 能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率。
epoll 会在 epoll_ctl 时为每个描述符指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的描述符加入一个就绪链表。epoll_wait 实际上就是去就绪链表中查看有没有就绪的描述符。
优势
没有最大并发连接的限制
不采取轮询的方式,效率高,只会处理活跃的连接,与连接总数无关
两种模式
epoll 提供了两种模式,一种是水平触发,一种是边缘触发。边缘触发与水平触发相比较,可以使用户空间程序可能缓存 IO 状态,并减少 epoll_wait 的调用,从而提高应用程序的效率。
水平触发(LT):默认工作模式,当 epoll_wait 检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;等到下次调用 epoll_wait 时,会再次通知此事件
边缘触发(ET):当 epoll_wait 检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次通知此事件
ET 模式减少了 epoll 事件的触发次数,其效率比 LT 模式下高。为什么呢?
如果我们使用 LT 模式的话,系统中一旦有大量不需要读写的就绪文件描述符,每次调用 epoll_wait 都会返回,大大降低处理程序检索自己关心的就绪文件描述符的效率。如果使用的是 ET 模式,当被监控的文件描述符上有可读写事件发生时,epoll_wait 会通知处理程序去读写,如果这次没有把数据全部读写完,下次调用 epoll_wait 不会通知你,即它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。在这种模式下,系统不会充斥大量你不关心的就绪文件描述符,故其效率较高。
总结
select,poll 需要自己不断轮询所有描述符集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。epoll 其实也需要调用 epoll_wait 不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪描述符放入就绪链表中,并唤醒在 epoll_wait 中进入睡眠的进程。虽然都要睡眠和交替,但是 select 和 poll 在醒着的时候要遍历整个描述符集合,而epoll在醒着的时候只要判断一下就绪链表是否为空即可,这就是回调机制带来的性能提升,节省了大量的 CPU 时间。
select,poll 每次调用都要将描述符集合从用户态往内核态拷贝一次,而 epoll 只需要一次拷贝即可。
select,poll,epoll 之间的对比
IO 效率:select 只知道有 IO 事件发生,却不知道是哪几个流,只能采取轮询所有流的方式,故其具有 O(n) 的无差别轮询复杂度,处理的流越多,无差别轮询时间就越长;poll 与 select 并无区别,它的时间复杂度也是 O(n);epoll 会将哪个流发生了怎样的 IO 事件通知我们(当描述符就绪时,系统注册的回调函数会被调用,将就绪描述符放到 readyList 里面),它是事件驱动的,其时间复杂度为 O(1)
操作方式:select 和 poll 都是采取遍历的方式,而 epoll 则是采取了回调的方式
底层实现:select 的底层实现为数组,poll 的底层实现为链表,而 epoll 的底层实现为红黑树
最大连接数:select 的最大连接数为 1024 或 2048,而 poll 和 epoll 是无上限的
对描述符的拷贝:select 和 poll 每次被调用时都会把描述符集合从用户态拷贝到内核态,而 epoll 在调用 epoll_ctl 时会拷贝进内核并保存,之后每次 epoll_wait 时不会拷贝
性能:epoll 在绝大多数情况下性能远超 select 和 poll,但在连接数少并且连接都十分活跃的情况下,select 和 poll 的性能可能比 epoll 好,因为 epoll 的通知机制需要很多函数回调
10、什么是 Reactor 模式?
Netty是典型的Reactor模型结构,Reactor模式也叫反应器模式,大多数IO相关组件如Netty、Redis在使用的IO模式,为什么需要这种模式,它是如何设计来解决高性能并发的呢?
Reactor线程模型是一种事件驱动的网络编程模型,其中有一个或多个输入源(如套接字)将事件(如数据到达)传递给一个或多个事件处理器。这种模型非常适合处理大量并发连接,因为它可以在单个线程或多个线程上有效地处理多个输入源。
Netty中的Reactor线程模型
Netty使用了两种Reactor线程模型:单Reactor单线程模型和主从Reactor多线程模型。
单Reactor单线程模型:在这个模型中,所有的事件处理都在同一个线程中进行。这种模型简单明了,但在处理大量并发连接时可能会遇到性能瓶颈,因为所有的事件处理都在同一个线程上执行。
主从Reactor多线程模型:在这个模型中,有一个主Reactor线程负责监听和接收新的连接,并将这些连接分配给多个从Reactor线程进行处理。每个从Reactor线程都有自己的事件循环,负责处理分配给它的连接上的事件。这种模型能够更好地利用多核CPU的性能,从而提高应用程序的吞吐量。
Netty中的实现
Netty通过EventLoopGroup和EventLoop实现了Reactor线程模型。EventLoopGroup是一个线程组,负责处理I/O操作;而EventLoop则是EventLoopGroup中的一个线程,它有自己的事件循环,负责处理分配给它的连接上的事件。
当Netty服务器启动时,它会创建一个或多个EventLoopGroup,每个EventLoopGroup包含多个EventLoop。这些EventLoop会监听和接收新的连接,并将这些连接分配给相应的ChannelHandler进行处理。
每个EventLoop都有一个Selector,用于监听多个Channel上的事件。当有事件发生时,Selector会通知EventLoop,然后由EventLoop调用相应的ChannelHandler来处理这个事件。
优势和应用
使用Reactor线程模型可以让Netty在处理大量并发连接时保持高性能。通过合理地配置EventLoopGroup的数量和每个EventLoop的线程数,可以根据硬件资源和应用程序的需求来优化性能。
此外,Netty还提供了丰富的ChannelHandler和ChannelPipeline机制,使得开发者可以灵活地处理各种网络事件,如数据接收、写操作、连接关闭等。
总结
Reactor线程模型是Netty高性能的关键之一。通过深入理解这种模型的工作原理和优势,我们可以更好地利用Netty来编写高性能、高可靠性的网络应用。
在实际应用中,我们需要根据硬件资源和应用程序的需求来合理配置EventLoopGroup和EventLoop的数量,以充分利用多核CPU的性能。同时,我们还需要灵活地使用各种ChannelHandler来处理各种网络事件,以满足应用程序的需求。
优点
1)响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
2)编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
3)可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;
4)可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性;
缺点
1)相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
2)Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。
3) Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用改进版的Reactor模式如Proactor模式。
二. 架构组件
1、Netty 有哪些核心组件?
Channel
基础的 IO 操作,如绑定、连接、读写等都依赖于底层网络传输所提供的原语,在 Java 的网络编程中,基础核心类是 Socket,而 Netty 的 Channel 提供了一组 API,极大地简化了直接与 Socket 进行操作的复杂性,并且 Channel 是很多类的父类,如 EmbeddedChannel、LocalServerChannel、NioDatagramChannel、NioSctpChannel、NioSocketChannel 等。
EventLoop
EventLoop 定义了处理在连接过程中发生的事件的核心抽象。
说白了,EventLoop 的主要作用实际就是负责监听网络事件并调用事件处理器进行相关 IO 操作的处理。
那 Channel 和 EventLoop 直接有啥联系呢?
Channel 为 Netty 网络操作(读写等操作)抽象类,EventLoop 负责处理注册到其上的 Channel 处理 IO 操作,两者配合参与 IO 操作。
ChannelFuture
由于 Netty 是异步非阻塞的,所有的 IO 操作也都为异步的,我们不能立刻得到操作是否执行成功,因此 Netty 提供 ChannelFuture 接口,使用其 addListener() 方法注册一个 ChannelFutureListener,当操作执行成功或者失败时,监听就会自动触发返回结果。
并且,我们还可以通过 ChannelFuture 的 channel() 方法获取关联的Channel,甚至使用 sync() 方法让异步的操作变成同步的。
ChannelHandler 和 ChannelPipeline
从应用开发者看来,ChannelHandler 是最重要的组件,其中存放用来处理进站和出站数据的用户逻辑。ChannelHandler 的方法被网络事件触发,可以用于几乎任何类型的操作,如将数据从一种格式转换为另一种格式或处理抛出的异常。如其子接口ChannelInboundHandler,接受进站的事件和数据以便被用户定义的逻辑处理,或者当响应所连接的客户端时刷新ChannelInboundHandler的数据。
ChannelPipeline为ChannelHandler 链提供了一个容器并定义了用于沿着链传播入站和出站事件流的 API。当创建 Channel 时,会自动创建一个附属的 ChannelPipeline。
Bootstrap 和 ServerBootstrap
Netty 的引导类应用程序网络层配置提供容器,其涉及将进程绑定到给定端口或连接一个进程到在指定主机上指定端口上运行的另一进程。引导类分为客户端引导 Bootstrap 和服务端引导 ServerBootstrap。
2、什么是 EventLoop 和 EventLoopGroup?
EventLoopGroup 包含多个 EventLoop(每一个 EventLoop 通常内部包含一个线程),上面我们已经说了 EventLoop 的主要作用实际就是负责监听网络事件并调用事件处理器进行相关 I/O 操作的处理。
并且 EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,即 Thread 和 EventLoop 属于 1 : 1 的关系,从而保证线程安全。
上图是一个服务端对 EventLoopGroup 使用的大致模块图,其中 Boss EventloopGroup 用于接收连接,Worker EventloopGroup 用于具体的处理(消息的读写以及其他逻辑处理)。
从上图可以看出:当客户端通过 connect 方法连接服务端时,bossGroup 处理客户端连接请求。当客户端处理完成后,会将这个连接提交给 workerGroup 来处理,然后 workerGroup 负责处理其 IO 相关操作。
3、说说 Netty 的线程模型?
Netty 通过 Reactor 模型基于多路复用器接收并处理用户请求,内部实现了两个线程池,boss 线程池和 work 线程池,其中 boss 线程池的线程负责处理请求的 accept 事件,当接收到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 work 线程池,其中 work 线程池负责请求的 read 和 write 事件,由对应的 Handler 处理。
单线程模型
所有 IO 操作都由一个线程完成,即多路复用、事件分发和处理都是在一个 Reactor 线程上完成的。既要接收客户端的连接请求,向服务端发起连接,又要发送、读取请求或应答、响应消息。一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,速度慢,若线程进入死循环,整个程序不可用,对于高负载、高并发的应用场景不合适。
//1.eventGroup既用于处理客户端连接,又负责具体的处理。
EventLoopGroup eventGroup = new NioEventLoopGroup(1);
//2.创建服务端启动引导/辅助类:
ServerBootstrap ServerBootstrap b = new ServerBootstrap();
boobtstrap.group(eventGroup, eventGroup)
多线程模型
有一个 NIO 线程(Acceptor) 只负责监听服务端,接收客户端的 TCP 连接请求;NIO 线程池负责网络 IO 的操作,即消息的读取、解码、编码和发送;1 个 NIO 线程可以同时处理 N 条链路,但是 1 个链路只对应 1 个 NIO 线程,这是为了防止发生并发操作问题。但在并发百万客户端连接或需要安全认证时,一个 Acceptor 线程可能会存在性能不足问题。
代码:
// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//2.创建服务端启动引导/辅助类:
ServerBootstrap ServerBootstrap b = new ServerBootstrap();
//3.给引导类配置两大线程组,确定了线程模型
b.group(bossGroup, workerGroup)
//......
}
主从多线程模型
Acceptor 线程用于绑定监听端口,接收客户端连接,将 SocketChannel 从主线程池的 Reactor 线程的多路复用器上移除,重新注册到 Sub 线程池的线程上,用于处理 IO 的读写等操作,从而保证主 Reactor 只负责接入认证、握手等操作。如果多线程模型无法满足你的需求的时候,可以考虑使用主从多线程模型 。
// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//2.创建服务端启动引导/辅助类:
ServerBootstrap ServerBootstrap b = new ServerBootstrap();
//3.给引导类配置两大线程组,确定了线程模型
b.group(bossGroup, workerGroup)
//......
}
4、Netty 服务端的启动过程?
先来看一段代码实现:
// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//2.创建服务端启动引导/辅助类:ServerBootstrap
ServerBootstrap b = new ServerBootstrap();
//3.给引导类配置两大线程组,确定了线程模型
b.group(bossGroup, workerGroup) // (非必备)打印日志
.handler(new LoggingHandler(LogLevel.INFO)) // 4.指定 IO 模型
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
//5.可以自定义客户端消息的业务处理逻辑
p.addLast(new HelloServerHandler());
}
});
// 6.绑定端口,调用 sync 方法阻塞知道绑定完成
ChannelFuture f = b.bind(port).sync();
// 7.阻塞等待直到服务器Channel关闭(closeFuture()方法获取Channel 的CloseFuture对象,然后调用sync()方法)
f.channel().closeFuture().sync();
} finally {
//8.优雅关闭相关线程组资源
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
三. 具体实现
1、Netty 的无锁化体现在哪里?
Netty 采用了串行无锁化设计,在 IO 线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。
Netty 的 NioEventLoop 读取到消息后,直接调用 ChannelPipeline 的 fireChannelRead(Object msg),只要用户不主动切换线程,一直会由 NioEventLoop 调用到用户的 handler,期间不进行线程切换,这种串行化处理方式避免了多线程操作导致的锁竞争,从性能角度看是最优的。
2、如何解决 JDK epoll 空轮询问题?
这个 BUG 是指 Java 的 NIO 在 Linux 下进行 selector.select() 时,本来如果轮询的结果为空并且不调用 wakeup 方法的话,这个 selector.select() 应该是一直阻塞的,但是 Java 却会打破阻塞,继续执行,导致程序无限空转,造成 CPU 使用率 100%。(这个问题只存在 Linux 是因为 Linux 的 NIO 是基于 epoll 实现的,而 Java 实现的 epoll 存在 BUG,windows 下 NIO 基于 poll 就不存在此问题)
Netty 的解决方案:
为 Selector 的 select 操作设置超时时间,同时定义可以跳出阻塞的四种情况
- 有事件发生
- wakeup
- 超时
- 空轮询 BUG
而前两种返回值不为 0,可以跳出循环,超时有时间戳记录,所以每次空轮询,有专门的计数器进行 +1,如果空轮询的次数超过了 512 次(默认),就认为其触发了空轮询 BUG。
当触发 BUG 后,Netty 直接重建一个 Selector,将原来的 Channel 重新注册到新的 Selector 上,并将旧的 Selector 关掉。
3、什么是拆包和粘包?
TCP 是一个面向流的传输协议,所谓流,就是没有界限的一串数据。TCP 底层并不了解上层业务数据的具体含义,它会根据 TCP 缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被 TCP 拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的 TCP 粘包和拆包问题。
粘包和拆包是 TCP 网络编程中不可避免的,无论是服务端还是客户端,当我们读取或者发送消息的时候,都需要考虑 TCP 底层的粘包/拆包机制。
数据从发送方到接收方需要经过操作系统的缓冲区,而造成粘包和拆包的主要原因就在这个缓冲区上。粘包可以理解为缓冲区数据堆积,导致多个请求数据粘在一起,而拆包可以理解为发送的数据大于缓冲区,进行拆分处理。
详细来说,造成粘包和拆包的原因主要有以下三个:
应用程序 write 写入的字节大小大于套接口发送缓冲区大小;
进行 MSS 大小的 TCP 分段;
以太网帧的 payload 大于 MTU 进行 IP 分片。
4、拆包粘包的解决方案?
由于底层的 TCP 无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议可以归纳出以下解决方案:
消息长度固定,累计读取到长度和为定长LEN的报文后,就认为读取到了一个完整的信息。
将特殊的分隔符作为消息的结束标志,如回车换行符。
通过在消息头中定义长度字段来标识消息的总长度。
5、Netty 如何解决拆包粘包?
相比粘包,拆包问题比较简单,用户可以自己定义自己的编码器进行处理,Netty 并没有提供相应的组件。对于粘包的问题,代码比较繁琐,Netty 提供了 4 种解码器来解决,分别如下:
固定长度的拆包器(FixedLengthFrameDecoder),每个应用层数据包的都拆分成都是固定长度的大小;
行拆包器(LineBasedFrameDecoder),每个应用层数据包都以换行符作为分隔符,进行分割拆分;
分隔符拆包器(DelimiterBasedFrameDecoder),每个应用层数据包,都通过自定义的分隔符,进行分割拆分;
基于数据包长度的拆包器(LengthFieldBasedFrameDecoder),将应用层数据包的长度,作为接收端应用层数据包的拆分依据。按照应用层数据包的大小,拆包。这个拆包器,有一个要求,就是应用层协议中包含数据包的长度。
6、Netty 零拷贝体现在哪里?
Zero-copy 就是在操作数据时, 不需要将数据 buffer从 一个内存区域拷贝到另一个内存区域。少了一次内存的拷贝,CPU 效率就得到的提升。
接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝;
提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作;
文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
和操作系统上的零拷贝的区别?
Netty 的 Zero-copy 完全是在用户态(Java 应用层)的, 更多的偏向于优化数据操作。而在 OS 层面上的 Zero-copy 通常指避免在用户态(User-space)与内核态(Kernel-space)之间来回拷贝数据。
7、TCP 的长连接和短连接?
我们知道 TCP 在进行读写之前,server 与 client 之间必须提前建立一个连接。建立连接的过程,需要我们常说的三次握手,释放/关闭连接的话需要四次挥手。这个过程是比较消耗网络资源并且有时间延迟的。
所谓,短连接说的就是 server 端 与 client 端建立连接之后,读写完成之后就关闭掉连接,如果下一次再要互相发送消息,就要重新连接。短连接的有点很明显,就是管理和实现都比较简单,缺点也很明显,每一次的读写都要建立连接必然会带来大量网络资源的消耗,并且连接的建立也需要耗费时间。
长连接说的就是 client 向 server 双方建立连接之后,即使 client 与 server 完成一次读写,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。长连接的可以省去较多的 TCP 建立和关闭的操作,降低对网络资源的依赖,节约时间。对于频繁请求资源的客户来说,非常适用长连接。
8、Netty 长连接、心跳机制了解么?
在 TCP 保持长连接的过程中,可能会出现断网等网络异常出现,异常发生的时候, client 与 server 之间如果没有交互的话,它们是无法发现对方已经掉线的。为了解决这个问题, 我们就需要引入心跳机制。
心跳机制的工作原理是: 在 client 与 server 之间在一定时间内没有数据交互(即处于 idle 状态)时, 客户端或服务器就会发送一个特殊的数据包给对方, 当接收方收到这个数据报文后, 也立即发送一个特殊的数据报文, 回应发送方, 此即一个 PING-PONG 交互。所以, 当某一端收到心跳消息后, 就知道了对方仍然在线, 这就确保 TCP 连接的有效性。
TCP 实际上自带的就有长连接选项,本身是也有心跳包机制,也就是 TCP 的选项:SO_KEEPALIVE。但 TCP 协议层面的长连接灵活性不够,所以,一般情况下我们都是在应用层协议上实现自定义心跳机制的,也就是在 Netty 层面通过编码实现。通过 Netty 实现心跳机制的话,核心类是 IdleStateHandler 。
9、说说 Netty 的对象池技术?
对象池其实就是缓存一些对象从而避免大量创建同一个类型的对象,类似线程池的概念。对象池缓存了一些已经创建好的对象,避免需要时才创建对象,同时限制了实例的个数。池化技术最终要的就是重复的使用池内已经创建的对象。从上面的内容就可以看出对象池适用于以下几个场景:
创建对象的开销大;
会创建大量的实例;
限制一些资源的使用。
Netty 自己实现了一套轻量级的对象池。在 Netty 中,通常会有多个 IO 线程独立工作(基于 NioEventLoop 实现)。每个 IO 线程轮询单独的 Selector 实例来检索 IO 事件,并在 IO 来临时开始处理。最常见的 IO 操作就是读写,具体到 NIO 就是从内核缓冲区拷贝数据到用户缓冲区或者从用户缓冲区拷贝数据到内核缓冲区。这里会涉及到大量的创建和回收 Buffer, Netty 对 Buffer 进行了池化从而降低系统开销。
10、有哪些序列化协议?
序列化(编码)是将对象序列化为二进制形式(字节数组),主要用于网络传输、数据持久化等;而反序列化(解码)则是将从网络、磁盘等读取的字节数组还原成原始对象,主要用于网络传输对象的解码,以便完成远程调用。
影响序列化性能的关键因素:序列化后的码流大小(网络带宽的占用)、序列化的性能(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调用、具有良好的跨防火墙的访问属性、适合应用层对象的持久化。
转载自[https://blog.csdn.net/adminpd/article/details/123564362]
本文来自博客园,作者:xiaolifc,转载请注明原文链接:https://www.cnblogs.com/xiaolibiji/p/18223059
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!