上期结合程序员小猿用温奶器给孩子热奶的故事,把面试中常聊的“同步、异步与阻塞、非阻塞有啥区别”简单进行普及。
不过,恰逢春节即将到来,应个景,不妨就通过实现新春送祝福的需求,深入了解一下 Java IO。
01. 全局视角:会当凌绝顶,一览众山小
IO 是 Input、Output 的缩写,翻译过来就是输入、输出。
在业务设计而言,输入输出主要是指 API 接口的规范定义,因为只要定义好输入输出,其它都是时间的问题;而在程序设计而言,主要是指磁盘 IO 以及网络 IO(个人愚论)。
在 Java 中 IO 模型,主要细分为 BIO(同步阻塞 )、NIO(同步非阻塞)、AIO(异步非阻塞 )。莫要怕,一切反动派都是纸老虎,下面就逐个击破。
02. 逐个击破:一切反动派都是纸老虎
一切从传统的 IO 开始。
BIO 是同步阻塞式的 IO,在 Java 中主要是指文件读写磁盘 IO 以及网络通信 IO,是指平常用的 java.io、java.net 这两个包。
喂,基本功扎实吗?
java.io 包的熟练应用,是 Java 程序员基本功之所在,在业务研发中用的比较多,例如用户信息文件同步、数据报表、对账等。
其中 IO 按照数据流向,分为输入流、输出流;按照处理数据的类型不同,细分为字节流、字符流。抛几张类图,补补基础,并体会一下背后的装饰器模式,一定要好好体会,因为面试过程中,会变着花样的问你。
以 InputStream 为根的字节输入流,提供从流中读取 Byte 数据的能力。
以 OutputStream 为根的字节输出流,提供向流中写出 Byte 数据的能力。
以 Reader 为根的字符输入流,提供了读取文本数据的编码支持。
以 Writer 为根的字符输出流,提供了写入文本数据的能力。
无论是字节流还是字符流的设计,背后都透漏了一个装饰器设计模式。假想有一款这样的咖啡机,默认咖啡机里装的是白开水,外面套一根咖啡的管道,就变成了苦咖啡;外面套一根牛奶的管道,你变成了牛奶咖啡;如若套一根美酒的管道,那就是美酒加咖啡。IO 流亦如此,经过一道道装饰后功能逐渐扩展(这也是面试中,我常问候选人的一个问题)。
这块不多说,因为是程序员最基本的能力,因此最好能把常用的 API 操作集成到一起,进而形成自己的 IOUtils 工具类,丰富一下自己的百宝箱,这样业务研发中方能得心应手。
喂,新春祝福收到了吗?
春节快到了,应个景,索性就使用 java.io + java.net 包提供的 API,搭建一个送新春祝福的服务,顺道给各位拜个早年。
如图所示,需求很简单,当客户端连接到服务端时,能够收到服务端发来的新春祝福。接下来,就快速编写新春送祝福的服务端代码。
import java.io.*;
import java.net.*; /**
* 新春送祝福 服务端
* @author 一猿小讲
*/
public class BlessingServer { public static void main(String[] args) {
ServerSocket serverSocket = null;
Socket socket = null; BufferedReader reader = null;
BufferedWriter writer = null; try {
String[] blessingWords = {"2020 新春快乐", "2020 恭喜发财", "2020 家和万事兴"};
System.out.println("我是服务端,新春送祝福已准备就绪^_^"); serverSocket = new ServerSocket(8888); while(true) {
System.out.println("关注点(一):serverSocket.accept() 是阻塞的吗?【是】");
socket = serverSocket.accept(); // 获取输入流
reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
System.out.println("关注点(二):reader.readLine() 是阻塞的吗?【是】");
String clientId = reader.readLine();
System.out.println("==========> 客户端发来的信息为【" + clientId + "】"); // 获取输出流
writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String blessingWord = blessingWords[(int) (System.currentTimeMillis() % blessingWords.length)];
System.out.println("关注点(三):writer.write() 是阻塞的吗?【是】");
writer.write(blessingWord + "\n");
writer.flush();
System.out.println("==========> 向" + clientId + "发去春节的问候:【" + blessingWord + "】");
System.out.println("@@@@@@@@@@@华丽的分割线@@@@@@@@@@@@@");
}
} catch (IOException e) {
} finally {
if (reader != null) try { reader.close(); } catch (IOException e) { }
if (writer != null) try { writer.close(); } catch (IOException e) { }
if (socket != null) try { socket.close(); } catch (IOException e) { }
if (serverSocket != null) try { serverSocket.close(); } catch (IOException e) { }
}
}
}
编写完服务端代码,再快速编写新春送祝福的客户端代码。
import java.io.*;
import java.net.Socket; /**
* 新春送祝福 客户端
* @author 一猿小讲
*/
public class BlessingClient { public static void main(String[] args){ Socket socket = null;
BufferedReader reader = null;
BufferedWriter writer = null; try{
socket = new Socket("127.0.0.1", 8888); // 故意休眠 10 秒,是为了验证服务端的关注点(二),你能 get 到我的想法吗?
Thread.sleep(10000);
writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
writer.write("客户端" + System.currentTimeMillis() + "\n");
writer.flush(); // 获取服务端送来的祝福
reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String blessingWord = reader.readLine();
System.out.println("收到的新春祝福是【"+blessingWord+"】");
}catch(Exception e){ //handle exception
e.printStackTrace();
}finally{
if(writer != null) try{ writer.close();}catch(IOException e){}
if(reader != null) try{ reader.close();}catch(IOException e){}
if(socket != null) try{ socket.close(); }catch(IOException e){}
}
}
}
是骡子是马,总要牵出来溜溜,也是检验效果的时候啦。首先,运行新春送祝福的服务端,控制台打印如下。
我是服务端,新春送祝福已准备就绪^_^
关注点(一):serverSocket.accept() 是阻塞的吗?【是】
然后,运行新春送祝福的客户端,此时服务端控制台打印又多了些内容。
我是服务端,新春送祝福已准备就绪^_^
关注点(一):serverSocket.accept() 是阻塞的吗?【是】
关注点(二):reader.readLine() 是阻塞的吗?【是】
过了大概 10 秒左右,此时控制台又多了些内容。
我是服务端,新春送祝福已准备就绪^_^
关注点(一):serverSocket.accept() 是阻塞的吗?【是】
关注点(二):reader.readLine() 是阻塞的吗?【是】
==========> 客户端发来的信息为【客户端【1578754253173】】
关注点(三):writer.write() 是阻塞的吗?【是】
==========> 向客户端【1578754253173】发去春节的问候:【2020 恭喜发财】
@@@@@@@@@@@华丽的分割线@@@@@@@@@@@@@
最后客户端收到的新春祝福,控制台输出如下(切记,祝福语是随机发送的呦)。
收到的新春祝福是【2020 恭喜发财】
好了,程序运行完了,通过这个实践,哪些是同步阻塞式的 API,你肯定心中已有数。
但是,聪明的小伙伴会发现,无论开启多少个客户端,服务端都是顺序发送祝福,为前一个客户端服务完成后,才能为另一个客户端送去祝福,你说这是不是有点扯淡?!
看来,服务端代码还是要改进改进啊。为了观察方便,本次把关注点的相关注释全部去掉,而且为了效果更明显,我让服务端休眠了 10 秒,服务端代码重构如下。
import java.io.*;
import java.net.*; /**
* 新春送祝福 服务端
* @author 一猿小讲
*/
public class BlessingServer { public static void main(String[] args) {
ServerSocket serverSocket = null;
Socket socket = null;
try {
System.out.println("我是服务端,新春送祝福已准备就绪^_^");
serverSocket = new ServerSocket(8888);
while(true) {
socket = serverSocket.accept();
new Thread(new BlessingHandle(socket)).start();
}
} catch (IOException e) { // TODO 异常处理
} finally { // TODO 释放资源
}
}
} /**
* 新春祝福业务逻辑处理
* @author 一猿小讲
*/
class BlessingHandle implements Runnable { private Socket socket; /**新春祝福语*/
public static final String[] BLESSING_WORDS = {"2020 新春快乐", "2020 恭喜发财", "2020 家和万事兴"}; public BlessingHandle(Socket socket) {
this.socket = socket;
} @Override
public void run() {
BufferedReader reader = null;
BufferedWriter writer = null;
try {
// 获取输入流
reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String clientId = reader.readLine();
System.out.println("==========> 客户端发来的信息为【" + clientId + "】"); // TODO 此处我故意休眠了 10 秒,为了能让你看出并发处理的效果,生产上可不要乱搞呦,不然会死的很惨!
Thread.sleep(10000); // 获取输出流
writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String blessingWord = BLESSING_WORDS[(int) (System.currentTimeMillis() % BLESSING_WORDS.length)];
writer.write(blessingWord + "\n");
writer.flush();
System.out.println("==========> 向" + clientId + "发去春节的问候:【" + blessingWord + "】");
System.out.println("@@@@@@@@@@@华丽的分割线@@@@@@@@@@@@@");
} catch (Exception e){ // TODO 异常处理
} finally { // TODO 释放资源
}
}
}
此时服务端送祝福变成了多线程的方式,不过用多线程重构之后,新春送祝福的服务端,确实能提供并发处理啦。
新春祝福送出去了,需求实现了,到这算完事了吗?微信创始人张小龙,在公开课中提到「要做一个深入的思考者,永远都要有自己的思考」。
那么回过头来,再思考思考。如上图所示,会发现在客户端访问过多的情况下,服务端需要频繁的启动、销毁线程,那么势必会有性能上的开销;另外,线程数过多,也有可能会拖垮服务器。
所以 new Thread(new BlessingHandle(socket)).start(); 这句代码有优化的空间,引入线程池稍微重构一下,在原有代码之上改动如下。
到这儿,我们通过引入线程池,来管理工作线程的数量,进而避免频繁创建、销毁线程带来的开销,在实际研发中若是并发量较小的应用,这种设计已经足矣。
但是,恰恰由于线程池限制了线程的数量,在高并发场景下,请求超过线程池的最大数量时,那么就只能等待,直到线程池中的有空闲的线程才可以被复用。那么,在网络较差、传输较大文件时,是不是就出现了链接超时?!这或许就是 BIO(同步阻塞) 的劣势吧,那该怎么办呢?
不妨去拜访一下 Java NIO。
java.nio 全称 java non-blocking IO,从 Java 1.4 开始,Java 就提供了一系列改进传统 IO 的新特性,所以这些功能被称之为新 IO,也就是 New IO。新增的许多用于输入输出的类,都放在了 java.nio 包下。
根据脑图,对 Java NIO 中重要概念先混个脸熟。概念本次不做深入讲解,感兴趣的可以自行去延展学习。
理论知识不多说,我们继续回到新春送祝福需求,接下来不局限于服务端自嗨,能否让客户端也嗨起来,都能互相送祝福,实现一个群聊版的新春送祝福。
服务端需求是很简单,能够让客户端进行连接,连接成功后能够向客户端发送欢迎语;并能够读取客户端发来的祝福语,然后群发给各个客户端。
明确了需求,那采用 NIO 的思想快速撸完新春送祝福的群聊版的服务端代码(代码拿去跑两遍,啥都懂啦,不信你试试)。
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset; /**
* 新春送祝福群聊 服务端
*
* @author 一猿小讲
*/
public class BlessingNIOServer { // 定义实现编码、解码的字符集对象
private Charset charset = Charset.forName("UTF-8"); /** 欢迎语 */
private String welcome = "服务端 :新春祝福群聊版服务已就绪,请嗨起来吧^_^";
private String tips = "Tips:欢迎在控制台输入您想送去的祝福呦^_^"; public void init() throws Exception {
System.out.println(welcome);
// 用于检测所有 Channel 状态的 Selector
Selector selector = Selector.open();
// 通过 open 方法来打开一个未绑定的 ServerSocketChannel 实例
ServerSocketChannel server = ServerSocketChannel.open();
// 将该 ServerSocketChannel 绑定到指定 IP 地址
server.socket().bind(new InetSocketAddress("127.0.0.1", 30000));
// 设置 ServerSockect 以非阻塞方式工作
server.configureBlocking(false);
// 将 server 注册到指定 Selector 对象
server.register(selector, SelectionKey.OP_ACCEPT);
// 监控所有注册的 Channel, select()返回的整数,代表有多少个 Channel 有需要处理的 IO 操作。
// 当所有 Channel 都没有要处理的 IO 操作时,select()方法会阻塞
while (selector.select() > 0) {
// 依次处理 selector 上的每个已经选择的 SelectionKey
for (SelectionKey selectionKey : selector.selectedKeys()) {
// 从 selector 上的已经选择 selectionKey,集中删除正在处理的 SelectionKey
selector.selectedKeys().remove(selectionKey); // 如果 selectionKey 对应的通道包含客户端的连接请求
if (selectionKey.isAcceptable()) {
// 调用 accept 方法接受连接,产生服务器端对应的 SocketChannel
SocketChannel socketChannel = server.accept();
// 设置采用非阻塞模式
socketChannel.configureBlocking(false);
// 将该 SocketChannel 也注册到 selector
socketChannel.register(selector, SelectionKey.OP_READ);
// 将 selectionKey 对应的 Channel 设置成准备接受其它请求
selectionKey.interestOps(SelectionKey.OP_ACCEPT);
// 送出祝福语
socketChannel.write(charset.encode(tips));
} // 如果 selectionKey 对应的通道有数据需要进行读取
if (selectionKey.isReadable()) {
// 获取该 selectionKey 对应的 Channel,该 Channel 中有可读的数据
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
// 定义准备执行读取数据的 ByteBuffer
ByteBuffer buff = ByteBuffer.allocate(1024);
String content = "";
try {
// 开始读取数据
while(socketChannel.read(buff) > 0) {
// 锁定 buffer 的空白区域,防止从 Buffer 中取出 null 值。
buff.flip();
// 将 ByteBuffer 的内容进行转码操作
content += charset.decode(buff);
}
// 打印从该 selectionKey 对应的 Channel 里读取到的数据
System.out.println(content);
// 将 selectionKey 对应的 Channel 设置成准备下一次读取
selectionKey.interestOps(SelectionKey.OP_READ);
// 如果捕捉到该 selectionKey 对应的 Channel 出现了异常,即表明该 Channel 对应的 Client 出现了问题
// 所以从 Selector 中取消 selectionKey 的注册
} catch (IOException e) {
// 从 Selector 中删除指定的 SelectionKey
selectionKey.cancel();
if (selectionKey.channel() != null) {
selectionKey.channel().close();
}
} // 如果 content 的长度大于 0,即新春祝福信息不为空
if(content.length() > 0) {
// 遍历该 Selector 里注册的所有 SelectionKey,代表了注册在该 Selector 上的 Channel
for(SelectionKey key : selector.keys()) {
// 获取该 key 对应的 Channel
Channel targetChannel = key.channel();
// 如果该 channel 是 SocketChannel 对象
if(targetChannel instanceof SocketChannel) {
// 将读到的内容写入该 Channel 中
SocketChannel dest = (SocketChannel) targetChannel;
dest.write(charset.encode(content));
}
}
}
}
}
}
} public static void main(String[] args) throws Exception {
new BlessingNIOServer().init();
}
}
相对服务端需求,客户端的需求很简单,提供控制台输入新春祝福的功能,另外还能够读取服务端群发古过来的祝福语。快速编写群聊版新春送祝福的客户端代码(不要认为你不懂,拿去跑两篇,深入思考一下,啥都懂啦)。
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.SocketChannel;
import java.nio.charset.Charset;
import java.util.Scanner; /**
* 新春送祝福群聊 客户端
*
* @author 一猿小讲
*/
public class BlessingNIOClient { // 定义检测 SocketChannel 的 Selector 对象
private Selector selector; // 客户端 SocketChannel
private SocketChannel socketChannel; // 定义处理编码和解码的字符集
private Charset charset = Charset.forName("UTF-8"); // 定义客户端昵称
private String[] names = {"一猿小讲","彩虹小猪妹","小猪佩奇","艾尔莎"}; public void init() throws Exception {
// 用于检测所有 Channel 状态的 Selector
selector = Selector.open();
// 调用 open 静态方法创建连接到指定主机的 SocketChannel
socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1",30000));
// 设置该 SocketChannel 以非阻塞方式工作
socketChannel.configureBlocking(false);
// 将 SocketChannel 对象注册到指定的 Selector
socketChannel.register(selector, SelectionKey.OP_READ);
// 启动读取服务器端数据的线程
new BlessingHandle().start(); // 客户端编号、昵称
long clientId = System.currentTimeMillis();
String clientNickName = names[(int)(clientId%names.length)] + clientId; // 创建键盘输入流
Scanner scan = new Scanner(System.in);
while(scan.hasNextLine()) {
// 读取键盘输入
String line = scan.nextLine();
// 将键盘输入的内容输出到 SocketChannel 中
socketChannel.write(charset.encode(clientNickName + " :" +line));
}
} /**
* 读取服务端数据的线程
*/
private class BlessingHandle extends Thread{
@Override
public void run() {
try {
while(selector.select() > 0) {
// 遍历每个有可用 IO 操作 Channel 对应的 SelectionKey
for (SelectionKey selectionKey : selector.selectedKeys()) {
// 删除正在处理的 SelectionKey
selector.selectedKeys().remove(selectionKey);
// 如果该 SelectionKey 对应的 Channel 中有可读的数据
if (selectionKey.isReadable()) {
// 使用 NIO 读取 Channel 中的数据
SocketChannel channel = (SocketChannel) selectionKey.channel();
// 定义准备执行读取数据的 ByteBuffer
ByteBuffer buff = ByteBuffer.allocate(1024);
String content = "";
// 开始读取数据
while(channel.read(buff) > 0) {
buff.flip();
content += charset.decode(buff);
}
// 打印从该 sk 对应的 Channel 里读取到的数据
System.out.println(content);
// 将 selectionKey 对应的 Channel 设置成准备下一次读取
selectionKey.interestOps(SelectionKey.OP_READ);
}
}
}
}catch (IOException e) {
e.printStackTrace();
}
}
} public static void main(String[] args) throws Exception {
new BlessingNIOClient().init();
}
}
代码撸完,是骡子是马,还是要牵出来遛一遛。
服务端控制台打印效果,很显然,能够收到各个客户端(一猿小讲、彩虹猪小妹)的新春祝福。
客户端一猿小讲的控制台打印效果,很显然,能够收到其它客户端(彩虹猪小妹)的新春祝福。
客户端彩虹小猪妹的控制台打印效果,很显然,能够收到其它客户端(一猿小讲)的新春祝福。
无论如何需求是满足啦,但是仅从编码过程而言,NIO 与传统 IO 相比,代码确实没有传统 IO 的方式简单、直观,这或许是很多网络通信框架流行的原因吧。
NIO 虽然编码稍显复杂,但是提升的效果还是有的。如图所示,NIO 利用了单线程管理一个 Selector,而一个 Selector 管理多个 Channel,也就是管理多个连接,那么就不用为每个连接都创建一个线程,可以有效避免高并发情况下,频繁线程切换等带来的问题。
另外,在 NIO 的基础之上,在 Java 7 中,引入了异步 IO 模式,被称之为 NIO.2。主要在 java.nio.channels 包下增加了四个异步通道AsynchronousSocketChannel、AsynchronousServerSocketChannel、AsynchronousFileChannel、AsynchronousDatagramChannel。
与 NIO 的主要差异在于读写 IO 操作时,在读写操作调用时都是异步的,完成后会会主动调用回调函数,所以又被称为异步 IO,简称为 AIO。有关 AIO 的深入,后续陆陆续续再补充。
03. 面试造火箭
行文至此,Java 中的 BIO(传统 IO)、NIO(新 IO)、AIO 咱们就谈到这里,下面简单列举几个常见的面试题,在本文中应该都能找到答案。
懂与不懂都要记得收藏,因为随着时间的推移不懂的会越来越少;创作不易,如果感觉稍微有点价值就来个转发。