系統且透徹理解Java NIO(內容較多,慎入)

本文揭示了Java NIO底層的諸多細節與使用和理解上的陷阱,對於NIO的學習非常有幫助。

本文是筆者在學習NIO過程中發現的一些比較容易讓人忽略的知識的一個總結,而這些讓人忽略的小細節恰恰是NIO網絡編程中必不可少。雖然現在我們不會直接編寫NIO來完成我們的網絡層通訊,而是使用成熟的基於NIO的網絡框架來實現我們的網絡層。如,netty、mina。但對NIO網絡編程過程的瞭解,非常有助於我們更深入的理解netty、mina等網絡框架,以至於能更好的使用它們。 因此,本文並不對NIO的一些底層知識做過多的介紹,主要側重於NIO編程中細節的講解。

NIO vs IO

  • 標準的IO基於字節流和字符流進行操作的;而NIO是基於通道(Channel)進行操作的。

  • 通道是雙向的,既可以寫數據到通道,又可以從通道中讀取數據;而流的讀寫通常是單向的,要麼是輸入流,要麼是輸出流,不能既是輸入流又是輸出流。

  • NIO能夠實現非阻塞的網絡通信,而IO只能實現阻塞式的網絡通信。 

Buffer

Java NIO中的Buffer用於和NIO通道進行交互。數據總是從通道讀取到緩衝區,或者從緩衝區寫入到通道中。
Buffer是一個特定的原生類型數據容器。
Buffer是一種特定的原生類型的線程的、有限的元素序列。除了它的內容之外,一個Buffer一個重要的本質屬性是它的capacity、limit、和position;

  • capacity:一個buffer的capacity指的就是它所包含的元素的個數。buffer的capacity永遠不會是負數,且永遠不會變化。

  • limit:一個buffer的limit指的是不應該被讀或寫的第一個元素的索引( position <= limit )。一個buffer的limit永遠不會是負數的,並且永遠不會超過它的capacity。

  • position:一個buffer的position指的是下一個將要被讀或寫的元素的索引。一個buffer的position永遠不會是負數的,並且永遠不會超過它的limit( 這裏也說明,position最多等於limit,當position==limit時,這個時候是不能夠在從buffer中讀取到數據了 )。

數據操作:

Buffer的每一個子類都定義了兩類get和put操作。

  • 相對操作:讀或寫 一個或多個元素 從當前position位置開始並且會根據轉換元素數量增加position的值。如果要求的轉換超過了limit,那麼一個相關的get操作會拋出BufferUnderflowException,一個相關的put操作會拋出一個BufferOverflowException,無論是這兩個哪種情況發生,都不會有數據被傳遞。

  • 絕對操作:會接受一個顯示元素的索引並且不會影響position。如果索引參數超過了limit,那麼絕對的get和put操作會拋出一個IndexOutOfBoundsException異常。

不變性:

0 <= mark <= position <= limit <= capacity

線程安全性:

buffer在多線程併發下並不是安全的。如果一個buffer會在多個線程使用,那麼需要使用恰當的同步操作來訪問buffer。也就是buffer本身並不是線程安全的。

Java NIO 內存分配

  • Heap buffer :堆棧的內存分配。堆棧就是Java內存模型當中內存的區域,位於堆上,堆是我們生成對象的區域。

  • Direct buffer :堆外內存分配。這個內存本身不是由JVM進行控制的,它是由操作系統進行統一的處理的。通過這種直接的緩衝就能實現zero-copy(零拷貝)的動作。 [ 關於堆外內存可詳見:堆外內存 之 DirectByteBuffer 詳解 ]

方法

  • flip()
    flip方法將Buffer從寫模式切換到讀模式。

  • rewind()
    rewind()方法將position設回0,limit保持不變,所以你可以重讀Buffer中的所有數據。可見在調用rewind()之前Buffer已經是處於讀模式了

  • clear()
    讓Buffer重新準備好重頭開始再次被寫入。該方法會將position、limit重置。如果此時還沒有讀取的數據,則就無法讀取到了。雖然clear()不會清楚數據,但是position、limit標誌位被重置了,所以無法找到哪些未讀取數據的位置了。

  • compact()
    compact()方法將所有未讀的數據拷貝到Buffer起始處。然後將position設到最後一個未讀元素正後面。limit屬性依然像clear()方法一樣,設置成capacity。現在Buffer準備好寫數據了,但是不會覆蓋未讀的數據。
    clear() VS compact()
    clear只是對position、limit、mark進行重置,而compact在對position進行設置,以及limit、mark進行重置的同時,還涉及到數據在內存中拷貝。所以compact比clear更耗性能。但compact能保存你未讀取的數據,將新數據追加到爲讀取的數據之後;而clear則不行,若你調用了clear,則未讀取的數據就無法再讀取到了。

  • Slice Buffer與原有buffer共享相同的底層數據
    ByteBuffer.slice(start, end) —————— [start, end),即包含start,不包含end
    slice返回的ByteBuffer底層數據和源ByteBuffer是共享的,所以無論對那個buffer進行修改,都會影響到另一buffer。

  • buffer.asReadOnlyBuffer()
    只讀buffer適用於方法傳遞時,你只希望你的調用端去讀取你所提供的buffer。即,將一個只讀buffer當做參數傳遞給某個方法。

  • ByteBuffer.wrap(byte[] array)
    該方法生成的ByteBuffer底層就是你傳進來的這個array數組,並沒有進行數組拷貝,所以是和你傳進來的array共享內容的。這也導致如果你修改了傳進來的array數組的內容,是會反映到ByteBuffer的。

  • 關於Buffer的Scattering與Gathering
    Scattering:允許read的時候傳遞一個buffer[]數組。將一個Channel中的數據給讀到了多個buffer當中,它是按照順序依次讀入buffer當中的,而且總是噹噹前buffer已經寫滿了纔會寫下一個buffer。
    Gathering:允許write的時候傳遞一個buffer[]數組。將多個buffer的數據寫到一個Channel中。它會將第一個buffer中可讀的數據都寫入channel後,再將下一個buffer中的數據寫入到channel中,以此依次將buffer中可讀取的數據寫到channel中。
    Scattering與Gathering適用於網絡操作中的自定義協議。比如,一個請求中帶有兩個請求頭以及一個body,第一個請求頭的數據長度固定是10個byte,第二個請求頭的數據長度固定是5個byte,而body的長度是不確定的。那麼我們就可以用3個buffer組成的數組來接這樣的請求。bytebuffer[]數組中,第一個bytebuffer元素的容量爲10,用於接受第一個請求頭的信息;第二個bytebuffer元素的容量爲5,用於接受第二個請求頭的信息;第三個定義一個大容量的bytebuffer用於接受body的信息。這樣就天然的實現了一種數據的分門別類。

Selector

爲什麼使用Selector?

僅用單個線程來處理多個Channels的好處是,只需要更少的線程來處理通道。事實上,可以只用一個線程處理所有的通道。因爲對於操作系統來說,線程之間上下文切換的開銷很大,而且每個線程都要佔用系統的一些資源。因此,使用的線程越少越好。

selector的非阻塞模式

與Selector一起使用時,Channel必須處於非阻塞模式下。這意味着不能將FileChannel與Selector一起使用,因爲FileChannel不能切換到非阻塞模式。而套接字通道都可以。 

廣告

NIO與Socket編程技術指南

作者:高洪巖

京東

方法

  • wakeUp()
    如果有其它線程調用了wakeup()方法,但當前沒有線程阻塞在select()方法上,下個調用select()方法的線程會立即"醒來(wake up)"。

  • close()
    用完Selector後調用其close()方法會關閉該Selector,即使註冊到該Selector上的所有SelectionKey實例無效。通道本身並不會關閉。
    linux下Selector底層是通過epoll來實現的,當創建好epoll句柄後,它就會佔用一個fd值,所以在使用完epoll後,必須調用close()關閉,否則可能導致fd被耗盡。

關於selector的詳細實現可見淺談 Linux 中 Selector 的實現原理 

SocketChannel

Java NIO中的SocketChannel是一個連接到TCP網絡套接字的通道。 

方法

  • connect()
    如果這個channel是非阻塞模式的,那麼該方法的調用將啓動一個非阻塞的操作。如果連接立即建立,當在連接一個本地地址時會發生,那麼該方法會返回true。否則若連接還未建立該方法會返回一個false,並且連接操作最後必須通過調用finishConnect方法來完成。
    這個方法可能在任何時候被調用。如果在該方法調用時,對應的channel執行了read或write操作,那麼read或write操作將先會被阻塞直到connect操作完成。如果連接嘗試啓動但是失敗了,也就是說,如果connect方法的調用拋出了一個檢查異常,那麼該通道將被關閉。
    寫了代碼測試了下,無論是是本機,還是跨機器調用,都是返回false。

  • finishConnect()
    通過設置一個socket爲非阻塞模式來開啓一個非阻塞連接操作,然後調用該socket的connect方法。一旦連接建立,或者嘗試連接失敗,那麼socket channel將變爲可連接的並且該方法可能被調用已完成連接的後續事件。如果連接操作失敗,則調用該方法將導致一個相關的IOException異常被拋出。
    如果這個channel已經連接了,那麼調用該方法不會阻塞並會立即返回true。如果這個channel是非阻塞模式的,那麼該方法將返回false如果連接操作還沒完成。如果這個channel是阻塞模式的,那麼該方法將會阻塞直到連接成功或失敗,如果連接成功則返回true,否則將拋出一個檢查異常以描述失敗。
    這個方法可能在任何時候被調用。如果在該方法調用時,對應的channel執行了read或write操作,那麼read或write操作將先會被阻塞直到connect操作完成。如果瞭解嘗試啓動但是失敗了,也就是說,如果connect方法的調用拋出了一個檢查異常,那麼該通道將被關閉。

  • isConnectionPending()
    告知這個channel是否正在進行連接操作。
    僅當這個channel的連接操作已經啓動,但是還沒完成( 用通過調用finishConnect方法來完成 )。

示例:

無論如何在connect後finishConnect()sorry 方法都是需要被調用的。調用finishConnect()的三種返回:

① 如果你在connect()後直接調用了finishConnect()( 並非在CONNECT事件中調用 ),則若finishConnect()返回了true,則表示channel連接已經建立,而且CONNECT事件也不會被觸發了。
② 如果finishConnect()方法返回false,則表示連接還未建立好。那麼就可以通過CONNECT事件來監聽連接的完成。當然也可以像上面的寫法,無論如何都會給SocketChannel註冊CONNECT事件,finishConnect()方法的調用放到CONNECT事件處理中調用。
③ 如果finishConnect()方法拋出了一個IOException異常,則表示連接操作失敗。

 

支持的事件:SelectionKey.OP_CONNECT | SelectionKey.OP_READ | SelectionKey.OP_WRITE

ServerSocketChannel Java NIO中的 ServerSocketChannel 是一個可以監聽新進來的TCP連接的通道, 就像標準IO中的ServerSocket一樣。

支持的事件:SelectionKey.OP_ACCEPT

ServerSocketChannel & SocketChannel 關於selectedKey集合的處理 對於已經處理的SelectionKey需要充selectedKey集合中移除,如果不將已經處理的SelectionKey從selectedKey集合中移除,那麼下次有新事件到來時,在遍歷selectedKey集合時又會遍歷到這個SelectionKey,這個時候就很可能出錯了。比如,如果沒有在處理完OP_ACCEPT事件後將對應SelectionKey從selectedKey集合移除,那麼下次遍歷selectedKey集合時,處理到到該SelectionKey,相應的ServerSocketChannel.accept()將返回一個空(null)的SocketChannel。

關於OP_WRITE事件:

OP_WRITE事件的就緒條件並不是發生在調用channel的write方法之後,而是在當底層緩衝區有空閒空間的情況下。因爲寫緩衝區在絕大部分時候都是有空閒空間的,所以如果你註冊了寫事件,這會使得寫事件一直處於就就緒,選擇處理現場就會一直佔用着CPU資源。所以,只有當你確實有數據要寫時再註冊寫操作,並在寫完以後馬上取消註冊。

其實,在大部分情況下,我們直接調用channel的write方法寫數據就好了,沒必要都用OP_WRITE事件。那麼OP_WRITE事件主要是在什麼情況下使用的了?

其實OP_WRITE事件主要是在發送緩衝區空間滿的情況下使用的。如:

while (buffer.hasRemaining()) {     int len = socketChannel.write(buffer);   
     if (len == 0) {
          selectionKey.interestOps(selectionKey.interestOps() | SelectionKey.OP_WRITE);
          selector.wakeup();          break;
     }
}

當buffer還有數據,但緩衝區已經滿的情況下,socketChannel.write(buffer)會返回已經寫出去的字節數,此時爲0。那麼這個時候我們就需要註冊OP_WRITE事件,這樣當緩衝區又有空閒空間的時候就會觸發OP_WRITE事件,這是我們就可以繼續將沒寫完的數據繼續寫出了。

而且在寫完後,一定要記得將OP_WRITE事件註銷:

selectionKey.interestOps(sk.interestOps() & ~SelectionKey.OP_WRITE);

注意,這裏在修改了interest之後調用了wakeup();方法是爲了喚醒被堵塞的selector方法,這樣當while中判斷selector返回的是0時,會再次調用selector.select()。而selectionKey的interest是在每次selector.select()操作的時候註冊到系統進行監聽的,所以在selector.select()調用之後修改的interest需要在下一次selector.select()調用纔會生效。

關於遠端關閉事件

SelectionKey並沒有提供關閉事件,其實通過OP_READ是可以監聽到遠端的關閉操作的。
當OP_READ事件觸發使,int readByteNum = channel.read(buffer)會返回從channel讀取到的字節數。
① 當readByteNum > 0 時,表示從channel讀取到了readByteNum個字節到buffer中。
② 當readByteNum == 0 時,表示channel中已經沒有數據可以讀取了,這個時候buffer的position==limit。
③ 當 readByteNum == -1 時,表示遠端channel正常關閉了。這個時候我們就需要進行該通道的關閉和註銷操作了。 netty源碼中OP_READ事件也會根據讀取到的字節數爲-1時,進行channel的關閉操作。


 

這裏closeOnRead(pipeline)方法最終會調用channel.close()方法來完成tcp套接字的關閉(這點下面會詳細說明)


 

如何正確的關閉一個已經註冊的SelectableChannel了?

需要調用channel.close()


最終調用的會使AbstractInterruptibleChannel的close方法 


 

總歸來說,調用channel.close()方法:

① 能夠調動channel對應的SelectionKey的cancel()方法使該SelectionKey加到Selector的cancel selectionKey set集合中,這樣在下一次selector的時候,就會將其從selector中相關的selectionKey集合中移除,並且不會監聽該selectionKey所感興趣的事件了。
② 會關閉底層的套接字連接。
這裏注意:如果只是通過調用SelectionKey.cancel()來註銷一個遠端已經關閉了的channel,是一個不對的方法。因爲selector.select()在處理cancel selectionKey set(註銷的SelectionKey集合)的時候,會判斷若該SelectionKey對應的channel已經沒有註冊到其他的selector,並且該channel open表示爲false的情況下,纔會去調用底層套接字的關閉操作。所以如果之調用SelectionKey.cancel()來註銷一個遠端已經關閉了的channel,會導致本段的TCP連接處於“CLOSE_WAIT”狀態,一直在等待程序調用套接字的關閉。
補充:channel的open標誌,只有在下面兩種情況下才會將open置爲false。
a) 調用了channel.close()方法;


b) 或者操作channel讀/寫的當前線程發生中斷時。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章