Java NIO全面詳解(看這篇就夠了)

mikechen的網際網路架構發表於2022-08-15

很多技術框架都使用 NIO 技術,學習和掌握 Java NIO 技術對於高效能、高併發網路的應用是非常關鍵的 @mikechen

NIO 簡介

NIO 中的 N 可以理解為 Non-blocking,不單純是 New,是解決高併發、I/O 高效能的有效方式。

Java NIO 是 Java1.4 之後推出來的一套 IO 介面,NIO 提供了一種完全不同的操作方式, NIO 支援面向緩衝區的、基於通道的 IO 操作。

新增了許多用於處理輸入輸出的類,這些類都被放在 java.nio 包及子包下,並且對原 java.io 包中的很多類進行改寫,新增了滿足 NIO 的功能。

 

NIO VS BIO

BIO

BIO 全稱是 Blocking IO,同步阻塞式 IO,是 JDK1.4 之前的傳統 IO 模型。

Java BIO:伺服器實現模式為一個連線一個執行緒,即客戶端有連線請求時伺服器端就需要啟動一個執行緒進行處理,如下圖所示:

雖然此時伺服器具備了高併發能力,即能夠同時處理多個客戶端請求了,但是卻帶來了一個問題,隨著開啟的執行緒數目增多,將會消耗過多的記憶體資源,導致伺服器變慢甚至崩潰,NIO 可以一定程度解決這個問題。

 

NIO

Java NIO: 同步非阻塞,伺服器實現模式為一個執行緒處理多個請求 (連線),即客戶端傳送的連線請求都會註冊到多路複用器上,多路複用器輪詢到連線有 I/O 請求就進行處理。

一個執行緒中就可以呼叫多路複用介面(java 中是 select)阻塞同時監聽來自多個客戶端的 IO 請求,一旦有收到 IO 請求就呼叫對應函式處理,NIO 擅長 1 個執行緒管理多條連線,節約系統資源。

 

NIO 的核心實現

NIO 包含 3 個核心的元件:

  • Channel (通道)
  • Buffer (緩衝區)
  • Selector (選擇器)

關係圖的說明:

  1. 每個 Channel 對應一個 Buffer。
  2. Selector 對應一個執行緒,一個執行緒對應多個 Channel。
  3. 該圖反應了有三個 Channel 註冊到該 Selector。
  4. 程式切換到那個 Channel 是由事件決定的(Event)。
  5. Selector 會根據不同的事件,在各個通道上切換。
  6. Buffer 就是一個記憶體塊,底層是有一個陣列。
  7. 資料的讀取和寫入是透過 Buffer,但是需要 flip () 切換讀寫模式,而 BIO 是單向的,要麼輸入流要麼輸出流。

 

Channel (通道)

Channel 是 NIO 的核心概念,它表示一個開啟的連線,這個連線可以連線到 I/O 裝置(例如:磁碟檔案,Socket)或者一個支援 I/O 訪問的應用程式,Java NIO 使用緩衝區和通道來進行資料傳輸。

通道的主要實現類:

FileChannel 類

本地檔案 IO 通道,用於讀取、寫入、對映和操作檔案的通道,使用檔案通道操作檔案的一般流程為:

1)獲取通道

檔案通道透過 FileChannel 的靜態方法 open () 來獲取,獲取時需要指定檔案路徑和檔案開啟方式。

// 獲取檔案通道FileChannel.open(Paths.get(fileName), StandardOpenOption.READ);

2)建立位元組緩衝區

檔案相關的位元組緩衝區有兩種,一種是基於堆的 HeapByteBuffer,另一種是基於檔案對映,放在堆外記憶體中的 MappedByteBuffer。

// 分配位元組快取ByteBuffer buf = ByteBuffer.allocate(10);

3)讀寫操作

讀取資料

一般需要一個迴圈結構來讀取資料,讀取資料時需要注意切換 ByteBuffer 的讀寫模式。

while (channel.read(buf) != -1){ // 讀取通道中的資料,並寫入到 buf 中
    buf.flip(); // 快取區切換到讀模式
    while (buf.position() < buf.limit()){ // 讀取 buf 中的資料
        text.append((char)buf.get());
    }    buf.clear(); // 清空 buffer,快取區切換到寫模式}

寫入資料

for (int i = 0; i < text.length(); i++) {    buf.put((byte)text.charAt(i)); // 填充緩衝區,需要將 2 位元組的 char 強轉為 1 自己的 byte
    if (buf.position() == buf.limit() || i == text.length() - 1) { // 快取區已滿或者已經遍歷到最後一個字元
        buf.flip(); // 將緩衝區由寫模式置為讀模式
        channel.write(buf); // 將緩衝區的資料寫到通道
        buf.clear(); // 清空快取區,將緩衝區置為寫模式,下次才能使用
    }
}

4)將資料刷出到物理磁碟,FileChannel 的 force (boolean metaData) 方法可以確保對檔案的操作能夠更新到磁碟。

channel.force(false);

5)關閉通道

channel.close();

SocketChannel 類

網路套接字 IO 通道,TCP 協議,針對面向流的連線套接字的可選擇通道(一般用在客戶端)。

TCP 客戶端使用 SocketChannel 與服務端進行互動的流程為:

1)開啟通道,連線到服務端。

SocketChannel channel = SocketChannel.open(); // 開啟通道,此時還沒有開啟 TCP 連線channel.connect(new InetSocketAddress("localhost", 9090)); // 連線到服務端

2)分配緩衝區

ByteBuffer buf = ByteBuffer.allocate(10); // 分配一個 10 位元組的緩衝區,不實用,容量太小

3)配置是否為阻塞方式。(預設為阻塞方式)

channel.configureBlocking(false); // 配置通道為非阻塞模式

4)與服務端進行資料互動

5)關閉連線

channel.close();          // 關閉通道

 

ServerSocketChannel 類

網路通訊 IO 操作,TCP 協議,針對面向流的監聽套接字的可選擇通道(一般用於服務端),流程如下:

1)開啟一個 ServerSocketChannel 通道,繫結埠。

ServerSocketChannel server = ServerSocketChannel.open(); // 開啟通道

2)繫結埠

server.bind(new InetSocketAddress(9090)); // 繫結埠

3)阻塞等待連線到來,有新連線時會建立一個 SocketChannel 通道,服務端可以透過這個通道與連線過來的客戶端進行通訊。等待連線到來的程式碼一般放在一個迴圈結構中。

SocketChannel client = server.accept(); // 阻塞,直到有連線過來

4)透過 SocketChannel 與客戶端進行資料互動

5)關閉 SocketChannel

client.close();

 

Buffer (緩衝區)

緩衝區 Buffer 是 Java NIO 中一個核心概念,在 NIO 庫中,所有資料都是用緩衝區處理的。

在讀取資料時,它是直接讀到緩衝區中的,在寫入資料時,它也是寫入到緩衝區中的,任何時候訪問 NIO 中的資料,都是將它放到緩衝區中。

而在面向流 I/O 系統中,所有資料都是直接寫入或者直接將資料讀取到 Stream 物件中。

Buffer 資料型別

從類圖中可以看到,7 種資料型別對應著 7 種子類,這些名字是 Heap 開頭子類,資料是存放在 JVM 堆中的。

 

MappedByteBuffer

而 MappedByteBuffer 則是存放在堆外的直接記憶體中,可以對映到檔案。

透過 java.nio 包和 MappedByteBuffer 允許 Java 程式直接從記憶體中讀取檔案內容,透過將整個或部分檔案對映到記憶體,由作業系統來處理載入請求和寫入檔案,應用只需要和記憶體打交道,這使得 IO 操作非常快。

Mmap 記憶體對映和普通標準 IO 操作的本質區別在於它並不需要將檔案中的資料先複製至 OS 的核心 IO 緩衝區,而是可以直接將使用者程式私有地址空間中的一塊區域與檔案物件建立對映關係,這樣程式就好像可以直接從記憶體中完成對檔案讀 / 寫操作一樣。

只有當缺頁中斷髮生時,直接將檔案從磁碟複製至使用者態的程式空間內,只進行了一次資料複製,對於容量較大的檔案來說(檔案大小一般需要限制在 1.5~2G 以下),採用 Mmap 的方式其讀 / 寫的效率和效能都非常高,大家熟知的   就使用了該技術。

 

Buffer 資料流程

應用程式可以透過與 I/O 裝置建立通道來實現對 I/O 裝置的讀寫操作,操作的資料透過緩衝區 Buffer 來進行互動。

從 I/O 裝置讀取資料時:

1)應用程式呼叫通道 Channel 的 read () 方法;

2)通道往緩衝區 Buffer 中填入 I/O 裝置中的資料,填充完成之後返回;

3)應用程式從緩衝區 Buffer 中獲取資料。

往 I/O 裝置寫資料時:
1)應用程式往緩衝區 Buffer 中填入要寫到 I/O 裝置中的資料;
2)呼叫通道 Channel 的 write () 方法,通道將資料傳輸至 I/O 裝置。

緩衝區核心方法

緩衝區存取資料的兩個核心方法:

1)put (): 存入資料到緩衝區

  • put (byte b):將給定單個位元組寫入緩衝區的當前位置
  • put (byte [] src):將 src 中的位元組寫入緩衝區的當前位置
  • put (int index, byte b):將指定位元組寫入緩衝區的索引位置 (不會移動 position)

2)get (): 獲取緩衝區的資料

  • get () :讀取單個位元組
  • get (byte [] dst):批次讀取多個位元組到 dst 中
  • get (int index):讀取指定索引位置的位元組 (不會移動 position)

 

Selector (選擇器)

Selector 類是 NIO 的核心類,Selector(選擇器)選擇器提供了選擇已經就緒的任務的能力。

Selector 會不斷的輪詢註冊在上面的所有 channel,如果某個 channel 為讀寫等事件做好準備,那麼就處於就緒狀態,透過 Selector 可以不斷輪詢發現出就緒的 channel,進行後續的 IO 操作。

一個 Selector 能夠同時輪詢多個 channel,這樣,一個單獨的執行緒就可以管理多個 channel,從而管理多個網路連線,這樣就不用為每一個連線都建立一個執行緒,同時也避免了多執行緒之間上下文切換導致的開銷。

選擇器使用步驟

1 獲取選擇器

與通道和緩衝區的獲取類似,選擇器的獲取也是透過靜態工廠方法 open () 來得到的。

Selector selector = Selector.open(); // 獲取一個選擇器例項

2 獲取可選擇通道

能夠被選擇器監控的通道必須實現了 SelectableChannel 介面,並且需要將通道配置成非阻塞模式,否則後續的註冊步驟會丟擲 IllegalBlockingModeException。

SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 9090)); // 開啟 SocketChannel 並連線到本機 9090 埠socketChannel.configureBlocking(false); // 配置通道為非阻塞模式

3 將通道註冊到選擇器

通道在被指定的選擇器監控之前,應該先告訴選擇器,並且告知監控的事件,即:將通道註冊到選擇器。

通道的註冊透過 SelectableChannel.register (Selector selector, int ops) 來完成,ops 表示關注的事件,如果需要關注該通道的多個 I/O 事件,可以傳入這些事件型別或運算之後的結果。這些事件必須是通道所支援的,否則丟擲 IllegalArgumentException。

socketChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE); // 將套接字透過到註冊到選擇器,關注 read 和 write 事件

4 輪詢 select 就緒事件
透過呼叫選擇器的 Selector.select () 方法可以獲取就緒事件,該方法會將就緒事件放到一個 SelectionKey 集合中,然後返回就緒的事件的個數。這個方法對映多路複用 I/O 模型中的 select 系統呼叫,它是一個阻塞方法。正常情況下,直到至少有一個就緒事件,或者其它執行緒呼叫了當前 Selector 物件的 wakeup () 方法,或者當前執行緒被中斷時返回。

while (selector.select() > 0){ // 輪詢,且返回時有就緒事件Set<SelectionKey> keys = selector.selectedKeys(); // 獲取就緒事件集合.......
}

有 3 種方式可以 select 就緒事件:

1)select () 阻塞方法,有一個就緒事件,或者其它執行緒呼叫了 wakeup () 或者當前執行緒被中斷時返回。

2)select (long timeout) 阻塞方法,有一個就緒事件,或者其它執行緒呼叫了 wakeup (),或者當前執行緒被中斷,或者阻塞時長達到了 timeout 時返回。不丟擲超時異常。

3)selectNode () 不阻塞,如果無就緒事件,則返回 0;如果有就緒事件,則將就緒事件放到一個集合,返回就緒事件的數量。

5 處理就緒事件
每次可以 select 出一批就緒的事件,所以需要對這些事件進行迭代。

for(SelectionKey key : keys){if(key.isWritable()){ // 可寫事件if("Bye".equals( (line = scanner.nextLine()) )){socketChannel.shutdownOutput();socketChannel.close();break;
}buf.put(line.getBytes());buf.flip();socketChannel.write(buf);buf.compact();
}
}

從一個 SelectionKey 物件可以得到:1)就緒事件的對應的通道;2)就緒的事件。透過這些資訊,就可以很方便地進行 I/O 操作。

 

NIO 原始碼案例

NIOServer

public static void main(String[] args) throws  Exception{        //建立ServerSocketChannel,-->> ServerSocket
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        InetSocketAddress inetSocketAddress = new InetSocketAddress(5555);
        serverSocketChannel.socket().bind(inetSocketAddress);
        serverSocketChannel.configureBlocking(false); //設定成非阻塞
 
        //開啟selector,並註冊accept事件
        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); 
        while(true) {
            selector.select(2000);  //監聽所有通道
            //遍歷selectionKeys
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();                if(key.isAcceptable()) {  //處理連線事件
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    socketChannel.configureBlocking(false);  //設定為非阻塞
                    System.out.println("client:" + socketChannel.getLocalAddress() + " is connect");
                    socketChannel.register(selector, SelectionKey.OP_READ); //註冊客戶端讀取事件到selector
                } else if (key.isReadable()) {  //處理讀取事件
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    SocketChannel channel = (SocketChannel) key.channel();
                    channel.read(byteBuffer);
                    System.out.println("client:" + channel.getLocalAddress() + " send " + new String(byteBuffer.array()));
                }
                iterator.remove();  //事件處理完畢,要記得清除
            }
        }
 
    }

 

NIOClient

public class NIOClient { 
public static void main(String[] args) throws Exception{
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 5555); 
        if(!socketChannel.connect(inetSocketAddress)) {            while (!socketChannel.finishConnect()) {
                System.out.println("客戶端正在連線中,請耐心等待");
            }
        }
 
        ByteBuffer byteBuffer = ByteBuffer.wrap("mikechen的網際網路架構".getBytes());
        socketChannel.write(byteBuffer);
        socketChannel.close();
}
}

 

以上

作者簡介

陳睿|mikechen,10年+大廠架構經驗,《BAT架構技術500期》系列文章作者,分享十餘年BAT架構經驗以及面試心得!

閱讀mikechen的網際網路架構更多技術文章合集

| | | | | | | 架構師

關注「mikechen 的網際網路架構」公眾號,回覆 【架構】領取我原創的《300 期 + BAT 架構技術系列與 1000 + 大廠面試題答案》


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70011997/viewspace-2910431/,如需轉載,請註明出處,否則將追究法律責任。

相關文章