NIO的API解释

每日一诗

庆历四年春,滕子京谪守巴陵郡。越明年,政通人和,百废具兴,乃重修岳阳楼,增其旧制,刻唐贤今人诗赋于其上,属予作文以记之。
予观夫巴陵胜状,在洞庭一湖。衔远山,吞长江,浩浩汤汤,横无际涯,朝晖夕阴,气象万千,此则岳阳楼之大观也,前人之述备矣。然则北通巫峡,南极潇湘,迁客骚人,多会于此,览物之情,得无异乎?
若夫淫雨霏霏,连月不开,阴风怒号,浊浪排空,日星隐曜,山岳潜形,商旅不行,樯倾楫摧,薄暮冥冥,虎啸猿啼。登斯楼也,则有去国怀乡,忧谗畏讥,满目萧然,感极而悲者矣。
至若春和景明,波澜不惊,上下天光,一碧万顷,沙鸥翔集,锦鳞游泳,岸芷汀兰,郁郁青青。而或长烟一空,皓月千里,浮光跃金,静影沉璧,渔歌互答,此乐何极!登斯楼也,则有心旷神怡,宠辱偕忘,把酒临风,其喜洋洋者矣。
嗟夫!予尝求古仁人之心,或异二者之为,何哉?不以物喜,不以己悲,居庙堂之高则忧其民,处江湖之远则忧其君。是进亦忧,退亦忧。然则何时而乐耶?其必曰“先天下之忧而忧,后天下之乐而乐”乎!噫!微斯人,吾谁与归?
时六年九月十五日。

——范仲淹《岳阳楼记》

一、前言

前面的文章已经写过了nio的api操作,但是写完后并没有对nio的api有深刻一些的了解,甚至再让我盲写我都不一定能写的出来。所以这一篇,综合网上的资料,对nio的api做一个详细的解释。后续也将写一篇关于netty的api的解释

二、用自己的话说说NIO与BIO的不同

BIO是面向流的,他是同步阻塞的。NIO是面向块(缓冲区)的,是同步非阻塞。

非阻塞(nonblocking)这个词其实是非常具有混淆性。不管上面说的非阻塞还是IO多路复用,本质上真正的IO操作其实都是阻塞的。唯一区别是在数据准备好之前nonblocking实际上是不断去check,所以这部分时间可以认为是没有阻塞。而IO多路复用则是全程阻塞。只是check阶段的阻塞是多个连接可以阻塞在单个线程上,由这一个线程去check多个连接事件,从而节省了线程资源。

NIO和BIO最大的区别是数据打包和传输方式。IO是以的方式处理数据,而NIO是以的方式处理数据。
面向流的IO一次一个字节的处理数据,一个输入流产生一个字节,一个输出流就消费一个字节。
面向块的IO系统以块的形式处理数据。每一个操作都在一步中产生或消费一个数据块。按块要比按流快的多,但面向块的IO缺少了面向流IO所具有的有雅兴和简单性。

IO中的读和写,对应的是数据和Stream,NIO中的读和写,则对应的就是通道和缓冲区NIO中从通道中读取:创建一个缓冲区,然后让通道读取数据到缓冲区。NIO写入数据到通道:创建一个缓冲区,用数据填充它,然后让通道用这些数据来执行写入。

 

三、NIO基础

通过前面几篇文章的学习,我们知道NIO三个重要的核心对象:Selector、Buffer、Channel。

3.1 Buffer

Buffer是一个对象,它要包含一些写入或读出的数据。在Nio中数据是要放在Buffer对象中的,而在IO中,数据是直接写入或者读到Stream对象的。应用程序不能直接对 Channel 进行读写操作,而必须通过 Buffer 来进行,即 Channel 是通过 Buffer 来读写数据的。在NIO中,所有的数据都是用Buffer处理的,它是NIO读写数据的中转池。Buffer实质上是一个数组,通常是一个字节数据,但也可以是其他类型的数组。但一个缓冲区不仅仅是一个数组,重要的是它提供了对数据的结构化访问,而且还可以跟踪系统的读写进程。

使用 Buffer 读写数据一般遵循以下四个步骤:

  1. 写入数据到 Buffer;
  2. 调用 flip() 方法;
  3. 从 Buffer 中读取数据;
  4. 调用 clear() 方法或者 compact() 方法。

当向 Buffer 写入数据时,Buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip() 方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 Buffer 的所有数据。

一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用 clear() 或 compact() 方法。clear() 方法会清空整个缓冲区。compact() 方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

Buffer的种类:

NIO的API解释

3.2 Channel

Channel是一个对象,可以通过它读取和写入数据。可以把它看做IO中的流。但是它和流相比还有一些不同:

  1. Channel是双向的,既可以读又可以写,而流是单向的
  2. Channel可以进行异步的读写
  3. 对Channel的读写必须通过buffer对象(这是最重要的)

正如上面提到的,所有数据都通过Buffer对象处理,所以,您永远不会将字节直接写入到Channel中,相反,您是将数据写入到Buffer中;同样,您也不会从Channel中读取字节,而是将数据从Channel读入Buffer,再从Buffer获取这个字节。

因为Channel是双向的,所以Channel可以比流更好地反映出底层操作系统的真实情况。特别是在Unix模型中,底层操作系统通常都是双向的。 

NIO的API解释

在Java NIO中Channel主要有如下几种类型:

  • FileChannel:从文件读取数据的
  • DatagramChannel:读写UDP网络协议数据
  • SocketChannel:读写TCP网络协议数据
  • ServerSocketChannel:可以监听TCP连接

3.3 从以前的代码实践逐步分析

服务端读取客户端数据,并写数据到客户端代码:

public static void main(String[] args) {
		//NIO,很重要的三个东西:【Channel、Buffer、Selector】
		//管道、缓冲区、多路复用器
		try {
			selector=Selector.open();
			ServerSocketChannel serverSocketChannel= ServerSocketChannel.open();
			//!!!表示设置为非阻塞;不设置会是阻塞
			serverSocketChannel.configureBlocking(false);
			serverSocketChannel.socket().bind(new InetSocketAddress(8080));
			//1.把连接事件注册到selector上
			serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
			while (true){
				//2.阻塞机制;只有事件到达的时候才会被唤醒,否则是阻塞
				selector.select();
				Set<SelectionKey> selectionKeySet = selector.selectedKeys();
				Iterator<SelectionKey> iterator = selectionKeySet.iterator();
				while (iterator.hasNext()){
					SelectionKey key = iterator.next();
					//说明响应了,应该移除掉
					iterator.remove();

					if (key.isAcceptable()){//响应事件
						//TODO
						handleAccept(key);
					}else if(key.isReadable()){//读的就绪事件
						handleRead(key);
					}
				}

				//监听一个客户端请求
				SocketChannel socketChannel = serverSocketChannel.accept();
				if (socketChannel!=null){
					//分配缓冲区的大小
					ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
					//把数据加载到缓冲区
					socketChannel.read(byteBuffer);
					//读取
					System.out.println(new String(byteBuffer.array()));
					/*写出数据*/
					//反转,读出来的数据和写出去的数据是相反的
					byteBuffer.flip();
					//写出
					socketChannel.write(byteBuffer);

				}else {
					Thread.sleep(1000);
					System.out.println("连接未就绪");
				}
			}
		} catch (IOException e) {
			e.printStackTrace();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

	}

3.3.1 第一步:获取通道

SocketChannel socketChannel = serverSocketChannel.accept();

3.3.2 第二步:创建缓冲区,将数据读到缓冲区

//分配缓冲区的大小
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
//把数据加载到缓冲区
socketChannel.read(byteBuffer);
//从写模式切换到读模式; 重设一下buffer的position=0,limit=position
byteBuffer.flip();

3.3.3 第三步:把缓冲区数据写入通道中

//写出
socketChannel.write(byteBuffer);

四、需要注意的点

4.1 检查状态

当没有更多的数据时,拷贝就算完成,此时 read() 方法会返回 -1 ,我们可以根据这个方法判断是否读完。

NIO的API解释

在很多场景中的这个地方应该加上检查状态的代码:

/判断是否读完文件
int eof =socketChannel.read(buffer);
if(eof==-1){
   break;  
}

4.2 Buffer类的flip、clear方法

4.2.1 控制buffer状态的三个变量

  • position:跟踪已经写了多少数据或读了多少数据,它指向的是下一个字节来自哪个位置
  • limit:代表还有多少数据可以取出或还有多少空间可以写入,它的值小于等于capacity。
  • capacity:代表缓冲区的最大容量,一般新建一个缓冲区的时候,limit的值和capacity的值默认是相等的。

 4.2.2 flip的源码:

public final Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
 }

NIO的API解释

在上面的上面的程序中,写入数据之前我们调用了buffer.flip();方法,这个方法把当前的指针位置position设置成了limit,再将当前指针position指向数据的最开始端,我们现在可以将数据从缓冲区写入通道了。 position 被设置为 0,这意味着我们得到的下一个字节是第一个字节。 limit 已被设置为原来的 position,这意味着它包括以前读到的所有字节,并且一个字节也不多。

4.2.3 clear方法

先看一下clear的源码:

public final Buffer clear() {
    position = 0;
    limit = capacity;
    mark = -1;
    return this;
}

NIO的API解释

在上面的程序中,写入数据之后也就是读数据之前,我们调用了 buffer.clear();方法,这个方法重设缓冲区以便接收更多的字节。上图显示了在调用 clear() 后缓冲区的状态。

五、Selector

Selector是一个对象,它可以注册到很多个Channel上,监听各个Channel上发生的事件,并且能够根据事件情况决定Channel读写。这样,通过一个线程管理多个Channel,就可以处理大量网络连接了。
有了Selector,我们就可以利用一个线程来处理所有的channels。线程之间的切换对操作系统来说代价是很高的,并且每个线程也会占用一定的系统资源。所以,对系统来说使用的线程越少越好。

在这里插一个疑问,Netty不是以NIO为底层的嘛?NIO是同步非阻塞,为什么Netty又说自己是异步事件驱动的?

贴两位网友的解答:

这两个没啥矛盾吧?
NIO为底层:netty使用了IO多路复用比如epoll。
事件驱动:一般有一个主循环和一个任务队列,所有事件只管往队列里塞,主循环则从队列里取出并处理。如果不依赖于多路复用处理多个任务就会需要多线程(与连接数对等),但是依赖于多路复用,这个循环就可以在单线程的情况下处理多个连接。无论是哪个连接发生了什么事件,都会被主循环从队列取出并处理(可能用回调函数处理等),也就是说程序的走向由事件驱动
至于异步,只是说对用户来说开启来是异步,但是实际nio只是同步非阻塞。

Netty说自己是异步事件驱动的框架,并没有说网络模型用的是异步模型,异步事件驱动框架体现在所有的I/O操作是异步的,所有的IO调用会立即返回,并不保证调用成功与否,但是调用会返回ChannelFuturenetty会通过ChannelFuture通知你调用是成功了还是失败了亦或是取消了。

5.1 如何创建一个Selector

Selector 就是您注册对各种 I/O 事件兴趣的地方,而且当那些事件发生时,就是这个对象告诉您所发生的事件。、

selector=Selector.open();

然后,就需要注册Channel到Selector了。 

5.2 如何注册Channel到Selector

为了能让Channel和Selector配合使用,我们需要把Channel注册到Selector上。通过调用 channel.register()方法来实现注册:

ServerSocketChannel serverSocketChannel= ServerSocketChannel.open();
//!!!表示设置为非阻塞;不设置会是阻塞
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
//1.把连接事件注册到selector上
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

 注意:注册的Channel 必须设置成异步模式 才可以,,否则异步IO就无法工作。

需要注意register()方法的第二个参数,它是一个“interest set”,意思是注册的Selector对Channel中的哪些时间感兴趣,事件类型有四种:

  1. Connect
  2. Accept
  3. Read
  4. Write

 通道触发了一个事件意思是该事件已经 Ready(就绪)。所以,某个Channel成功连接到另一个服务器称为 Connect Ready。一个ServerSocketChannel准备好接收新连接称为 Accept Ready,一个有数据可读的通道可以说是 Read Ready,等待写数据的通道可以说是Write Ready

上面这四个事件对应到SelectionKey中的四个常量:

  1. SelectionKey.OP_CONNECT
  2. SelectionKey.OP_ACCEPT
  3. SelectionKey.OP_READ
  4. SelectionKey.OP_WRITE
     

如果你对多个事件感兴趣,可以通过or操作符来连接这些常量:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

 5.3 关于SelectionKey

对register()的调用的返回值是一个SelectionKey。 SelectionKey 代表这个通道在此 Selector 上的这个注册。当某个Selector通知你某个传入事件时,它是通过提供对应于该事件的 SelectionKey 来进行的。SelectionKey 还可以用于取消通道的注册。SelectionKey中包含如下属性和方法:

NIO的API解释

SelectionKey中包含如下属性:

  • The interest set
  • The ready set
  • The Channel
  • The Selector
  • An attached object (optional)

5.3.1 Interest Set

就像我们在前面讲到的把Channel注册到Selector来监听感兴趣的事件,interest set就是你要选择的感兴趣的事件的集合。你可以通过SelectionKey对象来读写interest set:

int interestSet = selectionKey.interestOps();

  • boolean isInterestedInAccept  = interestSet & SelectionKey.OP_ACCEPT;
  • boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
  • boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
  • boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;   


通过上面例子可以看到,我们可以通过用AND 和SelectionKey 中的常量做运算,从SelectionKey中找到我们感兴趣的事件。

5.3.2 Ready Set

ready set 是通道已经准备就绪的操作的集合。在一次选Selection之后,你应该会首先访问这个ready set。Selection将在下一小节进行解释。可以这样访问ready集合:

int readySet = selectionKey.readyOps();
可以用像检测interest集合那样的方法,来检测Channel中什么事件或操作已经就绪。但是,也可以使用以下四个方法,它们都会返回一个布尔类型:

  • selectionKey.isAcceptable();
  • selectionKey.isConnectable();
  • selectionKey.isReadable();
  • selectionKey.isWritable();

5.3.3 Channel 和Selector

我们可以通过SelectionKey获得Selector和注册的Channel:

Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector(); 

5.3.4 Attach 一个对象

可以将一个对象或者更多信息attach 到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下:

selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();

还可以在用register()方法向Selector注册Channel的时候附加对象。如:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

5.4 通过Selector选择通道

一旦向Selector注册了一或多个通道,就可以调用几个重载的select()方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。换句话说,如果你对“Read Ready”的通道感兴趣,select()方法会返回读事件已经就绪的那些通道:

  • int select(): 阻塞到至少有一个通道在你注册的事件上就绪
  • int select(long timeout):select()一样,除了最长会阻塞timeout毫秒(参数)
  • int selectNow(): 不会阻塞,不管什么通道就绪都立刻返回,此方法执行非阻塞的选择操作。如果自从前一次选择操作后,没有通道变成可选择的,则此方法直接返回零。

select()方法返回的int值表示有多少通道已经就绪。亦即,自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道处于就绪状态。

5.4.1 selectedKeys()

一旦调用了select()方法,它就会返回一个数值,表示一个或多个通道已经就绪,然后你就可以通过调用selector.selectedKeys()方法返回的SelectionKey集合来获得就绪的Channel。请看演示方法:

Set<SelectionKey> selectedKeys = selector.selectedKeys();

当你通过Selector注册一个Channel时,channel.register()方法会返回一个SelectionKey对象,这个对象就代表了你注册的Channel。这些对象可以通过selectedKeys()方法获得。你可以通过迭代这些selected key来获得就绪的Channel,下面是演示代码:
 

Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) { 
SelectionKey key = keyIterator.next();
if(key.isAcceptable()) {
    // a connection was accepted by a ServerSocketChannel.
} else if (key.isConnectable()) {
    // a connection was established with a remote server.
} else if (key.isReadable()) {
    // a channel is ready for reading
} else if (key.isWritable()) {
    // a channel is ready for writing
}
keyIterator.remove();
}

这个循环遍历selected key的集合中的每个key,并对每个key做测试来判断哪个Channel已经就绪。
请注意循环中最后的keyIterator.remove()方法。Selector对象并不会从自己的selected key集合中自动移除SelectionKey实例。我们需要在处理完一个Channel的时候自己去移除。当下一次Channel就绪的时候,Selector会再次把它添加到selected key集合中。
SelectionKey.channel()方法返回的Channel需要转换成你具体要处理的类型,比如是ServerSocketChannel或者SocketChannel等等。

5.5 WakeUp()和Close()

某个线程调用select()方法后阻塞了,即使没有通道就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。

如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”

当用完Selector后调应当掉用close()方法,它将关闭Selector并且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。
 

六、NIO思维导图

NIO的API解释

上一篇:Druid 执行时间分布规则


下一篇:SpringBoot - 整合Mybatis