Java BIO NIO 與 AIO 分析第二部分之NIO

原創文章, 轉載請私信. 訂閱號 tastejava 回覆 nio思維導圖獲取思維導圖源文件

NIO 部分

在上篇文章中已經分析了BIO部分, 接下來分析一下NIO部分. 瞭解NIO主要是瞭解它的三個重要組件, 分別是Buffer, Channel, Selector. 三個組件相互配合, 實現了IO的能力, 它們各自作用爲:

  1. Buffer 緩存, 給Channel提供數據, 或者從Channel中讀取數據
  2. Channel 通道, Channel用於連接內存和數據源, 數據源可能是文件或者網絡資源, 數據通過Channel流入或流出(讀取/寫出)
  3. Selector 選擇器, Selector是NIO實現IO多路複用的關鍵, 有能力通過一個線程監控多個Channel是否可以讀寫.

NIO與BIO差別

①使用上NIO提供了七種常見的Buffer, 不需要自己創建字節數組當緩存.
②性能上NIO的實現類由各種操作系統提供, 文件IO操作最終交給了操作系統, 所以性能更好.網絡IO操作由於Selector的存在, 實現了多路IO複用, 一個線程就能處理成千個網絡請求, 大大節省了線程資源.

JDK1.4引入NIO後, BIO底層也用NIO重新優化了, 具體分析見:
https://blog.csdn.net/infant09/article/details/80044868

NIO 主要組件結構

NIO體系結構圖
從圖中我們可以看到NIO都能幹什麼, NIO提供了八種基本數據類型除Boolean外的七種Buffer, 對應存儲處理七種類型數據, 提供了四種Channel, 對應處理文件, 處理網絡資源(TCP/UDP)的能力.Selector只有統一的一種, Selector只能配合Non-Blocking即非阻塞的Channel使用, 而FileChannel只有阻塞模式, 即Selector只能配合其他網絡Channel使用.接下來分析一下四種Channel的使用方式.Selector和Buffer是配合Channel使用的, 瞭解了Channel自然瞭解了NIO.

FileChannel分析

FileChannel的使用主要分兩步走, 第一步怎麼獲取FileChannel實例, 第二步怎麼從Channel裏獲取或者怎麼往Channel裏寫數據.

FileChannel實例的獲取.

// 獲得到的Channel實例與InputStream相關聯, 只能讀取數據
FileChannel channel1 = new FileInputStream(filePath).getChannel();
// 獲得到的Channel實例與OutputStream相關聯, 只能寫數據
FileChannel channel2 = new FileOutputStream(filePath).getChannel();
// 通過RandomAccessFile讀寫模式獲取的實例可以讀取數據也可以寫數據
FileChannel channel3 = new RandomAccessFile(filePath, "rw").getChannel();

FileChannel實例數據的讀取

	// 不管是向Channel裏寫數據還是從Channel裏讀數據, 都要用Buffer當做數據存儲者.
    @Test
    public void testNioSimpleReadFileWidthCharBuffer() throws IOException {
        String filePath = getClass().getResource("/").getPath() + "/SimpleReadFile.txt";
        RandomAccessFile randomAccessFile =
                new RandomAccessFile(filePath, "rw");
        FileChannel channel = randomAccessFile.getChannel();
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        // read方法返回讀取到的字節數, 讀取到文件結束會返回 -1. 反之就是沒讀取到文件結束
        int result = channel.read(byteBuffer);
        while (result != -1) {
            // 切換讀模式
            byteBuffer.flip();
            // 將ByteBuffer轉換成CharBuffer, 完成字節到字符的解碼
            CharBuffer charBuffer = Charsets.UTF_8.decode(byteBuffer);
            // 緩存中有剩餘未讀取的內容, 就會循環讀取
            while (charBuffer.hasRemaining()) {
                char b = charBuffer.get();
                System.out.print(b);
            }
            // 緩存中數據讀取完畢, 將緩存清空切換回寫模式
            byteBuffer.clear();
            // 再次讀取數據
            result = channel.read(byteBuffer);
        }
        channel.close();
    }

FileChannel實例數據的寫入

	// rw讀寫模式的RandomAccessFile實例寫數據時, 文件不存在會自動創建
    @Test
    public void testNioSimpleWriteFile() throws IOException {
        String filePath = getClass().getResource("/").getPath() + "/SimpleWriteFile.txt";
        RandomAccessFile randomAccessFile =
                new RandomAccessFile(filePath, "rw");
        FileChannel channel = randomAccessFile.getChannel();
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        byte[] bytes = "通過NIO向文件寫入數據".getBytes();
        // 將字節數據放入Buffer中
        byteBuffer.put(bytes);
        // Buffer切換讀模式
        byteBuffer.flip();
        channel.write(byteBuffer);
        // 關閉Channel資源
        channel.close();
        log.info("文件路徑爲{}", filePath);
    }

ServerSocketChannel與SocketChannel分析

上面已經分析過怎樣通過NIO的FileChannel操作文件, 接下來看一下怎麼通過TCP相關的兩個Channel操作網絡資源. ServerSocketChannel只在服務器端存在, 用於監聽服務器端某個端口, 有客戶端請求連接時, ServerSocketChannel就能感知到並建立連接. SocketChannel在客戶端和服務器端都存在, 在客戶端作爲發起連接的工具, 在服務器端由ServerSocketChannel感知到連接請求時, 調用accept方法完成連接建立後產生的SocketChannel實例, 服務器端和客戶端的SocketChannel代表了服務器端與客戶端的網絡連接.
上面提到, SocketChannel代表了服務器與客戶端的連接, 那麼通信就是對SocketChannel進行操作, 首先準備兩個公共方法, 一個方法用於接收消息, 一個方法用於發送消息. TCP連接是雙工的, 所以兩個工具方法在客戶端和服務器端都可以使用.代碼和詳細註釋如下:
文章後面的示例幾乎都用到了下方兩個工具方法
接收消息工具方法

// 接收消息需要注意的是read方法阻塞模式會阻塞住等待數據, 非阻塞模式沒有數據會直接返回0
// 關於什麼時候返回-1, 源碼中註釋原話是
//"The number of bytes read, possibly zero, or -1 if the channel has reached end-of-stream"
// 指的是客戶端主動關閉連接時, channel再read就到達了stream的終點, 也就是返回-1
private void receiveMessage(SocketChannel socketChannel, ByteBuffer receiveByteBuffer) throws IOException {
    // 讀取數據到ByteBuffer
    int receiveByteCount = socketChannel.read(receiveByteBuffer);
    // 非阻塞模式read方法不會阻塞, 沒有數據會直接返回0
    while (receiveByteCount != -1 && receiveByteCount != 0) {
        // 將ByteBuffer切換到讀模式
        receiveByteBuffer.flip();
        // 將ByteBuffer解碼成CharBuffer
        CharBuffer receiveCharBuffer = Charsets.UTF_8.decode(receiveByteBuffer);
        // 解碼後不需要flip, 得到的Buffer默認是讀模式
        // charBuffer.flip();
        int totalCharCount = receiveCharBuffer.limit();
        char[] receiveChar = new char[totalCharCount];
        receiveCharBuffer.get(receiveChar);
        log.info("{}", new String(receiveChar));
        // 清空ByteBuffer並切換到寫模式, 繼續從Channel讀取數據
        receiveByteBuffer.clear();
        receiveByteCount = socketChannel.read(receiveByteBuffer);
    }
}

發送消息工具方法

private void sendMessage(SocketChannel socketChannel, ByteBuffer sendByteBuffer) throws IOException {
    // 切換ByteBuffer爲讀模式, 爲寫數據到客戶端做準備
    sendByteBuffer.flip();
    // 將數據寫到服務器
    socketChannel.write(sendByteBuffer);
    // 清空byteBuffer, 並將其切換到寫模式
    sendByteBuffer.clear();
}

從兩個TCP Channel的名字可以看出來與之前多麼的相似, 甚至在阻塞模式調用TCP Channel的過程都很像, 下面代碼用傳統的阻塞方式使用ServerSocketChannel和SocketChannel進行通信:
阻塞模式ServerSocketChannel監聽服務器

// 接收到客戶端連接請求, 建立連接後會給客戶端發送一條消息, 然後阻塞一直讀取客戶端消息.
@Test
public void testServerSocketChannel() throws IOException {
    // 分配字節緩存
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    // 開啓一個ServerSocketChannel
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 綁定監聽端口9999
    serverSocketChannel.bind(new InetSocketAddress(9999));
    byteBuffer.put("消息來自服務器端: 連接已建立".getBytes());
    // 阻塞接收連接
    SocketChannel socketChannel = serverSocketChannel.accept();
    // 向客戶端發送數據
    sendMessage(socketChannel, byteBuffer);
    // 阻塞接收客戶端發來的數據
    receiveMessage(socketChannel, byteBuffer);
}

阻塞模式SocketChannel客戶端

@Test
public void testSocketChannel() throws IOException, InterruptedException {
    // 分配字節緩存
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    // 打開一個SocketChannel
    SocketChannel socketChannel = SocketChannel.open();
    // 指定連接ip和端口
    socketChannel.connect(new InetSocketAddress("127.0.0.1", 9999));
    // 接受動作是阻塞的, 單獨開啓線程接收服務器消息
    new Thread(() -> {
        try {
            receiveMessage(socketChannel, byteBuffer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }).start();
    // 每隔5秒給服務器發送一次數據
    while (true) {
        byteBuffer.put("消息來自客戶端: 來自客戶端".getBytes());
        sendMessage(socketChannel, byteBuffer);
        Thread.sleep(5000);
    }
}

非阻塞的ServerSocketChannel實現多路IO複用

NIO的精華在於IO多路複用, IO多路複用指的是多路網絡IO複用一個服務器端線程下面簡單對比一下采用IO多路複用的服務器和原始阻塞方式的服務器差異.
①ServerSocket實現服務器監聽情況下, 當ServerSocket實例調用accept方法時, 當前服務器線程就會阻塞住, 直到有客戶端建立連接.假設有多個請求要併發訪問, 那麼服務器端單線程只能處理完第一個請求, 才能繼續accept之後的請求.如果想要同時響應多個請求, 那麼就要建立多個線程, 每個線程空閒時都阻塞在accept方法, 等待連接.
②IO多路複用情況下, 以ServerSocketChannel爲例, 在非阻塞模式下, ServerSocketChannel的accept方法不會阻塞線程, 有連接會立刻返回對應的SocketChannel, 沒有連接會立刻返回null.也就是說一在IO多路複用的情況下, 最多服務器會阻塞一個線程, 所有的線程都在實際進行IO操作, 沒有線程資源的浪費.具體示例代碼如下:

// 非阻塞模式的ServerSocketChannel
@Test
public void testServerSocketChannelWidthNonBlockingMode() throws IOException {
    // 分配字節緩存
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress(9999));
    // 配置非阻塞模式
    serverSocketChannel.configureBlocking(false);
    while (true) {
        // 接收客戶端連接, 此處不會阻塞當前線程, 沒有連接建立會立刻返回null
        SocketChannel socketChannel = serverSocketChannel.accept();
        // 判斷沒有連接建立立刻循環下一次, 判斷是否有連接建立
        if (socketChannel == null) {
            continue;
        }
        // 已經有連接建立, 向客戶端發送消息
        byteBuffer.put("消息來自服務器端: 連接已建立".getBytes());
        // 向客戶端發送數據
        sendMessage(socketChannel, byteBuffer);
        // 接收客戶端的數據
        receiveMessage(socketChannel, byteBuffer);
    }
}

Selector實現IO多路複用

Selector可以感知通道狀態, 是NIO裏很重要的三個組件之一, 用於實現IO多路複用.上面我們用while輪詢的方式, 初步實現了IO多路複用, 那麼爲什麼要用Selector呢, 原因在於Selector可以細粒度的感知通道狀態, 不需要不間斷的while執行accept動作.Selector可以感知四種通道狀態變化:

  1. SelectionKey.OP_ACCEPT 接收就緒, 有客戶端申請建立連接時
  2. SelectionKey.OP_CONNECT 連接就緒, 服務器與客戶端可以建立時(還需要調用socketChannel.finishConnect()真正的完成連接建立)
  3. SelectionKey.OP_READ 讀就緒, 有數據寫入時
  4. SelectionKey.OP_WRITE 寫就緒, 連接建立後可以寫數據時

其中, SeverSocketChannel支持OP_ACCEPT連接就緒事件, SocketChannel支持剩下三種事件, 具體怎麼用還是來看代碼:
服務器端

@Test
public void testServerSocketChannelWidthSelector() throws IOException {
    // 分配字節緩存
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress(9999));
    log.info("開始監聽9999端口");
    serverSocketChannel.configureBlocking(false);
    Selector selector = Selector.open();
    // ServerSocketChannel 只支持註冊接收就緒事件
    // 在這裏向Selector註冊該Channel要監聽的時間, 符合Selector會以SelectionKey Set的方式返回Channel實例, 提示進行操作.
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    while (true) {
        // 判斷是否有準備好讀寫的連接, 沒有繼續循環
        int ready = selector.select();
        if (ready == 0) {
            continue;
        }
        // 獲取到所有準備好IO操作的連接
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = selectionKeys.iterator();
        while (iterator.hasNext()) {
            SelectionKey selectionKey = iterator.next();
            if (selectionKey.isAcceptable()) {
                log.info("連接就緒, 有客戶端請求連接服務器");
                ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();
                // 接收到的SocketChannel默認是阻塞的, 所以接收數據方法中read沒數據會阻塞住, 而不是返回0
                SocketChannel socketChannel = channel.accept();
                byteBuffer.put("消息來自服務器: 成功建立服務器連接".getBytes(Charsets.UTF_8));
                // 將數據發送到客戶端
                this.sendMessage(socketChannel, byteBuffer);
                // 從客戶端接收數據
                this.receiveMessage(socketChannel, byteBuffer);
                // 事件處理完畢, 移除channel, 再次發生監聽事件後, Selector會再次將Channel添加到Set
                iterator.remove();
            }
        }
    }
}

客戶端

@Test
public void testSocketChannelWidthSelector() throws IOException, InterruptedException {
    // 分配字節緩存
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    SocketChannel socketChannel = SocketChannel.open();
    socketChannel.configureBlocking(false);
    Selector selector = Selector.open();
    socketChannel.register(selector, SelectionKey.OP_CONNECT | SelectionKey.OP_READ | SelectionKey.OP_WRITE);
    socketChannel.connect(new InetSocketAddress("127.0.0.1", 9999));
    while(true) {
        int readyChannels = selector.select();
        if(readyChannels == 0) continue;
        Set selectedKeys = selector.selectedKeys();
        Iterator keyIterator = selectedKeys.iterator();
        while(keyIterator.hasNext()) {
            SelectionKey key = (SelectionKey) keyIterator.next();
            if (key.isConnectable()) {
                // a connection was established with a remote server.
                // 連接就緒
                // 連接就緒後完成連接動作
                socketChannel.finishConnect();
            }
            if (key.isReadable()) {
                Thread.sleep(1000);
                // a channel is ready for reading
                // 讀就緒, 當服務器端向客戶端發送數據時, 客戶端連接就是讀就緒
                SocketChannel selectedChannel = (SocketChannel) key.channel();
                // 從服務器接收數據
                this.receiveMessage(selectedChannel, byteBuffer);
            }
            if (key.isWritable()) {
                // 讀就緒時, 客戶端每隔5秒向服務器發送一次消息
                Thread.sleep(5000);
                // a channel is ready for writing
                // 連接建立完成後, 客戶端連接進入寫就緒狀態, 可以向服務器寫數據
                byteBuffer.put("消息來自客戶端: 成功建立服務器連接".getBytes());
                // 向服務器發送數據
                this.sendMessage(socketChannel, byteBuffer);
            }
            keyIterator.remove();
        }
    }
}

總結

NIO很強大, 但是使用複雜, Buffer flip設計比較繁瑣, Buffer小的時候可能有字符半讀問題, 想要深度使用NIO可能Netty這個NIO框架會是更好的選擇.篇幅有限, AIO分析放在下一篇.

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