背景知識
同步、異步、阻塞、非阻塞
首先,這幾個概念非常容易搞混淆,但NIO中又有涉及,所以總結一下。
同步:API調用返回時調用者就知道操作的結果如何了(實際讀取/寫入了多少字節)。
異步:相對於同步,API調用返回時調用者不知道操作的結果,後面才會回調通知結果。
阻塞:當無數據可讀,或者不能寫入所有數據時,掛起當前線程等待。
非阻塞:讀取時,可以讀多少數據就讀多少然後返回,寫入時,可以寫入多少數據就寫入多少然後返回。
對於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三種機制,我們下文所說的是前兩種,JDK1.7才開始引入異步I/O,那稱之為NIO.2。
傳統IO
我們知道,一個新技術的出現總是伴隨著改進和提升,JavaNIO的出現亦如此。
傳統I/O是阻塞式I/O,主要問題是系統資源的浪費。比如我們為了讀取一個TCP連接的數據,調用InputStream的read()方法,這會使當前線程被掛起,直到有數據到達才被喚醒,那該線程在數據到達這段時間內,佔用著內存資源(存儲線程棧)卻無所作為,也就是俗話說的佔著茅坑不拉屎,為了讀取其他連接的數據,我們不得不啟動另外的線程。在並發連接數量不多的時候,這可能沒什麼問題,然而當連接數量達到一定規模,內存資源會被大量線程消耗殆盡。另一方面,線程切換需要更改處理器的狀態,比如程序計數器、寄存器的值,因此非常頻繁的在大量線程之間切換,同樣是一種資源浪費。
隨著技術的發展,現代操作系統提供了新的I/O機制,可以避免這種資源浪費。基於此,誕生了JavaNIO,NIO的代表性特徵就是非阻塞I/O。緊接著我們發現,簡單的使用非阻塞I/O並不能解決問題,因為在非阻塞模式下,read()方法在沒有讀取到數據時就會立即返回,不知道數據何時到達的我們,只能不停的調用read()方法進行重試,這顯然太浪費CPU資源了,從下文可以知道,Selector組件正是為解決此問題而生。
JavaNIO核心組件
1.Channel
概念
JavaNIO中的所有I/O操作都基於Channel對象,就像流操作都要基於Stream對像一樣,因此很有必要先了解Channel是什麼。以下內容摘自JDK1.8的文檔
Achannelrepresentsanopenconnectiontoanentitysuchasahardwaredevice,afile,anetworksocket,oraprogramcomponentthatiscapableofperformingoneormoredistinctI/Ooperations,forexamplereadingorwriting.
從上述內容可知,一個Channel(通道)代表和某一實體的連接,這個實體可以是文件、網絡套接字等。也就是說,通道是JavaNIO提供的一座橋樑,用於我們的程序和操作系統底層I/O服務進行交互。
通道是一種很基本很抽象的描述,和不同的I/O服務交互,執行不同的I/O操作,實現不一樣,因此具體的有FileChannel、SocketChannel等。
通道使用起來跟Stream比較像,可以讀取數據到Buffer中,也可以把Buffer中的數據寫入通道。
當然,也有區別,主要體現在如下兩點:
一個通道,既可以讀又可以寫,而一個Stream是單向的(所以分InputStream和OutputStream)
通道有非阻塞I/O模式
實現
JavaNIO中最常用的通道實現是如下幾個,可以看出跟傳統的I/O操作類是一一對應的。
FileChannel:讀寫文件
DatagramChannel:UDP協議網絡通信
SocketChannel:TCP協議網絡通信
ServerSocketChannel:監聽TCP連接
2.Buffer
NIO中所使用的緩衝區不是一個簡單的byte數組,而是封裝過的Buffer類,通過它提供的API,我們可以靈活的操縱數據,下面細細道來。
與Java基本類型相對應,NIO提供了多種Buffer類型,如ByteBuffer、CharBuffer、IntBuffer等,區別就是讀寫緩衝區時的單位長度不一樣(以對應類型的變量為單位進行讀寫)。
Buffer中有3個很重要的變量,它們是理解Buffer工作機制的關鍵,分別是
capacity(總容量)
position(指針當前位置)
limit(讀/寫邊界位置)
Buffer的工作方式跟C語言裡的字符數組非常的像,類比一下,capacity就是數組的總長度,position就是我們讀/寫字符的下標變量,limit就是結束符的位置。 Buffer初始時3個變量的情況如下圖
在對Buffer進行讀/寫的過程中,position會往後移動,而limit就是position移動的邊界。由此不難想像,在對Buffer進行寫入操作時,limit應當設置為capacity的大小,而對Buffer進行讀取操作時,limit應當設置為數據的實際結束位置。 (注意:將Buffer數據寫入通道是Buffer讀取操作,從通道讀取數據到Buffer是Buffer寫入操作)
在對Buffer進行讀/寫操作前,我們可以調用Buffer類提供的一些輔助方法來正確設置position和limit的值,主要有如下幾個
flip():設置limit為position的值,然後position置為0。對Buffer進行讀取操作前調用。
rewind():僅僅將position置0。一般是在重新讀取Buffer數據前調用,比如要讀取同一個Buffer的數據寫入多個通道時會用到。
clear():回到初始狀態,即limit等於capacity,position置0。重新對Buffer進行寫入操作前調用。
compact():將未讀取完的數據(position與limit之間的數據)移動到緩衝區開頭,並將position設置為這段數據末尾的下一個位置。其實就等價於重新向緩衝區中寫入了這麼一段數據。
然後,看一個實例,使用FileChannel讀寫文本文件,通過這個例子驗證通道可讀可寫的特性以及Buffer的基本用法(注意FileChannel不能設置為非阻塞模式)。
FileChannel channel = new RandomAccessFile("test.txt", "rw").getChannel();channel.position(channel.size());// 移動文件指針到末尾(追加寫入)ByteBuffer byteBuffer = ByteBuffer.allocate(20);// 數據寫入BufferbyteBuffer.put("你好,世界!/n".getBytes(StandardCharsets.UTF_8));// Buffer -> ChannelbyteBuffer.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 到前面,以正確的和後序數據拼接起來。關於字符編碼,大家可以參閱《 ANSI,Unicode,BMP,UTF等編碼概念實例講解》
BTW,例子中的CharsetDecoder也是JavaNIO的一個新特性,所以大家應該發現了一點哈,NIO的操作是面向緩衝區的(傳統I/O是面向流的)。
至此,我們了解了Channel與Buffer的基本用法。接下來要說的是讓一個線程管理多個Channel的重要組件。
3.Selector
Selector是什麼
Selector(選擇器)是一個特殊的組件,用於採集各個通道的狀態(或者說事件)。我們先將通道註冊到選擇器,並設置好關心的事件,然後就可以通過調用select()方法,靜靜地等待事件發生。
通道有如下4個事件可供我們監聽:
Accept:有可以接受的連接
Connect:連接成功
Read:有數據可讀
Write:可以寫入數據了
為什麼要用Selector
前文說了,如果用阻塞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的集合來獲取到那些狀態就緒了的通道。
一個完整實例
概念和理論的東西闡述完了(其實寫到這裡,我發現沒寫出多少東西,好尷尬(⊙ˍ⊙)),看一個完整的例子吧。
這個例子使用JavaNIO實現了一個單線程的服務端,功能很簡單,監聽客戶端連接,當連接建立後,讀取客戶端的消息,並向客戶端響應一條消息。
需要注意的是,我用字符'/0′(一個值為0的字節)來標識消息結束。
單線程Server
public class NioServer {public static void main(String[] args) throws IOException {// 創建一個selectorSelector 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) {// 客戶端消息末尾的/0System.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
這個客戶端純粹測試用,為了看起來不那麼費勁,就用傳統的寫法了,代碼很簡短。
要嚴謹一點測試的話,應該並發運行大量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();}}總結
以上就是本文關於快速了解Java中NIO核心組件的全部內容,希望對大家有所幫助。感興趣的朋友可以繼續參閱本站其他相關內容,如有不足之處,歡迎留言指出。感謝朋友們對本站的支持!