目錄
1 前言
學習Java NIO這個知識點花費了幾天時間,通過閱讀大量的譯文和博客文章,終於有點頭緒了,雖說不是深入瞭解,但好歹是明白BIO、NIO、AIO這幾種模型之間的區別,附帶着瞭解了Java NIO的相關類源碼,也算是有點收穫。這裏總結一下自己所瞭解的IO模型,以及它們在Java NIO中的應用。
2 五種網絡IO模型
以linux系統爲例,分別是阻塞IO模型(BIO)、非阻塞IO模型(NIO)、多路複用IO模型、異步IO模型(AIO)、信號驅動IO模型。對於一個network IO (這裏我們以read舉例),它會涉及到兩個系統對象,一個是調用這個IO的process (or thread),另一個就是系統內核(kernel)。當一個read操作發生時,它會經歷兩個階段:
- 等待數據在內核中準備就緒
- 將數據從內核拷貝到用戶進程中
2.1 阻塞IO模型(BIO)
當進程發起read請求後,產生了一條系統調用:recvfrom,之後進程便陷入了阻塞狀態直到內核return OK。內核中經歷了 數據未就緒——>數據準備就緒——>將數據從內核拷貝到進程空間中——>return ok 這個過程,所以,阻塞IO模型的特點就是在IO執行的兩個階段(等待數據和拷貝數據兩個階段)都被block了。同理,進程發起的write請求也會阻塞,直到所有的數據都被寫入到內核中。
在阻塞IO模型下,socket通信的server同一時刻只能處理一個客戶端的連接請求,在多用戶連接的場景下會導致其他用戶連接等待,所以都會採用“一連接一線程”的方式來處理,server每accept一個connect,就會new一個線程去單獨處理這個連接。爲了避免過多的創建線程(線程會佔用系統資源,同時線程的切換代價也比較大),可以使用線程池去管理,複用已創建的線程資源,減少線程的創建個數。
2.2 非阻塞IO(NIO)
當進程發起read請求後,若內核中數據未準備就緒,不會阻塞。內核會返回一個標誌,進程通過這個標誌可以知道數據未就緒,從而可以做其他事情,在後續操作中,進程可以不斷地發起read請求來判斷數據是否就緒。若內核中數據就緒,進程會阻塞在本次read請求操作上,直到將數據從內核複製到進程空間中並return OK。write請求也類似,當進程執行write操作時,操作會立刻返回,可以通過內核返回的標誌判斷數據是否完全寫入。
所以,該模型在等待數據就緒階段是非阻塞的,在數據從內核複製到進程中時是阻塞的。非阻塞的接口相比於阻塞型接口的顯著差異在於,在被調用之後立即返回。
在非阻塞IO模型中,可以只用一個線程來管理多個連接,即一個server線程在accept多個連接後,循環對這些連接執行read操作,若當前遍歷連接數據已就緒,則讀取數據,然後繼續遍歷。但這種模式決不被推薦,因爲,循環調用recv()將大幅度推高CPU 佔用率;此外,在這個方案中recv()更多的是起到檢測“操作是否完成”的作用,實際操作系統提供了更爲高效的檢測“操作是否完成“作用的接口,例如select()多路複用模式,可以一次檢測多個連接是否活躍。
2.3 多路複用IO模型
它的基本原理就是Linux中select/epoll這個function會不斷的輪詢所負責的所有socket,當某個socket有數據到達了,就通知用戶進程。從圖中可以看到,這次不需要我們主動執行read操作了,而是進程首先調用select函數,調用select之後進程會阻塞,直到內核中數據準備就緒返回標誌通知進程,此時進程從阻塞中回覆,直接執行read操作讀取數據,阻塞直到return OK。
多路複用IO模型在檢測連接數據是否就緒和數據複製過程中都會阻塞。
顯然,多路複用IO模型比非阻塞IO模型更適合一個線程管理多個連接的場景,因爲使用select函數來判斷數據是否準備就緒比主動執行recvfrom來輪訓具有更好的性能,判斷直接發生在內核空間,省去了大量的recvfrom系統調用。
注意:
- 如果處理的連接數不是很高的話,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。select/epoll的優勢並不是對於單個連接能處理得更快,而是在於能處理更多的連接。
- 該模型將數據探測和事件相應夾雜在一起,一旦某個連接的事件執行體龐大,則會大大推遲其它連接的數據探測和事件執行。
2.4 異步IO(AIO)
從圖中可以看到,當進程請求一個aio_read系統調用後就直接返回,接下來在內核中自動完成數據的準備的複製工作,之後內核給進程發送一個信號,通知它數據準備完畢,然後進程執行read操作讀取即可。
異步模型全程非阻塞,數據的準備工作都在內核中異步完成。
異步IO是真正非阻塞的,它不會對請求進程產生任何的阻塞,因此對高併發的網絡服務器實現至關重要。
2.5 信號驅動IO模型
信號驅動IO,調用sigaltion系統調用,當內核中IO數據就緒時以SIGIO信號通知請求進程,請求進程再把數據從內核讀入到用戶空間,這一步是阻塞的。
3 Java NIO與IO的區別
IO | NIO |
面向流 | 面向緩衝區 |
阻塞IO | 非阻塞IO |
無選擇器 | 有選擇器 |
3.1 面向流與面向緩衝區
Java IO面向流意味着每次從流中讀一個或多個字節,直至讀取所有字節,它們沒有被緩存在任何地方。此外,它不能前後移動流中的數據。如果需要前後移動從流中讀取的數據,需要先將它緩存到一個緩衝區。
Java NIO的緩衝導向方法略有不同。數據讀取到一個它稍後處理的緩衝區,需要時可在緩衝區中前後移動。這就增加了處理過程中的靈活性。但是,還需要檢查是否該緩衝區中包含所有您需要處理的數據。而且,需確保當更多的數據讀入緩衝區時,不要覆蓋緩衝區裏尚未處理的數據。
3.2 阻塞與非阻塞IO
參考上面提到的阻塞IO模型與非阻塞IO模型。
Java IO的各種流是阻塞的。這意味着,當一個線程調用read() 或 write()時,該線程被阻塞,直到有一些數據被讀取,或數據完全寫入。該線程在此期間不能再幹任何事情了。
Java NIO的非阻塞模式,使一個線程從某通道發送請求讀取數據,但是它僅能得到目前可用的數據,如果目前沒有數據可用時,就什麼都不會獲取。而不是保持線程阻塞,所以直至數據變的可以讀取之前,該線程可以繼續做其他的事情。 非阻塞寫也是如此。一個線程請求寫入一些數據到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情。 線程通常將非阻塞IO的空閒時間用於在其它通道上執行IO操作,所以一個單獨的線程現在可以管理多個輸入和輸出通道(channel)。
4 Java NIO與IO模型
Java NIO是阻塞IO模型、非阻塞IO模型和多路複用IO模型的組合體,可以單獨實現這三種模型中的任何一種,使用最多的是多路複用IO模型。使用的組件包括channel、buffer和selector,這裏不介紹了。下面簡單使用Java NIO代碼體現這三種模型的socket通信。
4.1 Java NIO實現阻塞IO模型
顯然,如同Java IO一樣,這裏socketChannel.read(buf)會阻塞直到內核空間中數據準備和複製完畢,然後將數據讀到緩衝區。
// client創建通道並建立連接
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8888));
// 創建緩衝區並從通道中讀數據到緩衝區中
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);
socketChannel.close();
4.2 Java NIO實現非阻塞IO模型
通過socketChannel.configureBlocking(false);可以將通道設置爲非阻塞模式,該模式下read方法執行候會直接返回,通過返回的int值可以判斷是否有數據可讀(0)、讀到了多少字節數據(>0)、是否讀完了數據(-1)。
// client創建通道並建立連接
SocketChannel socketChannel = SocketChannel.open();
// #############這裏配置非阻塞模式###################
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8888));
// 創建緩衝區並從通道中讀數據到緩衝區中
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf); // bytesRead爲讀到的字節個數
byte[] bytes = new byte[Integer.MAX_VALUE];
// bytesRead = -1表示讀到了尾部
while (bytesRead != -1){
if (bytesRead == 0){
// 沒有數據可讀
// do something else
}
// 從緩衝區中取出讀到的數據
buf.flip();
buf.get(bytes);
buf.clear();
bytesRead = socketChannel.read(buf);
}
// 打印結果
System.out.println(new String(bytes));
socketChannel.close();
4.3 Java NIO實現多路複用IO模型
通過selector選擇器,可以讓一個server線程管理大量建立的連接,閱讀下面代碼你就會發現設計的精妙之處。
public class Server implements Runnable{
//1 多路複用器(管理所有的通道)
private Selector seletor;
//2 建立緩衝區
private ByteBuffer readBuf = ByteBuffer.allocate(1024);
private ByteBuffer writeBuf = ByteBuffer.allocate(1024);
public Server(int port){
try {
//1 打開路複用器
this.seletor = Selector.open();
//2 打開服務器通道
ServerSocketChannel ssc = ServerSocketChannel.open();
//3 設置服務器通道爲非阻塞模式
ssc.configureBlocking(false);
//4 綁定地址
ssc.bind(new InetSocketAddress(port));
//5 把服務器通道註冊到多路複用器上,並且監聽連接事件
ssc.register(this.seletor, SelectionKey.OP_ACCEPT);
System.out.println("Server start, port :" + port);
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
while(true){
try {
//1 必須要讓多路複用器開始監聽
this.seletor.select();
//2 返回多路複用器已經選擇的結果集
Iterator<SelectionKey> keys = this.seletor.selectedKeys().iterator();
//3 進行遍歷
while(keys.hasNext()){
//4 獲取一個選擇的元素
SelectionKey key = keys.next();
//5 直接從容器中移除就可以了
keys.remove();
//6 如果是有效的
if(key.isValid()){
//7 如果爲阻塞狀態
if(key.isAcceptable()){
this.accept(key);
}
//8 如果爲可讀狀態
if(key.isReadable()){
this.read(key);
}
//9 寫數據
if(key.isWritable()){
this.write(key);
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
private void write(SelectionKey key){}
private void read(SelectionKey key) {
try {
//1 清空緩衝區舊的數據
this.readBuf.clear();
//2 獲取之前註冊的socket通道對象
SocketChannel sc = (SocketChannel) key.channel();
//3 讀取數據
int count = sc.read(this.readBuf);
//4 如果沒有數據
if(count == -1){
key.channel().close();
key.cancel();
return;
}
//5 有數據則進行讀取 讀取之前需要進行復位方法(把position 和limit進行復位)
this.readBuf.flip();
//6 根據緩衝區的數據長度創建相應大小的byte數組,接收緩衝區的數據
byte[] bytes = new byte[this.readBuf.remaining()];
//7 接收緩衝區數據
this.readBuf.get(bytes);
//8 打印結果
String body = new String(bytes).trim();
System.out.println("Server : " + body);
// 9..可以寫回給客戶端數據
} catch (IOException e) {
e.printStackTrace();
}
}
private void accept(SelectionKey key) {
try {
//1 獲取服務通道
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
//2 執行阻塞方法
SocketChannel sc = ssc.accept();
//3 設置阻塞模式
sc.configureBlocking(false);
//4 註冊到多路複用器上,並設置讀取標識
sc.register(this.seletor, SelectionKey.OP_READ);
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
new Thread(new Server(7788)).start();;
}
}
參考:
http://ifeve.com/socket-channel/
http://ifeve.com/java-nio-vs-io/