目录
1 前言
学习Java NIO这个知识点花费了几天时间,通过阅读大量的译文和博客文章,终于有点头绪了,虽说不是深入了解,但好歹是明白BIO、NIO、AIO这几种模型之间的区别,附带着了解了Java NIO的相关类源码,也算是有点收获。这里总结一下自己所了解的IO模型,以及它们在Java NIO中的应用。
2 五种网络IO模型
以linux系统为例,分别是阻塞IO模型(BIO)、非阻塞IO模型(NIO)、多路复用IO模型、异步IO模型(AIO)、信号驱动IO模型。对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
- 等待数据在内核中准备就绪
- 将数据从内核拷贝到用户进程中
2.1 阻塞IO模型(BIO)
当进程发起read请求后,产生了一条系统调用:recvfrom,之后进程便陷入了阻塞状态直到内核return OK。内核中经历了 数据未就绪——>数据准备就绪——>将数据从内核拷贝到进程空间中——>return ok 这个过程,所以,阻塞IO模型的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。同理,进程发起的write请求也会阻塞,直到所有的数据都被写入到内核中。
在阻塞IO模型下,socket通信的server同一时刻只能处理一个客户端的连接请求,在多用户连接的场景下会导致其他用户连接等待,所以都会采用“一连接一线程”的方式来处理,server每accept一个connect,就会new一个线程去单独处理这个连接。为了避免过多的创建线程(线程会占用系统资源,同时线程的切换代价也比较大),可以使用线程池去管理,复用已创建的线程资源,减少线程的创建个数。
2.2 非阻塞IO(NIO)
当进程发起read请求后,若内核中数据未准备就绪,不会阻塞。内核会返回一个标志,进程通过这个标志可以知道数据未就绪,从而可以做其他事情,在后续操作中,进程可以不断地发起read请求来判断数据是否就绪。若内核中数据就绪,进程会阻塞在本次read请求操作上,直到将数据从内核复制到进程空间中并return OK。write请求也类似,当进程执行write操作时,操作会立刻返回,可以通过内核返回的标志判断数据是否完全写入。
所以,该模型在等待数据就绪阶段是非阻塞的,在数据从内核复制到进程中时是阻塞的。非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。
在非阻塞IO模型中,可以只用一个线程来管理多个连接,即一个server线程在accept多个连接后,循环对这些连接执行read操作,若当前遍历连接数据已就绪,则读取数据,然后继续遍历。但这种模式决不被推荐,因为,循环调用recv()将大幅度推高CPU 占用率;此外,在这个方案中recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如select()多路复用模式,可以一次检测多个连接是否活跃。
2.3 多路复用IO模型
它的基本原理就是Linux中select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。从图中可以看到,这次不需要我们主动执行read操作了,而是进程首先调用select函数,调用select之后进程会阻塞,直到内核中数据准备就绪返回标志通知进程,此时进程从阻塞中回复,直接执行read操作读取数据,阻塞直到return OK。
多路复用IO模型在检测连接数据是否就绪和数据复制过程中都会阻塞。
显然,多路复用IO模型比非阻塞IO模型更适合一个线程管理多个连接的场景,因为使用select函数来判断数据是否准备就绪比主动执行recvfrom来轮训具有更好的性能,判断直接发生在内核空间,省去了大量的recvfrom系统调用。
注意:
- 如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对於单个连接能处理得更快,而是在于能处理更多的连接。
- 该模型将数据探测和事件相应夹杂在一起,一旦某个连接的事件执行体庞大,则会大大推迟其它连接的数据探测和事件执行。
2.4 异步IO(AIO)
从图中可以看到,当进程请求一个aio_read系统调用后就直接返回,接下来在内核中自动完成数据的准备的复制工作,之后内核给进程发送一个信号,通知它数据准备完毕,然后进程执行read操作读取即可。
异步模型全程非阻塞,数据的准备工作都在内核中异步完成。
异步IO是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。
2.5 信号驱动IO模型
信号驱动IO,调用sigaltion系统调用,当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。
3 Java NIO与IO的区别
IO | NIO |
面向流 | 面向缓冲区 |
阻塞IO | 非阻塞IO |
无选择器 | 有选择器 |
3.1 面向流与面向缓冲区
Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。
Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
3.2 阻塞与非阻塞IO
参考上面提到的阻塞IO模型与非阻塞IO模型。
Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
4 Java NIO与IO模型
Java NIO是阻塞IO模型、非阻塞IO模型和多路复用IO模型的组合体,可以单独实现这三种模型中的任何一种,使用最多的是多路复用IO模型。使用的组件包括channel、buffer和selector,这里不介绍了。下面简单使用Java NIO代码体现这三种模型的socket通信。
4.1 Java NIO实现阻塞IO模型
显然,如同Java IO一样,这里socketChannel.read(buf)会阻塞直到内核空间中数据准备和复制完毕,然后将数据读到缓冲区。
// client创建通道并建立连接
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8888));
// 创建缓冲区并从通道中读数据到缓冲区中
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);
socketChannel.close();
4.2 Java NIO实现非阻塞IO模型
通过socketChannel.configureBlocking(false);可以将通道设置为非阻塞模式,该模式下read方法执行候会直接返回,通过返回的int值可以判断是否有数据可读(0)、读到了多少字节数据(>0)、是否读完了数据(-1)。
// client创建通道并建立连接
SocketChannel socketChannel = SocketChannel.open();
// #############这里配置非阻塞模式###################
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8888));
// 创建缓冲区并从通道中读数据到缓冲区中
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf); // bytesRead为读到的字节个数
byte[] bytes = new byte[Integer.MAX_VALUE];
// bytesRead = -1表示读到了尾部
while (bytesRead != -1){
if (bytesRead == 0){
// 没有数据可读
// do something else
}
// 从缓冲区中取出读到的数据
buf.flip();
buf.get(bytes);
buf.clear();
bytesRead = socketChannel.read(buf);
}
// 打印结果
System.out.println(new String(bytes));
socketChannel.close();
4.3 Java NIO实现多路复用IO模型
通过selector选择器,可以让一个server线程管理大量建立的连接,阅读下面代码你就会发现设计的精妙之处。
public class Server implements Runnable{
//1 多路复用器(管理所有的通道)
private Selector seletor;
//2 建立缓冲区
private ByteBuffer readBuf = ByteBuffer.allocate(1024);
private ByteBuffer writeBuf = ByteBuffer.allocate(1024);
public Server(int port){
try {
//1 打开路复用器
this.seletor = Selector.open();
//2 打开服务器通道
ServerSocketChannel ssc = ServerSocketChannel.open();
//3 设置服务器通道为非阻塞模式
ssc.configureBlocking(false);
//4 绑定地址
ssc.bind(new InetSocketAddress(port));
//5 把服务器通道注册到多路复用器上,并且监听连接事件
ssc.register(this.seletor, SelectionKey.OP_ACCEPT);
System.out.println("Server start, port :" + port);
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
while(true){
try {
//1 必须要让多路复用器开始监听
this.seletor.select();
//2 返回多路复用器已经选择的结果集
Iterator<SelectionKey> keys = this.seletor.selectedKeys().iterator();
//3 进行遍历
while(keys.hasNext()){
//4 获取一个选择的元素
SelectionKey key = keys.next();
//5 直接从容器中移除就可以了
keys.remove();
//6 如果是有效的
if(key.isValid()){
//7 如果为阻塞状态
if(key.isAcceptable()){
this.accept(key);
}
//8 如果为可读状态
if(key.isReadable()){
this.read(key);
}
//9 写数据
if(key.isWritable()){
this.write(key);
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
private void write(SelectionKey key){}
private void read(SelectionKey key) {
try {
//1 清空缓冲区旧的数据
this.readBuf.clear();
//2 获取之前注册的socket通道对象
SocketChannel sc = (SocketChannel) key.channel();
//3 读取数据
int count = sc.read(this.readBuf);
//4 如果没有数据
if(count == -1){
key.channel().close();
key.cancel();
return;
}
//5 有数据则进行读取 读取之前需要进行复位方法(把position 和limit进行复位)
this.readBuf.flip();
//6 根据缓冲区的数据长度创建相应大小的byte数组,接收缓冲区的数据
byte[] bytes = new byte[this.readBuf.remaining()];
//7 接收缓冲区数据
this.readBuf.get(bytes);
//8 打印结果
String body = new String(bytes).trim();
System.out.println("Server : " + body);
// 9..可以写回给客户端数据
} catch (IOException e) {
e.printStackTrace();
}
}
private void accept(SelectionKey key) {
try {
//1 获取服务通道
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
//2 执行阻塞方法
SocketChannel sc = ssc.accept();
//3 设置阻塞模式
sc.configureBlocking(false);
//4 注册到多路复用器上,并设置读取标识
sc.register(this.seletor, SelectionKey.OP_READ);
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
new Thread(new Server(7788)).start();;
}
}
参考:
http://ifeve.com/socket-channel/
http://ifeve.com/java-nio-vs-io/