前言
我們在開發過程中經常跟I/O打交道,很多人在學習I/O模型過程和進行I/O程式設計過程中,對很多概念可能不明朗,特別是像Java這樣的高階語言,它對底層作業系統的各種I/O模型進行了封裝,使得我們可以很輕鬆的進行開發,但是在方便之餘你是否對Java中各種I/O模型,以及它們和作業系統之間的關聯是否有過了解?
什麼是I/O?
I/O 在計算機中指Input/Output,即輸入輸出。以一次檔案讀取為例,我們需要將磁碟上的資料讀取到使用者空間,那麼這次資料轉移操作其實就是一次I/O操作,也就是一次檔案I/O;我們每天都瀏覽著各種各樣的網頁,在我們每請求一個網頁,伺服器通過網路將一個個的分組資料傳送給我們,應用程式從TCP緩衝區將資料複製到使用者空間的過程也是一次I/O,即一次網路I/O。可以發現I/O如此重要,它時刻都在。
Liunx 網路 I/O模型
根據UNIX網路程式設計對I/O模型的分類,UNIX提供了5中I/O模型分別如下:
阻塞I/O模型
這是最傳統的I/O模型,即在讀寫資料過程中會阻塞,我們通過圖可以看到,在應用程式呼叫recvfrom,系統呼叫直到資料從核心從複製到使用者空間,應用程式在這一段時間內一直是被阻塞的。這種模型適合併發量較小的對時延不敏感的系統。
非阻塞I/O模型
應用程式不停的通過recvfrom呼叫不停的和核心互動直到資料被被準備好,將他複製到使用者空間中,如果recvfrom呼叫沒有資料可以返回時返回一個EWOULDBLOCK錯誤,我們將這樣的操作稱作輪詢,這麼做往往需要耗費大量的CPU時間。
I/O複用模型
在Liunx中為我們提供了select/poll,也就是管道,我們就可以將呼叫它們阻塞在這兩個系統呼叫中的一個上,而不是阻塞在真正的I/O呼叫上,我們阻塞select呼叫當資料返回可讀條件時,通過recvfrom呼叫將資料複製到應用程式緩衝區。 多路I/O複用本質上並不是非阻塞的,對比阻塞I/O模型它並沒有什麼優勢,事實上使用select需要兩個系統而不是當個呼叫,I/O複用其實稍有劣勢,它只是能處理更多的連線(等待多個I/O就緒)
訊號驅動式I/O模型
我們首先開啟套接字的訊號驅動I/O功能,通過sigaction系統呼叫安裝一個訊號處理函式,系統呼叫立即返回,程式繼續工作,當資料包準備好時核心產生一個SIGIO訊號通知,我們通過recvfrom呼叫讀取資料包。訊號驅動式I/O模型的優點是我們在資料包到達期間程式不會被阻塞,我們只要等待訊號處理函式的通知即可
非同步I/O模型
告知核心啟動某個操作(包括將資料從核心複製到自己的緩衝區)之後通知我們。訊號驅動模型是核心通知我們何時啟動一個I/O操作,而非同步I/O模型是由核心通知我們I/O何時完成
同步I/O和非同步I/O對比
同步I/O操作:導致請求程式阻塞,直到I/O操作完成
非同步I/O操作:不導致請求程式阻塞
綜上阻塞式I/O模型、非阻塞式I/O模型、I/O複用模型和訊號驅動模型都是同步I/O模型,他們真正的I/O操作將程式阻塞,只有非同步I/O模型是非同步I/O操作Java I/O 模型
Java I/O歷史
在JDK 1.4之前,基於Java的所有Socket通訊都使用了同步阻塞模式(Blocking I/O),這種一請求一應答的通訊模型簡化了上層開發,但效能可靠性存在巨大瓶頸,對高併發和低時延支援不好
在JDK 1.4之後,提供了新的NIO(New I/O)類庫,Java也可以支援非阻塞I/O了,新增了java.nio包,提供了很多非同步I/O開發的API和類庫。
JDK 1.7釋出後,將原來的NIO類庫進行了升級,提供了AIO功能,支援基於檔案的非同步I/O操作和針對套接字的非同步I/O操作等功能
BIO 程式設計
使用BIO通訊模型的服務端,通常通過一個獨立的Acceptor執行緒負責監聽客戶端的連線,監聽到客戶端連線請求後為每一個客戶端建立一個新的執行緒鏈路進行處理,處理完成通過輸出流回應客戶端,執行緒消耗,這就是典型一對一答模型,下面我們通過程式碼對BIO模式進行具體分析,我們實現客戶端傳送訊息服務端將訊息回傳我們的功能。
服務端:
int port = 3000;
try(ServerSocket serverSocket = new ServerSocket(port)) {
Socket socket = null;
while (true) {
//主程式阻塞在accept操作上
socket = serverSocket.accept();
new Thread(new BioExampleServerHandle(socket)).start();
}
} catch (Exception e) {
e.printStackTrace();
}
複製程式碼
private Socket socket;
public BioExampleServerHandle(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try(BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true)) {
String message = reader.readLine();
System.out.println("收到客戶端訊息:" + message);
writer.println("answer: " + message);
} catch (Exception e) {
e.printStackTrace();
}
}
複製程式碼
客戶端:
String host = "127.0.0.1";
int port = 3000;
try(Socket socket = new Socket(host, port);
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true)) {
Scanner input = new Scanner(System.in);
System.out.println("輸入你想說的話:");
String message = input.nextLine();
writer.println(message);
String answer = reader.readLine();
System.out.println(answer);
} catch (Exception e) {
e.printStackTrace();
}
複製程式碼
執行結果如下:
客戶端:
服務端: 通過程式碼我們可以發現BIO的主要問題在於,每當一個連線接入時我們都需要new一個執行緒進行處理,這顯然是不合適的,因為一個執行緒只能處理一個連線,如果在高併發的情況下,我們的程式肯定無法滿足效能需求,同時我們對執行緒建立也缺乏管理。為了改進這種模型我們可以通過訊息佇列和執行緒池技術對他加以優化,我們稱它為偽非同步I/O,程式碼如下: int port = 3000;
ThreadPoolExecutor socketPool = null;
try(ServerSocket serverSocket = new ServerSocket(port)) {
Socket socket = null;
int cpuNum = Runtime.getRuntime().availableProcessors();
socketPool = new ThreadPoolExecutor(cpuNum, cpuNum * 2, 1000,
TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(1000));
while (true) {
socket = serverSocket.accept();
socketPool.submit(new BioExampleServerHandle(socket));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
socketPool.shutdown();
}
複製程式碼
可以看到每當有新連線接入,我們都將他投遞給執行緒池進行處理,由於我們設定了執行緒池大小和阻塞佇列大小,因此在併發情況下都不會導致服務崩潰,但是如果併發數大於阻塞佇列大小,或服務端處理連線緩慢時,阻塞佇列無法繼續處理,會導致客戶端連線超時,影響使用者體驗。
NIO 程式設計
NIO 彌補了同步阻塞I/O的不足,它提供了高速、面向塊的I/O,我們對一些概念介紹一下:
Buffer: Buffer用於和NIO通道進行互動。資料從通道讀入緩衝區,從緩衝區寫入到通道中,任何時候訪問NIO中的資料,同時通過緩衝區進行的。
Channel: Channel是一個通道,可以通過它讀取和寫入資料,通道是雙向的,通道可以用於讀、寫或者同時讀寫。
Selector: Selector會不斷的輪詢註冊在它上面的Channe,如果Channel上面有新的連線讀寫事件的時候就會被輪詢出來,一個Selector可以註冊對個Channel,只需要一個執行緒負責Selector輪詢,就可以支援成千上萬的連線,可以說為高併發伺服器的開發提供了很好的支撐。
我們通過實際程式碼演示NIO的使用:
服務端程式碼:
int port = 3000;
ServerSocketChannel socketChannel = null;
Selector selector = null;
try {
selector = Selector.open();
socketChannel = ServerSocketChannel.open();
//設定連線模式為非阻塞模式
socketChannel.configureBlocking(false);
socketChannel.socket().bind(new InetSocketAddress(port));
//在selector上註冊通道,監聽連線事件
socketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
//設定selector 每隔一秒掃描所有channel
selector.select(1000);
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterable = selectionKeys.iterator();
SelectionKey key = null;
while (iterable.hasNext()) {
key = iterable.next();
//對key進行處理
try {
handlerKey(key, selector);
} catch (Exception e) {
if (null != key) {
key.cancel();
if (null != key.channel()) {
key.channel().close();
}
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != selector) {
selector.close();
}
if (null != socketChannel) {
socketChannel.close();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
複製程式碼
handlerKey程式碼如下:
private void handlerKey(SelectionKey key, Selector selector) throws IOException {
if (key.isValid()) {
//判斷是否是連線請求,對所有連線請求進行處理
if (key.isAcceptable()) {
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel channel = serverSocketChannel.accept();
channel.configureBlocking(false);
//在selector上註冊通道,監聽讀事件
channel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
//分配一個1024位元組的緩衝區
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int readBytes = channel.read(byteBuffer);
if (readBytes > 0) {
//從寫模式切換到讀模式
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
String message = new String(bytes, "UTF-8");
System.out.println("收到客戶端訊息: " + message);
//回覆客戶端
message = "answer: " + message;
byte[] responseByte = message.getBytes();
ByteBuffer writeBuffer = ByteBuffer.allocate(responseByte.length);
writeBuffer.put(responseByte);
writeBuffer.flip();
channel.write(writeBuffer);
}
}
}
}
複製程式碼
客戶端程式碼:
int port = 3000;
String host = "127.0.0.1";
SocketChannel channel = null;
Selector selector = null;
try {
selector = Selector.open();
channel = SocketChannel.open();
channel.configureBlocking(false);
if (channel.connect(new InetSocketAddress(host, port))) {
channel.register(selector, SelectionKey.OP_READ);
write(channel);
} else {
channel.register(selector, SelectionKey.OP_CONNECT);
}
while (true) {
selector.select(1000);
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
SelectionKey key = null;
while (iterator.hasNext()) {
try {
key = iterator.next();
handle(key, selector);
} catch (Exception e) {
e.printStackTrace();
if (null != key.channel()) {
key.channel().close();
}
if (null != key) {
key.cancel();
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != channel) {
channel.close();
}
if (null != selector) {
selector.close();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
複製程式碼
write 方法:
private void write(SocketChannel channel) throws IOException {
Scanner in = new Scanner(System.in);
System.out.println("輸入你想說的話:");
String message = in.next();
byte[] bytes = message.getBytes();
ByteBuffer byteBuffer = ByteBuffer.allocate(bytes.length);
byteBuffer.put(bytes);
byteBuffer.flip();
channel.write(byteBuffer);
}
複製程式碼
handle 方法:
private void handle(SelectionKey key, Selector selector) throws IOException {
if (key.isValid()) {
SocketChannel channel = (SocketChannel) key.channel();
if (key.isConnectable()) {
if (channel.finishConnect()) {
channel.register(selector, SelectionKey.OP_READ);
write(channel);
}
} else if (key.isReadable()) {
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int readBytes = channel.read(byteBuffer);
if (readBytes > 0) {
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
String message = new String(bytes, "UTF-8");
System.out.println(message);
} else if (readBytes < 0) {
key.cancel();
channel.close();
}
}
}
}
複製程式碼
通過程式碼我們發現NIO比BIO複雜太多,這個程式碼量也是刷刷的增長啊,但是複雜NIO的優點,也值得我們去嘗試,比起BIO客戶端連線操作是非同步的,我們可以註冊OP_CONNECT事件等待結果而不用像那樣被同步阻塞,Channel的讀寫操作都是非同步的,沒有等待資料它不會等待直接返回,比起BIO我們不需要頻繁的建立執行緒來處理客戶端連線,我們通過一個Selector處理多個客戶端連線,而且效能也可以得到保障,適合做高效能伺服器開發
AIO 程式設計
NIO2.0 引入了非同步通道的概念,提供了非同步檔案通道和非同步套接字通道的實現,我們可以通過Future類來表示非同步操作結果,也可以在執行非同步操作的時候傳入一個Channels,實現CompletionHandler介面為操作回撥。示例程式碼如下
服務端:
int port = 3000;
AsynchronousServerSocketChannel socketChannel = null;
try {
socketChannel = AsynchronousServerSocketChannel.open();
socketChannel.bind(new InetSocketAddress(port));
//接收客戶端連線,傳入AcceptCompletionHandler作為回撥來接收連線訊息
socketChannel.accept(socketChannel, new AcceptCompletionHandler());
Thread.currentThread().join();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != socketChannel) {
socketChannel.close();
}
} catch (Exception e1) {
throw new RuntimeException(e1);
}
}
複製程式碼
AcceptCompletionHandler 類:
public class AcceptCompletionHandler implements CompletionHandler<AsynchronousSocketChannel, AsynchronousServerSocketChannel> {
@Override
public void completed(AsynchronousSocketChannel result, AsynchronousServerSocketChannel attachment) {
//繼續接受其他客戶端的連線請求,形成一個迴圈
attachment.accept(attachment, this);
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
//呼叫read操作進行非同步讀取操作,傳入ReadCompletionHandler作為回撥
result.read(byteBuffer, byteBuffer, new ReadCompletionHandler(result));
}
@Override
public void failed(Throwable exc, AsynchronousServerSocketChannel attachment) {
//異常失敗處理在這裡
}
}
複製程式碼
ReadCompletionHandler 類
public class ReadCompletionHandler implements CompletionHandler<Integer, ByteBuffer> {
private AsynchronousSocketChannel channel;
public ReadCompletionHandler(AsynchronousSocketChannel channel) {
this.channel = channel;
}
@Override
public void completed(Integer result, ByteBuffer byteBuffer) {
try {
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
String message = new String(bytes, "UTF-8");
System.out.println("收到客戶端訊息:: " + message);
write(message);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
try {
channel.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void write(String message) {
message = "answer: " + message;
byte[] bytes = message.getBytes();
ByteBuffer byteBuffer = ByteBuffer.allocate(bytes.length);
byteBuffer.put(bytes);
byteBuffer.flip();
channel.write(byteBuffer, byteBuffer, new WriteCompletionHandler(channel));
}
}
複製程式碼
客戶端:
int port = 3000;
String host = "127.0.0.1";
AsynchronousSocketChannel channel = null;
try {
channel = AsynchronousSocketChannel.open();
channel.connect(new InetSocketAddress(host, port), channel, new AioClientHandler());
Thread.currentThread().join();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != channel) {
channel.close();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
複製程式碼
AioClientHandler 類(由於客戶端比較簡單我這裡使用了巢狀類部類):
public class AioClientHandler implements CompletionHandler<Void, AsynchronousSocketChannel> {
@Override
public void completed(Void result, AsynchronousSocketChannel channel) {
Scanner in = new Scanner(System.in);
System.out.println("輸入你想說的話:");
String message = in.next();
byte[] bytes = message.getBytes();
ByteBuffer byteBuffer = ByteBuffer.allocate(bytes.length);
byteBuffer.put(bytes);
byteBuffer.flip();
channel.write(byteBuffer, byteBuffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buffer) {
//判斷是否寫完如果沒有繼續寫
if (buffer.hasRemaining()) {
channel.write(buffer, buffer, this);
} else {
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
channel.read(readBuffer, readBuffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer attachment) {
try {
attachment.flip();
byte[] bytes1 = new byte[attachment.remaining()];
attachment.get(bytes1);
String message = new String(bytes1, "UTF-8");
System.out.println(message);
System.exit(1);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
try {
channel.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
}
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
try {
channel.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
}
@Override
public void failed(Throwable exc, AsynchronousSocketChannel attachment) {
}
複製程式碼
通過對比程式碼我們發現AIO比BIO簡單,這是因為我們不需要建立一個獨立的I/O執行緒來來處理讀寫操作, AsynchronousSocketChannel、AsynchronousServerSocketChannel由JDK底層執行緒池負責回撥驅動讀寫操作。
對比
同步阻塞I/O(BIO) | 偽非同步I/O | 非阻塞I/O(NIO) | 非同步I/O(AIO) | |
---|---|---|---|---|
是否阻塞 | 是 | 是 | 否 | 否 |
是否同步 | 是 | 是 | 是 | 否(非同步) |
程式設計師友好程度 | 簡單 | 簡單 | 非常難 | 比較難 |
可靠性 | 非常差 | 差 | 高 | 高 |
吞吐量 | 低 | 中 | 高 | 高 |
總結
通過學習Lunix底層I/O模型和JavaI/O模型我們發現上層只是對底層的抽象和封裝,BIO其實是對阻塞I/O模型的實現,NIO是對I/O複用模型的實現,AIO是對訊號驅動I/O的實現,理解了底層I/O模型,在實際開發中應該可以很自如。如果你覺得不錯的話就點個贊吧,如果有bug也您請批評指正,您的讚賞和批評是進步路上的好夥伴。