一篇文章带你搞定 Java 高并发中的网络 NIO

Java NIO是New IO的简称,它是一种可以替代Java IO的一套新的IO机制。它提供了一套不同于Java 标准IO的操作机制。严格来说,NIO与并发并无直接的关系,但是使用NIO技术可以大大提高线程的使用效率。

对于标准的网络IO来说,我们会使用Socket进行网络的读写。为了让服务器可以支持更多的客户端连接,通常的做法是为每一个客户端连接开启一个线程。

一、基于Socket的服务端多线程模式

这里以一个简单的Echo服务器为例。对于Echo服务器,它会读取客户端的一个输入,并将这个输入原封不动地返回给客户端。这看起来很简单,但是麻雀虽小五脏俱全。为了完成这个功能,服务器还是需要有一套完整的Socket处理机制。因此,这个Echo服务器非常适合进行学习。实际上,我认为任何业务逻辑简单的系统都很适合学习,大家不用为了去理解业务上复杂的功能而忽略了系统的重点。

服务端使用多线程进行处理时的结构示意图,如图5.19所示:

在这里插入图片描述
服务器会为每一个客户端连接启用一个线程,这个新的线程将全心全意为这个客户端服务。同时,为了接受客户端连接,服务器还会额外使用一个派发线程。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
第2行使用了一个线程池来处理每一个客户端连接。第3~33行定义了 HandleMsg 线程,它由一个客户端 Socket 构造而成,它的任务是读取这个 Socket 的内容并将其进行返回,返回成功后,任务完成,客户端 Soceket 就被正常关闭。其中第23行统计并输出了服务端线程处理一次客户端请求所花费的时间(包括读取数据和回写数据的时间)。主线程 main 的主要作用是在 8000 端口上进行等待。一旦有新的客户端连接,它就根据这个连接创建 HandleMsg 线程进行处理(第47~49行)。

这就是一个支持多线程的服务端的核心内容。它的特点是,在相同可支持的线程范围内,尽量多地支持客户端的数量,同时和单线程服务器相比,它也可以更好地使用多核CPU。

为了方便大家学习,这里再给出一个客户端的参考实现。

在这里插入图片描述

(1)一篇文章带你搞定 Java 中的打印流
(2)一篇文章带你搞定 Java 中的 BufferReader 类

上述代码在第7行连接了服务器的8000端口,并发送字符串。接着在第12行读取服务器的返回信息并进行输出。

这种多线程的服务器开发模式是极其常用的。对于绝大多数应用来说,这种模式可以很好地工作。但是,如果你想让你的程序工作得更加有效,就必须知道这种模式的一个重大弱点:它倾向于让CPU进行IO等待。为了理解这一点,让我们看一下下面这个比较极端的例子。

在这里插入图片描述
在这里插入图片描述
上述代码定义了一个新的客户端,它会进行10次请求(第49~50行开启10个线程),每一次请求都会访问8000端口。连接成功后,会向服务器输出“Hello!”字符串(第13~26行),但是在这一次交互中,客户端会慢慢地进行输出,每次只输出一个字符,之后进行1秒的等待。因此,整个过程会持续6秒。

开启多线程池的服务器和上述客户端。服务器端的部分输出如下:

在这里插入图片描述
由此可见对于服务端来说,每一个请求的处理时间都在6秒左右。这很容易理解,因为服务器要先读入客户端的输入,而客户端缓慢的处理速度(当然也可能是一个拥挤的网络环境)使得服务器花费了不少等待时间。

我们可以试想一下,服务器要处理大量的请求连接,如果每个请求都像这样拖慢了服务器的处理速度,那么服务端能够处理的并发数量就会大幅度减少。反之,如果服务器每次都能很快地处理一次请求,那么相对的,它的并发能力就上升了。

在这个案例中,服务器处理请求之所以慢,并不是因为在服务端有多少繁重的任务,而是因为服务线程在等待IO而已。让高速运转的CPU去等待极其低效的网络IO是非常不合算的行为。那么,我们是不是可以想一个方法,将网络IO的等待时间从线程中分离出来呢?

二、使用NIO进行网络编程

使用Java的NIO就可以将上节的网络IO等待时间从业务处理线程中抽取出来。那么NIO是什么,它又是如何工作的呢?

要了解NIO,首先需要知道在NIO中的一个关键组件Channel。Channel有点类似于流,一个Channel可以和文件或者网络Socket对应。如果Channel对应着一个Soceket,那么往这个Channel中写数据,就等于向Socket中写入数据

和Channel一起使用的另外一个重要组件就是Buffer。大家可以简单地把Buffer理解成一个内存区域或者byte数组。数据需要包装成Buffer的形式才能和Channel交互(写入或者读取)

另外一个与Channel密切相关的是Selector(选择器)。在Channel的众多实现中,有一个SelectableChannel实现,表示可被选择的通道。任何一个SelectableChannel都可以将自己注册到一个Selector中,因此这个Channel就能为Selector所管理。而一个Selector可以管理多个SelectableChannel。当SelectableChannel的数据准备好时,Selector就会接到通知,得到那些已经准备好的数据,而SocketChannel就是SelectableChannel的一种。因此,它们构成了如图5.20所示的结构。

在这里插入图片描述

大家可以看到,一个Selector可以由一个线程进行管理,而一个SocketChannel则可以表示一个客户端连接,因此这就构成由一个或者极少数线程来处理大量客户端连接的结构。当与客户端连接的数据没有准备好时,Selector会处于等待状态(不过幸好,用于管理Selector的线程数是极少量的),而一旦有任何一个SocketChannel准备好了数据,Selector就能立即得到通知,获取数据进行处理。

下面就让我们用NIO来重新构造这个多线程的Echo服务器吧!

首先,我们需要定义一个Selector和线程池。

在这里插入图片描述
其中,Selector用于处理所有的网络连接,线程池tp用于对每一个客户端进行相应的处理,每一个请求都会委托给线程池中的线程进行实际的处理。

为了能够统计服务器线程在一个客户端上花费的时间,这里还需要定义一个与时间统计有关的类:

在这里插入图片描述
它用于统计在某一个Socket上花费的时间,time_stat的key为Socket,value为时间戳(可以记录处理开始时间)。

下面来看一下NIO服务器的核心代码,startServer()方法用于启动NIOServer。

在这里插入图片描述
上述代码第2行通过工厂方法获得一个Selector对象的实例。第3行获得表示服务端的SocketChannel实例。第4行将这个SocketChannel设置为非阻塞模式。实际上,Channel也可以像传统的Socket那样按照阻塞的方式工作。但这里更倾向于让其工作在非阻塞模式,在这种模式下,我们才可以向Channel注册感兴趣的事件,并且在数据准备好时,得到必要的通知。在第6~8行进行端口绑定,将这个Channel绑定在8000端口。

在第10行将这个ServerSocketChannel绑定到Selector上,并注册它感兴趣的时间为Accept。这样,Selector就能为这个Channel服务了。当Selector发现ServerSocketChannel有新的客户端连接时,就会通知ServerSocketChannel进行处理。方法register()的返回值是一个SelectionKey,SelectionKey表示一对Selector和Channel的关系。当Channel注册到Selector上时,就相当于确立了两者的服务关系,而SelectionKey就是这个契约。当Selector或者Channel被关闭时,它们对应的SelectionKey就会失效。

第12~37行是一个无穷循环,它的主要任务就是等待-分发网络消息。

第13行的select()是一个阻塞方法。如果当前没有任何数据准备好,它就会等待。一旦有数据可读,它就会返回。它的返回值是已经准备就绪的SelectionKey的数量。这里简单地将其忽略。

第14行获取那些准备好的SelectionKey。因为Selector同时为多个Channel服务,所以已经准备就绪的Channel就有可能是多个,这里得到的自然是一个集合。得到这个就绪集合后,剩下的就是遍历这个集合,挨个处理所有的Channel数据。

第15行得到这个集合的迭代器。第17行使用迭代器遍历整个集合。第18行根据迭代器获得一个集合内的SelectionKey实例。

第19行将这个元素移除。注意,这个非常重要,当你处理完一个SelectionKey后,务必将其从集合内删除,否则就会重复处理相同的SelectionKey。

第21行判断当前SelectionKey所代表的Channel是否在Acceptable状态,如果是,就进行客户端的接收(执行doAccept()方法)。

第24行判断Channel是否已经可以读了,如果是就进行读取(doRead()方法)。这里为了统计系统处理每一个连接的时间,在第25~27行记录了在读取数据之前的一个时间戳。

第30行判断通道是否准备好进行写。如果是就写入(doWrite()方法),同时在写入完成后,根据读取前的时间戳,输出处理这个Socket连接的耗时。

在了解服务端的整体框架后,下面让我们从细节着手,学习一下几个主要方法的内部实现。首先是doAccept()方法,它与客户端建立连接:

在这里插入图片描述
和Socket编程很类似,当有一个新的客户端连接接入时,就会产生一个新的Channel来代表这个连接。上述代码第5行生成的clientChannel就表示和客户端通信的通道。第6行将这个Channel配置为非阻塞模式,也就是要求系统在准备好IO后,再通知线程来读取或者写入。

第9行很关键,它将新生成的Channel注册到选择器上,并告诉Selector现在对读(OP_READ)操作感兴趣。这样,当Selector发现这个Channel已经准备好读时,就能给线程一个通知。

第11行新建一个对象实例,一个EchoClient实例代表一个客户端。在第12行,我们将这个客户端实例作为附件,附加到表示这个连接的SelectionKey上。这样在整个连接的处理过程中,我们都可以共享这个EchoClient实例。

EchoClient的定义很简单,它封装了一个队列,保存在需要回复给这个客户端的所有信息上,这样再进行回复时,只要从outq对象中弹出元素即可。

在这里插入图片描述
下面来看一下另外一个重要的方法doRead()。当Channel可以读取时,doRead()方法就会被调用。

在这里插入图片描述
方法doRead()接收一个SelectionKey参数,通过它可以得到当前的客户端Channel(第2行)。在这里,我们准备8K的缓冲区读取数据,所有读取的数据存放在变量bb中(第7行)。读取完成后,重置缓冲区,为数据处理做准备(第19行)。

在这个示例中,我们对数据的处理很简单。但是为了模拟复杂的场景,还是使用了线程池进行数据处理(第20行)。这样,如果数据处理很复杂,就能在单独的线程中进行,而不用阻塞任务派发线程。

HandleMsg的实现也很简单。

在这里插入图片描述
上述代码简单地将接收到的数据压入EchoClient的队列(第11行)。如果需要处理业务逻辑,就可以在这里进行处理。

在数据处理完成后,就可以准备将结果回写到客户端,因此,重新注册感兴趣的消息事件,将写操作(OP_WRITE)也作为感兴趣的事件进行提交(第12行)。这样在通道准备好写入时,就能通知线程。

写入操作使用doWrite()函数实现。

在这里插入图片描述
函数doWrite()也接收一个SelectionKey参数,当然对一个客户端来说,这个SelectionKey参数和函数doRead()拿到的SelectionKey参数是同一个。因此,通过SelectionKey参数就可以在这两个操作中共享EchoClient实例了。在上述代码第3~4行中,我们取得了EchoClient实例及它的发送内容列表。第6行获得列表顶部元素,准备写回客户端。第8行进行写回操作。如果全部发送完成,则移除这个缓存对象(第16行)。

在函数doWrite()中最重要的,也是最容易被忽略的是在全部数据发送完成后(也就是outq的长度为0),需要将写事件(OP_WRITE)从感兴趣的操作中移除(第25行)。如果不这么做,每次Channel准备好写时,都会来执行函数doWrite()。而实际上,又无数据可写,这显然是不合理的。因此,这个操作很重要。

上面我们已经介绍了核心代码,现在使用NIO服务器来处理上一节中客户端的访问。同样的,客户端也是要花费将近6秒才能完成一次消息的发送,使用NIO技术后,服务端线程需要花费多少时间来处理这些请求呢?答案如下:

在这里插入图片描述

可以看到,在使用NIO技术后,即使客户端迟钝或者出现了网络延迟等现象,并不会给服务器带来太大的问题。

三、使用NIO来实现客户端

在前面的案例中,我们使用Socket编程来构建客户端,使用NIO来实现服务端。实际上,NIO也可以用来创建客户端。这里,我们再演示一下使用NIO创建客户端的例子。

它和构造服务器类似,核心的元素也是Selector、Channel和SelectionKey。

首先,我们需要初始化Selector和Channel。

在这里插入图片描述
上述代码第3行创建一个SocketChannel实例,并设置为非阻塞模式。第5行创建了一个Selector。第6行将SocketChannel绑定到Socket上,但由于当前Channel是非阻塞的,因此当connect()方法返回时,连接并不一定建立成功,在后续使用这个连接时,还需要使用finishConnect()方法再次确认。第7行将这个Channel和Selector进行绑定,并注册了感兴趣的事件作为连接(OP_CONNECT)。

初始化完成后,就是程序的主要执行逻辑。

在这里插入图片描述

在上述代码中,第5行通过Selector得到已经准备好的事件。如果当前没有任何事件准备就绪,这里就会阻塞。这里的整个处理机制和服务端非常类似,主要处理两个事件,首先是表示连接就绪的Connct事件(由connect()函数处理),以及表示通道可读的Read事件(由read()函数处理)。

函数connect()的实现如下:
在这里插入图片描述
上述connect()函数接收SelectionKey作为其参数。在第4~6行,它首先判断是否连接已经建立,如果没有,则调用finishConnect()方法完成连接。建立连接后,向Channel写入数据,并同时注册读事件为感兴趣的事件(第10行)。

当Channel可读时,会执行read()方法,进行数据读取。

在这里插入图片描述
上述read()函数首先创建了100字节的缓冲区(第4行),接着从Channel中读取数据,并将其打印在控制台上。最后,关闭Channel和Selector。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值