上篇文章介绍了BIO,是阻塞式的输入输出。客户端和服务端在发起请求和响应请求的时候需要保持同步连接,如果数据源或客户端没有数据或请求,或者请求量太大,都会造成线程阻塞,从而影响效率,浪费资源。
从JDK1.4开始,Java提供了一些列改进的输入输出处理的新功能,被称为新IO,即NIO,新增了许多用于处理输入输出的类,这些类都被放在java.nio包以及子包下,并且地缘java.io保中的很多类都NIO为基础进行了改写。
什么是NIO?如何理解NIO的非阻塞呢?
NIO是同步非阻塞通信。NIO是对BIO的改进,BIO当调用 accept()创建连接时会因为等待一个连接而阻塞;当一个连接创建后,调用read()或write()时,该线程可能由于没有数据可读或可写而别阻塞,直到有一些数据被读取或数据完全写入。该线程在此期间不能再干任何事情了。
NIO的非阻塞模式,需要配置Channel的阻塞行为(channel.configureBlocking(false))为非阻塞,以实现非阻塞式的信道。在非阻塞式信道上调用给一个方法总是立即返回。例如,在一个非阻塞式ServerSocketChannel上调用accept()方法,如果连接请求来了,则返回客户端SocketChannel,否则返回null。从而实现非阻塞模式。
举例:总是立即返回,如何理解呢?简单理解就是多步分而治之。比如顾客到银行办理业务,总是需要先填写申请表。如果用NIO方式处理,就是专员负责每个顾客的填写申请表的过程,填写好后交由窗口处理。如果用BIO方式处理,就是没有专员处理,填写申请表的流程交由每个窗口负责,在顾客填写申请表的时候,该窗口处于等待状态,也就是阻塞状态。
NIO三大核心
NIO主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector。
Channel(通道)
通道是对原 I/O 包中的流的模拟。到任何目的地(或来自任何地方)的所有数据都必须通过一个 Channel 对象(通道)。一个 Buffer 实质上是一个容器对象。发送给一个通道的所有对象都必须首先放到缓冲区中;同样地,从通道中读取的任何数据都要读到缓冲区中。 Channel是一个对象,可以通过它读取和写入数据。拿 NIO 与原来的 I/O 做个比较,通道就像是流。
Buffer(缓冲区)
buffer是一个对象,它包含一些要写入或刚读出的数据、在NIO中加入Buffer对象,和原IO最重要的区别是,原IO是面向流的,NIO是面向缓冲区的。而且缓冲区Buffer实质上是一个数据。所以原IO是字节流的形式读取和写入数据,NIO是以数组或块的形式,效率会更高。
Buffer属性
- capacity 缓冲区数组的总长度
- position 下一个要操作的数据元素的位置
- limit 缓冲区数组中不可操作的下一个元素的位置,limit<=capacity
- mark 用于记录当前 position 的前一个位置或者默认是 0
Buffer基本使用步骤
- 调用xxxBuffer.allocate(int)创建buffer
- 调用put方法往Buffer中写数据
- 调用Buffer.flip()将Buffer转为读模式
- 读取bufer中的数据
- 调用buffer.clear()或buffer.compact()
Buffer工作原理
1、我们刚刚初始化Buffer时,开始默认是这样的:
2、但是当你往buffer数组中开始写入的时候几个字节的时候就会变成下面的图,position会移动你数据的结束的下一个位置,这个时候你需要把buffer中的数据写到channel管道中,所以此时我们就需要用这个buffer.flip();方法
3、当调用完buffer.flip()方法后,会变成下面的图。这样的话其实就可以知道你刚刚写到buffer中的数据是在position-limit之间,然后channel就可以从buffer中读取数据了。在下一次写数据之前我们需要调动buffer.clear()或者buffer.compact()。缓冲区的索引状态又回到初始位置。
Selector(选择器)
一个Selector选择器可以监视多个通道,Selector选择器可以理解为一个线程,或者说一个传送带,而Channel是连接在传送带传送带上的管道,管道分为不同种类:连接,读,写,接受数据等。Selector轮询时,就会启动传送带,当轮询到某个管道,会先检查该管道是否被注册,如果注册了而且有响应的请求,就会对改管道进行处理,比如把该管道传送给别的线程,让其他线程完成读写操作。这种选择机制,使得一个单独的线程很容易来管理多个通道,可极大的减少线程数。
举例:比如银行顾客填写申请表,通常会有一个地方有一个专员负责,该专员就相当于Selector选择器。该专员负责所有类型的申请表,不同类型申请相当于不同的管道。该专员负责所有不同需求的顾客填写申请表,如果顾客填写好了申请表,相当于该类型管道有了请求,然后该专员就把该顾客交给窗口去处理顾客接下来的流程。
如果用BIO来解释银行工作方式的话,就是顾客来了银行后,没有专员负责填写申请表,顾客直接到窗口,每个窗口都得先让顾客来填写申请表,而每次顾客填写申请表的时候,窗口工作人员都处于等待状态,相当于阻塞状态,因为该顾客没有处理完,不能为其他顾客处理,其他顾客也处于等待状态。这样,每个窗口的服务人员都不是处于满负荷状态,相当于资源浪费。
Selector的用法
- 调用Selector.open()创建Selector
- 将要交给Selector检测的Channel注册进来:SelectorKey key = Channel.register(selector.Selector.OP_READ);
- 通过Selector来选择就绪的Channel,int n = selector.select();返回当前的就绪数量。有三个select()方法如下:
- int select(); 阻塞知道有就绪的channel
- int select(long timeout); 阻塞最长多久
- int select(Now); 不阻塞
- 获得就绪的Selectorkey集合(当有就绪的channel时)
- 通过调用selector的selectedkeys()方法,处理就绪通道
注意:
select()方法将返回可进行IO操作的信道数量。在一个单独的线程中,通过调用select()方法就能检查多个信道是否准备好进行IO操作,会返回两种结果:一种是如果经过一段时间后仍然没有信道准备好,select()方法就会返回0,并允许程序继续执行其他任务。另一种结果是一组需要IO操作的客户端。
这样的好处就是避免了BIO模式下,循环地一个一个地去检查所有的客户端是否有IO操作,如果当前客户端有IO操作,则可能吧当前客户端扔给一个线程池去处理,如果没有IO操作则进行下一轮询,当所有的客户端都轮询过了有接着从头开始轮询,这种方法是非常笨而且非常浪费资源,因为大部分客户端是没有IO操作,我们也要去检查。
Java NIO原生类库使用
最简单的NIO服务端创建程序流程:
最简单的java NIO客户端创建流程:
直接使用java 原生NIO类库进行开发,会相当困难:比如NIO类库和API繁杂,使用麻烦、需要具备多线程,网络编程等基础、可靠性能力补齐,工作量和难度大、以及epoll bug等。
结语
一般在工作遇到NIO相关的问题时,大多都有封装好的NIO框架,比如Netty,但并不是说没有必要了解其实现原理,知其然也要知其所以然。下篇文章我们用Netty实现通信。