高性能的Java通信,离不开Java NIO组件,现在主流的技术框架或中间件服务器,都使用了Java NIO组件,譬如Tomcat、 Jetty、 Netty、Redis、RabbitMQ等的网络通信模块。在1.4版本之前, Java IO类库是阻塞式IO;从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为Java NIO。 称“老的”阻塞式Java IO为OIO(Old IO)。总体上说, NIO弥补了原来面向流的OIO同步阻塞的不足,它为标准Java代码提供了高速的、面向缓冲区的IO。 学习NIO最主要的要理解Channel(通道)、Selector(选择器)、Buffer(缓冲区)三个核心组件。
在Java中, NIO和OIO的区别,主要体现在三个方面:
OIO是面向流(Stream Oriented)的, NIO是面向缓冲区(Buffer Oriented)的。
如何理解呢?非常简单,假设两个计算机的进程建立了通信,OIO的 read() 操作总是以输入流式的方式顺序地读取读取一个或多个字节,期间要么读要么不读,不能随意乱序读取。而写数据也只能往输出流写入数据。输入流和输出流是单向数据传输的。而NIO引入了Channel(通道)和Buffer(缓冲区)的概念。面向缓冲区的读取和写入,都是与Buffer进行交互。用户程序只需要从通道中读取数据到缓冲区中,或将数据从缓冲区中写入到通道中。 NIO不像OIO那样是顺序操作,可以随意地读取Buffer中任意位置的数据,可以随意修改Buffer中任意位置的数据。如下图所示,通道处于应用层,所有的通信都需要经过Channel通道:
OIO的操作是阻塞的,而NIO的操作是非阻塞的。
OIO阻塞: 当线程执行到read()和write()和连接服务器时的方法期间,如果没有对应的IO事件发生(客户端发送了连接请求、客户端发数据过来了)则一直在这些代码处等待,期间不做任何事情,线程挂起阻塞。直到发生了相应的IO事件才继续往下执行。并且如果还有其他的客户端触发了不是导致阻塞的IO事件也可能被阻塞。假设有两个客户端A和B,服务器伪代码如下所示,当没有客户端建立连接时则下面代码阻塞在第2行,假设此时A请求连接,那么下面代码就执行第2行然后阻塞在第3行。此时B也请求建立连接,这时候B是无法连接的,因为服务器代码阻塞在第3行。如果此时A发送了一个消息,那么下面程序继续执行,获取到A发生过来的消息msg。程序继续执行到第2行,如果因为B还在请求,直到这个时候B才能连接上服务器程序。这个例子就是OIO阻塞。
// OIO阻塞伪代码,先建立连接在读取客户端发来的数据的代码
1 while(true){
2 sc = ssc.accept() // 接受客户端连接,阻塞方法
3 msg = sc.read() // 读取客户端发送过来的数据,阻塞方法
4 }
NIO非阻塞: NIO的非阻塞实现方法是IO多路复用技术,通过selector来监测事件,如果没有事件发生则进行阻塞,如果有IO事件发生则获取到对应的事件处理对应的事件即可。首先A、B客户端没有连接时,下面程序执行到第2行进行阻塞,因为没有IO事件发生,如果此时A发起了连接则程序执行到第5行代码建立连接,如果此时B突然也请求连接,下面代码因为没有被阻塞经过10纳秒左右就能运行到第2行代码,然后发现B客户的请求连接事件,于是继续往下执行,同样执行到第5行代码。如果B请求连接的同时A突然发生了消息呢?那么没关系,events中有两个事件,通过for循环都能处理,A的消息发生请求会在第7行代码处理掉。下面的代码只是NIO的伪代码,实际上用法有一点点语法上的区别,逻辑是这样的。可以看见NIO是不会阻塞其他客户端的事件的,一般阻塞指的是较长时间的等待,例如1秒或者以上,下面也是单线程的服务器代码。如果你非要说10纳秒也是阻塞,那么下面的代码也确实无法0秒响应,实际上世界上也不存在这样的代码。什么是阻塞,我相信你已经有了自己的认知了!
// NIO非阻塞伪代码,先建立连接在读取客户端发来的数据的代码
1 while(true){
2 events = selector.select() // 没有事件线程阻塞,有事件则接着往下运行
3 for(E e : event){
4 if(e是请求事件){
5 e.connect();
6 }else if (e是读事件){
7 msg = e.read();
8 }....
9 }
10 }
OIO没有选择器( Selector)概念,而NIO有选择器的概念。
NIO技术的实现, 是基于底层的IO多路复用技术实现的,比如在Windows中需要select多路复用组件的支持,在Linux系统中需要select/poll/epoll多路复用组件的支持。 所以NIO的需要底层操作系统提供支持。而OIO不需要用到选择器selector,相信上面的NIO伪代码让你见识到了selector的威力!
到这里,你已经基本直到了NIO有多么牛了吧,但是还不够,前辈们的核心思想学习还刚刚开始,接下来需要学习非常核心的NIO类库的三大组件:Channel(通道)、Buffer(缓冲区)、Selector(选择器)。
Channel的角色和OIO中的Stream(流)是差不多的。 在OIO中,同一个网络连接会关联到两个流:一个输入流( Input Stream),另一个输出流(Output Stream), Java应用程序通过这两个流,不断地进行输入和输出的操作。在NIO中,一个网络连接使用一个Channel( 通道) 表示,所有的NIO的IO操作都是通过连接通道完成的。一个通道类似于OIO中的两个流的结合体,既可以从通道读取数据,也可以向通道写入数据。Channel和Stream的一个显著的不同是: Stream是单向的,譬如InputStream是单向的只读流, OutputStream是单向的只写流; 而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作。如下图所示:
NIO中的Channel的主要实现有:
首先回顾一下IO多路复用,指的是一个进程/线程可以同时监视多个socket连接,一旦其中的一个或者多个连接可读或者可写,该监听进程/线程能够进行IO事件的查询。在Java应用层面,Selector 选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。什么是IO事件呢?表示通道某种IO操作已经就绪、或者说已经做好了准备。例如,如果一个新Channel连接建立成功了,就会在Server Socket Channel上发生一个IO事件,代表一个新连接一个准备好,这个IO事件叫做“接收就绪”事件。 再例如, 一个Channel通道如果有数据可读,就会发生一个IO事件,代表该连接数据已经准备好,这个IO事件叫做 ―“读就绪”事件。Java NIO将NIO事件进行了简化,只定义了四个事件,这四种事件用SelectionKey的四个常量来表示:
Selector本质就是去查询这些IO就绪事件的,从编程实现维度来说, IO多路复用编程的第一步,是把通道Channel注册到选择器中,第二步则是通过选择器Selector所提供的事件查询(select)方法,这些注册的通道是否有已经就绪的IO事件(例如可读、可写、网络连接完成等)。由于一个选择器只需要一个线程进行监控,所以,我们可以很简单地使用一个线程,通过选择器去管理多个连接通道。与OIO相比, NIO使用选择器的最大优势:系统开销小,系统不必为每一个网络连接(文件描述符)创建进程/线程,从而大大减小了系统的开销。总之, 通过Java NIO可以达到一个线程负责多个连接通道的IO处理, 这是非常高效的。这种高效,恰恰就来自于Java的选择器组件Selector以及其底层的操作系统IO多路复用技术的支持。
应用程序通过缓冲区与通道建立数据读写交互,Buffer顾名思义:缓冲区,实际上是一个容器,一个连续数组。 Channel提供从文件、网络读取数据的渠道,但是读写的数据都必须经过Buffer。 如下图所示:
所谓通道的读取,就是将数据从通道读取到缓冲区中;所谓通道的写入,就是将数据从缓冲区中写入到通道中。缓冲区的使用,是面向流进行读写操作的OIO所没有的,也是NIO非阻塞的重要前提。Buffer类是一个抽象类,对应于Java的主要数据类型,在NIO中有8种缓冲区类,分别如下: ByteBuffer、 CharBuffer、 DoubleBuffer、 FloatBuffer、 IntBuffer、 LongBuffer、 ShortBuffer、MappedByteBuffer。 不同的Buffer子类,其能操作的数据类型能够通过名称进行判断,比如IntBuffer只能操作Integer类型的对象。 最常用的是ByteBuffer。如ByteBuffer子类就拥有一个byte[]类型的数组成员final byte[] hb,作为自己的读写缓冲区,数组的元素类型与Buffer子类的操作类型相互对应。那么在接下来的博文中将介绍Buffer具体的用法。
NIO的知识体系就是围绕Buffer(缓冲区)、 Channel(通道)、Selector(选择器)三大核心组件的,下面学习只需要学习这三个核心组件的用法即可,最后将结合代码举例说明!
经典神书推荐:《Java高并发核心编程系列》——尼恩