不管你平时是否接触大量的 IO 网络编程,IO 模型都是高级 Java 工程师面试非常高频的一道题。你了解 Java 的 IO 模型吗?多路复用技术是什么?
在了解 Java IO 模型之前,我们先来明确几个概念,初学者通常会被如下几个概念给误导:
同步和异步
同步指的是当程序在做一个任务的时候,必须做完当前任务才能继续做下一个任务,这是一种可靠有序的运行机制,假设当前任务执行失败了,可能就不会进行下一个任务了,往往在一些有依赖性的任务之间,我们使用同步机制。而异步恰恰相反,它不能保证有序性。程序在提交当前任务后,并不会等待任务结果,而是直接进行下一个任务,通常在一些任务之间没有依赖性关系的时候可以使用异步机制。
这么说可能还是有点抽象,我们举个例子来说吧。假设有 4 个数字 a, b, c, d,我们需要计算它们连续相除的结果。那么可以写这样一个函数:
public static int divide(int paraA, int paraB) {
return paraA / paraB;
}
如上即为我们的方法,假设我们使用同步机制去做,程序会写成类似如下这样:
int tmp1 = divide(a, b);
int tmp2 = divide(tmp1, c);
int result = divide(tmp2, d);
此处假如我们定义了 4 个数字的值为如下:
int a = 1;
int b = 0;
int c = 1;
int d = 1;
这时候我们编写的同步机制的程序,tmp2 的计算需要依赖于 tmp1,result 又依赖于 tmp2,事实上计算 tmp1 的值时候即会发生除数为的 0 的异常 ArithmeticException。
我们也可以通过多线程来将这个程序转换为异步机制的方式去做,如下(我们不考虑整数进位造成的结果不同问题):
Callable cA = () -> divide(a, b);
FutureTask taskA = new FutureTask<>(cA);
new Thread(taskA).start();
Callable cB = () -> divide(c, d);
FutureTask taskB = new FutureTask<>(cB);
new Thread(taskB).start();
int tResult = taskA.get() / taskB.get();
如上我们使用多线程将同步的运作的程序修改为了异步,先去同时计算 a / b 和 b / c 的结果,它俩直接没有相互依赖,taskB 不会等待 taskA 的结果,taskA 出现 ArithmeticException 也不会影响 taskB 的运行。
这就是同步与异步,你 get 到了吗?
阻塞和非阻塞
阻塞指的是当前线程在执行运算的时候会阻塞直到预期的结果出现后,线程才可以继续进行后续的操作。而非阻塞则是在执行某项操作后直接返回,无论结果是什么。是不是还有点抽象,我们来举个例子。改造一下上面的 divide 方法,将 divide 方法改造为会阻塞的方法:
public synchronized int blockingDivide(int paraA, int paraB) throws InterruptedException {
synchronized (SyncOrAsyncDemo.class) {
wait(5000);
return paraA / paraB;
}
}
如上,我们将 divide 方法修改为了一个会阻塞的方法,当我们的主线程去调用 blockingDivide 方法的时候,该方法会将当前线程阻塞直到方法运行结束。我们也可以使用多线程和回调将该方法修改为一个非阻塞方法:
public synchronized void nonBlockingDivide(int paraA, int paraB, Callback callback) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (SyncOrAsyncDemo.class) {
try {
wait(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
callback.handleResult(paraA / paraB);
}
}
}).start();
}
如上,我们将业务逻辑包装到了一个单独的线程中,执行结束后调用主线程设置好的回调函数即可。而主线程在调用该方法时不会阻塞,会直接返回结果,然后进行接下来的操作,这就是非阻塞机制。
弄清楚这几个概念以后,让我们一起来看看 Java IO 的几种模型吧。
Blocking IO(同步阻塞 IO)
在 Java 1.0 时代 JDK 提供了面向 Stream 流的同步阻塞式 IO 模型的实现,让我们用一段伪代码实际感受一下:
try (ServerSocket serverSocket = new ServerSocket(8888)) {
while (true) {
Socket socket = serverSocket.accept();
// 提交到线程池处理后续的任务
executorService.submit(new ProcessRequest(socket));
}
} catch (Exception e) {
e.printStackTrace();
}
我们在一个死循环里面调用了 ServerSocket 的阻塞方法 accept 方法,该方法调用后会阻塞直到有客户端连接过来。如果此时有客户端连接了,任务继续进行,我们此处将连接后的处理放在了线程池中去处理。接着我们模拟一个读取客户端内容的逻辑,也就是 ProcessRequest 的内在逻辑:
try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
int ch;
while ((ch = reader.read()) != -1) {
System.out.print((char)ch);
}
} catch (Exception e) {
e.printStackTrace();
}
我们采用 BufferedReader 读取来自客户端的内容,调用 read 方法后,服务器端线程会一直阻塞直至收到客户端发送的内容过来,这就是 Java 1.0 提供的同步阻塞式 IO 模型。
Non-Blocking IO(同步非阻塞 IO)
在 Java 1.4 时代 JDK 为我们提供了面 Channel 的同步非阻塞的 IO 模型实现,同样以一段伪代码来展示:
try {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 8888));
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
executorService.execute(new ProcessChannel(socketChannel));
}
} catch (Exception e) {
e.printStackTrace();
}
默认情况下 ServerSocketChannel 采用的阻塞方式,调用 accept 方法会阻塞直到有客户端连接过来,通过 Channel 的 read 方法获取管道里面的内容时候同样会阻塞直到客户端有内容输入到服务器端:
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
try {
if (socketChannel.read(buffer) != -1) {
// do something
}
} catch (IOException e) {
e.printStackTrace();
}
}
这时候我们可以调用 configureBlocking 方法,将管道设置为非阻塞模式:
serverSocketChannel.configureBlocking(false);
这个时候调用 accept 方法就是非阻塞方式了,它会立即返回结果,但是返回结果有可能是 null,所以我们做额外的判断处理,如:
if (socketChannel == null) continue;
你需要注意的是此时调用 Channel 的 read 方法仍然会阻塞当前线程知道有客户端有结果返回,不是说非阻塞吗,怎么还是阻塞呢?是时候亮出大杀器 Selector 了。
Selector 多路复用器可以让阻塞 IO 变得更加灵活,注意注册 Selector 必须将 Channel 设置为非阻塞模式:
/**省略部分相同的代码**/
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Set keys = selector.selectedKeys();
Iterator iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove();
if (key.isAcceptable()) {
SocketChannel socketChannel = ((ServerSocketChannel)key.channel()).accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
if (channel.read(buffer) != -1) {
buffer.flip();
System.out.println(Charset.forName("utf-8").decode(buffer));
}
key.cancel();
}
}
}
使用了 Selector 以后,我们会使用它的 select 方法来阻塞当前线程直到监听到操作系统的 IO 就绪事件,这里首先设置了 SelectionKey.OP_ACCEPT,当 select 方法返回时候代表 accept 已经就绪,服务器端与客户端可以正式连接,这时候的连接操作会立即返回属于非阻塞操作。
当与客户端建立连接后,我们关注的是 SelectionKey.OP_READ 事件,伪代码中使用
socketChannel.register(selector, SelectionKey.OP_READ)
注册了这个事件,当 select 方法再次返回时候代表 IO 目前已经到达可读状态,可以直接调用 channel.read(buffer) 来读取客户端发送过来的内容,这时候的 read 方法同样是一个非阻塞的操作。
如上就是 Java 1.4 为我们提供的非阻塞 IO 模式加上 Selector 多路复用技术,从而摆脱一个客户端连接占用一个线程资源的窘境,此处只有 select 方法阻塞,其余方法都是非阻塞运作。
虽然多路复用技术在性能上带来了提升,但是你也看到了。非阻塞编程相对于阻塞模式的代码段变得更加复杂了,而且还需要处理 NPE 问题。
Async Non-Blocking IO(异步非阻塞 IO)
Java 1.7 时代推出了异步非阻塞的 IO 模型,同样以一段伪代码来展示一下:
AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel
.open()
.bind(new InetSocketAddress(8888));
serverChannel.accept(serverChannel, new CompletionHandler() {
@Override
public void completed(AsynchronousSocketChannel result, AsynchronousServerSocketChannel attachment) {
serverChannel.accept(serverChannel, this);
ByteBuffer buffer = ByteBuffer.allocate(1024);
/**连接客户端成功后注册 read 事件**/
result.read(buffer, buffer, new CompletionHandler() {
@Override
public void completed(Integer result, ByteBuffer attachment) {
/**IO 可读事件出现的时候,读取客户端发送过来的内容**/
attachment.flip();
System.out.println(Charset.forName("utf-8").decode(attachment));
}
/**省略无关紧要的方法**/
});
}
/**省略无关紧要的方法**/
});
你会发现异步非阻塞的代码量很少,而且AsynchronousServerSocketChannel 的 accept 方法使用后完全不会阻塞我们的主线程。主线程继续做后续的事情,在回调方法里面处理 IO 就绪事件后面的流程,这与前面介绍的 2 种同步 IO 模型编程思想上有比较大的区别。
想必通过开头介绍的几个概念你已经可以想到这款异步非阻塞的 IO 模型背后的实现原理了,无非就是 JDK 帮助我们启动了单独的线程,将同步的 IO 操作转换为了异步的 IO 操作,同时利用操作的 IO 事件模型,将阻塞的方法转换为了非阻塞的方法。
当然啦,NIO 为我们提供的也不仅仅是 Selector 多路复用技术,还有一些其他黑科技我们没有提到,感兴趣的话欢迎关注我等待后续的内容。
如上就是 Java 给我们提供的三种 IO 模型,通过我们一起探讨,你现在是不是已经掌握了它们之间的区别呢?欢迎留言与我讨论。
以上即为昨天的问题的答案,小伙伴们对这个答案是否满意呢?欢迎留言和我讨论。
又要到年末了,你是不是又悄咪咪的开始看机会啦。为了广大小伙伴能充足电量,能顺利通过 BAT 的面试官无情三连炮,我特意推出大型刷题节目。每天一道题目,第二天给答案,前一天给小伙伴们独立思考的机会。