【NIO】Java的NIO的實現與BIO的優勢

BIO實現一個服務器

爲了更好的演示BIO與NIO之間的區別,我們先用一個服務器示例來了解一個BIO實現網絡通行的過程。

單線程下的BIO服務器

服務端

public class BioServer {
    public static void main(String[] args) throws IOException {
        byte[] bs = new byte[1024];

        // 創建一個新的ServerSocket,綁定一個InetSocketAddress,監聽8000端口上的連接請求
        ServerSocket serverSocket = new ServerSocket();
        serverSocket.bind(new InetSocketAddress(8000));

        // accept專門負責通信
        while(true) {
            System.out.println("等待連接---");
            // =====①:accept()函數的執行
            Socket accept = serverSocket.accept(); // 這裏會阻塞以釋放CPU資源
            System.out.println("連接成功---");

            System.out.println("等待數據傳輸---");
            // =====②:getInputStrea()函數獲取客戶端傳送的輸入流
            int read = accept.getInputStream().read(bs); // 這裏也可能阻塞
            System.out.println("數據傳輸成功---" + read);

            String content = new String(bs);
            System.out.println(content);
        }
    }
}

客戶端

public class Client {
    public static void main(String[] args) throws IOException {
        // 建立一個socket去連接服務端
        Socket socket = new Socket();
        socket.connect(new InetSocketAddress("127.0.0.1", 8000));

        Scanner scanner = new Scanner(System.in);
        while (true) {
            // =====③:getOutputStream()函數中寫入的是從控制檯輸入的字符
            String next = scanner.next();
            socket.getOutputStream().write(next.getBytes());
        }

        // socket.close();
    }
}

缺陷分析

首先我們先開啓服務端,開啓後的控制檯輸出如下,程序會在運行到①的地方停下來阻塞掉,等待客戶端連接上來。如果沒有客戶端連接的話,這個線程將會一直停在這裏。

那麼我們現在先開啓客戶端,然後不在控制檯輸入數據,如下圖所示,服務端程序會一直卡在②的地方停下來,因爲客戶端卡在了③的位置,你一直沒有在控制檯輸入字符,客戶端的沒有輸出流,那麼服務端沒辦法接收到客戶端發送過來的數據,從而阻塞在②的位置。

假設現在客戶端傳來一條信息,那麼客戶端程序就可以接受到這條數據,阻塞在②處的線程就會從新運行下去。

從這裏我們很容易想到這種模式的服務器的缺陷,首先,它一次只能接收一個接收一個客戶端的請求,要是有多個,沒辦法,在處理完前面的連接前,它是沒辦法往下執行的,那麼如果前面連接一直不傳送消息過來,就像我們剛剛將程序阻塞在③處一樣,那麼服務端就無法往下運行了,面對這種問題,我們想到用多線程來解決,一個請求對應一個線程,那麼就沒有線程在③阻塞的問題了。

多線程下的BIO服務器

客戶端

public static void main(String[] args) throws IOException {
    byte[] bs = new byte[1024];

    // 創建一個新的ServerSocket,綁定一個InetSocketAddress,監聽8000端口上的連接請求
    ServerSocket serverSocket = new ServerSocket();
    serverSocket.bind(new InetSocketAddress(8000));

    // accept專門負責通信
    while(true) {
        System.out.println("等待連接---");
        // =====①:accept()函數的執行
        Socket socket = serverSocket.accept(); // 這裏會阻塞以釋放CPU資源
        System.out.println("連接成功---");

        // =====④:新建一個線程來處理這個客戶端連接
        Thread thread = new Thread(new ExecuteSocket(socket));
        thread.start();
    }
}

static class ExecuteSocket implements Runnable {
    byte[] bs = new byte[1024];
    Socket socket;

    // 處理每個客戶端連接——讀寫
    public ExecuteSocket(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // =====⑤:這裏還是有阻塞的,不過是在線程裏阻塞,不影響主線程
            socket.getInputStream().read(bs);
        } catch (IOException e) {
            e.printStackTrace();
        }
        String content = new String(bs);
        System.out.println(content);
    }
}
}

問題分析

客戶端還是用剛纔的客戶端,沒什麼影響畢竟。

那麼現在我們就可以開啓客戶端和服務端了,我們嘗試下開啓兩個客戶端,服務端的控制檯輸出如下:

我們可以發現現在服務端的main線程並沒有阻塞,而是可以繼續往下執行,因爲在④處它開啓了一個子線程去處理這個連接的請求了,所以哪怕是客戶端不發送數據,阻塞也是在子線程中的⑤處發生的,這樣對服務端處理下一個請求並沒有太大的影響。

問題到這裏看似好像解決了,但是讓我們考慮一下這種方案的影響,當我們要管理多個併發客戶端時,我們需要爲每個新的客戶端Socket創建一個新的Thread,如下圖所示:

所以這種模型也有很多的吐槽點,首先,在任何時候都有可能有大量的線程處於休眠狀態,只是等待輸入或者輸出數據就緒,這對於我們的系統來說就是一種巨大的資源浪費;然後,我們需要爲每個線程都分配內存,其默認值大小區間爲64kb到1M。而且,我們還要考慮到,哪怕虛擬機本身是可以支持大量線程,但是遠在達到該極限之前,上下文切換所帶來的開銷就會給我們的系統帶來巨大的資源消耗。

NIO的原理/實現一個NIO服務器

用僞代碼解釋NIO

首先我們來了解一下NIO的原理。假設現在Java開發了兩個API,一個叫Socket.setNoBlock(boolean),可以讓socket所在線程在沒有得到客戶端發送過來的數據時也不會阻塞,而是繼續進行。另外一個叫ServerSocket.setNoBlock(boolean),可以讓ServerSocket所在線程在沒有得到客戶端連接時也不會阻塞而往下運行。下面我們用僞代碼來分析一波:

public class BioServer {

    public static void main(String[] args) throws IOException {
        List<Socket> socketList = null; // 用以存放連接服務端的socket
        byte[] bs = new byte[1024];

        ServerSocket serverSocket = new ServerSocket();
        // =====①:這個地方是僞代碼,現在假設方法執行後serverSocket在沒有客戶端連接的情況下也會繼續執行
        serverSocket.setNoBlock(true);
        serverSocket.bind(new InetSocketAddress(8000));

        while(true) {
            System.out.println("等待連接---");
            Socket socket = serverSocket.accept(); // 現在這裏不會阻塞以釋放CPU資源

            if (socket == null) { // 沒客戶端連接過來
                // =====:②找到以前連接服務端的socket,看它們有沒有發給我數據
                for (Socket socket1 : socketList) {
                    int read = socket.getInputStream().read(bs);
                    if (read != 0) { // 這個socket有數據傳過來
                        // 這裏處理你的業務邏輯
                    }
                }

            } else { // 有客戶端連接過來
                // =====:③這個地方是僞代碼,現在假設方法設置後socket不會阻塞
                socket.setNoBlock(true);
                // =====:④將這個socket添加到socketList中
                socketList.add(socket);

                for (Socket socket1 : socketList) { // 遍歷socketList,看看哪個socket給服務端發送數據
                    int read = socket.getInputStream().read(bs);
                    if (read != 0) { // 這個socket有數據傳過來
                        // 這裏處理你的業務邏輯
                    }
                }
            }
        }
    }
}

這裏我們聲明瞭一個socketList,用以存放連接到服務端的socket。現在我們在①處設置了讓這個serverSocket在本次循環就算沒有客戶端連接上來也不會阻塞,而是繼續執行下去。執行下去之後判斷分兩叉,一叉是沒有客戶端連接過來的情況,那麼就在②拿出socketList,看看之前連接的socket裏面有沒有哪個給我發數據,有的話就來處理一下。另外一叉就是在有客戶端連接上來的情況了,首先我們在③處將socket也設置爲非阻塞的,然後將這個socket添加到SocketList當中,然後繼續拿出socket,看看有沒有哪個socket給我發數據,有就處理一下。

現在到這裏,NIO的思路基本理清了,下面我們用代碼來實現一個簡單的服務端。

用NIO實現一個簡單服務端

這裏我們還是利用List來緩存Socket,之後再輪詢是否有傳輸的數據。

public class NioServer {

    public static void main(String[] args) {
        List<SocketChannel> list = new ArrayList<>();
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

        try {
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.bind(new InetSocketAddress(8001));
            ssc.configureBlocking(false); // 在這裏設置爲非阻塞

            while (true) {
                SocketChannel socketChannel = ssc.accept();

                if (socketChannel == null) {
                    Thread.sleep(1000);
                    System.out.println("沒有客戶端連接上來");
                    for (SocketChannel channel : list) {
                        int k = channel.read(byteBuffer);
                        System.out.println(k + "===== no connection =====");
                        if (k != 0) { // 有連接發來數據
                            byteBuffer.flip();
                            System.out.println(new String(byteBuffer.array()));
                        }
                    }
                } else {
                    socketChannel.configureBlocking(false);
                    list.add(socketChannel);
                    // 得到套接字,循環所有的套接字,通過套接字獲取數據
                    for (SocketChannel channel : list) {
                        int k = channel.read(byteBuffer);
                        System.out.println(k + "===== connection =====");
                        if (k != 0) {
                            byteBuffer.flip();
                            System.out.println(new String(byteBuffer.array()));
                        }
                    }
                }
            }
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }

}

OK,現在將上面的代碼運行起來,再運行兩個客戶端代碼,向8001端口發送數據,運行結果如下:

這種非阻塞實現可以讓服務端節省下許多資源。但是這樣的實現還是有弊端:

我們在這裏採用了輪詢的方式來接收消息,每次都會輪詢所有的連接,查看哪個套接字中有準備好的消息。在連接到服務端的連接還少的時候,這種方式是沒有問題的,但是如果現在有100w個連接,此時再使用輪詢的話,效率就變得十分低下。而且很大一部分的連接基本都不發消息的,在100w個連接中可能只有10w個連接會有消息,但是每次連接程序後我們都得去輪詢,這是很不適合的。

用NIO加強服務端

首先我們要知道一個class java.nio.channels.Selector,它是實現Java的非阻塞I/O的關鍵。什麼是Selector,這裏舉例做解釋:

在一個養雞場,有這麼一個人,每天的工作就是不停檢查幾個特殊的雞籠,如果有雞進來,有雞出去,有雞生蛋,有雞生病等等,就把相應的情況記錄下來,如果雞場的負責人想知道情況,只需要詢問那個人即可。

在這裏,這個人就相當Selector,每個雞籠相當於一個SocketChannel,每個線程通過一個Selector可以管理多個SocketChannel。

爲了實現Selector管理多個SocketChannel,必須將具體的SocketChannel對象註冊到Selector,並聲明需要監聽的事件(這樣Selector才知道需要記錄什麼數據),一共有4種事件:

  • connect:客戶端連接服務端事件,對應值爲SelectionKey.OP_CONNECT(8)
  • accept:服務端接收客戶端連接事件,對應值爲SelectionKey.OP_ACCEPT(16)
  • read:讀事件,對應值爲SelectionKey.OP_READ(1)
  • write:寫事件,對應值爲SelectionKey.OP_WRITE(4)

這個很好理解,每次請求到達服務器,都是從connect開始,connect成功後,服務端開始準備accept,準備就緒,開始讀數據,並處理,最後寫回數據返回。

所以,當SocketChannel有對應的事件發生時,Selector都可以觀察到,並進行相應的處理。

public class NioServer {

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

        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        ssc.socket().bind(new InetSocketAddress(8001));

        Selector selector = Selector.open();
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            int n = selector.select();
            if (n == 0) continue; // 如果沒有連接發來數據,跳過此次循環
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                if (key.isAcceptable()) {
                    SocketChannel socketChannel = ((ServerSocketChannel) key.channel()).accept();
                    socketChannel.configureBlocking(false);
                    // 將選擇器註冊到客戶端信道
                    // 並指定該信道key值的屬性爲OP_READ,
                    // 同時爲該信道指定關聯的附件
                    socketChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                }
                if (key.isReadable()) {
                    // handle Read
                }
                if (key.isWritable() && key.isValid()) {
                    // handle Write
                }
                if (key.isConnectable()) {
                    System.out.println("isConnectable = true");
                }

                iterator.remove();
            }
            
        }
    }
}

從這裏我們看出,雖然之前我們用NIO做了多個客戶端輪詢,但是在真正在NIO實現時,我們並不會去這麼做,而是使用Selector,將輪詢的邏輯交由Selector處理,而Selector最終會調用到系統函數select()/epoll()。

select/epoll比對直接輪詢

select底層邏輯

假設有多個連接同時連接服務器,那麼根據上下文的設計,程序將會遍歷這多個連接,輪詢每個連接以獲取各自數據的準備情況,那麼這和我們自己寫的程序有什麼區別呢?

首先,我們自己寫的Java程序本質也是在輪詢每個Socket的時候去調用系統函數,那麼輪詢一個調用一次,會造成不必要的上下文切換開銷。

而select會將請求從用戶態空間全量複製一份到內核態空間,在內核空間來判斷每個請求是否準備好數據,完全避免頻繁的上下文切換。所以效率是比我們直接在應用層輪詢要高的。

如果select沒有查詢到到有數據的請求,那麼將會一直阻塞(是的,select是一個阻塞函數)。如果有一個或者多個請求已經準備好數據了,那麼select將會先將有數據的文件描述符置位,然後select返回。返回後通過遍歷查看哪個請求有數據。

select的缺點:

  • 底層存儲依賴bitmap,處理的請求是有上限的,爲1024。
  • 文件描述符是會置位的,所以如果當被置位的文件描述符需要重新使用時,是需要重新賦空值的。
  • fd(文件描述符)從用戶態拷貝到內核態仍然有一筆開銷。
  • select返回後還要再次遍歷,來獲知是哪一個請求有數據。

poll函數邏輯

poll的工作原理和select很像,先看一段poll內部使用的一個結構體

struct pollfd{
    int fd;
    short events;
    short revents;
}

poll同樣會將所有的請求拷貝到內核態,和select一樣,poll同樣是一個阻塞函數,當一個或多個請求有數據的時候,也同樣會進行置位,但是它置位的是結構體pollfd中的events或者revents置位,而不是對fd本身進行置位,所以在下一次使用的時候不需要再進行重新賦空值的操作。poll內部存儲不依賴bitmap,而是使用pollfd數組的這樣一個數據結構,數組的大小肯定是大於1024的。解決了select 1、2兩點的缺點。

epoll

epoll是最新的一種多路IO複用的函數。這裏只說說它的特點。
epoll和上述兩個函數最大的不同是,它的fd是共享在用戶態和內核態之間的,所以可以不必進行從用戶態到內核態的一個拷貝,這樣可以節約系統資源;另外,在select和poll中,如果某個請求的數據已經準備好,它們會將所有的請求都返回,供程序去遍歷查看哪個請求存在數據,但是epoll只會返回存在數據的請求,這是因爲epoll在發現某個請求存在數據時,首先會進行一個重排操作,將所有有數據的fd放到最前面的位置,然後返回(返回值爲存在數據請求的個數N),那麼我們的上層程序就可以不必將所有請求都輪詢,而是直接遍歷epoll返回的前N個請求,這些請求都是有數據的請求。

參考

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