前言
最近在看Java NIO, 做下记录。操作平台皆基于Windows 10, JDK8, TCP Socket.
记录里面把 普通IO 成为 BIO。
记录提纲:
- Java NIO的设计模式
- Selector的阻塞原理
- ServerSocketChannel的TCP通讯流程
Java NIO 和 BIO的区别
编辑方式的区别
Java BIO是基于流的,一旦收到消息就会立马处理,但是存在等到流收取事件。 NIO是基于channel的,操作系统内核协助获取完毕数据流之后才会交给业务代码处理。
以Socket为例:
普通IO:
//1、创建一个服务器端Socket,即ServerSocket,指定绑定的端口,并监听此端口
ServerSocket serverSocket =newServerSocket(10086);//1024-65535的某个端口
//2、调用accept()方法开始监听,等待客户端的连接
Socket socket = serverSocket.accept();
//3、获取输入流,并读取客户端信息
InputStream is = socket.getInputStream();
...
socket.shutdownInput();//关闭输入流
//4、获取输出流,响应客户端的请求
OutputStream os = socket.getOutputStream();
...
//5、关闭资源
...
serverSocket.close();
NIO:
Selector selector = Selector.open();
// 打开监听信道
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
// 与本地端口绑定
listenerChannel.socket().bind(new InetSocketAddress(12345));
// 设置为非阻塞模式
listenerChannel.configureBlocking(false);
// 将选择器绑定到监听信道,只有非阻塞信道才可以注册选择器.并在注册过程中指出该信道可以进行Accept操作
listenerChannel.register(selector, SelectionKey.OP_ACCEPT);
// 反复循环,等待IO
while (true) {
// 等待某信道就绪(或超时)
if (selector.select(TIME_OUT) == 0) {
continue;
}
// 取得迭代器.selectedKeys()中包含了每个准备好某一I/O操作的信道的SelectionKey
Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator();
while (keyIter.hasNext()) {
SelectionKey key = keyIter.next();
// 处理信息
dealWith(key);
// 移除处理过的键
keyIter.remove();
}
}
可以看出, BIO需要获取到输入输出流,阻塞性的进行输入输出操作。
而NIO则使用了Selector,这个Selector可以绑定多个 Chanel, 例如实现TCP、UDP、File的操作,灵活性更高。
BIO的通讯模型
对于BIO而已,不同的IO类型处理的事件是不一样的,比如写TCP处理就必须使用ServerSocket
, 读写文件处理则必须使用InputStream/OutputStream等。
由于BIO的通讯模型中所有的操作都是阻塞式的,因此为了实现对多客户端的处理,则必须频繁的创建大量的线程来针对每一个连接进行处理。
BIO通讯模型在 少量请求、高数据流 的情况下处理非常有效。 但是针对于大量请求、低数据流的情况, 就面临着这样的问题:
- 大量客户端连接,则需要建立大量的线程处理。线程的创建、销毁、上下文切换开销巨大。
- 流式读取, 每次从内核空间将字节拷贝到线程空间,处理完毕之后再交给内核空间取发送,有IO开销。
- 阻塞式IO, 在网络环境较差的情况下,线程的
read()
/write()
方法是阻塞的,线程周期长,线程调度会占据较多时间。 - 如上述,如果在流式读取中,每次
read()
或者write()
还做一些其它的业务操作,线程调度将会占据更多的时间。 - 在使用线程池的情况下,高并发,低数据量的IO请求,可能导致内核空间大量请求堆积。
NIO的通讯模型刚好就解决掉了上述的问题。
NIO的通讯模型
NIO面向事件而设计。由 Selector
进行监听和分发。 每一个处理Chanel在向Selector
注册的时候必须选定监听的事件。
图片来源:Java NIO:浅析I/O模型
具体流程可以浅析为:
- 各种Chanel(TCP/UDP/File)向
Selector
注册(一般只会写一种类型,否则代码的可编辑性很低)。 Selector
根据不同的内核空间事件通知对不同的Channel进行调度。- Channel的处理面向缓冲区,通过建立堆外内存将待处理数据写入缓冲区内。
- 缓冲区写结束了之后Channel才会继续处理。
Selector
在这一系列操作中并不阻塞。
Selector 的工作模式
Selector的工作流程图
Selector除了在获取内核通知的时候,其它时候都不阻塞。见文章:Java NIO——Selector机制源码分析—转
下图是Selector调用内部类
SubSelector
的调用时序图。其中poll0()
方法是一个本地方法。它的作用是在一个等待时间段里面,如果有注册好的Channel的IO事件则立即返回,否则等到等待时间之后返回。
如上所述, 在poll0() 中会将当前等待时间段里面所监听到的所有的IO句柄进行归类分集,并根据数量的不同生成不同数量的线程,生成SelectKey
再交给业务代码处理。需要注意的是, 在没有使用 独立线程/线程池 去处理每个SelectKey的情况下, NIO是依然同步的
通用的NIO Socket的编写方式
通常的NIO Socket会这么编写:
{
Selector selector = Selector.open();
// 打开监听信道 目前只做TCP支持
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
// 与本地端口绑定
listenerChannel.socket().bind(new InetSocketAddress(ListenPort));
// 设置为非阻塞模式
listenerChannel.configureBlocking(false);
// 将选择器绑定到监听信道,只有非阻塞信道才可以注册选择器.并在注册过程中指出该信道可以进行Accept操作
listenerChannel.register(selector, SelectionKey.OP_ACCEPT);
// 反复循环,等待IO
while (true) {
// 等待某信道就绪(或超时)
if (selector.select(TIME_OUT) == 0) {
continue;
}
// 取得迭代器.selectedKeys()中包含了每个准备好某一I/O操作的信道的SelectionKey
Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator();
while (keyIter.hasNext()) {
SelectionKey key = keyIter.next();
try {
if (key.isAcceptable()) {
/*clientChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(bufferSize));*/
protocol.handleAccept(key);
}
if (key.isReadable()) {
/*key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);*/
protocol.handleRead(key);
}
if (key.isValid() && key.isWritable()) {
protocol.handleWrite(key);
}
} catch (IOException ex) {
}
// 移除处理过的键
keyIter.remove();
}
}
}
可是, 这么编写的原因是什么呢?
首先, ServerSocketChannel
是只支持SelectionKey.OP_ACCEPT
的(见ServerSocketChannel#validOps
)。这样的设计就是专为TCP连接而设定的。
其次,对于SocketChannel
,它支持SelectionKey.OP_READ | SelectionKey.OP_WRITE | SelectionKey.OP_CONNECT。 这种设计就表示它专门用于处理ServerSocketChannel
产生的连接信息,然后对其进行读写操作。
再次,因为Selector
在最初注册的时候, 根本不知道有没有链接会到来,会在什么时候到来。 因此必须由代码手动注册SocketChannel
给Selector
, 它才知道对每个不同的SocketChannel
进行处理(注意Selector
本身是没有显式的提供移除Channel
功能的)。
继续,如果在key.isAcceptable()
代码块里没有做SelectionKey.OP_READ
, 将会导致key.isReadable()
返回值为false。 同样的,对于一个既定的Channel而言,想要修改它在Selector
上注册的操作类型,需要调用key.interestOps()
方法去修改。它会让Selector
重新生成一条事件通知。
最后,NIO Servlet用于处理事件处理的线程只有一个。其包含不限个数(一般是3~5个)的子线程,专用于从操作系统获取事件信息及数据发送。
如上。 普通NIO的编写模式大多大同小异。都是通过
ServerSocketChannel
获取SocketChannel
,并向Selector
注册。 然后由独立的业务代码进行处理。 可以理解为用一个线程去处理所有的请求。 如果想要使用多线程去处理, 可以使用多个Selector, 再配合线程池。 这样效率会有大幅度的提升(需要注意每个Selector都会向自己注册的Channel发起通知,业务代码需要判断“SocketChannel是否已经被其它代码处理了)。
Selector 在 SocketChannel 上的通讯流程
NIO 与 BIO 的比较
NIO和BIO是同源的,且NIO也是基于BIO的。
BIO的几个问题,NIO在技术层面上做了规避。可以把 NIO理解为BIO的聚合,把IO操作交给了操作系统去做。
阻塞性对比:
NIO的不阻塞在于: NIO是等到数据拷贝已经完毕之后才会对数据进行处理。
BIO的阻塞在于:BIO对于每次的数据请求都会立即处理。
由此,NIO可以更快的处理更多的数据,之所以说它IO不阻塞,是因为它处理IO的时候IO都还没有完成,而操作系统的IO性能是高于JVM的; BIO可以处理少量但是更大的数据,因为它是流式的。
同异步对比:
二者皆支持同步/异步模式。 默认没有使用 线程/线程池 的情况下, 二者的编写方式都是同步的。
内存消耗对比:
BIO使用的堆内存,只要每次buffer不要设置太大,更多的是IO上、线程调度的性能考量。
NIO可以使用堆外内存,分批次将数据读取至缓冲区,需要注意连接的数量和每次缓冲区的大小。
NIO的Socket通讯时序图
依据以上描述, 补充时序图如下: