【这是一猿小讲的第 84 篇原创分享】
上期结合程序员小猿用温奶器给孩子热奶的故事,把面试中常聊的“同步、异步与阻塞、非阻塞有啥区别”简单进行普及。
不过,恰逢春节即将到来,应个景,不妨就通过实现新春送祝福的需求,深入了解一下 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 咱们就谈到这里,下面简单列举几个常见的面试题,在本文中应该都能找到答案。
问题 1. Java 中有几种类型的流?
问题 2. Java IO 用到了哪些设计模式?
问题 3. 什么是 BIO、NIO、AIO?它们的区别?
问题 4. BIO、NIO、AIO 各自的应用场景?
并发连接数不多时采用 BIO,因为它编程和调试都非常简单;高并发的情况,考虑选择 NIO 或 AIO,更好的建议是采用成熟网络通信框架。
懂与不懂都要记得收藏,因为随着时间的推移不懂的会越来越少;创作不易,如果感觉稍微有点价值就来个转发或者“在看”。
推荐阅读:
转载:https://blog.csdn.net/javaforwork/article/details/103951851