資深架構師總結:徹底搞懂NIO效率高的原理

2019-07-29     IT技術分享
\t來源:\t全菜工程師小輝

NIO相比BIO的優勢

NIO(Non-blocking I/O,在Java領域,也稱為New I/O),是一種同步非阻塞的I/O模型,也是I/O多路復用的基礎,已經被越來越多地應用到大型應用伺服器,成為解決高並發與大量連接、I/O處理問題的有效方式。

面向流與面向緩衝

Java NIO和BIO之間第一個最大的區別是,BIO是面向流的,NIO是面向緩衝區的。 JavaIO面向流意味著每次從流中讀一個或多個位元組,直至讀取所有位元組,它們沒有被緩存在任何地方。 此外,它不能前後移動流中的數據。 如果需要前後移動從流中讀取的數據,需要先將它緩存到一個緩衝區。 Java NIO的緩衝讀取方法略有不同。 數據讀取到一個緩衝區,需要時可在緩衝區中前後移動。 這就增加了處理過程中的靈活性。 但是,還需要檢查是否該緩衝區中包含所有需要處理的數據。 而且,需確保當更多的數據讀入緩衝區時,不要覆蓋緩衝區里尚未處理的數據。

阻塞IO與非阻塞IO

Java IO的各種流是阻塞的。 這意味著,當一個線程調用read() 或write()時,該線程被阻塞,直到有數據被讀取或者數據寫入。 該線程在阻塞期間不能做其他事情。 而Java NIO的非阻塞模式,如果通道沒有東西可讀,或不可寫,讀寫函數馬上返回,而不會阻塞,這個線程可以去做別的事情。 線程通常將非阻塞IO的空閒時間用於在其它通道上執行IO操作,所以一個單獨的線程可以管理多個輸入和輸出通道(channel),即IO多路復用的原理。

零拷貝

在傳統的文件IO操作中,我們都是調用作業系統提供的底層標準IO系統調用函數read()、write() ,此時調用此函數的進程(在JAVA中即java進程)由當前的用戶態切換到內核態,然後OS的內核代碼負責將相應的文件數據讀取到內核的IO緩衝區,然後再把數據從內核IO緩衝區拷貝到進程的私有地址空間中去,這樣便完成了一次IO操作。

而NIO的零拷貝與傳統的文件IO操作最大的不同之處就在於它雖然也是要從磁碟讀取數據,但是它並不需要將數據讀取到OS內核緩衝區,而是直接將進程的用戶私有地址空間中的一部分區域與文件對象建立起映射關係,這樣直接從內存中讀寫文件,速度大幅度提升。

詳細的解析,之後會有單獨的博客進行講解

NIO的核心部分

Java NIO主要由以下三個核心部分組成:

  • Channel
  • Buffer
  • Selector

Channel

基本上,所有的IO在NIO中都從一個Channel開始。 數據可以從Channel讀到Buffer中,也可以從Buffer寫到Channel中。 這裡有個圖示:

Channel和Buffer有好幾種類型。 下面是Java NIO中的一些主要Channel的實現:

  • FileChannel(file)
  • DatagramChannel(UDP)
  • SocketChannel(TCP)
  • ServerSocketChannel(TCP)

這些通道涵蓋了UDP和TCP網絡IO以及文件IO。

最後兩個channel的關係。 通過 ServerSocketChannel.accept() 方法監聽新進來的連接。 當 accept()方法返回的時候,它返回一個包含新進來的連接的 SocketChannel。 因此, accept()方法會一直阻塞到有新連接到達。 通常不會僅僅只監聽一個連接,在while循環中調用 accept()方法.

//打開 ServerSocketChannel

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

serverSocketChannel.socket().bind(new InetSocketAddress(9999));

while(true){

SocketChannel socketChannel = serverSocketChannel.accept();

//do something with socketChannel...

}

//關閉ServerSocketChannel

serverSocketChannel.close();

Buffer

緩衝區本質上是一塊可以寫入數據,然後可以從中讀取數據的內存。 這塊內存被包裝成NIO Buffer對象,並提供了一組方法,用來方便的訪問該塊內存。

Java NIO里關鍵的Buffer實現:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

這些Buffer覆蓋了你能通過IO發送的基本數據類型: byte、short、int、long、float、double和char。

為了理解Buffer的工作原理,需要熟悉它的三個屬性:

  • capacity
  • position
  • limit

position和limit的含義取決於Buffer處在讀模式還是寫模式。 不管Buffer處在什麼模式,capacity的含義總是一樣的。

capacity

作為一個內存塊,Buffer有個固定的最大值,就是capacity。 Buffer只能寫capacity個byte、long、char等類型。 一旦Buffer滿了,需要將其清空(通過讀數據或者清除數據)才能繼續寫數據往裡寫數據。

position

當寫數據到Buffer中時,position表示當前的位置。 初始的position值為0。 當一個byte、long等數據寫到Buffer後, position會向前移動到下一個可插入數據的Buffer單元。 position最大可為capacity – 1.

當讀取數據時,也是從某個特定位置讀。 當將Buffer從寫模式切換到讀模式,position會被重置為0。 當從Buffer的position處讀取數據時,position向前移動到下一個可讀的位置。

limit

在寫模式下,Buffer的limit表示最多能往Buffer里寫多少數據。 寫模式下,limit等於capacity。

當切換Buffer到讀模式時, limit表示你最多能讀到多少數據。 因此,當切換Buffer到讀模式時,limit會被設置成寫模式下的position值。

Selector

Selector允許單線程處理多個 Channel。 如果你的應用打開了多個連接(通道),但每個連接的流量都很低,使用Selector就會很方便。 例如,在一個聊天伺服器中。

這是在一個單線程中使用一個Selector處理3個Channel的圖示:

要使用Selector,得向Selector註冊Channel,然後調用它的select()方法。 這個方法會一直阻塞到某個註冊的通道有事件就緒。 一旦這個方法返回,線程就可以處理這些事件,事件例如有新連接進來,數據接收等。

NIO與epoll的關係

Java NIO根據作業系統不同, 針對NIO中的Selector有不同的實現:

  • macosx:KQueueSelectorProvider
  • solaris:DevPollSelectorProvider
  • Linux:EPollSelectorProvider (Linux kernels >= 2.6)或PollSelectorProvider
  • windows:WindowsSelectorProvider

所以不需要特別指定,Oracle JDK會自動選擇合適的Selector。 如果想設置特定的Selector,可以設置屬性,例如:

-Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.EPollSelectorProvider

JDK在Linux已經默認使用epoll方式,但是JDK的epoll採用的是水平觸發,所以Netty自4.0.16起, Netty為Linux通過JNI的方式提供了native socket transport。 Netty重新實現了epoll機制,

  1. 採用邊緣觸發方式
  2. netty epoll transport暴露了更多的nio沒有的配置參數,如 TCP_CORK, SO_REUSEADDR等等。
  3. C代碼,更少GC,更少synchronized

使用native socket transport的方法很簡單,只需將相應的類替換即可。

NioEventLoopGroup  EpollEventLoopGroup
NioEventLoop EpollEventLoop
NioServerSocketChannel EpollServerSocketChannel
NioSocketChannel EpollSocketChannel

NIO處理消息的核心思路

結合示例代碼,總結NIO的核心思路:

  1. NIO 模型中通常會有兩個線程,每個線程綁定一個輪詢器 selector ,在上面例子中serverSelector負責輪詢是否有新的連接,clientSelector負責輪詢連接是否有數據可讀
  2. 服務端監測到新的連接之後,不再創建一個新的線程,而是直接將新連接綁定到clientSelector上,這樣就不用BIO模型中1w 個while循環在阻塞,參見(1)
  3. clientSelector被一個 while 死循環包裹著,如果在某一時刻有多條連接有數據可讀,那麼通過clientSelector.select(1)方法可以輪詢出來,進而批量處理,參見(2)
  4. 數據的讀寫面向 Buffer,參見(3)

NIO的示例代碼

public class NIOServer {
public static void main(String[] args) throws IOException {
Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();
new Thread(() -> {
try {
// 對應IO編程中服務端啟動
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
listenerChannel.socket().bind(new InetSocketAddress(8000));
listenerChannel.configureBlocking(false);
listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
while (true) {
// 監測是否有新的連接,這裡的1指的是阻塞的時間為 1ms
if (serverSelector.select(1) > 0) {
Set set = serverSelector.selectedKeys();
Iterator keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
try {
// (1) 每來一個新連接,不需要創建一個線程,而是直接註冊到clientSelector
SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
clientChannel.configureBlocking(false);
clientChannel.register(clientSelector, SelectionKey.OP_READ);
} finally {
keyIterator.remove();
}
}
}
}
}
} catch (IOException ignored) {
}
}).start();
new Thread(() -> {
try {
while (true) {
// (2) 批量輪詢是否有哪些連接有數據可讀,這裡的1指的是阻塞的時間為 1ms
if (clientSelector.select(1) > 0) {
Set set = clientSelector.selectedKeys();
Iterator keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isReadable()) {
try {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// (3) 面向 Buffer
clientChannel.read(byteBuffer);
byteBuffer.flip();
System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer)
.toString());
} finally {
keyIterator.remove();
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
}
} catch (IOException ignored) {
}
}).start();
}
}

需要的Java架構師方面的資料可以關注之後私信哈,回復「資料」領取免費架構視頻資料,記得要點贊轉發噢!!!

文章來源: https://twgreatdaily.com/zh-cn/p0XQ9GwBJleJMoPMDLeM.html