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進行讀/寫操作前,我們可以呼叫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); // 資料寫入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個位元組,因此有很大概率出現在中間截斷的情況,請看下圖:
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
這種寫法一點都不陌生,支援位運算的程式語言裡都這麼玩,用一個整型變數可以標識多種狀態,它是怎麼做到的呢,其實很簡單,舉個例子,首先預定義一些常量,它們的值(二進位制)如下
然後,注意 register() 方法返回了一個SelectionKey的物件,這個物件包含了本次註冊的資訊,我們也可以通過它修改註冊資訊。從下面完整的例子中可以看到,select()之後,我們也是通過獲取一個 SelectionKey 的集合來獲取到那些狀態就緒了的通道。
一個完整例項
概念和理論的東西闡述完了(其實寫到這裡,我發現沒寫出多少東西,好尷尬(⊙ˍ⊙)),看一個完整的例子吧。
這個例子使用Java NIO實現了一個單執行緒的服務端,功能很簡單,監聽客戶端連線,當連線建立後,讀取客戶端的訊息,並向客戶端響應一條訊息。
需要注意的是,我用字元 ‘\0′(一個值為0的位元組) 來標識訊息結束。
單執行緒Server
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
這個客戶端純粹測試用,為了看起來不那麼費勁,就用傳統的寫法了,程式碼很簡短。
要嚴謹一點測試的話,應該併發執行大量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 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
[4] Package java.nio
相關文章
- Java IO學習筆記五:BIO到NIOJava筆記
- Java IO學習筆記六:NIO到多路複用Java筆記
- JAVA核心技術學習筆記--反射Java筆記反射
- JAVA學習筆記—JAVA WEB(二)JAVA WEB核心(下)Java筆記Web
- 一文理解:Java NIO 核心元件Java元件
- JVM核心學習筆記JVM筆記
- React學習筆記-元件React筆記元件
- java BIO、NIO學習Java
- Android 學習筆記核心篇Android筆記
- JAVA學習筆記Java筆記
- 《JAVA學習指南》學習筆記Java筆記
- java BIO/NIO/AIO 學習JavaAI
- webpack 學習筆記:核心概念(上)Web筆記
- webpack 學習筆記:核心概念(下)Web筆記
- elasticsearch學習筆記一:核心概念Elasticsearch筆記
- 一文讓你徹底理解 Java NIO 核心元件Java元件
- Java學習筆記4Java筆記
- Java JNI 學習筆記Java筆記
- java學習筆記6Java筆記
- Java 集合學習筆記Java筆記
- 《Java核心技術》第五章 繼承 學習筆記Java繼承筆記
- Flutter學習筆記(12)--列表元件Flutter筆記元件
- Flutter學習筆記(9)--元件WidgetFlutter筆記元件
- vue學習筆記(六) ----- vue元件Vue筆記元件
- Java NIO學習系列四:NIO和IO對比Java
- Java NIO學習系列二:ChannelJava
- Java NIO學習系列三:SelectorJava
- Java NIO學習系列一:BufferJava
- Java學習筆記記錄(二)Java筆記
- Flutter學習筆記(10)--容器元件、圖片元件Flutter筆記元件
- Java基礎學習筆記Java筆記
- 【部分】Java速成學習筆記Java筆記
- Java學習筆記--運算子Java筆記
- Kotlin for Java Developers 學習筆記KotlinJavaDeveloper筆記
- java學習筆記(異常)Java筆記
- 【Java學習筆記】Collections集合Java筆記
- Java JDK 9學習筆記JavaJDK筆記
- TensorFlow Java API 學習筆記JavaAPI筆記
- Java學習筆記系列-反射Java筆記反射