導讀:本文你將獲取到:同/異步 + 阻/非阻塞的性能區別;BIO、NIO、AIO 的區別;理解和實現 NIO 操作 Socket 時的多路復用;同時掌握 IO 最底層最核心的操作技巧。
BIO、NIO、AIO 的區別是什么?
同/異步、阻/非阻塞的區別是什么?
文件讀寫最優雅的實現方式是什么?
NIO 如何實現多路復用功能?
帶著以上這幾個問題,讓我們一起進入IO的世界吧。
在開始之前,我們先來思考一個問題:我們經常所說的“IO”的全稱到底是什么?
可能很多人看到這個問題和我一樣一臉懵逼,IO的全稱其實是:Input/Output的縮寫。
一、IO 介紹
我們通常所說的 BIO 是相對于 NIO 來說的,BIO 也就是 Java 開始之初推出的 IO 操作模塊,BIO 是 BlockingIO 的縮寫,顧名思義就是阻塞 IO 的意思。
1.1 BIO、NIO、AIO的區別
BIO 就是傳統的 java.io 包,它是基于流模型實現的,交互的方式是同步、阻塞方式,也就是說在讀入輸入流或者輸出流時,在讀寫動作完成之前,線程會一直阻塞在那里,它們之間的調用時可靠的線性順序。它的有點就是代碼比較簡單、直觀;缺點就是 IO 的效率和擴展性很低,容易成為應用性能瓶頸。
NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel、Selector、Buffer 等新的抽象,可以構建多路復用的、同步非阻塞 IO 程序,同時提供了更接近操作系統底層高性能的數據操作方式。
AIO 是 Java 1.7 之后引入的包,是 NIO 的升級版本,提供了異步非堵塞的 IO 操作方式,所以人們叫它 AIO(Asynchronous IO),異步 IO 是基于事件和回調機制實現的,也就是應用操作之后會直接返回,不會堵塞在那里,當后臺處理完成,操作系統會通知相應的線程進行后續的操作。
1.2 全面認識 IO
傳統的 IO 大致可以分為4種類型:
InputStream、OutputStream 基于字節操作的 IO
Writer、Reader 基于字符操作的 IO
File 基于磁盤操作的 IO
Socket 基于網絡操作的 IO
java.net 下提供的 Scoket 很多時候人們也把它歸為 同步阻塞 IO ,因為網絡通訊同樣是 IO 行為。
java.io 下的類和接口很多,但大體都是 InputStream、OutputStream、Writer、Reader 的子集,所有掌握這4個類和File的使用,是用好 IO 的關鍵。
1.3 IO 使用
接下來看 InputStream、OutputStream、Writer、Reader 的繼承關系圖和使用示例。
1.3.1 InputStream 使用
繼承關系圖和類方法,如下圖:
InputStream 使用示例:
InputStream inputStream = new FileInputStream("D:\\log.txt");
byte[] bytes = new byte[inputStream.available()];
inputStream.read(bytes);
String str = new String(bytes, "utf-8");
System.out.println(str);
inputStream.close();
1.3.2 OutputStream 使用
繼承關系圖和類方法,如下圖:
OutputStream 使用示例:
OutputStream outputStream = new FileOutputStream("D:\\log.txt",true); // 參數二,表示是否追加,true=追加
outputStream.write("你好,老王".getBytes("utf-8"));
outputStream.close();
1.3.3 Writer 使用
Writer 繼承關系圖和類方法,如下圖:
Writer 使用示例:
Writer writer = new FileWriter("D:\\log.txt",true); // 參數二,是否追加文件,true=追加
writer.append("老王,你好");
writer.close();
1.3.4 Reader 使用
Reader 繼承關系圖和類方法,如下圖:
Reader 使用示例:
Reader reader = new FileReader(filePath);
BufferedReader bufferedReader = new BufferedReader(reader);
StringBuffer bf = new StringBuffer();
String str;
while ((str = bufferedReader.readLine()) != null) {
bf.append(str + "\n");
}
bufferedReader.close();
reader.close();
System.out.println(bf.toString());
二、同步、異步、阻塞、非阻塞
上面說了很多關于同步、異步、阻塞和非阻塞的概念,接下來就具體聊一下它們4個的含義,以及組合之后形成的性能分析。
2.1 同步與異步
同步就是一個任務的完成需要依賴另外一個任務時,只有等待被依賴的任務完成后,依賴的任務才能算完成,這是一種可靠的任務序列。要么成功都成功,失敗都失敗,兩個任務的狀態可以保持一致。而異步是不需要等待被依賴的任務完成,只是通知被依賴的任務要完成什么工作,依賴的任務也立即執行,只要自己完成了整個任務就算完成了。至于被依賴的任務最終是否真正完成,依賴它的任務無法確定,所以它是不可靠的任務序列。我們可以用打電話和發短信來很好的比喻同步與異步操作。
2.2 阻塞與非阻塞
阻塞與非阻塞主要是從 CPU 的消耗上來說的,阻塞就是 CPU 停下來等待一個慢的操作完成 CPU 才接著完成其它的事。非阻塞就是在這個慢的操作在執行時 CPU 去干其它別的事,等這個慢的操作完成時,CPU 再接著完成后續的操作。雖然表面上看非阻塞的方式可以明顯的提高 CPU 的利用率,但是也帶了另外一種后果就是系統的線程切換增加。增加的 CPU 使用時間能不能補償系統的切換成本需要好好評估。
2.3 同/異、阻/非堵塞 組合
同/異、阻/非堵塞的組合,有四種類型,如下表:
組合方式性能分析
同步阻塞最常用的一種用法,使用也是最簡單的,但是 I/O 性能一般很差,CPU 大部分在空閑狀態。
同步非阻塞提升 I/O 性能的常用手段,就是將 I/O 的阻塞改成非阻塞方式,尤其在網絡 I/O 是長連接,同時傳輸數據也不是很多的情況下,提升性能非常有效。 這種方式通常能提升 I/O 性能,但是會增加CPU 消耗,要考慮增加的 I/O 性能能不能補償 CPU 的消耗,也就是系統的瓶頸是在 I/O 還是在 CPU 上。
異步阻塞這種方式在分布式數據庫中經常用到,例如在網一個分布式數據庫中寫一條記錄,通常會有一份是同步阻塞的記錄,而還有兩至三份是備份記錄會寫到其它機器上,這些備份記錄通常都是采用異步阻塞的方式寫 I/O。異步阻塞對網絡 I/O 能夠提升效率,尤其像上面這種同時寫多份相同數據的情況。
異步非阻塞這種組合方式用起來比較復雜,只有在一些非常復雜的分布式情況下使用,像集群之間的消息同步機制一般用這種 I/O 組合方式。如 Cassandra 的 Gossip 通信機制就是采用異步非阻塞的方式。它適合同時要傳多份相同的數據到集群中不同的機器,同時數據的傳輸量雖然不大,但是卻非常頻繁。這種網絡 I/O 用這個方式性能能達到最高。
# 三、優雅的文件讀寫
Java 7 之前文件的讀取是這樣的:
// 添加文件
FileWriter fileWriter = new FileWriter(filePath, true);
fileWriter.write(Content);
fileWriter.close();
// 讀取文件
FileReader fileReader = new FileReader(filePath);
BufferedReader bufferedReader = new BufferedReader(fileReader);
StringBuffer bf = new StringBuffer();
String str;
while ((str = bufferedReader.readLine()) != null) {
bf.append(str + "\n");
}
bufferedReader.close();
fileReader.close();
System.out.println(bf.toString());
Java 7 引入了Files(java.nio包下)的,大大簡化了文件的讀寫,如下:
// 寫入文件(追加方式:StandardOpenOption.APPEND)
Files.write(Paths.get(filePath), Content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND);
// 讀取文件
byte[] data = Files.readAllBytes(Paths.get(filePath));
System.out.println(new String(data, StandardCharsets.UTF_8));
讀寫文件都是一行代碼搞定,沒錯這就是最優雅的文件操作。
Files 下還有很多有用的方法,比如創建多層文件夾,寫法上也簡單了:
// 創建多(單)層目錄(如果不存在創建,存在不會報錯)
new File("D://a//b").mkdirs();
四、Socket 和 NIO 的多路復用
本節帶你實現最基礎的 Socket 的同時,同時會實現 NIO 多路復用,還有 AIO 中 Socket 的實現。
4.1 傳統的 Socket 實現
接下來我們將會實現一個簡單的 Socket,服務器端只發給客戶端信息,再由客戶端打印出來的例子,代碼如下:
int port = 4343; //端口號
// Socket 服務器端(簡單的發送信息)
Thread sThread = new Thread(new Runnable() {
@Override
public void run() {
try {
ServerSocket serverSocket = new ServerSocket(port);
while (true) {
// 等待連接
Socket socket = serverSocket.accept();
Thread sHandlerThread = new Thread(new Runnable() {
@Override
public void run() {
try (PrintWriter printWriter = new PrintWriter(socket.getOutputStream())) {
printWriter.println("hello world!");
printWriter.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
});
sHandlerThread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
});
sThread.start();
// Socket 客戶端(接收信息并打印)
try (Socket cSocket = new Socket(InetAddress.getLocalHost(), port)) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(cSocket.getInputStream()));
bufferedReader.lines().forEach(s -> System.out.println("客戶端:" + s));
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
調用 accept 方法,阻塞等待客戶端連接;
利用 Socket 模擬了一個簡單的客戶端,只進行連接、讀取和打印;
在 Java 中,線程的實現是比較重量級的,所以線程的啟動或者銷毀是很消耗服務器的資源的,即使使用線程池來實現,使用上述傳統的 Socket 方式,當連接數極具上升也會帶來性能瓶頸,原因是線程的上線文切換開銷會在高并發的時候體現的很明顯,并且以上操作方式還是同步阻塞式的編程,性能問題在高并發的時候就會體現的尤為明顯。
以上的流程,如下圖:
4.2 NIO 多路復用
介于以上高并發的問題,NIO 的多路復用功能就顯得意義非凡了。
NIO 是利用了單線程輪詢事件的機制,通過高效地定位就緒的 Channel,來決定做什么,僅僅 select 階段是阻塞的,可以有效避免大量客戶端連接時,頻繁線程切換帶來的問題,應用的擴展能力有了非常大的提高。
// NIO 多路復用
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(4, 4,
60L, TimeUnit.SECONDS, new LinkedBlockingQueue());
threadPool.execute(new Runnable() {
@Override
public void run() {
try (Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();) {
serverSocketChannel.bind(new InetSocketAddress(InetAddress.getLocalHost(), port));
serverSocketChannel.configureBlocking(false);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞等待就緒的Channel
Set selectionKeys = selector.selectedKeys();
Iterator iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
try (SocketChannel channel = ((ServerSocketChannel) key.channel()).accept()) {
channel.write(Charset.defaultCharset().encode("你好,世界"));
}
iterator.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
});
// Socket 客戶端(接收信息并打印)
try (Socket cSocket = new Socket(InetAddress.getLocalHost(), port)) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(cSocket.getInputStream()));
bufferedReader.lines().forEach(s -> System.out.println("NIO 客戶端:" + s));
} catch (IOException e) {
e.printStackTrace();
}
首先,通過 Selector.open() 創建一個 Selector,作為類似調度員的角色;
然后,創建一個 ServerSocketChannel,并且向 Selector 注冊,通過指定 SelectionKey.OP_ACCEPT,告訴調度員,它關注的是新的連接請求;
為什么我們要明確配置非阻塞模式呢?這是因為阻塞模式下,注冊操作是不允許的,會拋出 IllegalBlockingModeException 異常;
Selector 阻塞在 select 操作,當有 Channel 發生接入請求,就會被喚醒;
下面的圖,可以有效的說明 NIO 復用的流程:
就這樣 NIO 的多路復用就大大提升了服務器端響應高并發的能力。
4.3 AIO 版 Socket 實現
Java 1.7 提供了 AIO 實現的 Socket 是這樣的,如下代碼:
// AIO線程復用版
Thread sThread = new Thread(new Runnable() {
@Override
public void run() {
AsynchronousChannelGroup group = null;
try {
group = AsynchronousChannelGroup.withThreadPool(Executors.newFixedThreadPool(4));
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(group).bind(new InetSocketAddress(InetAddress.getLocalHost(), port));
server.accept(null, new CompletionHandler() {
@Override
public void completed(AsynchronousSocketChannel result, AsynchronousServerSocketChannel attachment) {
server.accept(null, this); // 接收下一個請求
try {
Future f = result.write(Charset.defaultCharset().encode("你好,世界"));
f.get();
System.out.println("服務端發送時間:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
result.close();
} catch (InterruptedException | ExecutionException | IOException e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, AsynchronousServerSocketChannel attachment) {
}
});
group.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
});
sThread.start();
// Socket 客戶端
AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
Future future = client.connect(new InetSocketAddress(InetAddress.getLocalHost(), port));
future.get();
ByteBuffer buffer = ByteBuffer.allocate(100);
client.read(buffer, null, new CompletionHandler() {
@Override
public void completed(Integer result, Void attachment) {
System.out.println("客戶端打印:" + new String(buffer.array()));
}
@Override
public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
try {
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}
});
Thread.sleep(10 * 1000);
五、總結
以上基本就是 IO 從 1.0 到目前版本(本文的版本)JDK 8 的核心使用操作了,可以看出來 IO 作為比較常用的基礎功能,發展變化的改動也很大,而且使用起來也越來越簡單了,IO 的操作也是比較好理解的,一個輸入一個輸出,掌握好了輸入輸出也就掌握好了 IO,Socket 作為網絡交互的集成功能,顯然 NIO 的多路復用,給 Socket 帶來了更多的活力和選擇,用戶可以根據自己的實際場景選擇相應的代碼策略。
六、參考文檔