java程序员要想升级高级工程师或者成为架构师,绕不开Netty的学习,就算你不做IM即时通信,也不是网络编程的工作岗位,仅仅只是CRUD程序员,当你想要了解一下Dubbo、Redis、kafka、rabbitMQ、ES、zookeeper、nginx等等的底层原理或者是源码时,你会发现他们在底层实现上都用了Netty。
那么什么是Netty呢:Netty是一个高性能、异步事件驱动的NIO框架,提供了对TCP、UDP和文件传输的支持,核心功能是让客户端和服务端两者之间进行通信交流。简单说,是对TCP/UDP编程进行了简化和封装,提供了更容易使用的网络编程接口,但凡是用java开发的跟网络IO相关的中间件,都少不了Netty的影子。
那么如何来学习Netty呢,首先我们要理解IO、NIO等是什么意思。
Java I/O
在 Java 的 IO 体系中,类将近有 80 个,基本位于java.io包下,初步看起来感觉非常复杂,但是经过一番梳理之后,你会发现还是有规律可循的。
从传输数据的格式角度看,可以大致分为两组:基于字节操作的 I/O 接口:InputStream 和 OutputStream
基于字符操作的 I/O 接口:Reader 和 Writer从传输数据的方式角度看,也可以大致分为两组:
基于磁盘操作的 I/O 接口:File
基于网络操作的 I/O 接口:Socket
Socket
Socket:这篇里面要说的Socket,在java中Socket类其实并不在java.io包下,是在java.net包下。Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口,是应用层和传输层之间的接口,用于在网络上实现进程之间的通信。Socket是指两个不同计算机之间的通信链路,包括IP地址和端口号。所以其实它并不在TCP五层模型中,算是TCP/UDP的封装,实在要算也应该算是传输层。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
通俗一点来理解,在网络中,通过IP地址找到网关,通过Mac地址找到子网内的机器,通过TCP/UDP协议帮我们建立端口-端口之间的通信,而一台机器中一个端口将值对应一个应用程序。socket封装了IP+端口,如果在客户端和服务器端都有一个socket封装了对方的ip和端口,那这两个socket就能相互传递信息,就如同我们每家安装的电话主机一样。
比较典型的基于 Socket 通信的应用程序场景,如下图:
简单看一个例子,了解一下Socket工作流程:
//这是客户端代码
public static void main(String[] args) throws IOException {
//通过IP和端口与服务端建立连接
Socket socket =new Socket("127.0.0.1",8080);
//将字符流转化成字节流,并输出
BufferedWriter bufferedWriter =new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String str="Hello,我是客户端!";
bufferedWriter.write(str);
bufferedWriter.flush();
bufferedWriter.close();
}
//这是服务器端代码
public static void main(String[] args) throws Exception {
//初始化服务端socket并且绑定 8080 端口
ServerSocket serverSocket = new ServerSocket(8080);
//循环监听所有连接的客户端请求
while (true){
try {
//等待客户端的连接,会阻塞在accep
Socket socket = serverSocket.accept();
//将字节流转化成字符流,读取客户端输入的内容
BufferedReader bufferedReader =new BufferedReader(new InputStreamReader(socket.getInputStream()));
//读取一行数据
String str = bufferedReader.readLine();
//输出打印
System.out.println("服务端收到客户端发送的信息:" + str);
} catch (Exception e) {
}
}
}
我们先启动服务器端,注意,服务器端的serverSocket.accept()其实是一直阻塞等待的,我们再运行客户端,客户端发来一个连接请求,服务器端接收到后就会打印出“服务端收到客户端发送的信息:Hello,我是客户端!”这个数据,处理完这一次之后,再继续循环等待新的通信数据。
那Socket和I/O模型有什么关系呢?
在服务端S和客户端C建立好Socket连接后,假设C向S发送了一条报文信息,服务器端操作系统收到之后,应用程序需要去操作系统中读取Socket里的报文信息(recvfrom命令),然后操作系统需要将报文数据从内核空间复制到用户空间,给到应用程序去处理。
- 如果这个过程中我们应用程序一直等到有数据发过来我们才处理,不做别的事情,就叫做阻塞IO,就好比我们一直坐在沙发上等一个电话,不干别的事。
- 如果我们边干别的事,边看电话有没有来电,这个过程我们不闲着,那么就叫非阻塞IO。
- 如果我们一直坐在沙发上等,但是我们同时盯着好几个电话,那就叫IO多路复用。
- 如果我们先干别的事,让电话来的时候会响铃提示我们,响了我们再来接电话,就叫做信号驱动IO模型。
- 如果我们再给电话一个功能叫免提,或者蓝牙耳机,不用拿着听筒接了,那么我们甚至可以边干别的事,别接听电话,这就叫异步IO。
上面这几种情况就对应我们接下来要讲的五种IO模型。
五种I/O模型
参考:《大白话详解5种网络IO模型》
阻塞IO(BIO)
blocking I/O的缩写,同步并阻塞(传统阻塞型)从下图可以看到,不管有无数据报到来,进程(线程)是阻塞于recvfrom系统调用的。这是什么意思呢?说白了就是假如我们要用套接字读取数据,此时我们必然会调用read方法,此时这个read方法就会触发操作系统内核的一次recvfrom系统调用。
非阻塞IO(NIO)
non-blocking I/O的缩写,同步非阻塞,当内核中的数据报还没准备好,此时recvfrom系统调用立即返回一个EWOULDBLOCK错误,即不会将用户进程(线程)置于阻塞状态。当内核中的数据报已经准备好时,此时recvfrom系统调用,用户进程(线程)还是会阻塞,直到内核中的数据报已经拷贝到了用户空间,此时用户进程(线程)才会被唤醒来处理接收的数据报。
IO复用
与上面NIO不同的是recvfrom操作换成了select,区别是一个线程的select操作可以选择多个文件描述符,而recvfrom每次只能选一个。用一个用户线程就能监听不同channel的OP_CONNECT,OP_ACCEPT,OP_READ和OP_WRITE这些就绪事件,然后根据某个就绪事件拿到相应的channel来做对应的操作。另一个区别是select是阻塞的,虽然它能同时监听多个连接多个文件描述符。
文件描述符(fd):分别对应Java NIO中的OP_CONNECT,OP_ACCEPT,OP_READ和OP_WRITE就绪事件,下面详细介绍IO多路复用时会用到。
信号驱动IO
信号驱动IO模型在等待数据报期间是不会阻塞的,即用户进程(线程)发送一个sigaction系统调用后,此时立刻返回,并不会阻塞,然后用户进程(线程)继续执行;当数据报准备好时,此时内核就为该进程(线程)产生一个SIGIO信号,此时该进程(线程)就发生一次recvfrom系统调用将数据报从内核复制到用户空间,注意,这个阶段是阻塞的。
异步IO(AIO)
Asynchronous I/O的缩写,异步非阻塞,异步IO模型也很好理解,即用户进程(线程)在等待数据报和数据报从内核拷贝到用户空间这两阶段都是非阻塞的,即用户进程(线程)发生一次系统调用后,立即返回,然后该用户进程(线程)继续往下执行。当内核把接收到数据报并把数据报拷贝到了用户空间后,此时再通知用户进程(线程)来处理用户空间的数据报。也就是说,这一些列IO操作都交给了内核去处理了,用户进程无须同步阻塞,因此是异步非阻塞的。
总结一下:
从下图可以看出,除了异步IO,其它四种IO的第二步调用recvfrom其实都是阻塞的,第一步通过主动检查或者被动通知的方式实现了非阻塞的能力。
IO多路复用
IO多路复用(IO Multiplexing)指单个进程/线程可以同时处理多个I/O请求。在unix系统中有三个函数select、poll、epoll,对应三种方式处理连接。
select
从下图可以看到,在调用select时,第3步中需要将我们所有监听的文件描述符(fd)传入内核空间中,遍历fd,如果有客户端client传入数到FDBuffer中,就会检测到某个fd就绪了,获取到就绪的fd去给服务应用程序处理。
缺点是fd_set数组有1024个限制,而且每次遍历获取就绪的fd,时间复杂度都是O(n)
poll
poll与select处理流程类似,只是修改了存储文件描述符的fd_set从数组改成了链表,这样就没有了1024的限制。
epoll(redis使用的就是这个模型)
针对select和poll的缺点,epoll做了几个优化,首先存放监控的文件描述符的fd_set改成了红黑树,然后就绪的文件描述符fd_set改成了双向链表,这样读取就绪文件描述符的时间复杂度就变成了O(1)。
client在向服务器传输数据后,就会把准备就绪的数据写入双向链表中,当有事件就绪的时候,epoll_wait只需要去检测就绪的链表中是否有数据就可以了。
执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据,所以当一个socket上有数据到了,内核再把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。执行epoll_wait时立刻返回准备就绪链表里的数据即可。
另一个优化是通过内存映射(mmap),不需要从用户空间频繁拷贝数据到内核空间。
NIO-reactor模型
如下图,传统IO模型,一个连接就会分配一个线程处理请求,这样连接数多了之后,就需要分配大量的线程去处理,每个线程不仅需要分配大量的内存空间,而且线程之间的上下文切换也会极大的消耗cpu资源。如果一个连接一直没有请求,那么这个线程也将一直空置,对资源是极大的浪费。
在java1.4之后,java提供了NIO的相关API,帮助我们可以避免上面的问题。NIO编程的本质是以事件驱动来处理我们的网络事件,Reactor就是基于这套API提供的一套IO模型。
Reactor模型思想:
分而治之 + 事件驱动(优点:模块化、高性能————把大拆小,减少阻塞时间)
分而治之:一个连接里完整的网络处理过程一般分为accept、read、decode、process、encode、send(write)这几步。Reactor模式将每个步骤映射为一个Task,服务端线程执行的最小逻辑单元不再是一次完整的网络请求,而是Task,且采用非阻塞方式执行。
事件驱动:相应的Task(accept、read、write)对应特定网络事件。当Task准备就绪时,Reactor收到对应的网络事件通知,并将Task分发给绑定了对应网络事件的Acceptor和Handler执行。
Reactor三大组件:
Reactor:事件分派器,将I/O事件分派给对应的Handler和Acceptor。
Acceptor:多路复用器,处理客户端新连接,创建handler。
Handler:事件处理器,有读写请求过来时,进handler处理。
单reactor单线程模型(redis基于这种实现)
一个请求进来后,如果是连接请求,会交给accecptor创建一个对应的handler。如果是非连接请求,如读写请求,则会有一个分发器dispatch交给这个链接对应的handler来处理。
单reactor多线程模型
单线程模型无法充分利用我们的多核cpu的优势,同时也会因为处理速度慢导致事件堆积。相比单线程模型,单reactor多线程模型增加了线程池,
主从reactor多线程模型(Netty、nginx基于这种实现)
一个reactor接收所有线程的请求和响应,也会存在性能问题,所以衍生出第三种主从reactor多线程模型。mainReactor只处理acceptor相关的请求,其它的handler处理的请求都交给subReactor去处理。
Netty
核心组件
Bootstrap和ServerBootstrap:当需要连接客户端或者服务器绑定指定端口时需要使用Bootstrap,ServerBootstrap有两种类型,一种是用于客户端的Bootstrap,一种是用于服务端 的ServerBootstrap。
Channel:相当于socket,与另一端进行通信的通道,具备bind、connect、read、write等IO操作的能力。
EventLoop:事件循环,负责处理Channel的IO事件,一个EventLoopGroup包含多个EventLoop,一个EventLoop可被分配至多个Channel,一个Channel只能注册于一个EventLoop,一个EventLoop只能与一个Thread绑定。
ChannelFuture:channel IO事件的异步操作结果。
ChannelHandler:包含IO事件具体的业务逻辑。
ChannelPipeline:ChannelHandler的管道容器。
Netty的高性能
从宏观来讲,Netty的高性能主要在于:Reactor模式、Zero Copy(零拷贝)和对象池
通过设置不同的启动参数,Netty可以同时支持单Reactor单线程模型、单Reactor多线程模型和主从Reactor多线层模型。
Netty 通过 Reactor 模型基于多路复用器接收并处理用户请求,内部实现了两个线程池, boss 线程池和 worker 线程池,其中 boss 线程池的线程负责处理请求的 accept 事件,当接收 到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 worker 线程池,其中 work 线程池负责请求的 read 和 write 事件,由对应的 Handler 处理。
1. Netty线程模型–Reactor 模型:
2、Zero Copy
Zero Copy技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域,这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。举例来说,如果要读取一个文件并通过网络发送它,传统方式下每个读/写周期都需要复制两次数据和切换两次上下文,而数据的复制都需要依靠CPU。通过零复制技术完成相同的操作,上下文切换减少到两次,并且不需要CPU复制数据。参考《Netty 中的零拷贝机制》
3、对象池
对象池模式(The Object Pool Pattern)是单例模式的一个变种,对象池模式管理一个可代替对象的集合,组件从池中借出对象,用它来完成一些任务并当任务完成时归还该对象。Netty中的Recycler,该类是个容器,基于ThreadLocal实现的的轻量级对象池,内部主要是一个Stack结构。当需要使用一个实例时,就弹出,当使用完毕时,就清空后入栈。