search
尋找貓咪~QQ 地點 桃園市桃園區 Taoyuan , Taoyuan

java核心筆記之NIO

同步、非同步、阻塞、非阻塞

首先,這幾個概念非常容易搞混淆,但NIO中又有涉及,所以總結一下[1]。

同步: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 三種機制,我們下文所說的是前兩種,JDK 1.7才開始引入非同步 I/O,那稱之為NIO.2。

傳統IO

我們知道,一個新技術的出現總是伴隨著改進和提升,Java NIO的出現亦如此。

傳統 I/O 是阻塞式I/O,主要問題是系統資源的浪費。比如我們為了讀取一個TCP連接的數據,調用 InputStream 的 read 方法,這會使當前線程被掛起,直到有數據到達才被喚醒,那該線程在數據到達這段時間內,佔用著內存資源(存儲線程棧)卻無所作為,也就是俗話說的占著茅坑不拉屎,為了讀取其他連接的數據,我們不得不啟動另外的線程。在併發連接數量不多的時候,這可能沒什麼問題,然而當連接數量達到一定規模,內存資源會被大量線程消耗殆盡。另一方面,線程切換需要更改處理器的狀態,比如程序計數器、寄存器的值,因此非常頻繁的在大量線程之間切換,同樣是一種資源浪費。

隨著技術的發展,現代操作系統提供了新的I/O機制,可以避免這種資源浪費。基於此,誕生了Java NIO,NIO的代表性特徵就是非阻塞I/O。緊接著我們發現,簡單的使用非阻塞I/O並不能解決問題,因為在非阻塞模式下,read方法在沒有讀取到數據時就會立即返回,不知道數據何時到達的我們,只能不停的調用read方法進行重試,這顯然太浪費CPU資源了,從下文可以知道,Selector組件正是為解決此問題而生。

Java NIO 核心組件

1.Channel

概念

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服務進行交互。

通道是一種很基本很抽象的描述,和不同的I/O服務交互,執行不同的I/O操作,實現不一樣,因此具體的有FileChannel、SocketChannel等。

通道使用起來跟Stream比較像,可以讀取數據到Buffer中,也可以把Buffer中的數據寫入通道。

當然,也有區別,主要體現在如下兩點:

一個通道,既可以讀又可以寫,而一個Stream是單向的(所以分 InputStream 和 OutputStream)

通道有非阻塞I/O模式

實現

Java NIO中最常用的通道實現是如下幾個,可以看出跟傳統的 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 不能設置為非阻塞模式)。

這個例子中使用了兩個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的重要組件。

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 的集合來獲取到那些狀態就緒了的通道。

一個完整實例

概念和理論的東西闡述完了(其實寫到這裡,我發現沒寫出多少東西,好尷尬(⊙ˍ⊙)),看一個完整的例子吧。

這個例子使用Java NIO實現了一個單線程的服務端,功能很簡單,監聽客戶端連接,當連接建立后,讀取客戶端的消息,並向客戶端響應一條消息。

需要注意的是,我用字元 『\0′(一個值為0的位元組) 來標識消息結束。

單線程Server

這個客戶端純粹測試用,為了看起來不那麼費勁,就用傳統的寫法了,代碼很簡短。

要嚴謹一點測試的話,應該併發運行大量Client,統計服務端的響應時間,而且連接建立后不要立刻發送數據,這樣才能發揮出服務端非阻塞I/O的優勢。

NIO vs IO

學習了NIO之後我們都會有這樣一個疑問:到底什麼時候該用NIO,什麼時候該用傳統的I/O呢?

其實了解他們的特性后,答案還是比較明確的,NIO擅長1個線程管理多條連接,節約系統資源,但是如果每條連接要傳輸的數據量很大的話,因為是同步I/O,會導致整體的響應速度很慢;而傳統I/O為每一條連接創建一個線程,能充分利用處理器并行處理的能力,但是如果連接數量太多,內存資源會很緊張。

總結就是:連接數多數據量小用NIO,連接數少用I/O(寫起來也簡單- -)。

Next

經過NIO核心組件的學習,了解了非阻塞服務端實現的基本方法。然而,細心的你們肯定也發現了,上面那個完整的例子,實際上就隱藏了很多問題。比如,例子中只是簡單的將讀取到的每個位元組輸出,實際環境中肯定是要讀取到完整的消息后才能進行下一步處理,由於NIO的非阻塞特性,一次可能只讀取到消息的一部分,這已經很糟糕了,如果同一條連接會連續發來多條消息,那不僅要對消息進行拼接,還需要切割,同理,例子中給客戶端響應的時候,用了個while循環,保證數據全部write完成再做其它工作,實際應用中為了性能,肯定不會這麼寫。另外,為了充分利用現代處理器多核心并行處理的能力,應該用一個線程組來管理這些連接的事件。

要解決這些問題,需要一個嚴謹而繁瑣的設計,不過幸運的是,我們有開源的框架可用,那就是優雅而強大的Netty,Netty基於Java NIO,提供非同步調用介面,開發高性能伺服器的一個很好的選擇,之前在項目中使用過,但沒有深入學習,打算下一步好好學學它,到時候再寫一篇筆記。

Java NIO設計的目標是為程序員提供API以享受現代操作系統最新的I/O機制,所以覆蓋面較廣,除了文中所涉及的組件與特性,還有很多其它的,比如 Pipe(管道)、Path(路徑)、Files(文件) 等,有的是用於提升I/O性能的新組件,有的是簡化I/O操作的工具,具體用法可以參看最後 References 里的鏈接。

References

[1] Differences Between Synchronous and Asynchronous I/O

[2] Java NIO – Wikipedia

[3] Java NIO Tutorial

[4] Package java.nio



熱門推薦

本文由 yidianzixun 提供 原文連結

寵物協尋 相信 終究能找到回家的路
寫了7763篇文章,獲得2次喜歡
留言回覆
回覆
精彩推薦