前面的一些文章中我總結了一些Java IO和NIO相關的主要知識點,也是管中窺豹,IO類庫已經功能很強大了,但是Java 為什麼又要引入NIO,這是我一直不是很清楚的?前面也只是簡單提及了一下:因為效能,但是僅僅是因為效能嗎,除此之外是否還有別的原因,或者說既然NIO效能好,那為什麼現在我們還在使用IO。本節我們就來詳細對比一下兩者的特性以及兩者之間的不一致對我們編碼所帶來的影響。
同樣,本文會主要圍繞下面幾個方面來總結:
1. Java NIO和IO的主要區別
兩者之間的不同主要體現在如下三個方面:
- Java IO是面向流(Stream)的,而Java NIO是面向緩衝區(Buffer)的;
- IO模型的不同,Java IO是屬於阻塞式IO(Blocking IO),而Java NIO是屬於非阻塞式IO(Non Blocking IO);
- Java NIO中還引入了Selector的概念,可以實現多路複用;
在接下來的部分,我們逐個討論這三個不同。
1.1 面向流與面向緩衝區
Java NIO和IO之間第一個不同點是IO是面向流(Stream)的而NIO是面向緩衝區(Buffer)的。
Java IO是面向流的,這意味著是一次性從流中讀取一批資料,這些資料並不會快取在任何地方,並且對於在流中的資料是不支援在資料中前後移動。如果需要在這些資料中移動(為什麼要移動,可以多次讀取),則還是需要將這部分資料先快取在緩衝區中。
而Java NIO採用的是面向緩衝區的方式,有些不同,資料會先讀取到緩衝區中以供稍後處理。在buffer中是可以方便地前移和後移,這使得在處理資料時可以有更大的靈活性。但是呢需要檢查buffer是否包含需要的所有資料以便能夠將其完整地處理,並且需要確保在通過channel往buffer讀資料的時候不能夠覆蓋還未處理的資料。
1.2 IO模型的區別
Java IO中使用的流是屬於阻塞式的,意味著當執行緒呼叫其read()或write()方法時執行緒會阻塞,直到完成了資料的讀寫,在讀寫的過程中執行緒是什麼都做不了的。
Java NIO提供了一種非阻塞模式,使得執行緒向channel請求讀資料時,只會獲取已經就緒的資料,並不會阻塞以等待所有資料都準備好(IO就是這樣做),這樣在資料準備的階段執行緒就能夠去處理別的事情。對於非阻塞式寫資料是一樣的。執行緒往channel中寫資料時,並不會阻塞以等待資料寫完,而是可以處理別的事情,等到資料已經寫好了,執行緒再處理這部分事情。
當執行緒在進行IO呼叫並且不會進入阻塞的情況下,這部分的空餘時間就可以花在和其他channel進行IO互動上。也就是說,這樣單個執行緒就能夠管理多個channel的輸入和輸出了。
1.3 Selector
Java NIO中的Selector允許單個執行緒監控多個channel,可以將多個channel註冊到一個Selector中,然後可以"select"出已經準備好資料的channel,或者準備好寫入的channel。這個selector機制使得單個執行緒同時管理多個channel變得更容易。
2. NIO和IO的不同對程式碼設計帶來的變化
選擇使用NIO還是IO作為開發工具包會在如下幾個方面影響應用設計:
- API是呼叫NIO類庫還是IO類庫;
- 資料的處理方式;
- 用來處理資料的執行緒的數量;
2.1 API的呼叫
採用NIO的API呼叫方式和IO是不一樣的,與直接從InputStream中讀取位元組資料不同,在NIO中,資料必須要先被讀到buffer中,然後再從那裡進行後續的處理。
2.2 資料的處理方式
採用NIO的設計還是IO的設計,資料的處理方式也是不一樣的。
在IO設計中是從InputStream或Reader中逐位元組讀取資料。在下面例子中,我們通過一個處理基於文字的簡單例子來說明兩種設計的區別:
Name: Anna
Age: 25
Email: anna@mailserver.com
Phone: 1234567890
採用IO的方式,這些資料流會像下面這樣處理:
InputStream input = ... ; // get the InputStream from the client socket BufferedReader reader = new BufferedReader(new InputStreamReader(input)); String nameLine = reader.readLine(); String ageLine = reader.readLine(); String emailLine = reader.readLine(); String phoneLine = reader.readLine();
注意在這裡處理狀態是通過程式執行了多少就能夠確定的。換句話說,當第一行reader.readLine()返回之後,可以確定已經讀了一整行。因為readLine()會阻塞直到整行資料讀完。而且我們能夠確切地知道所讀取的這第一行是包含名字的。類似,第二次呼叫readLine()返回之後我們確切地知道所讀取的內容包含年齡。
可以知道,上面的程式只有當有新的資料是可讀時才會進行處理,在每一步都知道資料是什麼。一旦執行讀寫的執行緒已經讀取了一些資料之後,是不能夠再返回到前面的資料(因為流的方式只能讀取一次,很好理解,像水一樣,流完了就流完了,除非你把它裝到容器裡面)。上面程式中所遵循的原則如下圖所示:
而NIO的實現則看起來有些不同,如下:
ByteBuffer buffer = ByteBuffer.allocate(48); int bytesRead = inChannel.read(buffer);
注意第二行是從channel讀取資料到buffer中,當read()方法返回時我們是不知道是否所有需要的資料有沒有全部讀到buffer中,我們知道的只是buffer中可能包含一部分資料,這會使得整個過程的處理有點麻煩。
假設,在第一次呼叫read()之後,所有讀到buffer中的資料只有半行,比如,"Name:An"。這時可以處理資料嗎,顯然是不可以的(因為還沒有讀完),需要等到至少一行資料被讀到buffer中。
那麼我們又如何來知道buffer中包含足夠可以處理的資料呢?唯一的辦法只有檢查buffer中的資料了。所以結果就是我們需要通過多次檢查buffer中的資料來判斷資料是否已經全部讀進buffer了。這樣就很低效,而且容易導致程式設計混亂。比如:
ByteBuffer buffer = ByteBuffer.allocate(48); int bytesRead = inChannel.read(buffer); while(! bufferFull(bytesRead) ) { bytesRead = inChannel.read(buffer); }
bufferFull()方法會跟蹤有多少資料被讀到buffer中了,並且返回true或者false,取決於buffer是否已滿。換言之,如果buffer中的資料已經可供處理,那就代表它已經滿了。
bufferFull()方法會掃描整個buffer,要保證掃描並不會影響整個buffer的狀態,不然可能導致後面要讀入buffer中的資料不能讀到正確地位置。這並非不可能,所以對於設計者來說這是一個需要關注的地方。
如果buffer已滿,那其中的資料就可供處理。如果沒滿,那可能需要部分地處理那些資料(如果需要的話),只是在大部分場景下是不需要的。
下圖描述了這種 is-data-in-buffer-ready的迴圈:
3. 兩種IO的各自適用場景
NIO使得通過單個或少量執行緒來管理多個channel(網路連線或者檔案)成為可能,但是代價是傳遞資料會比從阻塞的流中讀資料更復雜。我們學習一項新的技術時,既要看到其優點也要看到其缺點。
如果需要同時管理數以千計的連線,而且每個連線只會傳送少量的資料,比如聊天伺服器,用NIO的方式來實現這個伺服器則比較合適。類似的,如果需要長時間保持一些和別的電腦的連線,比如在一個P2P網路中,用單個執行緒來管理所有的對外連線也有優勢。如下圖描述了這種單個執行緒,多個連線的設計模型:
如果只有少量的連線,但是每個連線又都佔用大量的頻寬,短時間之內傳送大量資料,這時後也許傳統的IO模型會更適用,因為專一,所以在特定場景下可以更高效。如下圖描述了一個基於傳統IO模型設計的伺服器模型:
4. 總結
在前面總結了很多IO和NIO的相關知識之後,本文總結了Java中兩種IO類庫的區別即各自的優缺點:
- 傳統Java IO是面向流,從流中讀取資料或者寫入到流中,而Java NIO是面向緩衝區的,通過channel和buffer的搭配使用來讀取或者寫入資料;
- 面向流只能一次讀取資料;面向緩衝區可以多次讀取資料;
- 面向流的方式處理資料過程相對簡單,易於實現;而Java NIO中面向buffer的方式一般是非阻塞的方式,所以在資料的操作上會更復雜,從而會增加程式碼的複雜程度;
- Java NIO提供了Selector的概念,可以通過少量執行緒處理多個連線,可以有效處理併發;而Java IO則專注於單個執行緒阻塞式讀寫,對於少量連線但是每個連線都佔用大量寬頻的場景更適用;
技術沒有好壞,只有合適與否!