Netty 之旅:你想要的 NIO 知识点,这里都有!
高清思维导图原件(xmind/pdf/jpg
)可以关注公众号:一枝花算不算浪漫
回复nio
即可
前言
抱歉好久没更原创文章了,看了下上篇更新时间,已经拖更一个多月了。
这段时间也一直在学习Netty
相关知识,因为涉及知识点比较多,也走了不少弯路。目前网上关于 Netty 学习资料玲琅满目,不知如何下手,其实大家都是一样的,学习方法和技巧都是总结出来的,我们在没有找到很好的方法之前不如按部就班先从基础开始,一般从总分总的渐进方式,既观森林,又见草木。
之前恰巧跟杭州一个朋友小飞也提到过,两者在这方面的初衷是一致的,也希望更多的朋友能够加入一起学习和探讨。(PS:本篇文章是和小飞一起学习整理所得~)
Netty
是一款提供异步的、事件驱动的网络应用程序框架和工具,是基于NIO
客户端、服务器端的编程框架。所以这里我们先以NIO
和依赖相关的基础铺垫来进行剖析讲解,从而作为Netty
学习之旅的一个开端。
一、网络编程基础回顾
1. Socket
Socket
本身有“插座”的意思,不是 Java 中特有的概念,而是一个语言无关的标准,任何可以实现网络编程的编程语言都有Socket
。在Linux
环境下,用于表示进程间网络通信的特殊文件类型,其本质为内核借助缓冲区形成的伪文件。既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。
与管道类似的,Linux
系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。
可以这么理解:Socket
就是网络上的两个应用程序通过一个双向通信连接实现数据交换的编程接口 API。
Socket
通信的基本流程具体步骤如下所示:
(1)服务端通过Listen
开启监听,等待客户端接入。
(2)客户端的套接字通过Connect
连接服务器端的套接字,服务端通过Accept
接收客户端连接。在connect-accept
过程中,操作系统将会进行三次握手。
(3)客户端和服务端通过write
和read
发送和接收数据,操作系统将会完成TCP
数据的确认、重发等步骤。
(4)通过close
关闭连接,操作系统会进行四次挥手。
针对 Java 编程语言,java.net
包是网络编程的基础类库。其中ServerSocket
和Socket
是网络编程的基础类型。
SeverSocket
是服务端应用类型。Socket
是建立连接的类型。当连接建立成功后,服务器和客户端都会有一个Socket
对象示例,可以通过这个Socket
对象示例,完成会话的所有操作。对于一个完整的网络连接来说,Socket
是平等的,没有服务器客户端分级情况。
2. IO 模型介绍
对于一次 IO 操作,数据会先拷贝到内核空间中,然后再从内核空间拷贝到用户空间中,所以一次read
操作,会经历两个阶段:
(1)等待数据准备
(2)数据从内核空间拷贝到用户空间
基于以上两个阶段就产生了五种不同的 IO 模式。
阻塞 IO:从进程发起 IO 操作,一直等待上述两个阶段完成,此时两阶段一起阻塞。
非阻塞 IO:进程一直询问 IO 准备好了没有,准备好了再发起读取操作,这时才把数据从内核空间拷贝到用户空间。第一阶段不阻塞但要轮询,第二阶段阻塞。
多路复用 IO:多个连接使用同一个 select 去询问 IO 准备好了没有,如果有准备好了的,就返回有数据准备好了,然后对应的连接再发起读取操作,把数据从内核空间拷贝到用户空间。两阶段分开阻塞。
信号驱动 IO:进程发起读取操作会立即返回,当数据准备好了会以通知的形式告诉进程,进程再发起读取操作,把数据从内核空间拷贝到用户空间。第一阶段不阻塞,第二阶段阻塞。
异步 IO:进程发起读取操作会立即返回,等到数据准备好且已经拷贝到用户空间了再通知进程拿数据。两个阶段都不阻塞。
这五种 IO 模式不难发现存在这两对关系:同步和异步、阻塞和非阻塞。那么稍微解释一下:
同步和异步
同步: 同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。
异步: 异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。
同步和异步的区别最大在于异步的话调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。
阻塞和非阻塞
阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。
阻塞和非阻塞是针对进程在访问数据的时候,根据 IO 操作的就绪状态来采取的不同方式,说白了是一种读取或者写入操作方法的实现方式,阻塞方式下读取或者写入函数将一直等待,而非阻塞方式下,读取或者写入方法会立即返回一个状态值。
如果组合后的同步阻塞(blocking-IO
)简称BIO
、同步非阻塞(non-blocking-IO
)简称NIO
和异步非阻塞(asynchronous-non-blocking-IO
)简称AIO
又代表什么意思呢?
BIO (同步阻塞 I/O 模式): 数据的读取写入必须阻塞在一个线程内等待其完成。这里使用那个经典的烧开水例子,这里假设一个烧开水的场景,有一排水壶在烧开水,BIO 的工作模式就是, 叫一个线程停留在一个水壶那,直到这个水壶烧开,才去处理下一个水壶。但是实际上线程在等待水壶烧开的时间段什么都没有做。
NIO(同步非阻塞): 同时支持阻塞与非阻塞模式,但这里我们以其同步非阻塞 I/O 模式来说明,那么什么叫做同步非阻塞?如果还拿烧开水来说,NIO 的做法是叫一个线程不断的轮询每个水壶的状态,看看是否有水壶的状态发生了改变,从而进行下一步的操作。
AIO(异步非阻塞 I/O 模型): 异步非阻塞与同步非阻塞的区别在哪里?异步非阻塞无需一个线程去轮询所有 IO 操作的状态改变,在相应的状态改变后,系统会通知对应的线程来处理。对应到烧开水中就是,为每个水壶上面装了一个开关,水烧开之后,水壶会自动通知我水烧开了。
java
中的 BIO
、NIO
和AIO
理解为是 Java 语言
在操作系统层面对这三种 IO
模型的封装。程序员在使用这些 封装 API 的时候,不需要关心操作系统层面的知识,也不需要根据不同操作系统编写不同的代码,只需要使用Java
的 API 就可以了。由此,为了使读者对这三种模型有个比较具体和递推式的了解,并且和本文主题NIO
有个清晰的对比,下面继续延伸。
Java BIO
BIO
编程方式通常是是 Java 的上古产品,自 JDK 1.0-JDK1.4 就有的东西。编程实现过程为:首先在服务端启动一个ServerSocket
来监听网络请求,客户端启动Socket
发起网络请求,默认情况下SeverSocket
会建立一个线程来处理此请求,如果服务端没有线程可用,客户端则会阻塞等待或遭到拒绝。服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理。大致结构如下:
如果要让 BIO
通信模型能够同时处理多个客户端请求,就必须使用多线程(主要原因是 socket.accept()
、socket.read()
、 socket.write()
涉及的三个主要函数都是同步阻塞的),也就是说它在接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。这就是典型的 一请求一应答通信模型 。我们可以设想一下如果这个连接不做任何事情的话就会造成不必要的线程开销,不过可以通过线程池机制改善,线程池还可以让线程的创建和回收成本相对较低。使用线程池机制改善后的 BIO
模型图如下:
BIO
方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,是 JDK1.4 以前的唯一选择,但程序直观简单易懂。Java BIO
编程示例网上很多,这里就不进行 coding 举例了,毕竟后面NIO
才是重点。
Java NIO
NIO
(New IO 或者 No-Blocking IO),从 JDK1.4 开始引入的非阻塞IO
,是一种非阻塞
+ 同步
的通信模式。这里的No Blocking IO
用于区分上面的BIO
。
NIO
本身想解决 BIO
的并发问题,通过Reactor模式
的事件驱动机制来达到Non Blocking
的。当 socket
有流可读或可写入 socket
时,操作系统会相应的通知应用程序进行处理,应用再将流读取到缓冲区或写入操作系统。
也就是说,这个时候,已经不是一个连接就 要对应一个处理线程了,而是有效的请求,对应一个线程,当连接没有数据时,是没有工作线程来处理的。
当一个连接创建后,不需要对应一个线程,这个连接会被注册到 多路复用器
上面,所以所有的连接只需要一个线程就可以搞定,当这个线程中的多路复用器
进行轮询的时候,发现连接上有请求的话,才开启一个线程进行处理,也就是一个请求一个线程模式。
NIO
提供了与传统 BIO 模型中的Socket
和ServerSocket
相对应的SocketChannel
和ServerSocketChannel
两种不同的套接字通道实现,如下图结构所示。这里涉及的Reactor
设计模式、多路复用Selector
、Buffer
等暂时不用管,后面会讲到。
NIO 方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局 限于应用中,编程复杂,JDK1.4 开始支持。同时,NIO
和普通 IO 的区别主要可以从存储数据的载体、是否阻塞等来区分:
Java AIO
与 NIO
不同,当进行读写操作时,只须直接调用 API 的 read
或 write
方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入 read
方 法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将 write
方法传递的流写入完毕时,操作系统主动通知应用程序。即可以理解为,read/write
方法都是异步的,完成后会主动调用回调函数。在 JDK7
中,提供了异步文件通道和异步套接字通道的实现,这部分内容被称作 NIO
.
AIO
方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用 OS
参与并发操作,编程比较复杂,JDK7
开始支持。
目前来说 AIO
的应用还不是很广泛,Netty
之前也尝试使用过 AIO
,不过又放弃了。
二、NIO 核心组件介绍
1. Channel
在NIO
中,基本所有的 IO 操作都是从Channel
开始的,Channel
通过Buffer(缓冲区)
进行读写操作。
read()
表示读取通道中数据到缓冲区,write()
表示把缓冲区数据写入到通道。
Channel
有好多实现类,这里有三个最常用:
SocketChannel
:一个客户端发起 TCP 连接的 ChannelServerSocketChannel
:一个服务端监听新连接的 TCP Channel,对于每一个新的 Client 连接,都会建立一个对应的 SocketChannelFileChannel
:从文件中读写数据
其中SocketChannel
和ServerSocketChannel
是网络编程中最常用的,一会在最后的示例代码中会有讲解到具体用法。
2. Buffer
概念
Buffer
也被成为内存缓冲区,本质上就是内存中的一块,我们可以将数据写入这块内存,之后从这块内存中读取数据。也可以将这块内存封装成NIO Buffer
对象,并提供一组常用的方法,方便我们对该块内存进行读写操作。
Buffer
在java.nio
中被定义为抽象类:
我们可以将Buffer
理解为一个数组的封装,我们最常用的ByteBuffer
对应的数据结构就是byte[]
属性
Buffer
中有 4 个非常重要的属性:capacity、limit、position、mark
capacity
属性:容量,Buffer 能够容纳的数据元素的最大值,在 Buffer 初始化创建的时候被赋值,而且不能被修改。
上图中,初始化 Buffer 的容量为 8(图中从 0~7,共 8 个元素),所以 capacity = 8
limit
属性:代表 Buffer 可读可写的上限。
- 写模式下:limit
代表能写入数据的上限位置,这个时候limit = capacity
读模式下:在Buffer
完成所有数据写入后,通过调用flip()
方法,切换到读模式,此时limit
等于Buffer
中实际已经写入的数据大小。因为Buffer
可能没有被写满,所以 limit<=capacity
position
属性:代表读取或者写入Buffer
的位置。默认为 0。
- 写模式下:每往Buffer
中写入一个值,position
就会自动加 1,代表下一次写入的位置。
- 读模式下:每往Buffer
中读取一个值,position
就自动加 1,代表下一次读取的位置。
从上图就能很清晰看出,读写模式下 capacity、limit、position 的关系了。
mark
属性:代表标记,通过 mark()方法,记录当前 position 值,将 position 值赋值给 mark,在后续的写入或读取过程中,可以通过 reset()方法恢复当前 position 为 mark 记录的值。
这几个重要属性讲完,我们可以再来回顾下:
0 <= mark <= position <= limit <= capacity
现在应该很清晰这几个属性的关系了~
Buffer 常见操作
创建 Buffer
allocate(int capacity)
例子中创建的ByteBuffer
是基于堆内存的一个对象。
wrap(array)
wrap
方法可以将数组包装成一个Buffer
对象:
allocateDirect(int capacity)
通过allocateDirect
方法也可以快速实例化一个Buffer
对象,和allocate
很相似,这里区别的是allocateDirect
创建的是基于堆外内存的对象。
堆外内存不在 JVM 堆上,不受 GC 的管理。堆外内存进行一些底层系统的 IO 操作时,效率会更高。
Buffer 写操作
Buffer
写入可以通过put()
和channel.read(buffer)
两种方式写入。
通常我们 NIO 的读操作的时候,都是从Channel
中读取数据写入Buffer
,这个对应的是Buffer
的写操作。
Buffer 读操作
Buffer
读取可以通过get()
和channel.write(buffer)
两种方式读入。
还是同上,我们对Buffer
的读入操作,反过来说就是对Channel
的**写操作**。读取Buffer
中的数据然后写入Channel
中。
其他常见方法
rewind()
:重置 position 位置为 0,可以重新读取和写入 buffer,一般该方法适用于读操作,可以理解为对 buffer 的重复读。
flip()
:很常用的一个方法,一般在写模式切换到读模式的时候会经常用到。也会将 position 设置为 0,然后设置 limit 等于原来写入的 position。
clear()
:重置 buffer 中的数据,该方法主要是针对于写模式,因为 limit 设置为了 capacity,读模式下会出问题。
mark()&reset()
:mark()
方法是保存当前position
到变量mark
z 中,然后通过reset()
方法恢复当前position
为mark
,实现代码很简单,如下:
常用的读写方法可以用一张图总结一下:
3. Selector
概念
Selector
是 NIO 中最为重要的组件之一,我们常常说的多路复用器
就是指的Selector
组件。
Selector
组件用于轮询一个或多个NIO Channel
的状态是否处于可读、可写。通过轮询的机制就可以管理多个 Channel,也就是说可以管理多个网络连接。
轮询机制
首先,需要将 Channel 注册到 Selector 上,这样 Selector 才知道需要管理哪些 Channel
接着 Selector 会不断轮询其上注册的 Channel,如果某个 Channel 发生了读或写的时间,这个 Channel 就会被 Selector 轮询出来,然后通过 SelectionKey 可以获取就绪的 Channel 集合,进行后续的 IO 操作。
属性操作
创建 Selector
通过open()
方法,我们可以创建一个Selector
对象。
注册 Channel 到 Selector 中
我们需要将Channel
注册到Selector
中,才能够被Selector
管理。
某个Channel
要注册到Selector
中,那么该 Channel 必须是**非阻塞**,所有上面代码中有个configureBlocking()
的配置操作。
在register(Selector selector, int interestSet)
方法的第二个参数,标识一个interest
集合,意思是 Selector 对哪些事件感兴趣,可以监听四种不同类型的事件:
Connect事件
:连接完成事件( TCP 连接 ),仅适用于客户端,对应 SelectionKey.OP_CONNECT。Accept事件
:接受新连接事件,仅适用于服务端,对应 SelectionKey.OP_ACCEPT 。Read事件
:读事件,适用于两端,对应 SelectionKey.OP_READ ,表示 Buffer 可读。Write事件
:写时间,适用于两端,对应 SelectionKey.OP_WRITE ,表示 Buffer 可写。
Channel
触发了一个事件,表明该时间已经准备就绪:
一个 Client Channel 成功连接到另一个服务器,成为“连接就绪”
一个 Server Socket 准备好接收新进入的接,称为“接收就绪”
一个有数据可读的 Channel,称为“读就绪”
一个等待写数据的 Channel,称为”写就绪“
当然,Selector
是可以同时对多个事件感兴趣的,我们使用或运算即可组合多个事件:
Selector 其他一些操作
选择 Channel
当 Selector 执行select()
方法就会产生阻塞,等到注册在其上的 Channel 准备就绪就会立即返回,返回准备就绪的数量。
select(long timeout)
则是在select()
的基础上增加了超时机制。
selectNow()
立即返回,不产生阻塞。
有一点非常需要注意: select
方法返回的 int
值,表示有多少 Channel
已经就绪。
自上次调用select
方法后有多少 Channel
变成就绪状态。如果调用 select
方法,因为有一个 Channel
变成就绪状态则返回了 1 ;
若再次调用 select
方法,如果另一个 Channel
就绪了,它会再次返回 1。
获取可操作的 Channel
当有新增就绪的Channel
,调用select()
方法,就会将 key 添加到 Set 集合中。
三、代码示例
前面铺垫了这么多,主要是想让大家能够看懂NIO
代码示例,也方便后续大家来自己手写NIO
网络编程的程序。创建 NIO 服务端的主要步骤如下:
```
1. 打开 ServerSocketChannel,监听客户端连接
2. 绑定监听端口,设置连接为非阻塞模式
3. 创建 Reactor 线程,创建多路复用器并启动线程
4. 将 ServerSocketChannel 注册到 Reactor 线程中的 Selector 上,监听 ACCEPT 事件
5. Selector 轮询准备就绪的 key
6. Selector 监听到新的客户端接入,处理新的接入请求,完成 TCP 三次握手,建立物理链路
7. 设置客户端链路为非阻塞模式
8. 将新接入的客户端连接注册到 Reactor 线程的 Selector 上,监听读操作,读取客户端发送的网络消息
9. 异步读取客户端消息到缓冲区
10.对 Buffer 编解码,处理半包消息,将解码成功的消息封装成 Task
11.将应答消息编码为 Buffer,调用 SocketChannel 的 write 将消息异步发送给客户端
```
NIOServer.java
:
NIOClient.java
:
打印结果:
四、总结
回顾一下使用 NIO
开发服务端程序的步骤:
创建
ServerSocketChannel
和业务处理线程池。绑定监听端口,并配置为非阻塞模式。
创建
Selector
,将之前创建的ServerSocketChannel
注册到Selector
上,监听SelectionKey.OP_ACCEPT
。循环执行
Selector.select()
方法,轮询就绪的
Channel`。轮询就绪的
Channel
时,如果是处于OP_ACCEPT
状态,说明是新的客户端接入,调用ServerSocketChannel.accept
接收新的客户端。设置新接入的
SocketChannel
为非阻塞模式,并注册到Selector
上,监听OP_READ
。如果轮询的
Channel
状态是OP_READ
,说明有新的就绪数据包需要读取,则构造ByteBuffer
对象,读取数据。
那从这些步骤中基本知道开发者需要熟悉的知识点有:
jdk-nio
提供的几个关键类:Selector
,SocketChannel
,ServerSocketChannel
,FileChannel
,ByteBuffer
,SelectionKey
需要知道网络知识:tcp 粘包拆包 、网络闪断、包体溢出及重复发送等
需要知道
linux
底层实现,如何正确的关闭channel
,如何退出注销selector
,如何避免selector
太过于频繁需要知道如何让
client
端获得server
端的返回值,然后才返回给前端,需要如何等待或在怎样作熔断机制需要知道对象序列化,及序列化算法
省略等等,因为我已经有点不舒服了,作为程序员的我习惯了舒舒服服简单的 API,不用太知道底层细节,就能写出比较健壮和没有 Bug 的代码...
NIO 原生 API 的弊端 :
① NIO 组件复杂 : 使用原生 NIO
开发服务器端与客户端 , 需要涉及到 服务器套接字通道 ( ServerSocketChannel
) , 套接字通道 ( SocketChannel
) , 选择器 ( Selector
) , 缓冲区 ( ByteBuffer
) 等组件 , 这些组件的原理 和 API 都要熟悉 , 才能进行 NIO
的开发与调试 , 之后还需要针对应用进行调试优化
② NIO 开发基础 : NIO
门槛略高 , 需要开发者掌握多线程、网络编程等才能开发并且优化 NIO
网络通信的应用程序
③ 原生 API 开发网络通信模块的基本的传输处理 : 网络传输不光是实现服务器端和客户端的数据传输功能 , 还要处理各种异常情况 , 如 连接断开重连机制 , 网络堵塞处理 , 异常处理 , 粘包处理 , 拆包处理 , 缓存机制 等方面的问题 , 这是所有成熟的网络应用程序都要具有的功能 , 否则只能说是入门级的 Demo
④ NIO BUG : NIO
本身存在一些 BUG , 如 Epoll
, 导致 选择器 ( Selector
) 空轮询 , 在 JDK 1.7 中还没有解决
Netty
在 NIO
的基础上 , 封装了 Java 原生的 NIO API
, 解决了上述哪些问题呢 ?
相比 Java NIO,使用 Netty
开发程序,都简化了哪些步骤呢?...等等这系列问题也都是我们要问的问题。不过因为这篇只是介绍NIO
相关知识,没有介绍Netty API
的使用,所以介绍Netty API
使用简单开发门槛低等优点有点站不住脚。那么就留到后面跟大家一起开启Netty
学习之旅,探讨人人说好的Netty
到底是不是江湖传言的那么好。
一起期待后续的Netty
之旅吧!
版权声明: 本文为 InfoQ 作者【一枝花算不算浪漫】的原创文章。
原文链接:【http://xie.infoq.cn/article/a7a15211564c8b7c14df245c0】。文章转载请联系作者。
评论 (1 条评论)