BIO:Blocking I/O 阻塞IO
NIO:Non-blocking I/O 非阻塞IO
先让我们看一下什么是BIO,在我们平时写好了一个程序去访问计算机硬盘上的资源时,我们发起了这个请求一直阻塞在read方法,在等待的这个过程中当前线程什么事情也做不了。这个就叫阻塞式的IO。阻塞式IO是自jdk1.0发布java.io包就有的。
在JDK1.4之后,又发布了java.nio.***。这个就是同步非阻塞IO,当用户线程发起一个read操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。我们一般从硬盘读取数据分为下面两个步骤。
(1)硬盘数据复制到内核空间
(2)内核空间数据复制到用户空间[java应用程序]
BIO:java.io 同步阻塞IO
发起读请求:(1)和(2)java线程都在等待着
NIO:java.nio 同步非阻塞IO
发起读请求的时候:
(1)java线程不再等待
(2)java线程依然等待
所以NIO并不是全程都是非阻塞的
下图是同步非阻塞模型的数据请求过程。
从上图可以看出前三次调用recvfrom时没有数据可以返回,因此内核立即返回了一个EWOULDBLOCK错误。第四次调用recvfrom已有数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回。可以看出应用进程需要持续轮训内核以查看某个操作是否就绪,但这么做需要耗费大量的CPU时间,因此我们一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。为什么说它是非阻塞的呢?在持续轮训过程中,这个过程是非阻塞的,对应上面的过程(1),而在从内核空间复制数据到用户空间时(对应过程(2))这个过程是阻塞的。
好了,下面我们就来看看java.nio.*使用的IO模型,java.nio.*并不是用的上面同步非阻塞模型,而是用的IO多路复用模型。先解释一下什么是IO多路复用模型?就是通过一种新的系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核kernel能够通知程序进行相应的IO系统调用。当然了,我看完这段话是一脸懵逼!!!(狗头保命),下面我们就图解方式来理解一下这个模型。
先看一下我们常见的堵塞IO是怎么请求工作的。
这个很好理解,为了解决inputstream输入流堵塞的问题通常来一个客户端请求就需要新起一个线程去处理这个请求。好的,现在换成我们的IO多路复用模型看看长什么样子的。
可以看到,socket请求数据不再是以stream流的方式去请求,而是通过建立channel通道的方式,并且将每个channel都注册到一个selector上面,最后通过一个单独的线程去处理注册在selector上面的channel的各种操作(读、写)。数据请求过程见下图。
整个过程阻塞于select调用,等待数据报变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区(这段话可以把下面程序看完再来理解,不然理解起来会有点抽象)。作为对比,我们先看一下BIO请求数据的代码。
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* created with IDEA.
*
* @Author:qzxl
* @Date:2020-08-29
* @Description:IODemo
*/
public class BIOServerThread {
public static void main(String args[]) {
ExecutorService executorService = Executors.newFixedThreadPool(3);
try (ServerSocket serverSocket = new ServerSocket(9999)) {
System.out.println("BIOServer has started,listening on port:" + serverSocket.getLocalSocketAddress());
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("Connection from " + clientSocket.getRemoteSocketAddress());
executorService.submit(new ClientHandler(clientSocket));
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
class ClientHandler implements Runnable {
private final Socket clientSocket;
public ClientHandler(Socket clientSocket) {
this.clientSocket = clientSocket;
}
@Override
public void run() {
try (Scanner input = new Scanner(clientSocket.getInputStream())) {
while (true) {
String request = input.nextLine();
if ("exit".equals(request)) {
break;
}
System.out.println(String.format("From %s:%s", clientSocket.getRemoteSocketAddress(), request));
String response = "\r\n From Server: " + request + "\r\n";
clientSocket.getOutputStream().write(response.getBytes());
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
代码很简单,先是生成了一个固定大小的线程池,然后写了一个循环让serverSocket一直监听9999端口。
- 此时整个过程阻塞在accept处,等待客户端连接。
- 我们在命令行窗口用命令请求这个服务器。
从控制台可以看到有一个客户端已经成功连接上。
3、然后从线程池拿一个线程用于处理此时连接的客户端。
4、此时线程阻塞在getInputStream处,等待数据输入。
5、我们可以再起一个命令行窗口连接服务器,最多可以开三个窗口。
从上面的测试可以看出BIO在处理客户端与服务器端的通信时会发生两处堵塞,并且每处理一个客户端都需要新起一个线程,这大大限制了我们客户端并发的数量…。
下面我们看一下怎样用NIO实现同样的功能。
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
/**
* created with IDEA.
* * @Author:qzxl
* @Date:2020-08-30
* @Description:NIOServer
*/
public class NIOServer {
public static void main(String args[]) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(9999));
System.out.println("NIOServer has started,listening on port:" + serverSocketChannel.getLocalAddress());
//注册客户端过来的连接操作
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//从channel读取数据到buffer中,设置大小为1024字节
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
//不断监听selector中的socket的变化,如果socket需要读写,就需要进行后续的操作
int select = selector.select();
if (select == 0) {
continue;
}
//socket保存在Set集合中
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
//如果当前的socket是可accept
if (key.isAcceptable()) {
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel clientChannel = channel.accept();
System.out.println("Connection from " + clientChannel.getRemoteAddress());
clientChannel.configureBlocking(false);
//和客户端连接成功后将channel设置为read权限,这样selector就可以检测到channel的read状态了
clientChannel.register(selector, SelectionKey.OP_READ);
}
//当前的socket是可read的
if (key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
channel.read(buffer);
buffer.flip();
String request = new String(buffer.array()).trim();
buffer.clear();
System.out.println(String.format("From %s:%s", channel.getRemoteAddress(), request));
String response = "\r\nFrom Server: " + request + "\r\n";
channel.write(ByteBuffer.wrap(response.getBytes()));
}
//需要将已处理过的channel移除,不然会陷入死循环
iterator.remove();
}
}
}
}
在上面代码使用NIO进行通信时我们需要明白三个概念:
-
Channel:客户端与服务器端进行通信的桥梁,就可以想象成IO流中的stream。
-
Buffer:读取数据的载体。我们在操作IO流时可以直接在stream流中读取写入,而在NIO中我们不能直接在channel读取写入数据,我们需要将数据先读取(写入)到buffer中,然后再从buffer中拿数据。
-
Selector:可以把它想象成一个注册中心,所有的客户端要与服务器端都不能直接进行通信,客户端要想进行读写操作需要先将进行的操作请求注册到selector,然后selector轮训到这个客户端channel通道时,发现这个channel有一个请求,然后轮训结束,服务器端开始处理这个channel的请求。
如果能看明白上面三个概念再理解上面代码就简单了。
19-25行: 建立一个服务器端的channel,监听端口9999,创建一个selector并将该selector和accept描述符绑定到该channel上。
30行:selector一直轮训该channel监听有没有客户端请求连接。如果有客户端请求连接,轮训结束,继续执行下面代码。
38-50行:客户端的请求连接都保存在Set中,从Set中取出该连接,并满足key.isAcceptable()
条件(注意,在前面绑定selector时只注册了一个accept事件,因此这里也只能判断客户端的连接请求,读、写事件还没有注册到selector上面,服务端channel还不能检测到除accept之外的其他事件),第49行和客户端连接成功后将channel设置为read权限,这样selector就可以检测到channel的read状态了,执行结束后63行删除此channel,不然会陷入死循环,40行的循环结束。
52-63行:又回到循环开始selector又开始轮训,现在我们在命令行输入数据。
可以看到现在执行的是52处的条件判断,因为当前的socket是可read的,满足该条件,然后处理从客户端发送过来的数据。我们也可以多开几个命令行窗口测试。完全没有任何问题,不用担心线程的限制,与上面一条线程维护一个连接相比,I/O多路复用技术的最大优势是:系统不必创建线程,也不必维护这些线程,从而大大减小了系统的开销。
上面代码中selector.select();实际上我们可以看到它最终调用的是poll0()这个native方法,具体实现是由C实现的,然后查了一下网上大佬对C底层poll函数的解释:poll()函数有一个监听池,我们把要监听的文件描述符以及我们对该描述符感兴趣的条件(读,写等等)放进池子里,然后就等poll()帮我们监听,等poll()正常返回时,就是有描述符发生了变化,我们通过遍历找到这个变化的文件描述符,再去进行相应的操作(读,写等等)即可。这段解释也符合我们上面代码逻辑实现。
实际上,上面三种IO模型都是同步IO,都是阻塞的,如果想进一步提升效率想要完全没有阻塞那就只能将同步IO改成异步IO了,也就是asynchronous I/O,简称为AIO,这个后面再讲…