03-BIO、NIO到Netty

BIO、NIO到Netty

一、BIO

  • BIO(Blocking IO),即阻塞IO,在 Linux IO模型 中描述過BIO的特點,它在等待數據的過程中線程是會阻塞的,直到數據到來,因此一個連接就需要一個線程去處理。

1.1 代碼

1.1.1 客戶端

  • 客戶端代碼啓動後,將連接的socket對象傳到兩個線程中,一個寫線程負責寫數據到服務端,一個讀線程負責接收服務端的數據,客戶端寫入over 則會停止通信。
public class BioClient {

    private static final int PORT = Utils.PORT;
    private static final String IP = Utils.IP;
    static PrintWriter printWriter = null;

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket(IP, PORT);
        System.out.println("Input your info:");
        //新啓動一個寫線程用於發送消息到服務端
        //新啓動一個讀線程用於接收服務端的響應消息
        new WriteThread(socket).start();
        new ReadThread(socket).start();
    }

    static class WriteThread extends Thread {
        static Socket socket;

        WriteThread(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            try {
                PrintWriter printWriter = new PrintWriter(socket.getOutputStream(), true);
                String line;
                while (true) {
                    line = new Scanner(System.in).next();
                    printWriter.println(line);
                    if ("over".equalsIgnoreCase(line)) {
                        break;
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                System.out.println("write thread end ...");
            }
        }
    }

    static class ReadThread extends Thread {
        static Socket socket;

        ReadThread(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            try {
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                String line = null;
                while (true) {
                    while ((line = bufferedReader.readLine()) != null) {
                        if ("over".equalsIgnoreCase(line)) {
                            return;
                        }
                        System.out.printf("收到數據: %s", line);
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                System.out.println("read thread end ...");
                clear(socket);
            }
        }

    }

    private static void clear(Socket socket) {
        if (socket != null) {
            try {
                System.out.println("client close...");
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

1.1.2 服務端代碼

  • 服務端循環等待客戶端的連接,收到連接後交給一個線程去處理,處理的邏輯很簡單,如果收到的是over就結束,不是的話就返回收到的內容。
public class BioServer {

    private static final int PORT = Utils.PORT;
    private static ServerSocket serverSocket;

    /**
     * 執行任務的線程池
     */
    private static ExecutorService executorService = new ThreadPoolExecutor(
            5,
            10,
            10L,
            TimeUnit.SECONDS,
            new LinkedBlockingQueue<Runnable>(20),
            new NamedThreadFactory()
    );

    private static void start() throws Exception {
        try {
            //1.綁定端口
            serverSocket = new ServerSocket(PORT);
            System.out.println("Bio server start , the port listening is : " + PORT);
            while (true) {
                //1.服務端等待socket上客戶端的連接,注意這個方法會一直阻塞直到有連接位置
                Socket accept = serverSocket.accept();
                System.out.println("New connection established ...,ready to execute the task... ");
                //2.將建立的連接交給線程處理,因此一個連接需要一個線程處理
                executorService.execute(new BioServerHandler(accept));
            }
        } finally {
            if (serverSocket != null) {
                serverSocket.close();
            }
        }
    }
    public static void main(String[] args) throws Exception {
        start();
    }
}


/**
 * 處理邏輯很簡單,如果客戶端輸入的是“over”,那麼就斷開此次連接,反之則給與一個回覆表示自己收到了。
 * @Date 2019/4/22 12:43
 */
public class BioServerHandler implements Runnable {

    private Socket socket;

    public BioServerHandler(Socket socket) {
        this.socket = socket;
    }

    public void run() {
        try {
            System.out.println("服務端處理端口:" + socket.getPort());
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            PrintWriter printWriter = new PrintWriter(socket.getOutputStream(), true);
            String line;
            String result;
            while ((line = bufferedReader.readLine()) != null) {
                System.out.println("Server received :" + line);
                if ("over".equalsIgnoreCase(line)) {
                    printWriter.println("over");
                    break;
                }
                printWriter.println("收到了!len:" + line.length());
            }
        } catch (Exception e) {
            //如果客戶端關閉,則提示連接關閉
            if (e instanceof SocketException && "Connection reset".equalsIgnoreCase(e.getMessage())) {
                System.out.println("Connection closed ... ");
            } else {
                e.printStackTrace();
            }
        } finally {
            clear();
        }
    }

    private void clear() {
        if (socket != null) {
            try {
                System.out.println("server close...");
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        socket = null;
    }
}

下面是服務端打印:
Bio server start , the port listening is : 12345
New connection established ...,ready to execute the task... 
服務端處理端口:53930
New connection established ...,ready to execute the task... 
服務端處理端口:53937
New connection established ...,ready to execute the task... 
服務端處理端口:53944
Server received :client1
Server received :client2
Server received :client3

三個客戶端分表輸入client1、client2、client3:
Input your info:
client1

Input your info:
client2

Input your info:
client3
  • 可以看到,服務端監聽12345端口,但是每次接收到新的連接,通過 Socket accept = serverSocket.accept() 就會得到一個Socket對象,該Socket對象會使用一個新的空閒端口和客戶端建立連接,並交給一個線程處理。(這裏容易誤認爲通信所使用的端口就是監聽的端口)

1.2 BIO分析

  • BIO的優點是簡單,每接受一個新的客戶端連接之後,交給一個新的線程去處理業務,服務端監聽在一個固定的端口,每當接收一個新的連接之後,將使用一個新的空閒端口和客戶端進行通信。
  • 缺點:不能支持大量的連接,一個連接需要一個線程,因此需要消耗一個線程的資源,操作系統中線程是寶貴的資源,因此當併發的客戶端數量增多時,服務端的資源消耗很容易達到瓶頸。
  • 代碼上,我們重點可以只看服務端,服務端使用 ServerSocket 綁定一個端口,ServerSocket 在死循環中不斷的監聽客戶端,每收到一個客戶端連接之後通過 accept 方法返回一個 Socket 代表與客戶端的一個 TCP連接,然後由一個線程去處理這個連接,處理線程通過Socket對象可以拿到服務端處理所使用的端口,客戶端的地址端口,以及輸入輸出流等對象,完成讀寫操作。

二、NIO

2.1 NIO核心三劍客

  • Selector:選擇器,實現一個線程就能監聽多個 Channel 的狀態;可以把 Selector 理解爲一個管家(可以由一個線程或者線程池負責),它可以管理很多的通道,不再像BIO那樣一個連接需要一個線程了

在這裏插入圖片描述

1.Selector多路選擇器可以管理多個channel,在不同平臺對應的抽象不一樣,在Linux中channel對應文件描述符,在Windows中對應句柄。如此完成一個線程對多個連接的管理,
因此真正處理數據的線程就不需要阻塞了。等到真正某個channel有數據需要讀寫的時候,通知到對應的事件處理器來處理這個連接,這個處理的過程是阻塞的,在處理的過程中
也是對緩衝區的處理,而不需要對流進程處理。
  • Channel:Channel 通道代表一個連接,這個連接不僅僅是與網絡設備,也可能是文件。通道有打開和關閉狀態,打開狀態下可以執行IO操作。在NIO中比較核心的Channel是
    ServerSocketChannel 和 SocketChannel,二者分別代表服務端監聽等待連接的Channel和客戶端發起連接的Channel,有點類似於 ServerSocket 和 Socket 之間的關係。
    不過這兩個都是抽象類。不過這裏是講 NIO,Netty就是基於NIO的,但是在Netty中又重新定義了Channel接口以及 ServerSocketChannel 等繼承體系。
NIO中Channel和BIO中流的區別:
普通IO流中,中要麼是輸入流要麼是輸出流,不可能同時爲輸入和輸出流,Cahnnel是雙向的,NIO中通過flip既可以讀也可以寫
普通IO流中直接對流進行讀寫,NIO中是通過buffer操作,永遠不能直接操作Channel,
  • Buffer:緩衝區,用於和 Channel交互,從Channel中讀取數據到Buffer,從Buffer將數據寫入到Channel,緩存區的引入讓線程無需等待IO,數據到達時可以先寫入buffer,再由buffer交給線程,也讓讀寫更加靈活。Buffer本質是一塊內存區域,通過buffer和Channel通道打交道,不能直接去操作Channel,讀寫一塊內存區域顯然是靈活的,可以通過指針來靈活處理

在這裏插入圖片描述

2.2 代碼

  • NIO 代碼部分主要看服務端的代碼,看服務端如何通過一個線程來管理多個連接(Channel)
public class NioServerTest {

    private static final int[] PORTS = new int[]{12345, 12346, 12347};

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

        //1.創建一個Selector
        Selector selector = Selector.open();

        for (int port : PORTS) {
            //2.創建 serverSocketChannel,註冊到 selector 選擇器 , 設置非阻塞模式
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.configureBlocking(false);
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

            //3.端口綁定,通過 ServerSocketChannel 關聯的 ServerSocket 綁定
            ServerSocket serverSocket = serverSocketChannel.socket();
            serverSocket.bind(new InetSocketAddress(port));
        }

        while (true) {
            //4.select 是阻塞方法,有事件就返回
            int num = selector.select();

            //5.獲取事件,可能多個通道有事件,因此返回的是一個集合
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();

            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                //可接受事件
                if (selectionKey.isAcceptable()) {
                    //拿到channel對象
                    ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();

                    //得到 SocketChannel ,代表 TCP 連接對象
                    SocketChannel socketChannel = channel.accept();
                    System.out.println("服務端處理端口:" + socketChannel.socket().getPort());
                    //配置非阻塞,由此可以看到客戶端 socketChannel 也可以是非阻塞的,
                    // configureBlocking 方法實際上定義在父類,因此客戶端服務端都是非阻塞的
                    socketChannel.configureBlocking(false);

                    //也把連接對象註冊到selector,連接對象關心的應該是讀寫事件
                    socketChannel.register(selector, SelectionKey.OP_READ);

                    //移除非常關鍵,因此這個連接事件已經處理了,不移除的話會多次處理
                    iterator.remove();
                    System.out.println("獲取到客戶端的連接: " + socketChannel);
                } else if (selectionKey.isReadable()) {
                    //拿到channel對象
                    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(512);
                    int readBytes = socketChannel.read(byteBuffer);
                    if (readBytes > 0) {
                        byteBuffer.flip();
                        byte[] bytes = new byte[byteBuffer.remaining()];
                        byteBuffer.get(bytes);
                        String body = new String(bytes, "UTF-8");
                        System.out.println("服務端收到消息 : " + body);
                        //將消息寫回客戶端
                        byte[] resp = body.getBytes();
                        ByteBuffer write = ByteBuffer.allocate(body.getBytes().length);
                        write.put(resp).flip();
                        socketChannel.write(write);
                    }
                    iterator.remove();
                }
            }
        }
    }
}
  • 服務端監聽3個端口,多個客戶端分別從三個端口連接服務端,打印如下:
//服務端打印
獲取到客戶端的連接: java.nio.channels.SocketChannel[connected local=/127.0.0.1:12345 remote=/127.0.0.1:54844]
獲取到客戶端的連接: java.nio.channels.SocketChannel[connected local=/127.0.0.1:12346 remote=/127.0.0.1:54856]
獲取到客戶端的連接: java.nio.channels.SocketChannel[connected local=/127.0.0.1:12347 remote=/127.0.0.1:54866]
服務端收到消息 : client1
服務端收到消息 : client2
服務端收到消息 : client3
  • 可以看到,服務端可以監聽多個端口,每個端口都是由一個 ServerSocketChannel 監聽,(實際上是ServerSocket監聽),而這些ServerSocketChannel都可以交給一個 Selector 來管理;與此同時,監聽端口的 ServerSocketChannel 每次收到一個連接請求之後產生一個可連接事件,並通過accept方法會創建一個SocketChannel對象,這個SocketChannel也可以交給Selector來管理,處理後續的 Readable 可讀事件。由此我們看到 Selector 可以管理多個 ServerSocketChannel 和多個 SocketChannel,前者用於監聽客戶端的連接,連接到來然後accept之後就會產生一個SocketChannel,大致的流程圖如下:

在這裏插入圖片描述

2.3 NIO小結

  • NIO 實現了非阻塞的網絡通信,它通過 Selector 組件來管理很多連接(Channel代表一個連接,其實就是管理很多Channel),一個 Channel 沒有事件觸發時,就會去看下一個Channel是否有事件發生,因爲大量的Channel其實需要處理IO的工作量是很少的,大量的時間都無事可做,因此一個線程通過Selctor就可以處理大量的連接,由此實現了一個線程去應對高併發。
  • 值得注意的是NIO中 Selector 管理的 Channel 大致有兩類,一類是 ServerSocketChannel ,此類是監聽端口的Channel,它等待客戶端連接;另一類是 SocketChannel,在ServerSocketChannel 調用 accept 接受客戶端連接時會得到一個SocketChannel,它是處理與客戶端的讀寫事件的 Channel,這兩類 Channel 大致和 ServerSocket 和 Socket 的關係類似,關於兩組Socket和Channel更多內容在後續Channel的文章中介紹。

三、BIO和NIO

3.1 對比

  • Java中BIO和NIO有一些區別,如下:
對比項 BIO NIO
讀寫 基於流( Stream ) 基於緩衝區( Buffer )
模式 阻塞 IO 非阻塞 IO
核心組件 選擇器或者多路複用器(Selector)
優點 大數據量傳輸或者併發不高時,性能更好,響應更及時 較少數量的線程就可以管理較多的連接,支持更高併發
缺點 一個線程處理一個請求,難以支持併發數高的場景 響應有微弱延時
應用場景 併發不高場景下的基於線程的服務模式 基於事件的服務模式

3.2 關於流和buffer

  • BIO 是面向流的,字節流或者字符流,流的特點是:單向且順序;要麼輸入流,要麼輸出流,不能爲雙向,另一個就是順序讀寫,我們不能回溯或者跳轉到某個位置重新操作。
  • Buffer是一塊內存區域,NIO下是通過buffer和Channel通道打交道的,不能直接去操作Channel,讀寫一塊內存區域顯然是靈活的,可以通過指針來靈活處理。

3.3 關於阻塞

  • BIO在讀取數據時會一直阻塞,如果此時沒有數據或者數據尚未準備好,線程會一直阻塞直到數據準備好,顯然這個阻塞等待的過程線程不能做其他的事情是一種很大的資源浪費。這種模式下,對於一個客戶端的連接,服務端就需要一個線程來處理,即使客戶端這邊一直不發數據,服務端也要阻塞在那裏等着。

  • NIO模式下如果沒有數據,那麼線程會立即返回,返回後可以對下一個連接進行讀取操作,這樣不阻塞就可以讓一個線程處理多個連接。而在網絡連接中,讀取數據往往對時間的佔比不多,比如即時通信,很多連接只會在其一小部分時間裏面發送少量數據,此種情況用NIO就很有優勢,如果用BIO就需要很多線程且每個線程有大量的時間是被阻塞的。

四、Netty

4.1 瞭解Netty

  • Netty是基於Java NIO的一個異步事件驅動的網絡通信框架,其底層是依賴於NIO相關的API的。和NIO一樣,Netty的核心特點是異步和事件驅動;

  • 異步:Netty 中Api基本是異步的,調用Api之後會立刻返回,但是並不確定結果是否成功,需要通過回調來判斷結果。異步方式比同步方式複雜很多,但是性能更好;

  • 事件驅動:Netty本身實現了很多協議,將事件映射到一個個回調方法上,對應事件發生後,對應的方法就會被調用。

  • 參照 Netty官網 的部分描述

設計:各種傳輸類型(阻塞/非阻塞)下統一的API、可定製化的線程模型、靈活的事件模型
性能:更高的吞吐,更低的延遲、更少的資源消耗、減少不必要的內存拷貝(零拷貝)
安全:SSL支持  
  • 下面是Netty的模塊劃分:
Core :核心部分,底層的網絡通用抽象和部分實現。
        Extensible Event Model :可拓展的事件模型。Netty 是基於事件模型的網絡應用框架。
        Universal Communication API :通用的通信 API 層。Netty 定義了一套抽象的通用通信層的 API,比如NIO和BIO的切換,所使用的API非常接近 。
        Zero-Copy-Capable Rich Byte Buffer :支持零拷貝特性的 Byte Buffer 實現。
Transport Services :傳輸( 通信 )服務,具體的網絡傳輸的定義與實現。
        Socket & Datagram :TCP 和 UDP 的傳輸實現。
        HTTP Tunnel :HTTP 通道的傳輸實現。
        In-VM Piple :JVM 內部的傳輸實現。
Protocol Support :協議支持。Netty 對常用協議的編解碼實現。比如:HTTP、DNS、Redis、telnet、sctp 等,在Netty源碼的 example模塊有很多示例。

在這裏插入圖片描述

  • 另外Netty在很多場景和框架也得到了應用:網絡通信,Spark、Dubbo等

4.2 Netty 5

  • Netty5 爲什麼被廢棄?Netty5 使用 Fork-Join Pool,提高了複雜性,但是在性能上並沒有明顯提升,在github上已經被廢棄,可以不在Netty5上花太多時間。

五、NIO到Netty

  • 既然JDK的原生NIO就可以支持基於事件驅動的非阻塞IO,那爲什麼不直接使用JDK的NIO,而誕生並推薦使用Netty?通常選擇一個替代方案或者一個新的方案有兩點:做的更多或者做的更好,Netty 在做的更多更好這兩方面體現在何處呢?

5.1 更多

  • 支持應用層協議,比如用Netty實現Http協議就很方便,使用JDK的NIO,相關的編解碼等工繁雜,相關協議細節複雜性可想而知

  • TCP半包粘包問題,應用層的信息在TCP層可能會拆成多個包或者多個請求合成一個包,JDK NIO它只是構建了一個通信的通道但是不會處理這些問題,netty可以幫我們解決

  • 定製功能,比如流量整型,黑白名單等,在Netty中可以較爲容易的實現

  • 完善的斷連、Idel、異常處理等,Netty已經支持的很好了

5.2 更好

  • Netty解決了Java Nio中的 epoll bug,(一個導致Cpu 100%的bug,在Linux 2.4下異常喚醒,卻沒有事件發生,導致Cpu100% ,稱爲:epoll bug 也叫空輪訓bug,而且在JDK中並未根本解決,Netty中源碼會判斷Cpu空轉的次數,超過一定的次數就會rebuild selector 解決該bug)

  • TCP協議中的IP_TOS參數,它控制了IP包的優先級和Qos選型,使用的時候會拋出異常,提示選項找不到,在JDK12纔會解決該問題,是Netty的使用者發現由Netty維護者report該問題,Netty中直接不支持該選型,避免錯誤。

  • Api更加強大,友好;JDK中的Api友好性差,比如JDK中的buffer的單指針,內部不能擴容,而Netty讀寫切換更方便,可以擴容,另外ThreadLocal中,Netty中有對應的優化後的ThreadLocal,性能更好

  • Netty屏蔽了細節,比如NIO的切換,使用Java的NIO修改非常多,Netty可能只需要修改2行代碼

  • 屏蔽細節,Jdk的NIO我們需要關係事件,移除事件等,Netty更好用,很多時候我們只需要關注自己的Handler處理器就可以了。

5.3 組件區別

  • 在NIO中有三劍客,在Netty中也有對應的體現,不過Netty 並沒有直接使用 JDK NIO中原生的 Channel 和 Selector,

  • Channel: Netty 重新定義了 io.netty.channel.Channel 接口,其實現類也全部在 io.netty.channel 包下,對於 Channel 的理解我覺得對比 NIO 中的Channel 沒什麼問題,Netty 中重新定義只是說爲了更好的使用和封裝以及實現;

  • Buffer: Netty也沒有直接使用NIO的Buffer,而是定義了 io.netty.buffer.ByteBuf,後面的文章我們對比這兩個直接的區別,ByteBuf的實現類也都在io.netty.buffer包下;

  • Selector:對於Selector,Netty底層封裝了它,但是代碼上層體現的是事件循環組 EventLoopGroup ,內部有 EventLoop 代表事件循環,他會監聽事件的發送,看起來和Selector功能一樣,他的內部就封裝了 Selector,其本質是一個線程池,能夠不斷的監聽通道事件。

  • 本文大體上介紹和對比了BIO,NIO,引入了Netty,後續先分析NIO的三大組件,再開始Netty的學習和分析。

六、參考

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