JAVA NIO全称是Java non-blocking IO或者Java New IO。在之前的文章中(https://blog.csdn.net/weixin_42447959/article/details/107233189)提到过,NIO是IO理论思想,其中同步阻塞IO(Non-Blocking IO)演进为多路复用IO(Multiplexing IO),是IO理论思想的演进。JAVA NIO是NIO在JAVA领域的实现。
JAVA NIO的三要素,即Buffer、Channel和Selector。提到JAVA NIO,经常提到的两个概念就是零拷贝和多路复用IO了。其中Buffer和Channel和零拷贝有关,而Selector和多路复用IO有关。三要素中就体现了这两个重要概念,这三要素或者说这两个概念组合在一起,形成了JAVA NIO在网络IO中的高效应用。那篇文章中我只说JAVA NIO是在网络IO应用中实现了多路复用IO理论,那JAVA NIO中也有FileChannel啊也能读写文件啊,为啥和磁盘IO没有关系呢?这是因为FileChannel是零拷贝概念中的东西,和多路复用IO还是要撇清关系的。
一句话理解起来就是JAVA NIO在网络IO应用中实现了多路复用IO理论,在磁盘IO应用中实现了零拷贝。两个概念要区分理解清楚,接下来就从这两个概念的角度去理解JAVA NIO的三要素。
先看和零拷贝相关的Buffer和Channel。
JAVA中IO和NIO的区别是,IO是面向流(Stream)的io操作,而NIO是面向缓冲区(Buffer)的基于通道(Channel)的io操作。在之前的零拷贝文章(https://blog.csdn.net/weixin_42447959/article/details/103499353)中画过这么一张图:
其实Buffer本质上试内核空间中的一块缓存空间,即对应着图里的内核缓存和socket缓存,这块内存被包装成了NIO Buffer对象,并提供了一组方法,用于方面得访问和操作这块内存。JDK的rt包中有一个Buffer抽象类:
常用的继承类有ByteBuffer、CharBuffer、DoubleBuffer以及IntBuffer等。Buffer有三个重要属性:
1.capacity
即内存块的最大容量,只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。
2.position
当写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity。当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
3.limit
在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)。
Buffer的读写数据有两种方式,一是通过Buffer对象的get()/put()方法直接读写Buffer,另一个就是和Channel配合了,即从通道Channel中写数据进Buffer和读取Buffer中到通道Channel。先看一下通过Buffer直接读写,一般需要遵循四个步骤:
对应上诉步骤的代码示例:
当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。(清空缓冲区不会把数据清空只是opsiton位置指针归零,limit限制到缓冲区尾,再次写入到到缓冲区世区时,写入多少,limit会移动到写入位置,从缓冲区再次读取只能读到limit位置不用担心读到上次缓冲区残留数据compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
示例中创建缓冲区是通过ByteBuffer.allocate()的,这是分配间接缓冲区,还可以通过ByteBuffer.allocateDirect()分配直接缓冲区。间接缓冲区是在堆中开辟,易于管理,垃圾回收器可以回收,空间有限,读写文件速度较慢。而直接缓冲区不在堆中,物理内存中开辟空间,空间比较大,读写文件速度快,缺点是不受垃圾回收器控制,创建和销毁耗性能。
刚刚是利用Buffer对象直接操作读写,还有一种方式是和Channel配合了,即从通道Channel中写数据进Buffer和读取Buffer中到通道Channel,要用到channel.read(buffer)和channel.write(buffer),以FileChannel举例:
Channel表示打开到IO设备(磁盘文件、Socket套接字)的连接,通道可以异步双向传输,所以比IO中的Stream更加高效。但是Channel必须配合Buffer使用,理解起来就是和IO设备建立连接后,Channel可以通过channel.read(buffer)和channel.write(buffer)将磁盘文件中或者Socket中的数据直接写进内核缓存,数据读写操作绕过了用户空间,避免了线程上下文切换和CPU拷贝,这才是零拷贝的关键。
Channel除了有write()和read()方法可以用来读写数据进Buffer,还有transferTo()和transferFrom()用于通道直接的数据传输,一个通道的数据都可以传到另一个通道了,再配合write()和read(),不就是内核态中一块缓存的数据可以直接传到另一块缓存了,中间完全没有用户态的参与,典型场景可以用来文件复制等操作。
JDK的rt包下有Channel接口:
主要实现类有FileChannel、SocketChannel、ServerSocketChannel以及DatagramChannel等。FileChannel用于和磁盘文件建立连接读写文件数据,SocketChannel用于从TCP连接中读取网络数据,DatagramChannel用于从UDP连接中读取网络数据,ServerSocketChannel用于监听TCP连接,对每个新来的TCP连接都会建立一个SocketChannel。理解起来就是SocketChannel和ServerSocketChannel是一对,前者用于客户端,后者用于服务端。java对于支持通道的类提供了getChannel()方法:
上面是Buffer和Channel,再来看一下JAVA NIO中和多路复用相关的Selector。
Selector(选择器)是Java NIO中能够检测一到多个通道(Channel),并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个Channel,如果是SocketChannel即管理多个网络连接,这里也是多路复用概念的体现。
Selector对象的主要方法有open()、isOpen()、close()、select()以及wakeUp()等。
可以通过Selector.open()来创建一个选择器:
Selector selector = Selector.open()
isOpen()用于判断Selector是否处于打开状态。Selector对象创建后就处于打开状态了。当调用了Selector对象的close()方法,就进入关闭状态.。用完Selector后调用其close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。
为了将Channel和Selector配合使用,必须将channel注册到selector上。通过channel的register()方法来实现。
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
与Selector一起使用时,Channel必须处于非阻塞模式下,这意味着FIleChannel与Selector不能一起使用。register()方法的第二个参数,是选择器监听的通道事件类型,共有四种事件类型:Connect、Accept、Read和Write。这四种事件类型用SelectionKey的四个常量来表示:
通道触发了一个事件意思是该事件已经就绪。所以,某个channel成功连接到另一个服务器称为”连接就绪“。一个ServerSocketChannel准备好接收新进入的连接称为”接收就绪“。一个有数据可读的通道可以说是”读就绪“。等代写数据的通道可以说是”写就绪“。所以Selector在轮询就绪的事件时就是对应找这四种就绪的事件。
注册方法register()的返回值就是SelectionKey对象。只要ServerSocketChannel及SocketChannel向Selector注册了特定的事件,Selector就会监控这些事件是否发生。SelectionKey对象是用于跟踪这些被注册事件的句柄。一个Selector对象会包含3种类型的SelectionKey集合:
如果关闭了与SelectionKey对象关联的Channel对象,或者调用了SelectionKey对象的cancel()方法,这个SelectionKey对象就会被加入到cancelled-keys集合中,表示这个SelectionKey对象已经被取消。在执行Selector的select()方法轮询就绪事件时,如果与SelectionKey相关的事件发生了,这个SelectionKey就被加入到selected-keys集合中,程序直接调用selected-keys集合的remove()犯法,或者调用它的iterator的remove()方法,都可以从selected-keys集合中删除一个SelectionKey对象。
通过Selector的select()轮询选择就绪的通道,一旦向Selector注册了一个或多个通道,就可以调用几个重载的select()方法。select()方法返回的Int值表示多少通道就绪。
select() —— 阻塞到至少有一个通道在你注册的事件上就绪了
select(long timeout) ——和select()一样,除了最长会阻塞timeout毫秒
selectNow() ——不会阻塞,不管什么通道就绪都立刻返回;此方法执行非阻塞的选择操作,如果自从上一次选择操作后,没有通道变成可选择的,则此方法直接返回0
某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其他线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。
上诉是JAVA NIO的三要素的理解,那这和Netty的零拷贝有什么关系呢?
Netty是基于JAVA NIO的通信框架,Netty的零拷贝主要包括两方面:
1.如果场景不需要用户态参与,例如Netty的文件传输就利用了上诉JAVA NIO的Channel的Buffer的结合使用。采用Channel的transferTo()方法,它可以直接将一个文件内核缓冲区的数据发送到目标文件内核缓冲区,从而绕过了用户态直接在内核态完成了文件传输,避免了传统内存拷贝问题。
2.Netty毕竟是网络通信框架,从Socket连接里获取的数据更多的时候是需要业务处理的,这就必然要经过用户态。这时候采用的就是mmap+write的方式,就和JAVA NIO没什么关系了。关于mmap+write的零拷贝在之前的文章(https://blog.csdn.net/weixin_42447959/article/details/103499353)中有介绍过。