同步、異步、阻塞、非阻塞
首先,這幾個概念很是容易搞混淆,但NIO中又有涉及,因此總結一下[1]。程序員
對於I/O操做,根據Oracle官網的文檔,同步異步的劃分標準是「調用者是否須要等待I/O操做完成」,這個「等待I/O操做完成」的意思不是指必定要讀取到數據或者說寫入全部數據,而是指真正進行I/O操做時,好比數據在TCP/IP協議棧緩衝區和JVM緩衝區之間傳輸的這段時間,調用者是否要等待。編程
因此,咱們經常使用的 read() 和 write() 方法都是同步I/O,同步I/O又分爲阻塞和非阻塞兩種模式,若是是非阻塞模式,檢測到無數據可讀時,直接就返回了,並無真正執行I/O操做。數組
總結就是,Java中實際上只有 同步阻塞I/O、同步非阻塞I/O 與 異步I/O 三種機制,咱們下文所說的是前兩種,JDK 1.7纔開始引入異步 I/O,那稱之爲NIO.2。服務器
咱們知道,一個新技術的出現老是伴隨着改進和提高,Java NIO的出現亦如此。網絡
傳統 I/O 是阻塞式I/O,主要問題是系統資源的浪費。好比咱們爲了讀取一個TCP鏈接的數據,調用 InputStream 的 read() 方法,這會使當前線程被掛起,直到有數據到達才被喚醒,那該線程在數據到達這段時間內,佔用着內存資源(存儲線程棧)卻無所做爲,也就是俗話說的佔着茅坑不拉屎,爲了讀取其餘鏈接的數據,咱們不得不啓動另外的線程。在併發鏈接數量很少的時候,這可能沒什麼問題,然而當鏈接數量達到必定規模,內存資源會被大量線程消耗殆盡。另外一方面,線程切換須要更改處理器的狀態,好比程序計數器、寄存器的值,所以很是頻繁的在大量線程之間切換,一樣是一種資源浪費。多線程
隨着技術的發展,現代操做系統提供了新的I/O機制,能夠避免這種資源浪費。基於此,誕生了Java NIO,NIO的表明性特徵就是非阻塞I/O。緊接着咱們發現,簡單的使用非阻塞I/O並不能解決問題,由於在非阻塞模式下,read()方法在沒有讀取到數據時就會當即返回,不知道數據什麼時候到達的咱們,只能不停的調用read()方法進行重試,這顯然太浪費CPU資源了,從下文能夠知道,Selector組件正是爲解決此問題而生。併發
Java NIO中的全部I/O操做都基於Channel對象,就像流操做都要基於Stream對象同樣,所以頗有必要先了解Channel是什麼。如下內容摘自JDK 1.8的文檔框架
A channel represents an open connection to an entity such as a
hardware device, a file, a network socket, or a program component that
is capable of performing one or more distinct I/O operations, for
example reading or writing.
從上述內容可知,一個Channel(通道)表明和某一實體的鏈接,這個實體能夠是文件、網絡套接字等。也就是說,通道是Java NIO提供的一座橋樑,用於咱們的程序和操做系統底層I/O服務進行交互。dom
通道是一種很基本很抽象的描述,和不一樣的I/O服務交互,執行不一樣的I/O操做,實現不同,所以具體的有FileChannel、SocketChannel等。異步
通道使用起來跟Stream比較像,能夠讀取數據到Buffer中,也能夠把Buffer中的數據寫入通道。
固然,也有區別,主要體如今以下兩點:
Java NIO中最經常使用的通道實現是以下幾個,能夠看出跟傳統的 I/O 操做類是一一對應的。
NIO中所使用的緩衝區不是一個簡單的byte數組,而是封裝過的Buffer類,經過它提供的API,咱們能夠靈活的操縱數據,下面細細道來。
與Java基本類型相對應,NIO提供了多種 Buffer 類型,如ByteBuffer、CharBuffer、IntBuffer等,區別就是讀寫緩衝區時的單位長度不同(以對應類型的變量爲單位進行讀寫)。
Buffer中有3個很重要的變量,它們是理解Buffer工做機制的關鍵,分別是
Buffer的工做方式跟C語言裏的字符數組很是的像,類比一下,capacity就是數組的總長度,position就是咱們讀/寫字符的下標變量,limit就是結束符的位置。Buffer初始時3個變量的狀況以下圖
在對Buffer進行讀/寫的過程當中,position會日後移動,而 limit 就是 position 移動的邊界。由此不難想象,在對Buffer進行寫入操做時,limit應當設置爲capacity的大小,而對Buffer進行讀取操做時,limit應當設置爲數據的實際結束位置。(注意:將Buffer數據 寫入 通道是Buffer 讀取 操做,從通道 讀取 數據到Buffer是Buffer 寫入 操做)
在對Buffer進行讀/寫操做前,咱們能夠調用Buffer類提供的一些輔助方法來正確設置 position 和 limit 的值,主要有以下幾個
而後,看一個實例,使用 FileChannel 讀寫文本文件,經過這個例子驗證通道可讀可寫的特性以及Buffer的基本用法(注意 FileChannel 不能設置爲非阻塞模式)。
FileChannel channel = new RandomAccessFile("test.txt", "rw").getChannel(); channel.position(channel.size()); // 移動文件指針到末尾(追加寫入) ByteBuffer byteBuffer = ByteBuffer.allocate(20); // 數據寫入Buffer byteBuffer.put("你好,世界!\n".getBytes(StandardCharsets.UTF_8)); // Buffer -> Channel byteBuffer.flip(); while (byteBuffer.hasRemaining()) { channel.write(byteBuffer); } channel.position(0); // 移動文件指針到開頭(從頭讀取) CharBuffer charBuffer = CharBuffer.allocate(10); CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder(); // 讀出全部數據 byteBuffer.clear(); while (channel.read(byteBuffer) != -1 || byteBuffer.position() > 0) { byteBuffer.flip(); // 使用UTF-8解碼器解碼 charBuffer.clear(); decoder.decode(byteBuffer, charBuffer, false); System.out.print(charBuffer.flip().toString()); byteBuffer.compact(); // 數據可能有剩餘 } channel.close();
這個例子中使用了兩個Buffer,其中 byteBuffer 做爲通道讀寫的數據緩衝區,charBuffer 用於存儲解碼後的字符。clear() 和 flip() 的用法正如上文所述,須要注意的是最後那個 compact() 方法,即便 charBuffer 的大小徹底足以容納 byteBuffer 解碼後的數據,這個 compact() 也必不可少,這是由於經常使用中文字符的UTF-8編碼佔3個字節,所以有很大機率出如今中間截斷的狀況,請看下圖:
當 Decoder 讀取到緩衝區末尾的 0xe4 時,沒法將其映射到一個 Unicode,decode()方法第三個參數 false 的做用就是讓 Decoder 把沒法映射的字節及其後面的數據都視做附加數據,所以 decode() 方法會在此處中止,而且 position 會回退到 0xe4 的位置。如此一來, 緩衝區中就遺留了「中」字編碼的第一個字節,必須將其 compact 到前面,以正確的和後序數據拼接起來。
BTW,例子中的 CharsetDecoder 也是 Java NIO 的一個新特性,因此你們應該發現了一點哈,NIO的操做是面向緩衝區的(傳統I/O是面向流的)。
至此,咱們瞭解了 Channel 與 Buffer 的基本用法。接下來要說的是讓一個線程管理多個Channel的重要組件。
Selector(選擇器)是一個特殊的組件,用於採集各個通道的狀態(或者說事件)。咱們先將通道註冊到選擇器,並設置好關心的事件,而後就能夠經過調用select()方法,靜靜地等待事件發生。
通道有以下4個事件可供咱們監聽:
前文說了,若是用阻塞I/O,須要多線程(浪費內存),若是用非阻塞I/O,須要不斷重試(耗費CPU)。Selector的出現解決了這尷尬的問題,非阻塞模式下,經過Selector,咱們的線程只爲已就緒的通道工做,不用盲目的重試了。好比,當全部通道都沒有數據到達時,也就沒有Read事件發生,咱們的線程會在select()方法處被掛起,從而讓出了CPU資源。
以下所示,建立一個Selector,並註冊一個Channel。
注意:要將 Channel 註冊到 Selector,首先須要將 Channel 設置爲非阻塞模式,不然會拋異常。
Selector selector = Selector.open(); channel.configureBlocking(false); SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
register()方法的第二個參數名叫「interest set」,也就是你所關心的事件集合。若是你關心多個事件,用一個「按位或運算符」分隔,好比
SelectionKey.OP_READ | SelectionKey.OP_WRITE複製代碼
這種寫法一點都不陌生,支持位運算的編程語言裏都這麼玩,用一個整型變量能夠標識多種狀態,它是怎麼作到的呢,其實很簡單,舉個例子,首先預約義一些常量,它們的值(二進制)以下
能夠發現,它們值爲1的位都是錯開的,所以對它們進行按位或運算以後得出的值就沒有二義性,能夠反推出是由哪些變量運算而來。怎麼判斷呢,沒錯,就是「按位與」運算。好比,如今有一個狀態集合變量值爲 0011,咱們只須要判斷 「0011 & OP_READ」 的值是 1 仍是 0 就能肯定集合是否包含 OP_READ 狀態。
而後,注意 register() 方法返回了一個SelectionKey的對象,這個對象包含了本次註冊的信息,咱們也能夠經過它修改註冊信息。從下面完整的例子中能夠看到,select()以後,咱們也是經過獲取一個 SelectionKey 的集合來獲取到那些狀態就緒了的通道。
概念和理論的東西闡述完了(其實寫到這裏,我發現沒寫出多少東西,好尷尬(⊙ˍ⊙)),看一個完整的例子吧。
這個例子使用Java NIO實現了一個單線程的服務端,功能很簡單,監聽客戶端鏈接,當鏈接創建後,讀取客戶端的消息,並向客戶端響應一條消息。
須要注意的是,我用字符 ‘0′(一個值爲0的字節) 來標識消息結束。
public class NioServer { public static void main(String[] args) throws IOException { // 建立一個selector Selector selector = Selector.open(); // 初始化TCP鏈接監聽通道 ServerSocketChannel listenChannel = ServerSocketChannel.open(); listenChannel.bind(new InetSocketAddress(9999)); listenChannel.configureBlocking(false); // 註冊到selector(監聽其ACCEPT事件) listenChannel.register(selector, SelectionKey.OP_ACCEPT); // 建立一個緩衝區 ByteBuffer buffer = ByteBuffer.allocate(100); while (true) { selector.select(); //阻塞,直到有監聽的事件發生 Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator(); // 經過迭代器依次訪問select出來的Channel事件 while (keyIter.hasNext()) { SelectionKey key = keyIter.next(); if (key.isAcceptable()) { // 有鏈接能夠接受 SocketChannel channel = ((ServerSocketChannel) key.channel()).accept(); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_READ); System.out.println("與【" + channel.getRemoteAddress() + "】創建了鏈接!"); } else if (key.isReadable()) { // 有數據能夠讀取 buffer.clear(); // 讀取到流末尾說明TCP鏈接已斷開, // 所以須要關閉通道或者取消監聽READ事件 // 不然會無限循環 if (((SocketChannel) key.channel()).read(buffer) == -1) { key.channel().close(); continue; } // 按字節遍歷數據 buffer.flip(); while (buffer.hasRemaining()) { byte b = buffer.get(); if (b == 0) { // 客戶端消息末尾的\0 System.out.println(); // 響應客戶端 buffer.clear(); buffer.put("Hello, Client!\0".getBytes()); buffer.flip(); while (buffer.hasRemaining()) { ((SocketChannel) key.channel()).write(buffer); } } else { System.out.print((char) b); } } } // 已經處理的事件必定要手動移除 keyIter.remove(); } } } }
這個客戶端純粹測試用,爲了看起來不那麼費勁,就用傳統的寫法了,代碼很簡短。
要嚴謹一點測試的話,應該併發運行大量Client,統計服務端的響應時間,並且鏈接創建後不要馬上發送數據,這樣才能發揮出服務端非阻塞I/O的優點。
public class Client { public static void main(String[] args) throws Exception { Socket socket = new Socket("localhost", 9999); InputStream is = socket.getInputStream(); OutputStream os = socket.getOutputStream(); // 先向服務端發送數據 os.write("Hello, Server!\0".getBytes()); // 讀取服務端發來的數據 int b; while ((b = is.read()) != 0) { System.out.print((char) b); } System.out.println(); socket.close(); } }
學習了NIO以後咱們都會有這樣一個疑問:到底何時該用NIO,何時該用傳統的I/O呢?
其實瞭解他們的特性後,答案仍是比較明確的,NIO擅長1個線程管理多條鏈接,節約系統資源,可是若是每條鏈接要傳輸的數據量很大的話,由於是同步I/O,會致使總體的響應速度很慢;而傳統I/O爲每一條鏈接建立一個線程,能充分利用處理器並行處理的能力,可是若是鏈接數量太多,內存資源會很緊張。
總結就是:鏈接數多數據量小用NIO,鏈接數少用I/O(寫起來也簡單- -)。
通過NIO核心組件的學習,瞭解了非阻塞服務端實現的基本方法。然而,細心的大家確定也發現了,上面那個完整的例子,實際上就隱藏了不少問題。好比,例子中只是簡單的將讀取到的每一個字節輸出,實際環境中確定是要讀取到完整的消息後才能進行下一步處理,因爲NIO的非阻塞特性,一次可能只讀取到消息的一部分,這已經很糟糕了,若是同一條鏈接會連續發來多條消息,那不只要對消息進行拼接,還須要切割,同理,例子中給客戶端響應的時候,用了個while()循環,保證數據所有write完成再作其它工做,實際應用中爲了性能,確定不會這麼寫。另外,爲了充分利用現代處理器多核心並行處理的能力,應該用一個線程組來管理這些鏈接的事件。
要解決這些問題,須要一個嚴謹而繁瑣的設計,不過幸運的是,咱們有開源的框架可用,那就是優雅而強大的Netty,Netty基於Java NIO,提供異步調用接口,開發高性能服務器的一個很好的選擇,以前在項目中使用過,但沒有深刻學習,打算下一步好好學學它,到時候再寫一篇筆記。
Java NIO設計的目標是爲程序員提供API以享受現代操做系統最新的I/O機制,因此覆蓋面較廣,除了文中所涉及的組件與特性,還有不少其它的,好比 Pipe(管道)、Path(路徑)、Files(文件) 等,有的是用於提高I/O性能的新組件,有的是簡化I/O操做的工具,具體用法能夠參看最後 References 裏的連接。