Netty學習筆記(二):NIO簡介、緩衝區(Buffer)、通道(Channel)、選擇器(Selector)、NIO編程、零拷貝、AIO

第 3 章 JavaNIO 編程

一、簡介

1、基本概念

  • JavaNIO 全稱 java non-blocking IO,是指 JDK 提供的新 API。從 JDK1.4 開始,Java 提供了一系列改進的 輸入/輸出的新特性,被統稱爲 NIO(也稱爲 New IO),是同步非阻塞的
  • NIO 相關類都被放在 java.nio 包及子包下,並且對原 java.io 包中的很多類進行改寫。
  • NIO 有三大核心部分:Channel(通道),Buffer(緩衝區),Selector(選擇器)
  • NIO 是 面向緩衝區 ,或者面向塊編程的。數據讀取到的緩衝區,需要時可在緩衝區中前後
    移動,這就增加了處理過程中的靈活性,使用它可以提供非阻塞式的高伸縮性網絡
  • JavaNIO 的非阻塞模式,使一個線程從某通道發送請求或者讀取數據,但是它僅能得到目前可用的數據,如果
    目前沒有數據可用時,就什麼都不會獲取,而不是保持線程阻塞,所以直至數據變的可以讀取之前,該線程可
    以繼續做其他的事情。 非阻塞寫也是如此,一個線程請求寫入一些數據到某通道,但不需要等待它完全寫入,這個線程同時可以去做別的事情
  • 通俗理解:NIO 是可以做到用一個線程來處理多個操作的。假設有 10000 個請求過來,根據實際情況,可以分配 50 或者 100 個線程來處理。不像之前的阻塞 IO 那樣,非得分配 10000 個。

2、 NIO 的 Buffer 基本使用

示例代碼:

public static void main(String[] args) {

    //創建一個容量爲5的int型buffer
    IntBuffer intBuffer = IntBuffer.allocate(5);

    //向buffer中存放數據
    for (int i = 0; i < intBuffer.capacity(); i++) {
        intBuffer.put(i * 2);
    }

    //轉換buffer的讀寫狀態(必須加!)
    /**
         *     public final Buffer flip() {
         *         limit = position;
         *         position = 0;
         *         mark = -1;
         *         return this;
         *     }
         */
    intBuffer.flip();

    //從buffer中獲取數據
    while (intBuffer.hasRemaining()) {
        System.out.print(intBuffer.get() + " ");
    }
}

結果:

0 2 4 6 8 

3、NIO 和 BIO 的比較

  • BIO 以的方式處理數據,而 NIO 以的方式處理數據,塊 I/O 的效率比流 I/O 高很多
  • BIO 是阻塞的,NIO 則是非阻塞
  • BIO 基於字節流和字符流進行操作,而 NIO 基於 Channel(通道)和 Buffer(緩衝區)進行操作,數據總是從通道 讀取到緩衝區中,或者從緩衝區寫入到通道中。Selector(選擇器)用於監聽多個通道的事件(比如:連接請求,數據到達等),因此使用單個線程就可以監聽多個客戶端通道

二、 NIO 三大核心組件詳解

1、三大組件(Selector 、 Channel 和 Buffer )之間的關係

  • 每個 channel 都會對應一個 Buffer
  • Buffer 就是一個內存塊 , 底層是有一個數組
  • Selector 對應一個線程, 一個線程對應多個 channel(連接)
  • 程序切換到哪個 channel 是有事件(Event )決定的,Event 就是一個重要的概念
  • Selector 會根據不同的事件,在各個通道上切換
  • 數據的讀取寫入是通過 Buffer, 這個和 BIO ,BIO 中要麼是輸入流,或者是 輸出流, 不能雙向,但是 NIO 的 Buffer 是可以讀也可以寫, 需要 flip 方法切換。
  • channel 是雙向的, 可以返回底層操作系統的情況, 比如 Linux , 底層的操作系統 通道就是雙向的.

在這裏插入圖片描述

2、緩衝區(Buffer)

1)基本介紹

緩衝區(Buffer)本質上是一個可以讀寫數據的內存塊,可以理解成一個容器對象(含數組),該對象提供了一組方法,可以更輕鬆使用內存塊,緩衝區對象內置了一些機制,能夠跟蹤和記錄緩衝區的狀態變化情況。Channel 提供從文件、網絡讀取數據的渠道,但是讀取或寫入的數據都必須經由 Buffer

在這裏插入圖片描述

2)Buffer 類及其子類

  • 在 NIO 中,Buffer 是一個頂層抽象父類。Buffer類的子類有ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer 。其中這些子類各自還有自己的子類
    • ByteBuffer,存儲字節數據到緩衝區
    • ShortBuffer,存儲字符串數據到緩衝區
    • CharBuffer,存儲字符數據到緩衝區
    • IntBuffer,存儲整數數據到緩衝區
    • LongBuffer,存儲長整型數據到緩衝區
    • DoubleBuffer,存儲小數到緩衝區
    • FloatBuffer,存儲小數到緩衝區

在這裏插入圖片描述

  • Buffer類定義了所有的緩衝區都具有的四個屬性來提供關於其所包含的數據元素的信息:
屬性 描述
capacity 容量,即可以容納的最大數據量;在緩存區創建時被設定並且不能改變
Limit 表示緩衝區當前的終點,不能對緩衝區中超過Limit的部分進行讀寫(相當於哨兵)。而且Limit是可以修改的
Position 當前的讀/寫位置,下一個要被讀或寫的元素的索引,每次讀寫緩衝區數據時都會改變改值,爲下次讀寫作準備
Mark 標記

在這裏插入圖片描述

  • Buffer類相關方法
public abstract class Buffer {
    //JDK1.4時,引入的api
    public final int capacity()//返回此緩衝區的容量
    public final int position()//返回此緩衝區的位置
    public final Buffer position (int newPositio)//設置此緩衝區的位置
    public final int limit()//返回此緩衝區的限制
    public final Buffer limit (int newLimit)//設置此緩衝區的限制
    public final Buffer mark()//在此緩衝區的位置設置標記
    public final Buffer reset()//將此緩衝區的位置重置爲以前標記的位置
    public final Buffer clear()//清除此緩衝區, 即將各個標記恢復到初始狀態,但是數據並沒有真正擦除, 後面操作會覆蓋
    public final Buffer flip()//反轉此緩衝區
    public final Buffer rewind()//重繞此緩衝區
    public final int remaining()//返回當前位置與限制之間的元素數
    public final boolean hasRemaining()//告知在當前位置和限制之間是否有元素
    public abstract boolean isReadOnly();//告知此緩衝區是否爲只讀緩衝區
 
    //JDK1.6時引入的api
    public abstract boolean hasArray();//告知此緩衝區是否具有可訪問的底層實現數組
    public abstract Object array();//返回此緩衝區的底層實現數組
    public abstract int arrayOffset();//返回此緩衝區的底層實現數組中第一個緩衝區元素的偏移量
    public abstract boolean isDirect();//告知此緩衝區是否爲直接緩衝區
}

3)ByteBuffer介紹

從前面可以看出對於 Java 中的基本數據類型(boolean除外),都有一個 Buffer 類型與之相對應。最常用的是ByteBuffer 類(二進制數據),該類的主要方法如下:

public abstract class ByteBuffer {
    //緩衝區創建相關api
    public static ByteBuffer allocateDirect(int capacity)//創建直接緩衝區
    public static ByteBuffer allocate(int capacity)//設置緩衝區的初始容量
    public static ByteBuffer wrap(byte[] array)//把一個數組放到緩衝區中使用
    //構造初始化位置offset和上界length的緩衝區
    public static ByteBuffer wrap(byte[] array,int offset, int length)
     //緩存區存取相關API
    public abstract byte get( );//從當前位置position上get,get之後,position會自動+1
    public abstract byte get (int index);//從絕對位置get
    public abstract ByteBuffer put (byte b);//從當前位置上添加,put之後,position會自動+1
    public abstract ByteBuffer put (int index, byte b);//從絕對位置上put
 }

3、通道(Channel)

1)基本介紹

  • NIO 的通道(Channel)類似於流(Stream),但有些區別如下:
    • 通道可以同時進行讀寫,而流只能讀或者只能寫
    • 通道可以實現異步讀寫數據
  • BIO 中的 stream 是單向的,例如 FileInputStream 對象只能進行讀取數據的操作,而 NIO 中的通道(Channel)是雙向的,可以讀操作,也可以寫操作。
  • Channel 在 NIO 中是一個接口 public interface Channel extends Closeable{}
  • 常 用 的 Channel 類 有 : FileChannel 、 DatagramChannel 、 ServerSocketChannelSocketChannel
    • FileChannel 用於文件的數據讀寫
    • DatagramChannel 用於 UDP 的數據讀寫
    • ServerSocketChannel 和 SocketChannel 用於 TCP 的數據讀寫。 ServerSocketChanne 類似 ServerSocket ,SocketChannel 類似 Socket

在這裏插入圖片描述

2)FileChannel 類

FileChannel 主要用來對本地文件進行 IO 操作,常見的方法有:

  • public int read(ByteBuffer dst),從通道讀取數據並放到緩衝區中
  • public int write(ByteBuffer src),把緩衝區的數據寫到通道中
  • public long transferFrom(ReadableByteChannel src,long position,long count),從目標通道中複製數據到當前通道
  • public long transferTo(long position,long count,WritableByteChannel target),把數據從當前通道複製給目標通道

3)應用實例 1-本地文件寫數據

使用前面學習後的 ByteBuffer(緩衝) 和 FileChannel(通道), 將 “hello,world” 寫入到 hello.txt 中

    public static void main(String[] args) throws IOException {
        //創建字符串
        String hello = "Hello World!";

        //創建一個文件輸出流
        FileOutputStream stream = new FileOutputStream("C:\\hello.txt");

        //通過FileOutputStream獲取到對應的 channel
        //channel的實際類型是FileChannelImpl
        FileChannel channel = stream.getChannel();

        //創建一個緩衝區
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        //將str放入byteBuffer
        byteBuffer.put(hello.getBytes());
        //引入等下要從頭讀,所以要進行反轉
        byteBuffer.flip();

        //將byteBuffer中的內容寫入到channel
        channel.write(byteBuffer);
        //關閉流
        stream.close();
    }

4)應用實例 2-本地文件讀數據

使用前面學習後的 ByteBuffer(緩衝) 和 FileChannel(通道), 將 hello.txt 中的數據讀入到程序,並顯示在控制
臺屏幕

public static void main(String[] args) throws IOException {
    //創建文件輸入流
    File file = new File("C:\\hello.txt");
    FileInputStream stream = new FileInputStream(file);

    //從輸入流獲取 FileChannel
    FileChannel fileChannel = stream.getChannel();

    //創建 ByteBuffer 緩衝區用於存儲數據
    ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

    //將從 fileChannel 讀取的數據放入 ByteBuffer
    fileChannel.read(byteBuffer);

    //將byteBuffer的字節數據轉成String輸出
    System.out.println(new String(byteBuffer.array()));
    
    //關閉輸入流
    stream.close();
}

5)應用實例 3-使用一個 Buffer 完成文件讀取、寫入

使用 FileChannel(通道) 的方法 read和write完成文件的拷貝。其中inputChannel將文件內容寫入到ByteBuffer中,而outputChannel將ByteBuffer寫入到文件中。
在這裏插入圖片描述
代碼示例:

    public static void main(String[] args) throws IOException {

        //創建輸入流
        File file = new File("C:\\hello.txt");
        FileInputStream inputStream = new FileInputStream(file);
        //獲得輸入channel
        FileChannel inputChannel = inputStream.getChannel();

        //創建輸出流
        FileOutputStream outputStream = new FileOutputStream("C:\\hello_copy.txt");
        //獲得輸出channel
        FileChannel outputChannel = outputStream.getChannel();


        //-----------------------拷貝方法1-----------------------
        //創建緩衝區 byteBuffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(10);

        while (true) {

            //重置position和limit的值,
            // 否則下次循環position==limit,
            // 導致無法讀取內容,且read==0無法退出
            byteBuffer.clear();

            //將 inputChannel 中的數據讀取到 byteBuffer中
            int read = inputChannel.read(byteBuffer);
            if (read == -1){
                //read == -1表示讀取完畢
                break;
            }

            //進行flip準備寫入
            byteBuffer.flip();
            // 將byteBuffer中內容寫入到outputChannel
            outputChannel.write(byteBuffer);
        }

        //-----------------------拷貝方法2-----------------------
//        //創建緩衝區 byteBuffer
//        ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());
//
//        //將 inputChannel 中的數據讀取到 byteBuffer中
//        inputChannel.read(byteBuffer);
//
//        //需要對byteBuffer進行flip操作,準備進行讀取
//        byteBuffer.flip();
//
//        //將byteBuffer中的內容寫入到outputChannel
//        outputChannel.write(byteBuffer);
//
        //關閉輸入輸出流
        inputStream.close();
        outputStream.close();
    }

6)應用實例 4-拷貝文件 transferFrom 方法

使用 FileChannel(通道) 的方法 transferFrom ,完成文件的(零)拷貝

    public static void main(String[] args) throws IOException {
        //創建輸入流
        FileInputStream inputStream = new FileInputStream("C:\\hello.txt");
        //創建輸入channel
        FileChannel inputChannel = inputStream.getChannel();

        //創建輸入流
        FileOutputStream outputStream = new FileOutputStream("C:\\hello_copy.txt");
        //創建輸入channel
        FileChannel outputChannel = outputStream.getChannel();

        //使用transferTo或者transferFrom進行拷貝
//        inputChannel.transferTo(0,inputChannel.size(),outputChannel);
        outputChannel.transferFrom(inputChannel, 0, inputChannel.size());

        //關閉資源
        inputChannel.close();
        outputChannel.close();
        inputStream.close();
        outputStream.close();
    }

7)Buffer注意事項和細節

  • ByteBuffer 支持類型化的put 和 get(putInt, getInt,…), put 放入的是什麼數據類型,get就應該使用相應的數據類型來取出(並且不能僅僅用get),否則可能有 BufferUnderflowException 異常。
public static void testPutGetByType() {
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    byteBuffer.putChar('A');
    byteBuffer.putInt(42);
    byteBuffer.putLong(10L);
    byteBuffer.putDouble(2.33);

    byteBuffer.flip();

    System.out.println(byteBuffer.getChar());
    System.out.println(byteBuffer.getInt());
    System.out.println(byteBuffer.getLong());
    System.out.println(byteBuffer.getDouble());
}
  • 可以將一個普通Buffer 轉成只讀Buffer
public static void toReadOnlyBuffer() {
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    byteBuffer.putInt(42);

    //獲取該byteBuffer的只讀版本
    ByteBuffer readOnlyBuffer = byteBuffer.asReadOnlyBuffer();

    //注意position和limit的值也和byteBuffer一樣
    readOnlyBuffer.flip();

    System.out.println(readOnlyBuffer.getInt());

    readOnlyBuffer.putInt(10);
}

結果:

42
Exception in thread "main" java.nio.ReadOnlyBufferException
	at java.nio.HeapByteBufferR.putInt(HeapByteBufferR.java:375)
	at Netty.NIO.ByteBufferTest.toReadOnlyBuffer(ByteBufferTest.java:23)
	at Netty.NIO.ByteBufferTest.main(ByteBufferTest.java:8)
  • NIO 還提供了 MappedByteBuffer, 可以讓文件直接在內存(堆外的內存)中進行修改, 而如何同步到文件 由 NIO 來完成
    • MappedByteBuffer 可讓文件直接在內存(堆外內存)修改, 操作系統不需要拷貝一次
    public static void main(String[] args) throws IOException {

        //第一個參數是文件位置,第二個參數是操作類型(r:讀,w:寫)
        RandomAccessFile randomAccessFile =
                new RandomAccessFile("C:\\Users\\韓壯\\Desktop\\hello.txt", "rw");
        //獲得channel
        FileChannel channel = randomAccessFile.getChannel();

        /**
         * 通過channel的map方法獲得MappedByteBuffer,實際類型是其子類 DirectByteBuffer
         *      參數 mode:操作類型(讀、寫、讀寫)
         *      參數 position:文件映射到內存的起始地址,可以直接修改的起始位置
         *      參數 size:映射到內存的大小(不是索引位置),即將文件的多少個字節映射到內存
         */
        MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);

        //對文件進行修改,第一參數是要修改位置的下標,第二個是內容
        mappedByteBuffer.put(0, (byte) 'A');
        mappedByteBuffer.put(4, (byte) 'B');
//        mappedByteBuffer.put(5, (byte) 'C');//IndexOutOfBoundsException

        randomAccessFile.close();
    }
  • 前面我們講的讀寫操作,都是通過一個 Buffer 完成的,NIO 還支持 通過多個 Buffer(即 Buffer 數組) 完成讀 寫操作,即 Scattering 和 Gathering
public static void main(String[] args) throws IOException {

    //使用 ServerSocketChannel 和 SocketChannel 進行網絡通信

    //創建ServerSocketChannel
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    //創建端口對象
    InetSocketAddress inetSocketAddress = new InetSocketAddress(6666);
    //綁定端口到socket,並啓動
    serverSocketChannel.socket().bind(inetSocketAddress);

    //創建緩衝數組
    ByteBuffer[] buffers = new ByteBuffer[2];
    buffers[0] = ByteBuffer.allocate(5);
    buffers[1] = ByteBuffer.allocate(3);

    //等待客戶端連接(telnet)
    SocketChannel socketChannel = serverSocketChannel.accept();

    int messageLength = 8; //假定從客戶端接收 8 個字節
    while (true) {
        int byteRead = 0;

        while (byteRead < messageLength) {
            long l = socketChannel.read(buffers);
            byteRead += l;
            System.out.println("byteRead" + byteRead);
            for (ByteBuffer buffer : buffers) {
                System.out.println("position=" + buffer.position() + " limit=" + buffer.limit());
            }
        }

        //對所有buffer進行flip
        for (ByteBuffer buffer : buffers) {
            buffer.flip();
        }

        int byteWrite = 0;

        while (byteWrite < messageLength) {
            long l = socketChannel.write(buffers);
            byteWrite += l;
        }

        //對所有buffer進行flip
        for (ByteBuffer buffer : buffers) {
            buffer.clear();
        }

        System.out.println("byteRead" + byteRead + ", byteWrite" + byteWrite + ", messageLength" + messageLength);
    }
}

4、 Selector(選擇器)

1)基本介紹

  • Java 的 NIO,用非阻塞的 IO 方式。可以用一個線程,處理多個的客戶端連接,就會使用到 Selector(選擇器)
  • Selector 能夠檢測多個註冊的通道上是否有事件發生(注意:多個 Channel 以事件的方式可以註冊到同一個 Selector),如果有事件發生,便獲取事件然後針對每個事件進行相應的處理。這樣就可以只用一個單線程去管
    理多個通道,也就是管理多個連接和請求。
  • 只有在 連接/通道 真正有讀寫事件發生時,纔會進行讀寫,就大大地減少了系統開銷,並且不必爲每個連接都
    創建一個線程,不用去維護多個線程 。避免了多線程之間的上下文切換導致的開銷
    在這裏插入圖片描述

2)Selector 特點

  • Netty 的 IO 線程 NioEventLoop 聚合了 Selector(選擇器,也叫多路複用器),可以同時併發處理成百上千個客
    戶端連接。
  • 當線程從某客戶端 Socket 通道進行讀寫數據時,若沒有數據可用時,該線程可以進行其他任務。
  • 線程通常將非阻塞 IO 的空閒時間用於在其他通道上執行 IO 操作,所以單獨的線程可以管理多個輸入和輸出
    通道。
  • 由於讀寫操作都是非阻塞的,這就可以充分提升 IO 線程的運行效率,避免由於頻繁 I/O 阻塞導致的線程掛
    起。
  • 一個 I/O 線程可以併發處理 N 個客戶端連接和讀寫操作,這從根本上解決了傳統同步阻塞 I/O 一連接一線
    程模型,架構的性能、彈性伸縮能力和可靠性都得到了極大的提升

3)Selector類相關方法

public abstract class Selector implements Closeable { 
    //得到一個選擇器對象
    public static Selector open();

    //監控所有註冊的通道,當其中有 IO 操作可以進行時,將對應的SelectionKey加入到內部集合中並返回,
    public int select();
    //帶超時時間的select
    public int select(long timeout);
    //喚醒正在阻塞selector
    public void wakeup();
    //不阻塞,立馬返還
    public int selectNow();

    //從內部集合中得到所有的 SelectionKey	
    public Set<SelectionKey> selectedKeys();
}

5、NIO 非阻塞 網絡編程原理分析

網絡編程相關組件(Selector、SelectionKey、ServerScoketChannel和SocketChannel) 的關係梳理:

  • 當客戶端連接時,會通過ServerSocketChannel 得到 SocketChannel
  • Selector 進行監聽 (select 方法), 對於有事件發生的通道,將對應的SelectionKey加入到內部集合中並返回
  • 將socketChannel註冊到Selector上(register(Selector sel, int ops)方法), 一個selector上可以註冊多個SocketChannel
  • 註冊後返回一個 SelectionKey, 會和該Selector 關聯(集合)
  • 有事件發生時,得到SelectionKey
  • 在通過 SelectionKey 反向獲取 SocketChannel (方法 channel)
  • 可以通過得到的 channel , 完成業務處理

在這裏插入圖片描述

服務器端實現代碼:

public class NIOServer {

    public static void main(String[] args) throws IOException {

        //創建ServerSocketChannel對象,類似於BIO中的ServerSocket
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

        //創建Selector對象
        Selector selector = Selector.open();

        //綁定端口6666在服務器端監聽
        serverSocketChannel.socket().bind(new InetSocketAddress(6666));
        //將serverSocketChannel設置爲非阻塞
        serverSocketChannel.configureBlocking(false);

        //把serverSocketChannel註冊到selector,設置關心時間爲OP_ACCEPT
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {


            if (selector.select(2000) == 0) {
                System.out.println("服務器等待2秒,無連接");
                continue;
            }


            //select的返回值>0,說明有事件發生。獲取到相關的事件的selectionKeys
            //selector.selectedKeys()返回關注事件的集合
            //可以通過SelectionKey反向獲取channel
            Set<SelectionKey> selectionKeys = selector.selectedKeys();

            Iterator<SelectionKey> iterator = selectionKeys.iterator();

            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();

                //根據key,對應通道發生的事件做相應的處理
                if (key.isAcceptable()) { //如果是OP_ACCEPT,說明有新的客戶端連接

                    //爲該客戶端分配一個SocketChannel
                    SocketChannel socketChannel = serverSocketChannel.accept();
                    //設置爲非阻塞
                    socketChannel.configureBlocking(false);
                    System.out.println("客戶端連接成功,對應的socketChannel爲:" + socketChannel.hashCode());

                    //將SocketChannel註冊到selector上,關心的事件爲OP_READ
                    //同時給該socketChannel關聯一個buffer
                    socketChannel.register(selector,
                            SelectionKey.OP_READ, ByteBuffer.allocate(1024));

                }
                if (key.isReadable()) { //OP_READ
                    //根據SelectionKey獲取到對應的channel
                    SocketChannel channel = (SocketChannel) key.channel();
                    //獲取到該 Channel 關聯的 buffer
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    //從channel中讀取數據放入到buffer中
                    channel.read(buffer);
                    System.out.println("客戶端:" + new String(buffer.array()));
                }

                //手動移除當前的SelectionKey,防止重複操作
                iterator.remove();
            }
        }
    }

}

客戶端實現代碼:

public class NIOClient {

    public static void main(String[] args) throws IOException {

        //得到一個網絡通道
        SocketChannel socketChannel = SocketChannel.open();
        //設置非阻塞
        socketChannel.configureBlocking(false);
        //創建一個連接地址
        InetSocketAddress inetSocketAddress =
                new InetSocketAddress("127.0.0.1", 6666);

        //連接服務器
        if (!socketChannel.connect(inetSocketAddress)) {
            while (!socketChannel.finishConnect()){
                System.out.println("連接需要時間,如果連接失敗,客戶端不會阻塞");
            }
        }

        //如果連接成功,就發送數據
        String str = "Hello World";
        //將字符串的內容放入Buffer
        ByteBuffer byteBuffer = ByteBuffer.wrap(str.getBytes());
        //將byteBuffer的內容寫入到socketChannel
        socketChannel.write(byteBuffer);

        //暫停
        System.in.read();
    }
}

運行結果:

服務器等待2秒,無連接
服務器等待2秒,無連接
服務器等待2秒,無連接
客戶端連接成功,對應的socketChannel爲:94438417
客戶端:Hello World                                                                     服務器等待2秒,無連接
服務器等待2秒,無連接

6、相關組件分析

1)SelectionKey

SelectionKey,表示 Selector 和網絡通道的註冊關係, 共四種:

  • OP_ACCEPT:有新的網絡連接可以 accept,值爲 16
  • OP_CONNECT:代表連接已經建立,值爲 8
  • OP_READ:代表讀操作,值爲 1
  • OP_WRITE:代表寫操作,值爲 4

源碼:

public static final int OP_READ = 1 << 0; 
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

SelectionKey相關方法有:

public abstract class SelectionKey {
    public abstract Selector selector();//得到與之關聯的 Selector 對象

    public abstract SelectableChannel channel();//得到與之關聯的通道

    public final Object attachment();//得到與之關聯的共享數據

    public abstract SelectionKey interestOps(int ops);//設置或改變監聽事件

    public final boolean isAcceptable();//是否可以 accept

    public final boolean isReadable();//是否可以讀

    public final boolean isWritable();//是否可以寫
}

2)ServerSocketChannel

ServerSocketChannel 在服務器端監聽新的客戶端 Socket 連接

相關方法如下:

public abstract class ServerSocketChannel
    extends AbstractSelectableChannel
    implements NetworkChannel{
	
    public static ServerSocketChannel open();//得到一個 ServerSocketChannel 通道

    public final ServerSocketChannel bind(SocketAddress local);//設置服務器端端口號

    //設置阻塞或非阻塞模式,取值 false 表示採用非阻塞模式
    public final SelectableChannel configureBlocking(boolean block);

    public SocketChannel accept();//接受一個連接,返回代表這個連接的通道對象

    public final SelectionKey register(Selector sel, int ops);//註冊一個選擇器並設置監聽事件
}

3)SocketChannel

SocketChannel,網絡 IO 通道,具體負責進行讀寫操作。NIO 把緩衝區的數據寫入通道,或者把通道里的數據讀到緩衝區。

相關方法如下:

public abstract class SocketChannel
    extends AbstractSelectableChannel
    implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel{

    public static SocketChannel open();//得到一個 SocketChannel 通道

    public final SelectableChannel configureBlocking(boolean block);//設置阻塞或非阻塞模式,取值 false 表示採用非阻塞模式

    public boolean connect(SocketAddress remote);//連接服務器

    public boolean finishConnect();//如果上面的方法連接失敗,接下來就要通過該方法完成連接操作

    public int write(ByteBuffer src);//往通道里寫數據

    public int read(ByteBuffer dst);//從通道里讀數據

    public final SelectionKey register(Selector sel, int ops, Object att);//註冊一個選擇器並設置監聽事件,最後一個參數可以設置共享數據

    public final void close();//關閉通道
}

6、NIO 網絡編程應用實例-羣聊系統

實例要求:

  • 編寫一個 NIO 羣聊系統,實現服務器端和客戶端之間的數據簡單通訊(非阻塞)
  • 實現多人羣聊
  • 服務器端:可以監測用戶上線,離線,並實現消息轉發功能
  • 客戶端:通過channel 可以無阻塞發送消息給其它所有用戶,同時可以接受其它用戶發送的消息(有服務器轉發得到)
  • 目的:進一步理解NIO非阻塞網絡編程機制

服務器端代碼:

public class GroupChatServer {

    //Selector
    private Selector selector;
    //定義ServerSocketChannel
    private ServerSocketChannel listenChannel;
    //定義端口
    private static final int PORT = 6666;

    //定義構造方法,進行初始化工作
    public GroupChatServer(){
        try {
            //創建Selector
            selector = Selector.open();
            //創建ServerSocketChannel
            listenChannel = ServerSocketChannel.open();
            //設置端口
            listenChannel.socket().bind(new InetSocketAddress(PORT));
            //設置非阻塞
            listenChannel.configureBlocking(false);
            //將listenChannel註冊到selector中
            listenChannel.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        GroupChatServer server = new GroupChatServer();
        server.listen();
    }


    //進行監聽
    public void listen(){
        try {
            while (true) {
                int count = selector.select();
                if (count > 0) { //說明有事件發生
                    //獲得事件的SelectionKey
                    Set<SelectionKey> selectionKeys = selector.selectedKeys();

                    for (SelectionKey key : selectionKeys) { //對每個事件進行處理

                        if (key.isAcceptable()) { //如果是連接事件
                            //爲客戶端分配一個SocketChannel
                            SocketChannel socketChannel = listenChannel.accept();
                            //設置非阻塞
                            socketChannel.configureBlocking(false);

                            //將SocketChannel註冊到selector上,關心的事件爲OP_READ
                            socketChannel.register(selector,SelectionKey.OP_READ);

                            System.out.println(socketChannel.getRemoteAddress() + " 上線 ");
                        } else if (key.isReadable()) { //是read事件,即通道是可讀事件
                            //處理讀操作
                            readData(key);
                        }
                        selectionKeys.remove(key);
                    }
                } else {
                    System.out.println("等待中....");
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //從SocketChannel讀取客戶端發送的數據輸出並轉發
    private void readData(SelectionKey key) {
        SocketChannel channel = null;
        try {
            //從SelectionKey獲取SocketChannel
            channel = (SocketChannel) key.channel();
            //定義緩衝器
            ByteBuffer buffer = ByteBuffer.allocate(1024);

            //將SocketChannel中的數據讀取到buffer
            int read = channel.read(buffer);
            String msg = new String(buffer.array());
            if (read > 0) {
                //在服務器端輸出
                System.out.println(msg);
                //轉發到其他客戶端
                redirectMsgToOtherClient(msg, channel);
            }
        } catch (IOException e) {
            try {
                //發生異常說明有客戶端離線
                System.out.println(channel.getRemoteAddress() + " 離線了 ");
                //取消註冊
                key.cancel();
                //關閉通道
                channel.close();
            } catch (IOException e1) {
                e1.printStackTrace();
            }
        }
    }

    //將一個客戶端發送的信息轉發到其他客戶端(排除自己)
    private void redirectMsgToOtherClient(String msg, SocketChannel self) throws IOException {

        System.out.println("服務轉發消息中...");

        //遍歷所有註冊到selector上的SocketChannel,並排除self
        for (SelectionKey key : selector.keys()) {

            Channel channel = key.channel();

            //不對自己轉發
            if (channel instanceof SocketChannel && channel != self) {

                //轉型
                SocketChannel sc = (SocketChannel) channel;
                //創建緩衝區並賦值
                ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
                //將緩衝區的值寫入SocketChannel
                sc.write(buffer);
            }
        }
    }
}

客戶端代碼:

public class GroupCharClient {

    //定義屬性
    private static final String HOST = "127.0.0.1";
    private static final int PORT = 6666;
    private Selector selector;
    private SocketChannel socketChannel;
    private String username;

    //初始化屬性
    public GroupCharClient() throws IOException {
        selector = Selector.open();
        //連接服務器
        socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT));
        //設置非阻塞
        socketChannel.configureBlocking(false);
        //將socketChannel註冊到Selector
        socketChannel.register(selector, SelectionKey.OP_READ);
        //設置username爲本地地址
        username = socketChannel.getLocalAddress().toString().substring(1);
        System.out.println(username + " 初始化完成");
    }

    public static void main(String[] args) throws IOException {
        GroupCharClient client = new GroupCharClient();

        new Thread(()->{
            //每隔一秒讀取一次從服務器端轉發的消息
            while (true) {
                try {
                    client.getMsg();
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();

        //用於讀取輸入數據
        Scanner scanner = new Scanner(System.in);

        //發送輸入的數據
        while (scanner.hasNextLine()) {
            String s = scanner.nextLine();
            client.sendMsg(s);
        }
    }

    //向服務器發送消息
    public void sendMsg(String msg) {
        msg = username + " : " + msg;
        try {
            socketChannel.write(ByteBuffer.wrap(msg.getBytes()));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //讀取服務器轉發的消息
    public void getMsg() {
        try {
            int count = selector.select();
            if (count > 0) {//有可以用的通道

                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                for (SelectionKey key : selectionKeys) {
                    if (key.isReadable()) {
                        //根據key獲取SocketChannel
                        SocketChannel sc = (SocketChannel) key.channel();

                        ByteBuffer buffer = ByteBuffer.allocate(1024);

                        //將數據從SocketChannel讀到buffer
                        int read = sc.read(buffer);
                        if (read > 0) {
                            System.out.println(new String(buffer.array()));
                        }
                    }
                    //刪除當前的 selectionKey, 防止重複操作
                    selectionKeys.remove(key);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

測試結果:

客戶端 a 輸入輸出內容:

在這裏插入圖片描述

客戶端 b 輸入輸出內容:
在這裏插入圖片描述
服務器端輸出內容:
在這裏插入圖片描述

三、NIO與零拷貝

零拷貝是網絡編程的關鍵,很多性能優化都離不開。在 Java 程序中,常用的零拷貝有 mmap(內存映射) 和 sendFile。

注意:零拷貝從操作系統角度,是沒有cpu 拷貝

1、傳統IO數據讀寫

下面是Java 傳統 IO 和 網絡編程的一段代碼:

File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");

byte[] arr = new byte[(int) file.length()];
raf.read(arr);

Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

在這裏插入圖片描述
DMA: direct memory access 直接內存拷貝(不使用CPU)

2)mmap 優化

mmap 通過內存映射,將文件映射到內核緩衝區,同時,用戶空間可以共享內核空間的數據。這樣,在進行網絡傳輸時,就可以減少內核空間到用戶控件的拷貝次數。如下圖
在這裏插入圖片描述

3)sendFile 優化

  • Linux 2.1 版本 提供了 sendFile 函數,其基本原理如下:數據根本不經過用戶態,直接從內核緩衝區進入到 Socket Buffer,同時,由於和用戶態完全無關,就減少了一次上下文切換

在這裏插入圖片描述

  • Linux 在 2.4 版本中,做了一些修改,避免了從內核緩衝區拷貝到 Socket buffer 的操作,直接拷貝到協議棧,從而再一次減少了數據拷貝。具體如下圖和小結:
    在這裏插入圖片描述

這裏其實有 一次cpu 拷貝kernel buffer -> socket buffer。但是,拷貝的信息很少,比如 lenght , offset , 消耗低,可以忽略

總結

  • 我們說零拷貝,是從操作系統的角度來說的。因爲內核緩衝區之間,沒有數據是重複的(只有 kernel buffer 有一份數據)
  • 零拷貝不僅僅帶來更少的數據複製,還能帶來其他的性能優勢,例如更少的上下文切換,更少的 CPU 緩存僞共享以及無 CPU 校驗和計算。
  • mmap 適合小數據量讀寫,sendFile 適合大文件傳輸。
  • mmap 需要 4 次上下文切換,3 次數據拷貝;sendFile 需要 3 次上下文切換,最少 2 次數據拷貝。
  • sendFile 可以利用 DMA 方式,減少 CPU 拷貝,mmap 則不能(必須從內核拷貝到 Socket 緩衝區)。

四、 Java AIO 基本介紹

  • JDK 7 引入了 Asynchronous I/O,即 AIO。在進行 I/O 編程中,常用到兩種模式:Reactor和 Proactor。Java 的 NIO 就是 Reactor,當有事件觸發時,服務器端得到通知,進行相應的處理
  • AIO 即 NIO2.0,叫做異步不阻塞的 IO。AIO 引入異步通道的概念,採用了 Proactor 模式,簡化了程序編寫,有效的請求才啓動線程,它的特點是先由操作系統完成後才通知服務端程序啓動線程去處理,一般適用於連接數較多且連接時間較長的應用

BIO、NIO、AIO比較:

BIO NIO AIO
IO 模型 同步阻塞 同步非阻塞(多路複用) 異步非阻塞
編程難度 簡單 複雜 複雜
可靠性
吞吐量
  • 同步阻塞:到理髮店理髮,就一直等理髮師,直到輪到自己理髮。

  • 同步非阻塞:到理髮店理髮,發現前面有其它人理髮,給理髮師說下,先幹其他事情,一會過來看是否輪到自己.

  • 異步非阻塞:給理髮師打電話,讓理髮師上門服務,自己幹其它事情,之後理髮師會打電話通知你

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