網絡編程之BIO、NIO

       已經很久沒更新博客了,慚愧。在這之前先講一下面試可能會問到的三次握手與四次揮手,也就是Tcp如何建立連接?

       假設A城市往B城市發送信件,先A發到B,B收到,在發給A,在A發給B,建立起初步通信。三次揮手是爲了證明A,B的收信和發信能力是ok的,這樣就證明連接是通常的。

        第一次握手:當A發到B時,B收到信後,此時B城市就明白了,A城市的發信能力和B城市的收信能力是ok。

        第二次握手:當B發到A時,A收到信後,此時A城市就明白了,B城市的發信能力和A城市的收信能力是ok,加上之前的發信,同時也就知道了A(自己)的發信能力和B城的收信能力是ok的,這就相當於A知道了雙方都是OK的,但B還疑惑,因爲它第一次雖然知道了A的發信能力個自身的收信能力是OK的,但並不不知道城B(自身)的的發信能力和A城市的收信能力如何,所以需要第三次握手。

        第三次握手:A發給B  當B收到後,就知道了B(自身的發信能力)和A城市的收信能力同樣是ok的,既然雙方都知根知底,那就掏心掏肺喜結連理吧。即完成首次通信的建立。

       大概流程可見如下圖:

三次握手TCP協議 連接成功後,可以得出結論
第一次握手A-->B A得出結論:啥也不知道,不確定自己是否發送ok 
B得出結論:A發  B收 ok 
第二次握手B-->A A可以得出結論:A收  B發,加上之前的第一次發送可以推出A發  B收也ok,所以明白AB發送接受都ok
B得出結論:基於第一次知道A發  B收 ok,但是並不知道自身的發送是否成功和A的接受能力
第三次握手A-->B A發給B,B收到消息後就可以證明:B的發信能力和A的自收信能力ok

      第一次揮手:A發到B,告訴B我要掛了,此時B就明白了A準備要掛了。

      第二次揮手:B發到A,告訴A,我還在忙,先彆着急掛電話,於是A就知道B還沒準備好掛電話的意思。

      第三次揮手:B發到A,好了,我忙完了可以掛了,此時A知道了B想掛了,但A畢竟怕老婆,不敢先掛電話,於是就說那我掛了喲,這也就是第四次要發到B的話了。

      第四次揮手:A發到B,不管此時B有沒有收到,A都會等待2ms,如果時間內沒收到消息則說明老婆大人先掛了,如果老婆收到了,則說明A掛了,也會掛掉電話。

還可能面試涉及的問題:

1. 什麼是長連接和短連接?

       在Http1.0中默認使用的是短連接,也就是說瀏覽器與服務器每進行一次http操作就建立一次連接,但任務結束後就中斷連接,如果客戶端瀏覽器訪問的某個HTML或者其他類型的web頁包含其它web資源,如圖像文件、css文件;當瀏覽器每遇到這樣一個web資源,就會建立一次http會話。

      但在Http1.1起,默認使用長連接。用以保持連接特性,使用長連接的http協議會在響應頭中加入這行代碼:

在使用長連接的情況下,當一個網頁打開完成後,客戶端與服務器之間用於傳輸Http數據Tcp連接不會關閉,如果客戶端再次訪問這個服務器的網頁,就會繼續使用這一次建立好的連接,keep-alive不會永遠保持連接,它有一個保持時間,可以在不同的服務器軟件中設定這個時間。實現長連接要求客戶端和服務器都必須支持長連接。

2. Http協議與Tcp  Ip協議的關係?

      Http的長連接與短連接實際上本質是Tcp的長連接與短連接,Http屬於應用層,Tcp屬於傳輸層,Ip屬於網絡層(七層網絡模型)。Ip協議只要解決網絡路由與尋址問題,Tcp主要解決如何在Ip層之上可靠地傳遞數據包,使用網絡上的另一端收到客戶端發出的所有包,並且順序與發出順序一致。

 

      言歸正傳, 其實這個還得牽涉到我之前做的一個模塊,當時是在負責寫一個日誌下載的功能,其實現就是利用B/S架構去完成的,也就是利用socket編程在結合多線程去完成這樣的一個功能。設備作爲服務端(日誌源),瀏覽器作爲客戶端(發請求),形成一個通信,由於InputStream類read()方法是阻塞的,所以就必須利用到多線程或者線程池,每發一個請求就利用一個新的線程這樣就互不干擾了,下面是我一個小demo模擬(類似於聊天)了上述業務。

客戶端代碼:

package NIO.clientpkg;

import java.io.PrintStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.util.Scanner;


/**
 *目的:客戶端向服務端發送請求服務端接受請求,並回應相同的信息,一旦客戶端寫入bye,則結束本次操作
 */
public class Client {


    public static void main(String[] args) throws Exception{
        SocketAddress socketAddress = new InetSocketAddress ("localhost",9001);
        Socket socket = new Socket ();
        socket.connect (socketAddress,1000*5);//5second
        //發送輸入的數據  以打印流的方式
        PrintStream out = new PrintStream (socket.getOutputStream ());
        //接受服務端傳來的數據,將其保存在打印流中
        Scanner in = new Scanner (socket.getInputStream ());
        System.out.println ("請輸入你要發送給服務端的信息");
        Scanner scanner = new Scanner (System.in);
        while (true){
            //發送消息
            if (scanner.hasNext ()){ //hasNext和next都是半阻塞的方法,會一直處於等待,所以必須用一個變量來接收
                //輸入的消息
                String str = scanner.next ();
                out.println (str);
                if (str.equals ("bye")){
                    System.out.println ("服務端發過來的消息:" + in.next ());
                    break;//不能立即退出,會導致客戶端退出服務端還處於連接丟包
                }
                //接收消息
                if (in.hasNext ()){
                    System.out.println ("服務端發過來的消息:" + in.next ());
                }
            }
        }
        socket.close ();
        scanner.close ();
        in.close ();
        out.close ();
    }

}

服務端代碼:

package NIO.serverpkg;

import java.io.IOException;
import java.io.PrintStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 /**
 *目的:客戶端向服務端發送請求服務端接受請求,並回應相同的信息,一旦客戶端寫入byebye,則結束本次操作
 *TCP服務器端依次調用socket()、bind()、listen()之後,就會監聽指定的socket地址了。
 *TCP客戶端依次調用socket()、connect()之後就向TCP服務器發送了一個連接請求。TCP服務器監聽到這個請求之後,
 *就會調用accept()函數取接收請求,這樣連接就建立好了。之後就可以開始網絡I/O操作了,即類同於普通文件的讀寫I/O操作。
 */
public class Server2 {

    private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor (10,50,5, TimeUnit.SECONDS,new LinkedBlockingQueue<> (100));

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

        //通常服務端在啓動的時候回綁定一個衆所周知的地址(ip+端口)用於提供服務,客戶就可以通過它來接連服務器;而客戶端就不用指定,有系統自動
        //分配一個端口號和自身的ip地址組合。這就是爲什麼通常服務器端在listen之前會調用bind(),而客戶端就不會調用,而是在connect()時由系統隨機生成一個。
        ServerSocket serverSocket = new ServerSocket (9001);//初始化socket,對端口進行綁定,並且對端口進行監聽
        //這裏要用一個while來接受不斷髮過來的請求,,但是因爲accept會阻塞,故這裏必須用線程
        while (!serverSocket.isClosed ()){
            Socket socket = serverSocket.accept ();//偵聽並接受到此套接字的連接。此方法在進行連接之前一直阻塞。這應該是一個一直在進行的新線程,因爲她面對的可能是諸多追求者
            if (socket.isConnected ()){
                System.out.println ("連接成功男朋友爲:" + socket.toString ());
            }
            threadPoolExecutor.execute (() -> {
                try {
                    PrintStream out = new PrintStream (socket.getOutputStream ());//發送輸入的數據  以打印流的方式
                    Scanner in = new Scanner (socket.getInputStream());//掃描流負責接受客戶端發來的請求,一直不斷變化的,因爲socket是不斷變化的
                    while (true){
                        if (in.hasNext ()){//阻塞的方法 inputStream類的read()方法
                            String str = in.next ();
                            System.out.println ("接受到客戶端發來的信息:" + str);
                            out.println (str);//接收到消息  並回復同等內容
                            if (str.equals ("bye")){
                                break;
                            }
                        }
                    }
                    out.close ();
                    in.close ();
                    socket.close ();
                } catch (IOException e) {
                    e.printStackTrace ();
                }

            });
        }
        System.out.println ("結束服務端");
        serverSocket.close ();

    }

}

啓動一個服務端和兩個客戶端,客戶端分別向服務端發送:我是client2,我是client2_1:

題外話:

我們都知道socket是遵守tcp協議的,那如果瀏覽器訪問我的服務端可行嗎?我們測試一下:顯然是不可行的因爲瀏覽器請求是http請求,明顯可以看出客戶端請求是發過來了的,但是響應給瀏覽器確實無效的,因爲協議不一致,所以只需要返回固定格式的數據(基於http協議)給客戶端即可,tomcat服務器其實也是socket連接,然後對報文 請求頭和體實現了一個組裝。

改寫代碼(返回http格式):

package NIO.serverpkg;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class ServerHttp {


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

        ServerSocket serverSocket = new ServerSocket (9001);
        while (!serverSocket.isClosed ()){
            Socket socket = serverSocket.accept ();
            if (socket.isConnected ()){
                System.out.println ("連接成功男朋友爲:" + socket.toString ());
            }
            InputStream in = socket.getInputStream ();
            BufferedReader reader = new BufferedReader(new InputStreamReader (in, "utf-8"));
            String msg = "";
            while ( (msg = reader.readLine () ) != ""){
                System.out.println (msg);
                if (msg.length () == 0){
                    break;
                }
            }
            System.out.println("收到數據,來自:"+ socket.toString());
            OutputStream out = socket.getOutputStream ();
            out.write("HTTP/1.1 200 OK\r\n".getBytes());
            out.write("Content-Length: 11\r\n\r\n".getBytes());
            out.write("Hello World".getBytes());
            out.flush();
        }
        System.out.println ("結束服務端");
        serverSocket.close ();

    }

}

http格式如下:

上面只是演示下而已,言歸正傳,多線程或者固定線程池這樣設計是存在問題的,當併發高的話,線程會出現不夠用的情況,而且當線程處理的業務邏輯屬於耗時操作io或者長期佔用不關閉連接時,必定會出現線程不夠用的情況,則又會衍生性能問題,那麼該如何解決呢?

正題  NIO的引入

阻塞(blocking)IO含義:資源不可用時,IO請求一直阻塞,直到反饋結果(有數據或超時)。(一般用於獲取數據)

非阻塞(non-blocking)IO:資源不可用時,IO請求離開返回,返回一個不可用標識。(一般用於獲取數據)

同步(syncronous)IO:應用阻塞在發送或接受數據的狀態,知道數據傳輸或成功返回。(一般是拿到數據後進行的一個處理方式)

異步(asyncronous)IO:應用發送或接受數據後立刻返回,實際處理是異步執行的。(一般是拿到數據後進行的一個處理方式)

ServerSocket##accept方法,InputStream##read方法都是阻塞API,操作系統底層API中,默認socket操作都是阻塞的,send/recv等接口也都是阻塞的。所帶來的的問題就是在處理網絡IO操作時,一個線程只能處理一個網絡連接。好在jdk1.4提供了新的java非阻塞ioAPI--NIO。其中裏面涉及三個核心組件:Buffer緩衝區、

Buffer緩衝區

        緩存區的本質是一個可以寫入數據的內存塊(類似數組),然後可以再次讀取,此內存塊包括在NIO  Buffer對象中,該對象提供了一種範方法,可以更輕鬆的使用內存塊。

         使用Buffer進行數據寫入與讀取需要以下四個操作: 

  1. 將數據寫入緩衝區
  2. 調用buffer.flip(),轉換爲讀取模式
  3. 緩衝區讀取數據
  4. 調用buffer.clear()或者buffer.compact()轉爲寫模式

Buffer三個重要屬性:

  1. capacity容量:作爲一個內存塊,Buffer具有一定的固定大小,也可以稱之爲容量
  2. position位置:寫入模式時代表寫入模式的位置,讀取模式時代表讀取模式時的位置
  3. limit限制:寫入模式,限制等於buffer的容量(不動)。讀取模式下,限制等於寫入的數據量(動)

舉例說明:如下圖初始化的時候呢,假定是一個8個字節的數組,默認初始化的時候是一個寫模式,寫一個字節position位置就移動一格,假設寫了3個字節進去,想要讀取的話,則要調用flip()方法,切換至讀模式,那麼讀的limit就是寫入的數據量,也就3了,然後position從左到右從第一個位置讀到第三個位置爲節點。如下圖(讀完之後在寫注意覆蓋情況)

ByteBuffer的內存類型:

ByteBuffer提供了堆內內存和堆外內存的兩種實現(堆外內存的獲取方式:ByteBuffer bf = ByteBuffer.allocateDirect(noBytes));

好處:1.進行文件IO或者網絡IO是比heapBuffer少一次拷貝。(file/socket  ---> os memory --->jvm heap)GC會移動對象內存,在寫file或者socket的過程中,jvm的實現中會將數據複製到堆外,在進行寫入。

            2.GC範圍之外,降低GC壓力,但實現了自動管理,DirectBuffer中有一個cleaner對象(phatomReference),cleaner被GC前會執行clear方法,觸發DirectBuffer中定義的allocateDirect

建議:1.性能確實可觀的時候纔是用,分配給大型長壽命;(網絡傳輸、文件讀寫場景)

            2.通過虛擬機參數maxDirectMemorySize大小,防止耗盡整個機器的內存

堆內內存示例:

package com.dongnaoedu.network.nio.demo;

import java.nio.ByteBuffer;

public class BufferDemo {
    public static void main(String[] args) {
        // 構建一個byte字節緩衝區,容量是10  堆內內存
        ByteBuffer byteBuffer = ByteBuffer.allocate(10);
        // 默認寫入模式,查看三個重要的指標
        System.out.println(String.format("初始化:capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                byteBuffer.position(), byteBuffer.limit()));
        // 寫入2字節的數據
        byteBuffer.put((byte) 1);
        byteBuffer.put((byte) 2);
        byteBuffer.put((byte) 3);
        // 再看數據
        System.out.println(String.format("寫入3字節後,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                byteBuffer.position(), byteBuffer.limit()));

        // 轉換爲讀取模式(不調用flip方法,也是可以讀取數據的,但是position記錄讀取的位置不對)
        System.out.println("#######開始讀取");
        byteBuffer.flip();
        byte a = byteBuffer.get();
        System.out.println(a);
        byte b = byteBuffer.get();
        System.out.println(b);
        System.out.println(String.format("讀取2字節數據後,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                byteBuffer.position(), byteBuffer.limit()));
        // 繼續寫入3字節,此時讀模式下,limit=3,position=2.繼續寫入只能覆蓋寫入一條數據
        // clear()方法清除整個緩衝區。compact()方法僅清除已閱讀的數據。轉爲寫入模式
        byteBuffer.compact(); // buffer : 1 , 3
        byteBuffer.put((byte) 3);//從2開始 2 3 4位置爲4
        byteBuffer.put((byte) 4);
        byteBuffer.put((byte) 5);
        System.out.println(String.format("最終的情況,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                byteBuffer.position(), byteBuffer.limit()));
        // rewind() 重置position爲0
        // mark() 標記position的位置
        // reset() 重置position爲上次mark()標記的位置

控制檯輸出:
初始化:capacity容量:10, position位置:0, limit限制:10
寫入3字節後,capacity容量:10, position位置:3, limit限制:10
#######開始讀取
1
2
讀取2字節數據後,capacity容量:10, position位置:2, limit限制:3
最終的情況,capacity容量:10, position位置:4, limit限制:10

    }
}

 

Channer通道

ChannelApi覆蓋了整個UDP/TCP網絡和文件IO,比如FileChannel、SocketChannel、ServerSocketChannel 

與標準IO  Stream操作的區別:在一個管道內進行讀取和寫入  Stream通常是單向的(input和output),可以非阻塞讀取和寫入操作通道,通道始終讀取或寫入緩衝區。

SocketChannel:

SokcketChannel用於建立tcp網絡連接,類似java.net.socket 

有兩種socketChannel的形式:

1、客戶端主動發起的服務連接  SocketChannel socketChannel = SocketChannel.open ();

2、服務端獲取的新連接  SocketChannel socketChannel = serverSocketChannel.accept ();

注意點:

寫:使用socketChannel .write(byteBuffer)  向通道內寫數據時,可能尚未寫入任何數據時就可能返回,所以需要循環調用write()

讀:使用socketChannel.read (byteBuffer)讀取通道內數據時,可能直接返回而根本不讀取任何數據,根據返回的int判斷讀取多少字節

ServerSocketChannel:

ServerSocket則是監聽新建立的tcp連接通道,類似ServerSocket

SocketChannel socketChannel = serverSocketChannel.accept ();如果設置爲非阻塞模式,如果沒有連接進來則立即返回null,所以要檢查返回的socketChannel是否爲null

下面是通過socketchannel,serversocketchannel,bytebuffer 單線程實現服務器的多個客戶端連接

服務端代碼

package com.dongnaoedu.network.humm;


import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;

/**
 * @author Heian
 * @time 19/06/16 20:00
 * @copyright(C) 2019 深圳市長亮保泰
 * 用途:
 */
public class ServerSocketChannel1 {

    private static ArrayList<SocketChannel> SocketChannelList = new ArrayList<>();//解決無法獲取多個客戶端連接

    public static void main(String[] args) throws Exception{
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open ();
        serverSocketChannel.configureBlocking (false);//設置爲非阻塞模式
        serverSocketChannel.socket ().bind (new InetSocketAddress (8080));
        System.out.println ("服務端啓動了");

        while (true){
            SocketChannel socketChannel = serverSocketChannel.accept ();//非阻塞  如果沒有掛起的連接則直接返回null
            if(socketChannel != null){//因爲是非阻塞的,沒有連接返回null
                //tcp請求  讀取響應
                System.out.println("收到新連接 : " + socketChannel.getRemoteAddress());
                socketChannel.configureBlocking (false);// 默認是阻塞的,一定要設置爲非阻塞
                SocketChannelList.add (socketChannel);
            }else {
                // 沒有新連接的情況下,就去處理現有連接的數據,處理完的就刪除掉
               Iterator<SocketChannel> iterator = SocketChannelList.iterator ();
                while (iterator.hasNext ()){
                    SocketChannel channel = iterator.next ();//新的連接沒發送消息 就會去重新遍歷scoketchannnel
                    ByteBuffer receiveBf = ByteBuffer.allocate (1024);
                    if (channel.read(receiveBf) == 0) {// 等於0,代表這個通道沒有數據需要處理,那就待會再處理
                        continue;
                    }
                    while (channel.isOpen () && channel.read (receiveBf) != -1){//按照1kb大小去讀取socketchannel 的數據,沒有返回0,不阻塞但不斷做輪詢
                        // 長連接情況下,需要手動判斷讀取數據有沒有讀取結束,可能數據量很大,遠超過1kb (此處做一個簡單的判斷: 超過0字節就認爲請求結束了)
                        if (receiveBf.position() > 0) break;//如果讀到了,就相當於會把數據寫入到 receiveBf
                    }
                    if(receiveBf.position () == 0)continue;//如果沒有數據則結束此次循環,終止下面的操作
                    receiveBf.flip ();//切換至讀取模式
                    byte[] bytes = new byte[receiveBf.remaining ()];
                    ByteBuffer byteBf = receiveBf.get (bytes);
                    String reveiveMsg = new String (byteBf.array (),"utf-8");
                    System.out.println ("收到"+channel.getRemoteAddress ()+"客戶端發來的消息爲:" + reveiveMsg);
                    //響應結果  這裏隨便響應一個
                    String response = "HTTP/1.1 200 OK\r\n" +
                            "Content-Length: 11\r\n\r\n" +
                            "Hello World";
                    ByteBuffer sendBf = null;
                    if ("bye".equals (reveiveMsg)){
                        sendBf = ByteBuffer.wrap ("bye".getBytes ());
                    }else {
                        sendBf = ByteBuffer.wrap (response.getBytes ());
                    }
                    while (sendBf.hasRemaining ()){
                        channel.write (sendBf);//非阻塞  繼續循環等待新的連接  或者處理同一個客戶端發來的請求
                    }
                }

            }
        }
        // 用到了非阻塞的API, 在設計上,和BIO可以有很大的不同.繼續改進
    }

}

客戶端代碼

package com.dongnaoedu.network.humm;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.Scanner;

/**
 * @author Heian
 * @time 19/06/16 19:37
 * @copyright(C) 2019 深圳市長亮保泰
 * 用途:
 */
public class SocketChannel1 {

    public static void main(String[] args) throws Exception{
        SocketChannel socketChannel = SocketChannel.open ();
        socketChannel.configureBlocking (false);//設置爲非阻塞模式
        socketChannel.connect (new InetSocketAddress ("127.0.0.1",8080));
        while (!socketChannel.finishConnect ()){
            Thread.yield ();//沒有連接則阻塞在此
        }
        Scanner scanner = new Scanner(System.in);
        System.out.println("請輸入:");
        while (true){
            if (scanner.hasNext ()){  //不輸入就阻塞到此
                String sendMsg = scanner.next ();
                ByteBuffer sendBf = ByteBuffer.allocate (1024);
                sendBf.put (sendMsg.getBytes ());
                sendBf.flip ();
                while (sendBf.hasRemaining ()){
                    socketChannel.write (sendBf);//發送數據   向通道寫入數據
                }
                //讀取響應數據
                ByteBuffer receiveBf = ByteBuffer.allocate (1024);//默認是寫模式
                while (socketChannel.isConnected () && socketChannel.read (receiveBf) != -1){//非阻塞  沒有值就返回0
                    // 長連接情況下,需要手動判斷讀取數據有沒有讀取結束,可能數據量很大,遠超過1kb (此處做一個簡單的判斷: 超過0字節就認爲請求結束了)
                    if (receiveBf.position() > 0) break;//讀操作會默認讀到的數組存到receiveBf
                }
                receiveBf.flip ();//切換至讀模式
                byte[] bytes = new byte[receiveBf.limit()];
                ByteBuffer bf= receiveBf.get (bytes);//將剛纔寫入的數據 按照1kb大小讀取
                String receiveMsg = new String (bf.array ());
                System.out.println ("讀取到的數據爲:" + receiveMsg);
                if ("bye".equals (receiveMsg)){
                    break;
                }
            }
        }
        socketChannel.close ();
        scanner.close ();

    }

}



步驟:先啓動服務端,然後開啓多個客戶端實例,我這邊開了兩個。

 

但很明顯上述依然存在性能問題,就是不斷地通過輪詢很浪費cpu資源,假設有100個channel,那麼也就會去輪詢100次,而且有的是連接上的但是沒發數據過來,一直在那做輪詢,所以很浪費,那麼怎麼解決呢?往下看

selector選擇器

selector是nio的一個組件,可以檢查一個或多個nio通道,並確定哪些通道可以進行讀取和寫入,實現單個線程管理多個通道,實現單線程管理多個通道,從而實現多個網絡連接。說白了它就是一個管家,專門來管理channel通道的連接。

一個線程使用selector監聽多個channel的不同事件,四個事件分別對應selectionkey的四個常量:

  1. connect連接(SelectionKey.OP_CONNECT)          
  2. accept準備就緒(OP_ACCEPT)
  3. Read讀取(OP_READ)
  4. Write寫入(OP_WRITE)

實現一個通道處理多個通道的核心概念理解:事件驅動機制。

非阻塞的網絡通道,我們只需要selector註冊通道感興趣的事件類型,線程通過監聽事件來觸發響應代碼的執行(更底層的是操作系統的多路複用),channel對象通過註冊的方式,交給Selector管家管理,管理你需要關注的事件,每一個被管理的對象就是一個key,所以會形成一個keys數組,數組元素中就有channel和SelectionKey的狀態,一旦被監聽到會返回另外一個SelectionKey的集合,裏面每個selectionkey存儲着channel和這個被管理對象的一些狀態(比如是否收到一個連接selectionkey.isAcceptable(),是否有數據刻度selectionkey.isReadable(),是否有連接進來selectionkey.isConnectable()),然後遍歷集合,拿到你所需的key,繼續交給selector管理,繼續去偵聽你關注的事件。

下面是selector的大概操作流程:

package com.dongnaoedu.network.nio.demo;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

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

        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();//客戶端永遠是被動地,沒有read或者write方法
        Selector selector = Selector.open();// 創建Selector
        serverSocketChannel.configureBlocking(false); // 設置爲非阻塞模式
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);// serverSocketChannel註冊OP_READ事件
        serverSocketChannel.socket().bind(new InetSocketAddress(8080)); // 綁定端口一定要發生在註冊之後,防止你啓動之後有連接進來沒被監聽

        while(true) {
            int readyChannels = selector.select();// 會阻塞,直到有事件觸發  調用此方法,監聽纔開始工作,監聽所有連接進來的客戶端
            if(readyChannels == 0) continue;
            Set<SelectionKey> selectedKeys = selector.selectedKeys();// 獲取被觸發的事件集合
            Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
            while(keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                if(key.isAcceptable()) {
                    SocketChannel socket = ((ServerSocketChannel) key.channel()).accept();//通過key拿到對應客戶端的channel對象
                    socket.register(selector, SelectionKey.OP_READ);//先跳出此循環,返回上一個循環,當有新事件進來則(可能使我們剛註冊的事件)
                    // serverSocketChannel 收到一個新連接,只能作用於ServerSocketChannel

                } else if (key.isConnectable()) {
                    // 連接到遠程服務器,只在客戶端異步連接時生效

                } else if (key.isReadable()) {
                    // SocketChannel 中有數據可以讀

                } else if (key.isWritable()) {
                    // SocketChannel 可以開始寫入數據
                }

                // 將已處理的事件移除
                keyIterator.remove();
            }
        }

    }
}

        就是假設一個客戶端連接到服務端,然後交給管家管理,然後將事件指派給管家。需要明白的一點就是,同一時刻管家只會偵聽同一個通道的一件事情,比如多個客戶端將寫很多事件交給管家,然後管家就會幫很多人去偵聽它們關注的這些事件,然後在各自的客戶端去遍歷你自身偵聽事件的集合(不知道這裏說的對不對),比如,執行到寫,執行寫入操作完成後,你肯定要獲取響應,又可以切換到讀。然後管家又會去判斷您這邊是否允許讀操作。

下面是改良後的類似聊天的額小程序,有點redis多路複用和消息通知機制的味道,效率高。

服務端:

package com.dongnaoedu.network.humm;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
 * 結合Selector實現非阻塞服務器
 */
public class NIOServerV2 {

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

        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();// 1. 創建服務端的channel對象
        serverSocketChannel.configureBlocking(false); // 設置爲非阻塞模式
        Selector selector = Selector.open();// 2. 創建Selector
        SelectionKey selectionKey = serverSocketChannel.register(selector, 0); // 3. 把服務端的channel註冊到selector,註冊accept事件
        selectionKey.interestOps(SelectionKey.OP_ACCEPT);
        serverSocketChannel.socket().bind(new InetSocketAddress(8080)); // 4. 綁定端口,啓動服務
        System.out.println("啓動成功");
        while (true) {
            // 5. 啓動selector(管家)
            selector.select();// 阻塞,直到事件通知纔會返回
            Set<SelectionKey> selectionKeys = selector.selectedKeys();//拿到所有客戶端的事件
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                if (key.isAcceptable()) {
                    SocketChannel socketChannel = ((ServerSocketChannel) key.channel()).accept();//強轉爲ServerSocketChannel
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);
                    System.out.println("收到新連接:" + socketChannel);
                } else if (key.isReadable()) {// 客戶端連接有數據可以讀時觸發
                    try {
                        SocketChannel socketChannel = (SocketChannel) key.channel();// 不再是新連接,則直接強轉爲SocketChannel
                        ByteBuffer receivebf = ByteBuffer.allocateDirect(2048);
                        while (socketChannel.isOpen() && socketChannel.read(receivebf) != -1) {
                            // 長連接情況下,需要手動判斷數據有沒有讀取結束 (此處做一個簡單的判斷: 超過0字節就認爲請求結束了)
                            if (receivebf.position() > 0) break;
                        }
                        if (receivebf.position() == 0) continue; // 如果沒數據了, 則不繼續後面的處理
                        receivebf.flip();
                        byte[] content = new byte[receivebf.remaining()];
                        receivebf.get (content);
                        System.out.println("收到數據,來自:" + socketChannel.getRemoteAddress()+":" + new String (content));
                        // TODO 業務操作 數據庫 接口調用等等  服務端類似生產者   提供數據給消費者

                        // 響應結果 200
                        String response = "HTTP/1.1 200 OK\r\n" +
                                "Content-Length: 11\r\n\r\n" +
                                "Hello World";
                        ByteBuffer buffer = ByteBuffer.wrap(response.getBytes());
                        while (buffer.hasRemaining()) {
                            socketChannel.write(buffer);
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                        key.cancel();
                    }
                }
            }
        }
    }
}

客戶端:

package com.dongnaoedu.network.humm;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;
import java.util.Set;

public class NIOClientV2 {

    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        Selector selector = Selector.open();
        socketChannel.configureBlocking(false);
        socketChannel.register(selector, SelectionKey.OP_CONNECT);
        socketChannel.connect(new InetSocketAddress("localhost", 8080));//非阻塞會立即返回
      
        while (true) {
            selector.select();//開啓管家
            Set<SelectionKey> selectionKeys = selector.selectedKeys();//可讀 可寫 連接成功
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                iterator.remove();
                if (selectionKey.isConnectable()) {
                    try {
                        if (socketChannel.finishConnect()) {
                            System.out.println("連接成功-" + socketChannel);
                            //ByteBuffer buffer = ByteBuffer.allocateDirect(2048);
                            //selectionKey.attach(buffer); // attach 類似於我們發郵件中的附件 也可以不傳,這裏只是爲了演示此功能
                            selectionKey.interestOps(SelectionKey.OP_WRITE);//連接成功了,將事件切換至寫事件
                            //socketChannel.register (selector,SelectionKey.OP_WRITE,buffer);  //這個也可以  上面兩段代碼等於這一段
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                        return;
                    }
                } else if (selectionKey.isWritable()) {// 可以開始寫數據
                    //ByteBuffer buf = (ByteBuffer) selectionKey.attachment();
                    //buf.clear();//取到這個附件,將其清空  這裏沒必要寫  這是爲了演示下
                    ByteBuffer sendbf = ByteBuffer.allocate (1024);
                    Scanner scanner = new Scanner(System.in);
                    System.out.print("請輸入:");
                    String msg = scanner.next ();
                    //scanner.close();//這裏不能關閉 具體參考https://www.cnblogs.com/qingyibusi/p/5812725.html
                    sendbf.put(msg.getBytes());
                    sendbf.flip ();//在寫入數據後,一定要切換至讀模式
/*
                    如果我不做那個flip切換到寫模式,那麼它默認是寫模式,假設我寫了一個1,那麼position 就是1  limit1024,capacity也是1024,這樣通過socketchannel寫入通道內的就是
                    位置1到1024,那肯定是數據爲空的,如果我切換至寫,那麼position就變成了0,kimit就變成了1,那socketchannel寫入通道的就是0到1
*/
                    while (sendbf.hasRemaining()) {
                        socketChannel.write(sendbf);
                    }
                    selectionKey.interestOps(SelectionKey.OP_READ);// 切換到感興趣的事件
                } else if (selectionKey.isReadable()) {// 可以開始讀數據
                    System.out.println("收到服務端響應:");
                    ByteBuffer receivebf = ByteBuffer.allocate(1024);
                    while (socketChannel.isOpen() && socketChannel.read(receivebf) != -1) {//沒有數據,就不斷輪詢  不能說是阻塞
                        // 長連接情況下,需要手動判斷數據有沒有讀取結束 (此處做一個簡單的判斷: 超過0字節就認爲請求結束了)
                        if (receivebf.position() > 0) break;
                    }
                    receivebf.flip();//切換至讀取模式
                    byte[] content = new byte[receivebf.remaining()];
                    ByteBuffer bf = receivebf.get (content);
                    System.out.println("收到服務端端數據:" + socketChannel +new String(bf.array (),"utf-8"));
                    selectionKey.interestOps(SelectionKey.OP_WRITE);
                }
            }
        }
    }

}

總結:

BIO阻塞IO線程等待時間長,一個線程負責一個連接,線程多且利用率低,NIO非阻塞IO,線程利用率高,一個線程處理多個連接事件,性能強大,tomcat8已經移除了BIO網絡處理相關代碼,默認採用NIO處理網絡請求,NIO爲開發者提供了豐富的API,但是在網絡應用中直接使用API比較繁瑣,而且將性能提升光有NIO是不夠的,還需要將多線程結合,而且開源社區有對NIO進行封裝的框架,如Netty、Mina等,後續將繼續更新。

 

 

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