第3章 Java NIO核心详解
前言
高性能的Java通信绝对离不开Java NIO组件,现在主流的技术框架或中间件服务器都使用了Java NIO组件,譬如Tomcat、Jetty、Netty。学习和掌握Java NIO组件已经不是一项加分技能,而是一项必备技能。
Java NIO简介
在1.4版本之前,Java IO类库是阻塞IO;从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为Java NIO。
New IO类库的目标就是要让Java支持非阻塞IO,基于此,更多的人喜欢称Java NIO为非阻塞IO(Non-Blocking IO),称“老的”阻塞式Java IO为OIO(Old IO)。
Java NIO类库包含以下三个核心组件:
- Channel(通道)
- Buffer(缓冲区)
- Selector(选择器)
Java NIO属于第三种模型——IO多路复用模型。
NIO和OIO的对比
NIO和OIO的区别主要体现在三个方面:
- OIO是面向流(Stream Oriented)的,NIO是面向缓冲区(Buffer Oriented)的。NIO不像OIO那样是顺序操作,它可以随意读取Buffer中任意位置的数据。
- OIO的操作是阻塞的,而NIO的操作是非阻塞的。NIO的非阻塞是使用了通道和通道的多路复用技术。
- OIO没有选择器(Selector)的概念,而NIO有选择器的概念。
通道
在OIO中,同一个网络连接会关联到两个流:一个是输入流(Input Stream),另一个是输出流(Output Stream)。Java应用程序通过这两个流不断地进行输入和输出的操作。
在NIO中,一个网络连接使用一个通道表示,所有NIO的IO操作都是通过连接通道完成的。一个通道类似于OIO中两个流的结合体,既可以从通道读取数据,也可以向通道写入数据。
选择器
在Java应用层面,Java NIO组件——选择器。选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。
从编程实现维度来说,IO多路复用编程的第一步是把通道注册到选择器中,第二步是通过选择器所提供的事件查询(select)方法来查询这些注册的通道是否有已经就绪的IO事件(例如可读、可写、网络连接完成等)。
由于一个选择器只需要一个线程进行监控,因此我们可以很简单地使用一个线程,通过选择器去管理多个连接通道。
与OIO相比,NIO使用选择器的最大优势是系统开销小。系统不必为每一个网络连接(文件描述符)创建进程/线程,从而大大减少了系统的开销。总之,一个线程负责多个连接通道的IO处理是非常高效的,这种高效来自Java的选择器组件Selector及其底层的操作系统IO多路复用技术的支持。
缓冲区
应用程序与通道的交互主要是进行数据的读取和写入。为了完成NIO的非阻塞读写操作,NIO为大家准备了第三个重要的组件——Buffer。所谓通道的读取,就是将数据从通道读取到缓冲区中;所谓通道的写入,就是将数据从缓冲区写入通道中。缓冲区的使用是面向流进行读写操作的OIO所没有的,也是NIO非阻塞的重要前提和基础之一。
详解NIO Buffer类及其属性
NIO的Buffer本质上是一个内存块
,既可以写入数据,也可以从中读取数据。Java NIO中代表缓冲区的Buffer类是一个抽象类,位于java.nio包中。
NIO的Buffer内部是一个内存块(数组),与普通的内存块(Java数组)不同的是:NIO Buffer对象提供了一组比较有效的方法,用来进行写入和读取的交替访问。
Buffer类是一个非线程安全类。
Buffer类
Buffer类是一个抽象类,对应于Java的主要数据类型。在NIO中,有8种缓冲区类,分别是ByteBuffer、CharBuffer、DoubleBuffer、
FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedByteBuffer。前7种Buffer类型覆盖了能在IO中传输的所有Java基本数据类型,第8种类型是一种专门用于内存映射的ByteBuffer类型。
实际上,使用最多的是ByteBuffer(二进制字节缓冲区)类型。
Buffer类的重要属性
Buffer的子类会拥有一块内存,作为数据的读写缓冲区,但是读写缓冲区并没有定义在Buffer基类中,而是定义在具体的子类中
。例如,ByteBuffer子类就拥有一个byte[]类型的数组成员final byte[] hb,可以作为自己的读写缓冲区,数组的元素类型与Buffer子类的操作类型相对应。
- capacity:容量,即可以容纳的最大数据量,在缓冲区创建时设置并且不能改变;
- limit:读写的限制,缓冲区中当前的数据量;
- position:读写位置,缓冲区中下一个要被读或写的元素的索引;
- mark:调用markO方法来设置mark=position,再调用resetO让position恢复到mark标记的位置,即position=mark。
详解NIO Buffer类的重要方法
allocat()
在使用Buffer实例之前,我们首先需要获取Buffer子类的实例对象,并且分配内存空间。需要获取一个Buffer实例对象时,并不是使用子类的构造器来创建,而是调用子类的allocate()方法。
put()
在调用allocate()方法分配内存、返回了实例对象后,缓冲区实例对象处于写模式,可以写入对象,如果要把对象写入缓冲区,就需要调用put()方法。put()方法很简单,只有一个参数,即需要写入的对象,只不过要求写入的数据类型与缓冲区的类型保持一致。
flip()
向缓冲区写入数据之后,是否可以直接从缓冲区读取数据呢?不能!这时缓冲区还处于写模式,如果需要读取数据,要将缓冲区转换成读模式。
flip()翻转方法是Buffer类提供的一个模式转变的重要方法,作用是将写模式翻转成读模式。
get()
调用flip()方法将缓冲区切换成读模式之后,就可以开始从缓冲区读取数据了。读取数据的方法很简单,可以调用get()方法每次从position的位置读取一个数据,并且进行相应的缓冲区属性的调整。
rewind()
已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。
mark()和reset()
mark()和reset()两个方法是配套使用的:Buffer.mark()方法将当前position的值保存起来放在mark属性中,让mark属性记住这个临时位置;然后可以调用Buffer.reset()方法将mark的值恢复到position中。
clear()
在读模式下,调用clear()方法将缓冲区切换为写模式。此方法的作用是:
(1)将position清零。
(2)limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。
使用Buffer类的基本步骤
总体来说,使用Java NIO Buffer类的基本步骤如下:
(1)使用创建子类实例对象的allocate()方法创建一个Buffer类的实例对象。
(2)调用put()方法将数据写入缓冲区中。
(3)写入完成后,在开始读取数据前调用Buffer.flip()方法,将缓冲区转换为读模式。
(4)调用get()方法,可以从缓冲区中读取数据。
(5)读取完成后,调用Buffer.clear()方法或Buffer.compact()方法,将缓冲区转换为写模式,可以继续写入。
详解NIO Channel类
Java NIO的通道可以更加细化。例如,不同的网络传输协议类型,在Java中都有不同的NIO Channel实现。
仅着重介绍
其中最为重要的四种Channel实现:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。
(1)FileChannel:文件通道,用于文件的数据读写。
(2)SocketChannel:套接字通道,用于套接字TCP连接的数据读写。
(3)ServerSocketChannel:服务器套接字通道(或服务器监听通道),允许我们监听TCP连接请求,为每个监听到的请求创建一个SocketChannel通道。
(4)DatagramChannel:数据报通道,用于UDP的数据读写。
FileChannel
FileChannel(文件通道)是专门操作文件的通道。通过FileChannel,既可以从一个文件中读取数据,也可以将数据写入文件中。特别申明一下,FileChannel为阻塞模式,不能设置为非阻塞模式。
使用FileChannel完成文件复制的实战案例
略
SocketChannel
在NIO中,涉及网络连接的通道有两个:一个是SocketChannel,负责连接的数据传输;另一个是ServerSocketChannel,负责连接的监
听。其中,NIO中的SocketChannel传输通道与OIO中的Socket类对应,NIO中的ServerSocketChannel监听通道对应于OIO中的ServerSocket类。
使用SocketChannel发送文件的实战案例
略
DatagramChannel
当DatagramChannel通道可读时,可以从DatagramChannel读取数据。和前面的SocketChannel读取方式不同,这里不调用read()方法,而是调用receive(ByteBufferbuf)方法将数据从DatagramChannel读入,再写入ByteBuffer缓冲区中。
//创建缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
//从DatagramChannel读入,再写入ByteBuffer缓冲区
SocketAddress clientAddr = datagramChannel.receive(buf);
使用DatagramChannel发送数据的实战案例
略
详解NIO Selector
Java NIO的三大核心组件是Channel(通道)、Buffer(缓冲区)、Selector(选择器)。其中,通道和缓冲区的联系比较密切:数据总是从通道读到缓冲区内,或者从缓冲区写入通道中。
选择器与注册
选择器的使命是完成IO的多路复用,其主要工作是通道的注册、监听、事件查询。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。选择器和通道的关系是监控和被监控的关系。
选择器提供了独特的API方法,能够选出(select)所监控的通道已经发生了哪些IO事件,包括读写就绪的IO操作事件。
通道和选择器之间的关联通过register(注册)的方式完成。调用通道的Channel.register(Selector sel,int ops)方法,可以将通道实例注册到一个选择器中。register方法有两个参数:
- 第一个参数指定通道注册到的选择器实例;
- 第二个参数指定选择器要监控的IO事件类型。
可供选择器监控的通道IO事件类型包括以下四种:
(1)可读:SelectionKey.OP_READ。
(2)可写:SelectionKey.OP_WRITE。
(3)连接:SelectionKey.OP_CONNECT。
(4)接收:SelectionKey.OP_ACCEPT。
//监控通道的多种事件,用“按位或”运算符来实现
int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE ;
这里的IO事件不是对通道的IO操作,而是通道处于某个IO操作的就绪状态,表示通道具备执行某个IO操作的条件。
例如,某个SocketChannel传输通道如果完成了和对端的三次握手过程,就会发生“连接就绪”(OP_CONNECT)事件;
某个ServerSocketChannel服务器连接监听通道,连接到来时,则会发生“接收就绪”(OP_ACCEPT)事件;
一个SocketChannel通道有数据可读,就会发生“读就绪”(OP_READ)事件;
一个SocketChannel通道等待数据写入,就会发生“写就绪”(OP_WRITE)事件。
SelectableChannel
并不是所有的通道都是可以被选择器监控或选择的。例如,FileChannel就不能被选择器复用。判断一个通道能否被选择器监控或
选择有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道)
,如果是,就可以被选择,否则不能被选择。
简单地说,一个通道若能被选择,则必须继承SelectableChannel类。
SelectionKey
一旦在通道中发生了某些IO事件(就绪状态达成),并且是在选择器中注册过的IO事件,就会被选择器选中,并放入SelectionKey(选择键)的集合中。
在实际编程时,SelectionKey的功能是很强大的。通过SelectionKey,不仅可以获得通道的IO事件类型(比如SelectionKey.OP_READ),还可以获得发生IO事件所在的通道。另外,还可以获得选择器实例。
选择器使用流程
选择器的使用主要有以下三步:
(1)获取选择器实例。选择器实例是通过调用静态工厂方法open()来获取的,具体如下:
//调用静态工厂方法open()来获取Selector实例
Selector selector = Selector.open();
Selector的类方法open()的内部是向选择器SPI发出请求,通过默认的SelectorProvider(选择器提供者)对象获取一个新的选择器实例。Java中的SPI(Service Provider Interface,服务提供者接口)
是一种可以扩展的服务提供和发现机制。Java通过SPI的方式提供选择器的默认实现版本。也就是说,其他的服务提供者可以通过SPI的方式提供定制化版本的选择器的动态替换或者扩展。
(2)将通道注册到选择器实例。要实现选择器管理通道,需要将通道注册到相应的选择器上,简单的示例代码如下:
//获取通道
ServerSocketChannelserverSocketChannel =
ServerSocketChannel.open();
//设置为非阻塞
serverSocketChannel.configureBlocking(false);
//绑定连接
serverSocketChannel.bind(new InetSocketAddress(18899));
//将通道注册到选择器上,并指定监听事件为“接收连接”
serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);
(3)选出感兴趣的IO就绪事件(选择键集合)。通过Selector的select()方法,选出已经注册的、已经就绪的IO事件,并且保存到
SelectionKey集合中。SelectionKey集合保存在选择器实例内部,其元素为SelectionKey类型实例。调用选择器的selectedKeys()方法,可以取得选择键集合。
处理完成后,需要将选择键从SelectionKey集合中移除,以防止下一次循环时被重复处理。SelectionKey集合不能添加元素,如果试图向SelectionKey中添加元素,则将抛出java.lang.UnsupportedOperationException异常。
使用NIO实现Discard服务器的实战案例
Discard服务器的功能很简单:仅读取客户端通道的输入数据,读取完成后直接关闭客户端通道,并且直接抛弃掉(Discard)读取到的数据。Discard服务器足够简单明了,作为第一个学习NIO的通信实例比较有参考价值。
使用SocketChannel在服务端接收文件的实战案例
具体案例见github源码。
客户端每次传输文件都会分为多次传输:首先传入文件名称,其次是文件大小,然后是文件内容。
由于NIO传输是非阻塞、异步的,因此在传输过程中会出现“粘包”和“半包”问题
。正因如此,无论是前面NIO文件传输实例还是Discard服务器程序,都会在传输过程中出现异常现象(偶现)。