在Java Socket通信中,Java NIO是一个很重要的底层实现类,理解其核心组件和使用方法,将会帮助了解更多在Java NIO基础之上搭建的其它Socket通信应用,例如Netty IO等。
本文将对Java NIO 核心组件做一个简单介绍,读完本文,你将可以读Java NIO有个总体上的认识,并能够通过代码样例,编程实现一个多线程异步消息通信。
1. Java NIO概览
Java NIO全称是Java New Input/Output,是很早在JDK1.4中引入的输入输出类库,之后在JDK7中提供了升级版的NIO2,提供了异步编程模型的IO操作。下文若没有说明,Java NIO是指在JDK7之后的最新NIO类库。
有一个首先需要了解的问题是,为什么会有NIO?和之前的IO类库相比,其带来了哪些优点和改进?
在JDK1.4之前,所有IO操作都是基于流(stream)进行数据读写,其读写操作方法会阻塞,这将大大影响程序的效率。Java NIO一方面解决了阻塞的问题,另一方面对数据块存储、数据处理方面进行了抽象,提供了缓存区、通信通道和选择器三个组件概念,使得程序开发者可以在更深入的层次上介入IO处理过程,对相关的通信进行多线程优化,编程可扩展性大大提高。
下表列出IO和NIO在主要特性上的区别对比,
|
IO |
NIO |
操作对象 |
面向流 |
面向缓存区 |
操作特性 |
单向操作,或读,或写 |
支持读写双向操作 |
读写单位 |
按字节一一进行读/写 Bytes |
按指定块存储大小进行读/写 Block Buffer |
支持非阻塞 |
只支持阻塞读写 |
支持阻塞和非阻塞读写 |
通信通道 |
无,一个流本身就是一个通道 |
基于通道进行数据传输 |
单线程 多通道 |
不支持 |
支持,通过选择器实现一个线程处理多个通道 |
异步编程 |
不支持 |
支持异步编程模型(自JDK7) |
可以看到Java NIO在IO操作上进行了很大幅度的改进和提升。
在Java NIO类库中,有四个核心组件接口,
- Channel通道
- Buffer缓存区
- Selector 选择器
- CompletionHandler 异步回调处理器
本文将对这四个组件逐一进行讲解,了解其作用和使用方法,并提供简单的代码使用样例。最后给出一个多线程异步通信的使用样例。
2. 通道Channel
一个通道是数据传输的连接,可以和IO设备建立连接,进行数据的获取和传送。在一定程度上,可以认为通道是流的升级版实现。
整个Java NIO中最常用的通道类有如下几个,
- FileChannel,since 1.4
- DatagramChannel,since 1.4
- SocketChannel,since 1.4
- ServerSocketChannel,since 1.4
- AsynchronousFileChannel,since 1.7
- AsynchronousSocketChannel,since 1.7
- AsynchronousServerSocketChannel,since 1.7
其中Asynchronous*这几个类是在JDK7开始提供的NIO 2.0类库,主要提供了异步编程模型,详细见后面异步回调处理器的讨论。
(注:AsynchronousDatagramChannel在JDK7中并没有提供,原先是有准备发布这个类库的,但是由于某些原因,在发布前被删除。)
通道类中使用最多的四个方法是,
- Channel.bind() 绑定连接
- Channel.accept() 建立连接
- Channel.read(buffer) 把数据从缓冲区读到通道
- Channel.write(buffer) 把数据从通道写到缓冲区
一个简单的代码样例如下,
// please note: configure channel blocking state as false
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
System.out.println("A server is started on port 9000");
ServerSocket serverSocket = server.socket();
serverSocket.bind(new InetSocketAddress(9000));
// please note: server.accept() will not block current thread
// Accept method will return null directly if no client is connected
SocketChannel channel = null;
int count = 0;
while (channel == null) {
System.out.println("The server is trying to connect client, count=" + count++);
channel = server.accept();
Thread.sleep(500);
}
//channel.read(ByteBuffer)
System.out.println("the end");
channel.close();
server.close();
3. 缓冲区Buffer
Java NIO是基于块进行IO操作,缓冲区就是对这个块的抽象定义,在这个缓冲区中可以反复进行读写,通过通道实现数据的传输。
我们先看看缓冲区是什么样子,一个缓冲区是一个有指定大小的数组,其有读模式和写模式两种状态,请注意在读写不同模式下其Position和Limit的位置指向,
上图的缓冲区中显示了如下三个属性,
- Capacity:缓冲区容量大小
- Position:缓冲区的读写位置,根据当前读/写模式,含义如下
- 在读模式下,读的当前位置
- 在写模式下,写的当前位置
- Limit:缓冲区的读写限制位,根据当前读/写模式,含义如下
- 在读模式下,可以读的最大位置,等于当前缓冲区内数据量,防止读溢出
- 在写模式下,可以写的最大位置,其实就等于Capacity值,防止写溢出
Java NIO提供如下7个缓冲区类,
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
这几个缓冲类基本覆盖了Java中8个基本数据类型的7个,还有一个boolean没有支持,原因是Java的boolean使用1bit进行存储,而在IO操作中,最小单位是bytes。
缓冲区类中使用最多的几个方法是,
- Buffer.allocate(size) 创建一个指定大小的空缓冲区
- Buffer.wrap(array) 通过一个数组创建一个缓冲区
- Buffer.put() 保存一个数据
- Buffer.flip() 切换写模式到读模式,
- Buffer.get() 读取一个数据
- Buffer.clear() 清空缓冲区
一个简单的代码样例如下,
IntBuffer buffer = IntBuffer.allocate(10); // #1
System.out.println("write 5 int number into buffer");
for (int i = 0; i < 6; i++) {
buffer.put(i); // #2
}
buffer.flip(); // #3
for (int i = 0; i < 6; i++) {
int n = buffer.get(); // #4
System.out.println("read number: " + n);
}
buffer.clear(); // #5
结合样例代码,我们对缓冲区的操作流程描绘成下图,以助理解,
各个步骤的说明,
- 第一步:初始化一个整型缓冲区,大小为10
- 第二步:写入5个整型值到缓冲区
- 第三步:切换写模式到读模式
- 第四步:从缓冲区读取5个整型值
- 第五步:重置整型缓冲区,可以进行下一步的写操作
4. 选择器Selector
选择器能够同时监控多个通道的通信情况,方便多线程处理通道事件。开发者可以通过选择器为如下四个通道状态进行监听,
- Connect:一个客户端通道已经准备好了连接远程服务器
- Accept:一个服务器通道已经准备连接客户端,请
- Read:一个通道已经准备好了读操作
- Write:一个通道已经准备好了写操作
上述各个通道状态对应的SelectionKey事件为,
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
需要注意的是,只有通道设置为非阻塞(non-blocking),才能注册到选择器,否则注册时会收到一个IllegalBlockingModeException的异常。
对选择器的通用操作流程为,
- 创建一个选择器Selector selector=Selector.open()
- 注册通道channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE)
- 获取事件key = selector.select()
一个简单的代码样例如下,
Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while(true) {
int readyChannels = selector.select();
if(readyChannels == 0) continue;
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();
}
}
5. 异步回调处理器CompletionHandler
异步回调处理器是在JDK 7之后属于Java NIO 2的异步通信组件之一,它提供一种回调机制,让在通信完成之后异步执行通信结果的处理。CompletionHandler本身是一个接口定义,我们先看看这个接口提供的两个回调方法,
public interface CompletionHandler<V,A> {
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);
}
接口的两个回调方法,
1. completed() 当操作成功时调用,返回结果
2. failed() 当操作失败时调用,返回异常信息
在我们看如何使用异步回调处理器之前,我们先了解下为什么要异步回调。
前面讲到,我们知道IO操作是一个耗时的操作,于是在Java NIO编程中提供了非阻塞编程模型,使得在执行通信连接、读、写操作时,当前线程并不会挂起等待,
- Channel.configureBlocking(false);
- Channel.accept()
- Channel.read()
- Channel.write()
非阻塞的执行方式提高了IO操作线程的效率,通过非阻塞,使得当前线程不会没事也被长时间挂起,为当前线程的处理提供了灵活编程空间。但是仔细观察会发现,为了获取某个事件,在程序中会不得不启动一个循环线程,定时的检查连接情况、是否有数据到达等等。下面是两段循环线程的代码样例,
while (channel == null) {
channel = server.accept(); // #1
Thread.sleep(500);
}
while(true) {
int readyChannels = selector.select(); // #2
if(readyChannels == 0) continue;
// read the event keys
Set<SelectionKey> selectedKeys = selector.selectedKeys();
}
第一个:通过定时查询Channel的连接状态
第二个:通过选择器Selector查询Channel的连接和数据读写状态
这种循环线程若配合多线程处理,可以达到非常强大的效果,一个参考实现见后面提供的“非阻塞通信-多子线程异步处理”代码样例,实践过程中也多采用这种方式,通过ServerSocketChannel + Selector + Buffer实现“非阻塞通信-多子线程异步处理”。
但有没有一个办法,使得不再需要起一个循环线程?答案有,在JDK7 NIO 2中引入的三个异步通信通道就实现了这样的需求,
- AsynchronousFileChannel,since 1.7
- AsynchronousSocketChannel,since 1.7
- AsynchronousServerSocketChannel,since 1.7
上述几个异步通信通道在accept/read/write方法上,需要接受一个CompletionHandler回调对象。(注:异步通信通道还可以通过Future方式进行回调,本文为了更好地描述异步实现方式,将不对Future方式进行展开。)
这就是异步回调处理器是作为Java NIO 2的异步通信组件之一,配合异步通信通道实现异步处理机制。异步通信通道执行accept/read/write完毕后,JVM将会启动子线程,根据执行结果,若成功完成就调用对应的回调对象的completed方法,若失败则调用failed方法。
异步回调处理器解决了循环线程检查的问题,但随之而来就是出现各种嵌套回调,大大增加软件编程的复杂度。鱼和熊掌,不可兼得。
下面是一个简单的代码样例,
public static void main(String[] args) throws Exception {
AsynchronousServerSocketChannel server =
AsynchronousServerSocketChannel.open();
server = server.bind(new InetSocketAddress(9000));
OnAcceptCompletionHandler onAcceptCompletion =
new OnAcceptCompletionHandler();
server.accept(null, onAcceptCompletion);
// app continue to run...
Thread.sleep(100000);
server.close();
}
public static class OnAcceptCompletionHandler implements
CompletionHandler<AsynchronousSocketChannel, Object> {
@Override
public void completed(AsynchronousSocketChannel srv, Object attachment) {
System.out.println("Received a new client connection.");
//srv.read(buffer...)
}
@Override
public void failed(Throwable exc, Object attachment) {
System.out.println("Received a failure message on client connection.");
}
}
6. 多线程异步消息通信
为了更加深入的理解Java NIO通信类库,请点击这里查看一个多线程异步消息通信的简单实现。
在代码中实现了如下的消息通信流程,
在ServerSocketChannel通道有消息时,通过线程池启动异步线程处理消息。测试时,可以使用命令telnet localhost 9000同时启动多个客户端和服务端连接,查看消息通信情况。
7. 更多样例:使用Java NIO进行Socket通信
下面提供各种应用样例,实现从阻塞通信、非阻塞通信,到多线程通信、异步通信的各个实现方法,可供参考和调研时使用,
- 阻塞通信,通过ServerSocket,请点击这里查看代码。
- 阻塞通信,通过ServerSocketChannel,请点击这里查看代码。
- 非阻塞通信,通过ServerSocketChannel,请点击这里查看代码。
- 非阻塞通信-主线程处理,通过ServerSocketChannel + Selector + Buffer,请点击这里查看代码。
- 非阻塞通信-多子线程处理,通过ServerSocketChannel + Selector + Buffer,请点击这里查看代码。
- 非阻塞通信-多子线程异步处理,通过ServerSocketChannel + Selector + Buffer,请点击这里查看代码。
- 异步通信,通过AsynchronousServerSocketChannel + CompletionHandler,请点击这里查看代码。
上述样例在JDK 8中编译运行通过,并使用Windows 7 SP1 x64的Telnet工具、使用Ubuntu 16.10中的Telnet工具进行测试通信验证。
可以使用如下方法来运行样例和查看通信过程,
- 启动通信服务端,样例中所有通信服务端将启动在9000端口
- 使用Telnet工具
- 在Windows的Shell命令行中,执行telnet 127.0.0.1 9000命令,连接服务端。成功后,按ctrl+ }键退出telnet交互,然后可以通过send hello,world命令发送消息到服务端。若想关闭连接,可以通过close命令关闭连接。更多telnet详细命令见help。
- 在Linux的Shell命令行中,执行telnet 127.0.0.1 9000命令,连接服务端。成功后,然后可以通过输入hello,world字符,按回车键发送消息到服务端。若想关闭连接,按ctrl+ }键退出telnet交互,然后可以通过close命令关闭连接。更多telnet详细命令见help。
- 启动通信客户端
8. 本文讨论的概念和范围
在本文中提及了阻塞、非阻塞、异步的这三个概念,为了避免歧义,这里有必要对三个概念进行描述,这个描述主要针对其在本文中的定义,以助理解,
- 阻塞:在执行通信的连接、读操作、写操作时,若对方无响应,则当前线程会被挂起
- 非阻塞:在执行通信的连接、读操作、写操作时,若对方无响应,当前线程不会被挂起
- 异步:在执行通信的连接、读操作、写操作时,当前线程的执行不受其影响,当通信操作结束后,JVM会启动子线程以异步回调的方式处理通信结果。
上面的阻塞和非阻塞主要是从是否挂起线程的角度来判断。
本文讨论Java NIO的通信过程,讨论范围主要是Java应用程序和JVM之间的交互,
对于JDK/JVM和各个操作系统内核的IO通信交互实现,将需要另外一篇文章进行深入讨论,这将会涉及到JDK IO的更底层实现细节,也需要对各个操作系统的IO操作进行深入的了解。
9. 参考资料
- Java NIO Tutorial,作者Jakob Jenkov
- IBM DeveloperWorks – NIO入门
- IBM DeveloperWorks – NIO 2.0入门
- JDK-6993126 : (aio) remove AsynchronousDatagramChannel