Java網絡IO演進之路

前言

說起IO,很多人對它應該都有所耳聞,可能很多人對IO都有着一種既熟悉又陌生的感覺,因爲IO這一塊內容還是比較廣泛雜亂的,整個IO的體系也是十分龐大。那麼IO到底是個什麼東西呢?IO 是主存和外部設備 ( 硬盤、終端和網絡等 ) 拷貝數據的過程。 IO 是操作系統的底層功能實現,底層通過 I/O 指令進行完成。Java中的IO主要分爲文件IO和網絡IO兩大類,本文博主就與大家一同去網絡IO的演進之路上走一遭,看看網絡IO到底是如何一步步進化升級的。

正文

先講個小故事,體會一下IO爲何需要進化升級。

在Java1.4之前的早起版本中,Java對I/O的支持並不完善,開發人員在開發高性能I/O程序的時候,會面臨一些巨大的挑戰和困難,主要問題如下:

  • 沒有數據緩衝區,I/O性能存在問題
  • 沒有C和C++中Channel的概念,只有輸入流(InputStream)和輸出流(OutputStream)
  • 同步阻塞式I/O通信(BIO),經常會導致通信線程被長時間阻塞
  • 支持的字符集有限,硬件可移植性不好

在Java支持異步I/O之前的很長一段時間,高性能服務端開發領域一直被C和C++長期佔據,作爲Java開發者就很不服氣,畢竟Java“天下第一”,所以Sun公司的大佬們就對IO進行了一步步的升級。

必須知道的幾個概念

同步(Synchronization)和異步(Asynchronous)

同步:用戶線程發起IO請求後需要等待內核IO操作完成之後才能繼續執行,應用程序需要直接參與IO讀寫操作。簡單來說就是當線程發送了一個請求,在沒有得到結果之前,這個線程不能做任何事情。
實例:A調用B,B在接到A的調用後,會立即執行要做的事。A的本次調用可以得到結果。

異步:用戶線程發起IO請求之後繼續執行其他操作,內核IO操作完成之後會通知線程IO或調用線程註冊的回調函數,應用程序將所有的IO讀寫操作交給操作系統,它只需要等待結果通知。簡單來說就是當線程發送了一個請求,不再去傻傻的等待結果,操作系統處理完之後再將結果通知給線程。
實例:A調用B,B在接到A的調用後,不保證會立即執行要做的事,但是保證會去做,B在做好了之後會通知A。A的本次調用得不到結果,但是B執行完之後會通知A。

同步與異步是對應於調用者與被調用者,它們是線程之間的關係,兩個線程之間要麼是同步的,要麼是異步的。同步操作時,調用者需要等待被調用者返回結果,纔會進行下一步操作,而異步則相反,調用者不需要等待被調用者返回調用,即可進行下一步操作,被調用者通常依靠事件、回調等機制來通知調用者結果。

阻塞(Block)和非阻塞(Non-Block)

阻塞:IO操作完成之前,線程會被掛起,只有在得到返回結果或者拋出異常之後纔會返回。
實例:A調用B,A在發出調用後,要一直等待,等着B返回結果。

非阻塞:IO操作被調用之後立即得到一個返回狀態,不能馬上得到結果,線程不會被掛起,會立即返回。
實例:A調用B,A在發出調用後,不需要等待,可以去做自己的事情。

阻塞與非阻塞是線程在訪問數據的時候,數據是否準備就緒的一種處理方式,也是線程的一種狀態。阻塞調用是指調用結果返回之前,當前線程會被掛起。調用線程只有在得到結果之後纔會返回, 非阻塞調用指在不能立刻得到結果之前,該調用不會阻塞當前線程。

區別(這裏是重點哦,這幾個概念很容易混淆)

同步與異步用來描述兩個線程之間的關係(被調用方),是線程的一個過程。阻塞與非阻塞用來描述一個線程內的一種處理方式(調用方),是線程的一個狀態。同步不一定阻塞,異步也不一定非阻塞。沒有必然關係。!!!

用戶空間和內核空間

用戶空間:常規進程所在區域。 JVM 就是常規進程,駐守於用戶空間。用戶空間是非特權區域:比如,在該區域執行的代碼就不能直接訪問硬件設備。
內核空間:操作系統所在區域。內核代碼有特別的權力:它能與設備控制器通訊,控制着用戶區域進程的運行狀態等。最重要的是,所有 I/O 都直接(如這裏所述)或間接通過內核空間。
關係:當進程請求 I/O 操作的時候,它執行一個系統調用將控制權移交給內核。C/C++程序員所熟知的底層函數 open( )、 read( )、 write( )和 close( )要做的無非就是建立和執行適當的系統調用。當內核以這種方式被調用,它隨即採取任何必要步驟,找到進程所需數據,並把數據傳送到用戶空間內的指定緩衝區。內核試圖對數據進行高速緩存或預讀取,因此進程所需數據可能已經在內核空間裏了。如果是這樣,該數據只需簡單地拷貝出來即可。如果數據不在內核空間,則進程被掛起,內核着手把數據讀進內存。
在這裏插入圖片描述

Linux網絡I/O模型簡介

linux的內核將所有外部設備都看作一個文件來操作,對一個文件的讀寫操作會調用內核提供的系統命令,返回一個file descriptor(fd,文件描述符)。而對一個Socket(套接字)的讀寫也會有響應的描述符,成爲socket descriptor(socketfd,socket描述符),描述符就是一個數字,它指向內核中的一個結構體(文件路徑、數據區等一些數據)。

根據UNIX網絡編程對I/O模型的分類,UNIX提供了5中I/O模型,分別如下:

kernel代表操作系統內核
recvfrom是一個C語言函數
函數原型:ssize_t recvfrom(int sockfd,void *buf,size_t len,unsigned int flags,
struct sockaddr *from,socket_t *fromlen);
返回值說明:
成功則返回實際接收到的字符數,失敗返回-1,錯誤原因會存於errno 中。
參數說明:
s: socket描述符;
buf: UDP數據報緩存區(包含所接收的數據);
flags: 調用操作方式(一般設置爲0)。
from: 指向發送數據的客戶端地址信息的結構體(sockaddr_in需類型轉換);
fromlen: 指針,指向from結構體長度值。

  1. 阻塞I/O模型(Block IO)
    最常用的I/O模型就是阻塞I/O模型,缺省情況下,所有文件操作都是阻塞的。在進程空間中調用recvfrom函數,其系統調用直到數據包到達且被複制到應用進程的緩衝區當中或者發生異常時才返回,在此期間會一直等待,進程從調用recvfrom函數開始直到返回的整個時間段內都是被阻塞的,因此被稱爲I/O阻塞模型。
    解釋:當用戶線程發出IO請求後,內核會去查看數據是否準備就緒,如果沒有準備就緒就會等待數據就緒,此時用戶線程就會處於阻塞狀態,用戶線程交出CPU。當數據就緒後,內核會將數據拷貝到用戶線程並返回結果給用戶線程,用戶線程此時才能解除阻塞(block)狀態。
    特點:IO執行的兩個階段都被阻塞了。在這裏插入圖片描述
  2. 非阻塞I/O模型(Non-Block IO)
    recvfrom從應用層到內核的時候,如果緩衝區沒有數據,就直接返回一個EWOULDBLOCK錯誤,一般都對非阻塞I/O模型進行輪詢檢查這個狀態,看內核中是不是有數據到來。
    解釋:當用戶線程發起一個read操作之後,並不需要等待,而是立即得到一個結果。如果結果是一個error,它就知道數據還沒有準備好,於是它可以再次發送read操作。一旦內核中的數據準備好了,並且又再次受到用戶線程的read操作請求,那麼它會立即將數據拷貝到用戶線程然後返回。在非阻塞IO模型中,用戶線程需要不斷詢問內核數據是否準備就緒,也就是說非阻塞IO不會交出CPU,而是會一直佔用CPU。
    特點:用戶進程第一個階段不是阻塞的,需要不斷的主動詢問kernel數據好了沒有;第二個階段依然總是阻塞的。
    在這裏插入圖片描述
  3. I/O複用模型(IO Multiplex)
    Linux提供了select/poll,進程通過將一個或多個fd(文件描述符)傳遞個select或poll調用,阻塞在select操作上,這樣select/poll可以幫我們檢測多個fd是否處於就緒狀態。select/poll是順序掃描fd是否就緒,而且支持的fd數量有限,因此它的使用受到了一定的限制。Linux還提供了一個epoll系統調用,epoll使用基於事件驅動方式替代順序掃描,因此性能更高,當有fd就緒時,立即調用rollback。
    解釋:在多路複用IO模型中,會有一個線程不斷的去輪詢多個socket的狀態,只有當socket真正有讀寫時間的時候,才真正調用實際的IO讀寫操作。因爲在多路複用IO模型中,只需要一個線程就可以管理多個socket,系統不需要創建新的進程或者線程,也不需要維護這些進程或者線程,並且只有在真正有socket讀寫事件進行的時候,纔會使用IO資源,所以它大大減少了CPU的資源佔用。
    特點:IO複用同非阻塞IO本質一樣,不過利用了新的select系統調用,由內核來負責本來是請求進程該做的輪詢操作。看似比非阻塞IO還多了一個系統調用開銷,不過因爲可以支持多路IO,纔算提高了效率。多路複用IO比較適合連接數比較多的情況。
    在這裏插入圖片描述
  4. 信號驅動I/O模型(Signal Driven IO)
    首先開啓套接口信號驅動I/O功能,並通過系統調用sigaction執行一個信號處理函數(此係統調用立即返回,進程繼續工作,它是非阻塞的)。當數據準備就緒時,就爲該進程生成一個SIGIO信號,通過信號回調通知應用程序調用recvfrom函數讀取數據,並通知主循環函數處理數據。
    解釋:在信號驅動IO模型中,當用戶線程發起一個IO請求操作,會給對應的socket註冊一個信號函數,然後用戶線程會繼續執行,當內核數據就緒時會發送一個信號給用戶線程,用戶線程接收到信號之後,便在信號函數中調用IO讀寫操作來進行實際的IO請求操作。
    特點:當數據準備就緒時,內核會對用戶線程進行通知,用戶線程會收到一個信號,然後開始調用IO函數進行讀寫操作。
    在這裏插入圖片描述
  5. 異步I/O模型(Asynchronous IO)
    告知內核啓動某個操作,並讓內核在整個操作完成之後(包括將數據從內核拷貝到用戶線程的緩衝區中)通知我們。
    解釋:異步IO模型是比較理想的IO模型,在異步IO模型中,當用戶線程發起read操作之後,立刻就可以開始去做其它的事。而另一方面,從內核的角度,當它受到一個asynchronous read之後,它會立刻返回,說明read請求已經成功發起了,因此不會對用戶線程產生任何block。然後,內核會等待數據準備完成,然後將數據拷貝到用戶線程,當這一切都完成之後,內核會給用戶線程發送一個信號,告訴它read操作完成了。也就說用戶線程完全不需要實際的整個IO操作是如何進行的,只需要先發起一個請求,當接收內核返回的成功信號時表示IO操作已經完成,可以直接去使用數據了。
    特點:IO操作的兩個階段都不會阻塞用戶線程,這兩個階段都是由內核自動完成,然後發送一個信號通知用戶線程操作已完成,不需要再在用戶線程中調用IO函數進行實際的讀寫操作。
    在這裏插入圖片描述

5中I/O模型對比圖:
在這裏插入圖片描述

總結

其實前四種I/O模型都是同步I/O操作,他們的區別在於第一階段,而他們的第二階段是一樣的:在數據從內核拷貝到應用緩衝區期間(用戶空間),進程阻塞於recvfrom調用。 有人可能會說,non-blocking IO並沒有被block啊。這裏有個非常“狡猾”的地方,定義中所指的”IO operation”是指真實的IO操作,就是例子中的recvfrom這個system call。non-blocking IO在執行recvfrom這個system call的時候,如果kernel的數據沒有準備好,這時候不會block進程。但是,當kernel中數據準備好的時候,recvfrom會將數據從 kernel拷貝到用戶內存中,這個時候進程是被block了,在這段時間內,進程是被block的。

BIO演進之路

BIO簡介

BIO(Block IO)是Java1.4之前唯一的IO邏輯,在客戶端通過socket向服務端傳輸數據,服務端監聽端口。由於傳統IO讀數據的時候如果數據沒有傳達,IO會一直等待輸入傳入,所以當有新的請求過來的時候,會一直處於等待狀態,直到上一個請求處理完成,纔會再創建一個新的線程去處理這個請求,從而導致每一個鏈接都對應着服務器的一個線程。

BIO初級形態(單線程模式)

模型圖 B-1
在這裏插入圖片描述
服務端代碼 C-1

//同步阻塞IO模型---BIO服務端(單線程模式)
public class BIOServer {
    public static void main(String[] args) {
        try {
            //創建服務端監聽特定端口的ServerSocket(獲取端口對應的客戶端的連接對象)
            ServerSocket serverSocket = new ServerSocket(8888);
            System.out.println("BIOServer has started,listening on port:" + serverSocket.getLocalSocketAddress());
            //循環監聽客戶端的連接請求---處理多個客戶端的連接請求
            while (true){
                //創建一個客戶端在服務端的引用,accept()是阻塞方法,等待客戶端連接
                //如果第一個客戶端未斷開,第二個客戶端的連接會一直阻塞在這裏,直到第一個客戶端斷開連接
                //******阻塞點******
                Socket clientSocket = serverSocket.accept();
                System.out.println("Connection from:" + clientSocket.getRemoteSocketAddress());
                System.out.println("Data waiting......");
                //創建輸入流讀取客戶端發送的數據
                //******阻塞點******
                InputStream is = clientSocket.getInputStream();
                //將數據包裝到Scanner中
                Scanner clientInput = new Scanner(is);
                String serverResponse;
                //服務端---客戶端循環交互
                while (true){
                    //等待客戶端輸入
                    String clientScannerData = clientInput.nextLine();
                    if ("quit".equals(clientScannerData)){
                        serverResponse = "BIOServer has been disconnected" + ".\n";
                        //給客戶端做出響應,將響應信息寫出
                        clientSocket.getOutputStream().write(serverResponse.getBytes());
                        //與服務端斷開連接
                        break;
                    }
                    System.out.println("Client data:" + clientScannerData + "---Client address: " + clientSocket.getRemoteSocketAddress());
                    serverResponse = "The data you sent:" + clientScannerData + " BIOServer has been received" + ".\n";
                    //給客戶端做出響應,將響應信息寫出
                    clientSocket.getOutputStream().write(serverResponse.getBytes());
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

分析:首先由模型圖可以很明顯的看出,一個客戶端連接請求的處理,是由一個單向的閉合區間鎖構成的。只有當第一個客戶端的請求處理完成並且返回之後,第二個客戶端的請求纔可以連接到服務端。代碼中可以看到標有兩個阻塞點,一個是客戶端與服務端建立連接的時候,一個是讀取客戶端發送數據的時候。大家可以用telnet去測試一下連接服務端,會發現兩個問題:第一是當Client1與服務端連接成功之後,Client2是無法連接服務端的;第二是當Client1正在內核中進行數據處理的時候,Client2也是無法連接服務端的(你單身20年手速的話可以試試)。

思考:雖然通過這種方式可以完成客戶端與服務端的通信,但是一次只能處理一個客戶端請求啊。那麼想想有多個客戶端請求該如何解決呢?

解決方案:利用多線程,每當有一個客戶端與服務端建立連接的時候,就創建一個線程專門爲這個連接服務,這樣第一個客戶端請求就不會影響第二個客戶端請求了。那麼就由此方案對BIO進行升級。

BIO中級形態(多線程模式)

模型圖 B-2
在這裏插入圖片描述
爲了代碼結構的美觀,這裏對方法進行封裝

處理客戶端連接的類

//處理客戶端連接請求
public class ClientHandler implements Runnable {

    private final Socket clientSocket;

    private final RequestHandler requestHandler;

    public ClientHandler(Socket clientSocket, RequestHandler requestHandler) {
        this.clientSocket = clientSocket;
        this.requestHandler = requestHandler;
    }

    @Override
    public void run() {
        try {
            System.out.println("Connection from:" + clientSocket.getRemoteSocketAddress());
            System.out.println("Data waiting......");
            //創建輸入流讀取客戶端發送的數據
            //******阻塞點******
            InputStream is = clientSocket.getInputStream();
            //將數據包裝到Scanner中
            Scanner clientInput = new Scanner(is);
            String serverResponse;
            //服務端---客戶端循環交互
            while (true){
                //等待客戶端輸入
                String clientScannerData = clientInput.nextLine();
                if ("quit".equals(clientScannerData)){
                    serverResponse = "BIOServer has been disconnected" + ".\n";
                    //給客戶端做出響應,將響應信息寫出
                    clientSocket.getOutputStream().write(serverResponse.getBytes());
                    //與服務端斷開連接
                    break;
                }
                System.out.println("Client data:" + clientScannerData + "---Client address: " + clientSocket.getRemoteSocketAddress());
                serverResponse = requestHandler.hendler(clientScannerData);
                //給客戶端做出響應,將響應信息寫出
                clientSocket.getOutputStream().write(serverResponse.getBytes());
            }
        }catch (IOException e){
            e.printStackTrace();
        }
    }
}

處理讀寫數據的類(真正的開發中在這裏處理業務邏輯,這裏只進行一個簡單的字符串處理)

//處理讀寫數據---實際開發中可能需要對數據進行處理
public class RequestHandler {
    //業務邏輯處理
    public String hendler(String request){
        return "The data you sent:" + request + " BIOServer has been received" + ".\n";
    }
}

服務端代碼 C-2

//同步僞非阻塞IO模型---BIO服務端(多線程模式)
public class BIOServerMultiThread {
    public static void main(String[] args) {
        RequestHandler requestHandler = new RequestHandler();
        try {
            //創建服務端監聽特定端口的ServerSocket(獲取端口對應的客戶端的連接對象)
            ServerSocket serverSocket = new ServerSocket(8888);
            System.out.println("BIOServer has started,listening on port:" + serverSocket.getLocalSocketAddress());
            //循環監聽客戶端的連接請求---處理多個客戶端的連接請求
            while (true){
                //創建一個客戶端在服務端的引用,accept()是阻塞方法,等待客戶端連接
                Socket clientSocket = serverSocket.accept();
                //創建線程並執行方法
                new Thread(new ClientHandler(clientSocket,requestHandler)).start();
            }
        }catch (IOException e){
            e.printStackTrace();
        }
    }
}

分析:首先對模型圖進行分析,每當一個客戶端與服務端連接之後,都會去創建一個新的線程去處理這個連接,第一個客戶端連接與第二個客戶端連接是兩個不同的線程,互不影響、互補干涉。代碼部分在處理客戶端連接方面是一樣的,唯一的不同點就是在客戶端與服務端建立連接的時候進行了new Thread,爲這個連接單獨創建了一個線程。

思考:這樣做多客戶端是可以做到同時連接服務端了,那麼不妨問一下自己,現在這個IO還是阻塞的嗎?答案當然是肯定的!它依然是阻塞的。這是有人可能會產生疑問了,爲什麼Client1與Client2都互不影響了,爲什麼還是阻塞的呢?阻塞的是什麼呢?這裏我要強調一點,雖然客戶端連接服務端那一步是不阻塞了,但是在IO處理讀寫操作那裏依然是阻塞的,這是由流(Stream)的特性就是阻塞的,這裏只是用多線程去規避了IO的阻塞而已,並沒有真的讓IO不阻塞了,只是站在全局角度(所有客戶端連接)來看IO是非阻塞的,也理解爲是多線程實現了BIO的一個僞的非阻塞。

設想如果現在有10000個客戶端要進行連接,那是不是要創建10000個線程呢?答案是肯定的!那麼再思考一下這樣做有什麼弊端的?因爲連接和線程是一一對應的,在高併發的情況下,會創建很多很多的線程,這樣會極其浪費CPU的資源(CPU會對線程進行頻繁的上下文切換從而讓你感覺多個線程是“同時執行的”),甚至會導致服務器宕機。

解決方案:創建一個線程池,讓所有客戶端的連接都“共享”一個線程池,當一個客戶端連接處理完之後,再將這個連接對應的線程還給線程池,從而服務端不再針對每個client都創建一個新的線程,而是維護一個線程池。

BIO高級形態(線程池模式)

模型圖 B-3
在這裏插入圖片描述
服務端代碼 C-3

//同步僞非阻塞IO模型---BIO服務端(線程池模式)
public class BIOServerThreadPool {
    public static void main(String[] args) {
        //創建一個大小爲3的線程池
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        RequestHandler requestHandler = new RequestHandler();
        try {
            //創建服務端監聽特定端口的ServerSocket(獲取端口對應的客戶端的連接對象)
            ServerSocket serverSocket = new ServerSocket(8888);
            System.out.println("BIOServer has started,listening on port:" + serverSocket.getLocalSocketAddress());
            //循環監聽客戶端的連接請求---處理多個客戶端的連接請求
            while (true){
                //創建一個客戶端在服務端的引用,accept()是阻塞方法,等待客戶端連接
                Socket clientSocket = serverSocket.accept();
                //讓線程池爲其綁定池中的線程來執行
                executorService.submit(new ClientHandler(clientSocket,requestHandler));
            }
        }catch (IOException e){
            e.printStackTrace();
        }
    }
}

分析:首先由模型圖可以看出,每有一個客戶端與服務端建立連接的時候,都會爲該連接分配一個線程池中的線程。線程池的工作原理是,內部維護了一系列線程,接受到一個任務時,會找出一個當前空閒的線程來處理這個任務,這個任務處理完成之後,再將這個線程返回到池子中。因爲需要不斷的檢查一個client是否有新的請求,也就是調用其read方法,而這個方法是阻塞的,意味着,一旦調用了這個方法,如果沒有讀取到數據,那麼這個線程就會一直block在那裏,一直等到有數據,等到有了數據的時候,處理完成,立即由需要進行下一次判斷,這個client有沒有再次發送請求,如果沒有,又block住了,因此可以認爲,線程基本上是用一個少一個,因爲對於一個client如果沒有斷開連接,就相當於這個任務沒有處理完,任務沒有處理完,線程永遠不會返回到池子中,直到這個client斷開連接。

思考:這樣做確實是避免了CPU資源浪費的問題,那麼大家思考一下這樣做存在什麼問題?用了線程池,意味着線程池中維護的線程數,也就是server端支持最多有多少個client來連接,這個數量設大了不行設小了也不行。如果線程池大小設置爲100,此時併發有500個客戶端連接,那麼有400個連接就會進入等待隊列,沒有分配到線程的連接會等待很長的時間,可能會超時。其實不論多線程還是線程池,雖然在表面上解決了阻塞的問題,還是不可避免的出現了線程的浪費,因爲只要有一個客戶端與服務端建立連接就會對應一個線程去處理,如果這個線程只是做了一個客戶端的連接操作,而沒有去做IO操作,那麼這個線程就分配的毫無意義,完全是浪費。基於這種思考,我們爲什麼不想辦法去減少創建線程的數量呢?換句話說也就是減少線程執行任務的數量。比如做一個判斷,只有該請求做IO讀寫操作的時候纔去給他分配線程。

解決方案:每當客戶端與服務端建立連接時,將這個連接和連接當時的狀態(是否連接、是否可讀、是否可寫等)保存到一個容器中,比如Set,然後再設置一個迭代器不斷的去輪詢這個Set,判斷連接的狀態,如果是可讀或者可寫,就分配一個線程爲它工作。

這時候你可能覺得太麻煩了吧!沒錯!博主也覺得太麻煩啦!因爲這是在Java1.4之前IO埋下的“坑”,JDK官網肯定也意識到了這些問題,所以Sun公司在Java1.4版本推出了一個叫NIO的東西,它在java.io這個包下面,爲什麼不在java.io包下面進行改進呢?可能Sun公司覺得IO包已經比較“完善”了吧!那麼接下來我們一起看看,官方設計NIO的思想是否跟我們前面的設想一樣呢?

NIO閃亮登場

NIO簡介

NIO(Non-Block IO)是Java1.4以及以上版本提供的新的API,所以也叫作New IO。爲所有的原始類型(boolean類型除外)提供緩存支持的數據容器,使用它可以提供非阻塞式的高伸縮性網絡。與BIO中Socket類和ServerSocket類相對應,NIO也提供了SocketChannel和ServerSocketChannel兩種不同的套接字通道實現。這兩種新增的通道都只是阻塞和非阻塞兩種模式。
NIO彌補了原來同步阻塞IO的不足,它在標準Java代碼中提供了高速的、面向塊的IO。通過定義包含數據的類和以塊的形式處理這些數據,NIO不使用本機代碼就可以利用低級優化,這是原來的IO包所無法做到的,接下來博主就與小夥伴們一同認識NIO。

NIO三件套

在NIO中需要掌握的幾個核心對象:緩衝區(Buffer)、選擇器(Selector)、通道(Channel)。

緩衝區Buffer

緩衝區是包在一個對象內的基本數據元素數組。 Buffer 類相比一個簡單數組的優點 是它將關於數據的數據內容和信息包含在一個單一的對象中。 Buffer 類以及它專有的子類定義了 一個用於處理數據緩衝區的 API。一個Buffer對象是固定數量的數據的容器。其作用是一個存儲器,或者分段運輸區,在這裏數據可被存儲並在之後用於檢索。

  1. Buffer基本操作API
    緩衝區實際上是一個容器對象,更直接的說,其實就是一個數組,在 NIO 庫中,所有數據都是用緩衝區處理的。在讀 取數據時,它是直接讀到緩衝區中的; 在寫入數據時,它也是寫入到緩衝區中的;任何時候訪問 NIO 中的數據,都 是將它放到緩衝區中。而在面向流 I/O 系統中,所有數據都是直接寫入或者直接將數據讀取到 Stream 對象中。 在 NIO 中,所有的緩衝區類型都繼承於抽象類 Buffer,最常用的就是 ByteBuffer,對於 Java 中的基本類型,基本都有 一個具體 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();//告知此緩衝區是否爲直接緩衝區
}

Buffer類的七種基本數據類型的緩衝區實現也都是抽象的,這些類沒有一種能夠直接實例化。
下面創建一個簡單的IntBuffer實例:

public class IntBuffer {
    public static void main(String[] args) {
        // 分配新的 int 緩衝區,參數爲緩衝區容量
        // 新緩衝區的當前位置將爲零,其界限(限制位置)將爲其容量。它將具有一個底層實現數組,其數組偏移量將爲零。
        IntBuffer buffer = IntBuffer.allocate(8);
        for (int i = 0; i < buffer.capacity(); ++i) {
            int j = 2 * (i + 1);
            // 將給定整數寫入此緩衝區的當前位置,當前位置遞增
            buffer.put(j);
        }
        // 重設此緩衝區,將限制設置爲當前位置,然後將當前位置設置爲 0
        buffer.flip();
        // 查看在當前位置和限制位置之間是否有元素
        while (buffer.hasRemaining()) {
            // 讀取此緩衝區當前位置的整數,然後當前位置遞增
            int j = buffer.get();
            System.out.print(j + " ");
        }
    }
}

運行後查看結果:
在這裏插入圖片描述
實際開發中ByteBuffer會比較常用,接下來我們看看ByteBuffer API:

public abstract class ByteBuffer {
 
    //緩衝區創建相關api
    public static ByteBuffer allocateDirect(int capacity)
    public static ByteBuffer allocate(int capacity)
    public static ByteBuffer wrap(byte[] array)
    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,put之後,position會自動+1
    public abstract ByteBuffer put (int index, byte b);//從絕對位置上put
 
}

新的緩衝區是由分配(allocate)或包裝(wrap)操作創建的。allocate操作創建一個緩衝區對象並分配一個私有的空間來儲存容量大小的數據元素。wrap操作創建一個緩衝區對象但是不分配任何空間來儲存數據元素。它使用您所提供的數組作爲存儲空間來儲存緩衝區中的數據元素。

存儲操作是通過get和put操作進行的,get 和 put 可以是相對的或者是絕對的。在前面的程序列表中,相對方案是不帶有索引參數的函數。當相對函數被調用時,位置在返回時前進一。如果位置前進過多,相對運算就會拋 出 異 常 。 對 於 put() , 如 果 運 算 會 導 致 位 置 超 出 上 界 , 就 會 拋 出BufferOverflowException 異常。對於 get(),如果位置不小於上界,就會拋出BufferUnderflowException 異常。絕對存取不會影響緩衝區的位置屬性,但是如果您所提供的索引超出範圍(負數或不小於上界),也將拋出 IndexOutOfBoundsException 異常。

  1. Buffer的基本原理
    在談到緩衝區時,我們說緩衝區對象本質上是一個數組,但它其實是一個特殊的數組,緩衝區對象內置了一些機制, 能夠跟蹤和記錄緩衝區的狀態變化情況,如果我們使用 get()方法從緩衝區獲取數據或者使用 put()方法把數據寫入緩衝 區,都會引起緩衝區狀態的變化。

Buffer類定義的所有緩衝區都具有的四個屬性,它們一起合作完成對緩衝區內部狀態的變化跟蹤

public abstract class Buffer {
...
// Invariants: mark <= position <= limit <= capacity
  private int mark = -1;
  private int position = 0;
  private int limit;
  private int capacity;
...
}

標記( Mark):一個備忘位置。調用 mark( )來設定 mark = postion。調用 reset( )設定 position = mark。標記在設定前是未定義的(undefined)。

位置( Position):指定下一個將要被寫入或者讀取的元素索引,它的值由 get()/put()方法自動更新,在新創建一個 Buffer 對象 時,position 被初始化爲 0。

上界( Limit):指定還有多少數據需要取出(在從緩衝區寫入通道時),或者還有多少空間可以放入數據(在從通道讀入緩衝區時)。

容量( Capacity):指定了可以存儲在緩衝區中的最大數據容量,實際上,它指定了底層數組的大小,或者至少是指定了准許我 們使用的底層數組的容量。

這四個屬性中後面三個比較重要,如果我們創建一個新的容量大小爲 10 的 ByteBuffer 對象,在初始化的時候,position 設置爲 0,limit 和 capacity 被設置爲 10,capacity 的值不會再發生變化,而其它兩個個將會隨着使用而變化。接下來我們用代碼來驗證一下這四個值的變化情況:

public class BufferDemp {
    public static void main(String[] args) throws Exception {
        //這用用的是文件 IO 處理
        FileInputStream fin = new FileInputStream("F://testio/test.txt");
        //創建文件的操作管道 FileChannel fc = fin.getChannel();
        FileChannel fc = fin.getChannel();

        //分配一個 10 個大小緩衝區,說白了就是分配一個 10 個大小的 byte 數組
        ByteBuffer buffer = ByteBuffer.allocate(10);
        output("初始化", buffer);

        //先讀一下
        fc.read(buffer);
        output("調用 read()", buffer);

        //準備操作之前,先鎖定操作範圍
        buffer.flip();
        output("調用 flip()", buffer);

        //判斷有沒有可讀數據
        while (buffer.remaining() > 0) {
            byte b = buffer.get();
            // System.out.print(((char)b)); }
        }
        output("調用 get()", buffer);

        //可以理解爲解鎖,清空buffer
        buffer.clear();
        output("調用 clear()", buffer);
        //最後把管道關閉 fin.close();
        fin.close();
    }

    //把這個緩衝裏面實時狀態給答應出來
    public static void output(String step, ByteBuffer buffer) {
        System.out.println(step + " : ");
        //標記,備忘位置
        System.out.print("mark: " + buffer.mark() + ", ");
        //容量,數組大小
        System.out.print("capacity: " + buffer.capacity() + ", ");
        //當前操作數據所在的位置,也可以叫做遊標
        System.out.print("position: " + buffer.position() + ", ");
        //鎖定值,flip,數據操作範圍索引只能在 position - limit 之間
        System.out.println("limit: " + buffer.limit());
        System.out.println();
    }
}

輸出結果:
在這裏插入圖片描述
接下來對以上運行結果進行分析

創建緩衝區並初始化大小:
在這裏插入圖片描述
我們從通道(Channel)讀取一些數據到緩衝區(Buffer)中,相當於是將通道中的數據寫入緩衝區。如果讀取4個字節大小的數據,則此時 position 的值爲 4,即下一個將要被寫入的字節索引爲 4,而 limit 仍然是 10,如下圖所示:
在這裏插入圖片描述
下一步把讀取的數據寫入到輸出通道中,相當於從緩衝區中讀取數據,在此之前,必須調用 flip()方法,該方法將會完 成兩件事情,首先把 limit 設置爲當前的 position 值,再將把 position 設置爲 0。由於 position 被設置爲 0,所以可以保證在下一步輸出時讀取到的是緩衝區中的第一個字節,而 limit 被設置爲當前的 position,可以保證讀取的數據正好是之前寫入到緩衝區中的數據,如下圖所示:
在這裏插入圖片描述
現在調用 get()方法從緩衝區中讀取數據寫入到輸出通道,這會導致 position 的增加而 limit 保持不變,但 position 不 會超過 limit 的值,所以在讀取我們之前寫入到緩衝區中的 4 個自己之後,position 和 limit 的值都爲 4,如下圖所示:
在這裏插入圖片描述
在從緩衝區中讀取數據完畢後,limit 的值仍然保持在我們調用 flip()方法時的值,調用 clear()方法能夠把所有的狀態變 化設置爲初始化時的值,如下圖所示:
在這裏插入圖片描述

  1. 緩衝區的分配
    在創建一個緩衝區對象時,會調用靜態方法 allocate()來指定緩衝區的容量,其實調用 allocate()相當於創建了一個指定大小的數組,並把它包裝爲緩衝區對象。或者我們也可以直接將一個現有的數組,包裝爲緩衝區對象,如下示例代碼所示:
public class BufferAllot {
    public void myMethod() {
        //方式1:分配指定大小的緩衝區,allocate方式直接分配,內部將隱含的創建一個數組
        ByteBuffer allocate = ByteBuffer.allocate(10);
        //方式2:通過wrap對一個現有的數組進行包裝,數據元素存在於數組中
        byte[] bytes=new byte[10];
        ByteBuffer wrap = ByteBuffer.wrap(bytes);
        //方式3:通過wrap根據一個已有的數組指定區間創建
        ByteBuffer wrapoffset = ByteBuffer.wrap(bytes,2,5);
    }
}
  1. 緩衝區分片
    在 NIO 中,除了可以分配或者包裝一個緩衝區對象外,還可以根據現有的緩衝區對象來創建一個子緩衝區,即在現有緩衝區上切 出一片來作爲一個新的緩衝區,但現有的緩衝區與創建的子緩衝區在底層數組層面上是數據共享的,也就是說,子緩衝區相當於是 現有緩衝區的一個視圖窗口。調用 slice()方法可以創建一個子緩衝區,讓我們通過例子來看一下:
public class BufferSlice {
    public static void main(String args[]) throws Exception{
        ByteBuffer buffer = ByteBuffer.allocate(10);

        // 緩衝區中的數據 0-9
        for (int i=0; i<buffer.capacity(); ++i) {
            buffer.put( (byte)i );
        }

        // 創建子緩衝區
        buffer.position(3);
        buffer.limit(7);
        ByteBuffer slice = buffer.slice();

        // 改變子緩衝區的內容
        for (int i=0; i<slice.capacity(); ++i) {
            byte b = slice.get( i );
            b *= 10;
            slice.put( i, b );
        }

        buffer.position( 0 );
        buffer.limit( buffer.capacity() );

        while (buffer.remaining()>0) {
            System.out.println( buffer.get() );
        }
    }
}

在該示例中,分配了一個容量大小爲 10 的緩衝區,並在其中放入了數據 0-9,而在該緩衝區基礎之上又創建了一個子緩衝區,並改變子緩衝區中的內容,從最後輸出的結果來看,只有子緩衝區“可見的”那部分數據發生了變化,並且說明子緩衝區與原緩衝區是數據共享的,輸出結果如下所示:
在這裏插入圖片描述

  1. 只讀緩衝區
    只讀顧名思義就是只可以讀取數據,不能寫入數據。可以通過調用緩衝區的 asReadOnlyBuffer()方法,將任何常規緩 衝區轉 換爲只讀緩衝區,這個方法返回一個與原緩衝區完全相同的緩衝區,並與原緩衝區共享數據,只不過它是隻讀的。如果原 緩衝區的內容發生了變化,只讀緩衝區的內容也隨之發生變化:
public class ReadOnlyBuffer {
    public static void main(String args[]) throws Exception {
        ByteBuffer buffer = ByteBuffer.allocate(10);

        // 緩衝區中的數據 0-9
        for (int i = 0; i < buffer.capacity(); ++i) {
            buffer.put((byte) i);
        }

        // 創建只讀緩衝區
        ByteBuffer readonly = buffer.asReadOnlyBuffer();

        // 改變原緩衝區的內容
        for (int i = 0; i < buffer.capacity(); ++i) {
            byte b = buffer.get(i);
            b *= 10;
            buffer.put(i, b);
        }
        
        readonly.position(0);
        readonly.limit(buffer.capacity());

        // 只讀緩衝區的內容也隨之改變
        while (readonly.remaining() > 0) {
            System.out.println(readonly.get());
        }
    }
}

運行結果如下:
在這裏插入圖片描述
如果嘗試修改只讀緩衝區的內容,則會報 ReadOnlyBufferException 異常。只讀緩衝區對於保護數據很有用。在將緩衝區傳遞給某 個 對象的方法時,無法知道這個方法是否會修改緩衝區中的數據。創建一個只讀的緩衝區可以保證該緩衝區不會被修改。只可以 把常規緩衝區轉換爲只讀緩衝區,而不能將只讀的緩衝區轉換爲可寫的緩衝區。

  1. 直接緩衝區
    直接緩衝區通常是 I/O 操作最好的選擇,它是爲加快 I/O 速度,使用一種特殊方式爲其分配內存的緩衝區。它支持 JVM 可用的最高效I/O機制。
    通常非直接緩衝不可能成爲一個本地 I/O 操作的目標。如果您向一個通道中傳遞一個非直接 ByteBuffer 對象用於寫入,通道可能會在每次調用中隱含地創建一個臨時的直接ByteBuffer對象,再將非直接緩衝區的內容拷貝到臨時緩衝區中,使用臨時緩衝區執行底層I/O操作,當臨時緩衝區對象離開作用域的時候,會成爲被回收的無用數據。這可能導致緩衝區在每個 I/O 上覆制併產生大量對象,而這種事都是我們極力避免的。而直接緩衝區,JVM虛擬機將盡最大努力直接對它執行本機 I/O 操作。也就是說,它會在每一次調用底層操作系統的本機 I/O 操作之前(或之後),嘗試避免將緩衝區的內容拷貝到一箇中間緩衝區中或者從一箇中間緩衝區中拷貝數據。要分配直接緩衝區,需要調用 allocateDirect() 方法,而不是 allocate()方法,使用方式與普通緩衝區並無區別,如下面的拷貝文件示例:
public class DirectBuffer {
    public static void main(String args[]) throws Exception {
        //首先我們從磁盤上讀取剛纔我們寫出的文件內容
        String infile = "F://testio/test1.txt";
        FileInputStream fin = new FileInputStream(infile);
        FileChannel fcin = fin.getChannel();

        //把剛剛讀取的內容寫入到一個新的文件中
        String outfile = String.format("F://testio/test2.txt");
        FileOutputStream fout = new FileOutputStream(outfile);
        FileChannel fcout = fout.getChannel();

        // 使用 allocateDirect,而不是 allocate
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
        while (true) {
            buffer.clear();
            int r = fcin.read(buffer);
            if (r == -1) {
                break;
            }
            buffer.flip();
            fcout.write(buffer);
        }
    }
}

小科普:直接緩衝區時 I/O 的最佳選擇,但可能比創建非直接緩衝區要花費更高的成本。直接緩衝區使用的內存是通過調用本地操作系統方面的代碼分配的,繞過了標準 JVM 堆棧。建立和銷燬直接緩衝區會明顯比具有堆棧的緩衝區更加破費,這取決於主操作系統以及 JVM 實現。直接緩衝區的內存區域不受無用存儲單元收集支配,因爲它們位於標準 JVM 堆棧之外。使用直接緩衝區或非直接緩衝區的性能權衡會因JVM,操作系統,以及代碼設計而產生巨大差異。

回想一下文章前面講解UNIX 五種IO模型中的讀取數據的過程,讀取數據總是需要通過內核空間傳遞到用戶空間,而往外寫數據總是要通過用戶空間到內核空間。JVM堆棧屬於用戶空間。 而我們這裏提到的直接緩衝區,就是內核空間的內存。內核空間的內存在java中是通過Unsafe這個類來調用的。

Netty(一個異步、事件驅動的用來做高性能、高可靠性的網絡應用框架,對NIO進行了封裝)中所提到的零拷貝(通常是指計算機在網絡上發送文件時,不需要將文件內容拷貝到用戶空間而直接在內核空間中傳輸到網絡的方式),無非就是使用了這裏的直接緩衝區。沒有什麼神奇的。

  1. 內存映射緩衝區
    映射緩衝區通常是直接存取內存的,只能通過 FileChannel 類創建。映射緩衝區的用法和直接緩衝區類似,但是 MappedByteBuffer 對象(大文件處理方面性能比較高)可以處理獨立於文件存取形式的的許多特定字符。簡單來說內存映射就是是一種讀和寫文件數據的方法,它可以比常規的基於流或者基於通道的 I/O 快的多。內存映射文件 I/O 是通過使文件中的 數據出現爲 內存數組的內容來完成的,這其初聽起來似乎不過就是將整個文件讀到內存中,但是事實上並不是這樣。一般來說, 只有文件中實際讀取或者寫入的部分纔會映射到內存中。如下面的示例代碼:
public class MappedBuffer {

    private static final int start = 0;
    private static final int size = 1024;

    public static void main(String args[]) throws Exception {
        RandomAccessFile raf = new RandomAccessFile("F://testio/test.txt", "rw");
        FileChannel fc = raf.getChannel();

        //把緩衝區跟文件系統進行一個映射關聯
        //只要操作緩衝區裏面的內容,文件內容也會跟着改變
        MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, start, size);

        mbb.put(0, (byte) 97);
        mbb.put(1023, (byte) 122);

        raf.close();
    }
}
選擇器(Selector)

選擇器提供了詢問通道是否已經準備好執行每個 I/0 操作的能力,這使得多元 I/O 成爲可能,就緒選擇和多元執行使得單線程能夠有效率地同時管理多個 I/O 通道(channels)。

爲什麼需要選擇器?

傳統的 Server/Client 模式會基於 TPR(Thread per Request),服務器會爲每個客戶端請求建立一個線程,由該線程單獨負責處理 一個客戶請求。這種模式帶來的一個問題就是線程數量的劇增,大量的線程會增大服務器的開銷。大多數的實現爲了避免這個問題, 都採用了線程池模型,並設置線程池線程的最大數量,這又帶來了新的問題,如果線程池中有 200 個線程,而有 200 個用戶都在 進行大文件下載,會導致第201個用戶的請求無法及時處理,即便第201個用戶只想請求一個幾KB大小的頁面。傳統的 Server/Client 模式如下圖所示:
在這裏插入圖片描述
Selector做了什麼?它是怎麼做的?

NIO 中非阻塞 I/O 採用了基於 Reactor 模式的工作方式,I/O 調用不會被阻塞,相反是註冊感興趣的特定 I/O 事件,如可讀數據到 達,新的套接字連接等等,在發生特定事件時,系統再通知我們。NIO 中實現非阻塞 I/O 的核心對象就是 Selector,Selector 就是 註冊各種 I/O 事件地方,而且當那些事件發生時,就是這個對象告訴我們所發生的事件,如下圖所示:
在這裏插入圖片描述
從圖中可以看出,當有讀或寫等任何註冊的事件發生時,可以從 Selector 中獲得相應的 SelectionKey,同時從 SelectionKey 中可 以找到發生的事件和該事件所發生的具體的 SelectableChannel,以獲得客戶端發送過來的數據。

使用 NIO 中非阻塞 I/O 編寫服務器處理程序,大體上可以分爲下面三個步驟:

  1. 向 Selector 對象註冊感興趣的事件。
  2. 從 Selector 中獲取感興趣的事件。
  3. 根據不同的事件進行相應的處理。

選擇器(Selector)如何創建?

方式一:
//通過調用靜態工廠方法 open( )來實例化
Selector selector = Selector.open( );
 
方式二:
//通過調用一個自定義的 SelectorProvider對象的 openSelector( )方法來創建一個 Selector 實例
SelectorProvider provider = SelectorProvider.provider();
Selector abstractSelector = provider.openSelector();

如何將通道(Channel)註冊到選擇器(Selector)上?

public final SelectionKey register(Selector sel, int ops)

register( )方法接受一個 Selector 對象作爲參數,以及一個名爲ops 的整數參數。第二個參數表示所關心的通道操作,返回值是一個SelectionKey。

選擇器(Selector)API

public abstract class Selector
{
// This is a partial API listing

//返回與選擇器關聯的已經註冊的鍵的集合
public abstract Set keys( );
//返回已註冊的鍵的集合的子集
public abstract Set selectedKeys( );
//執行就緒檢查過程,在沒有通道就緒時將無限阻塞
public abstract int select( ) throws IOException;
//執行就緒檢查過程,在限制時間內沒有通道就緒時,它將返回 0
public abstract int select (long timeout) throws IOException;
//執行就緒檢查過程,但不阻塞。如果當前沒有通道就緒,它將立即返回 0
public abstract int selectNow( ) throws IOException;
//使線程從被阻塞的 select( )方法中退出
public abstract void wakeup( );
}

併發性,選擇器對象是線程安全的嗎?

protected Set<SelectionKey> selectedKeys = new HashSet();
protected HashSet<SelectionKey> keys = new HashSet();
private final Set<SelectionKey> cancelledKeys = new HashSet<SelectionKey>();

可以看到選擇鍵的集合是HashSet類型,HashSet是線程不安全。所以選擇器對象是線程安全的,但它們包含的鍵集合不是。

在多線程的場景中,如果您需要對任何一個鍵的集合進行更改,不管是直接更改還是其他操作帶來的副作用,您都需要首先以相同的順序,在同一對象上進行同步。鎖的過程是非常重要的。如果競爭的線程沒有以相同的順序請求鎖,就將會有死鎖的潛在隱患。如果您可以確保否其他線程不會同時訪問選擇器,那麼就不必要進行同步了。Selector 類的 close( )方法與 select( )方法的同步方式是一樣的,因此也有一直阻塞的可能性。在選擇過程還在進行的過程中,所有對 close( )的調用都會被阻塞,直到選擇過程結束,或者執行選擇的線程進入睡眠。在後面的情況下,執行選擇的線程將會在執行關閉的線程獲得鎖是立即被喚醒,並關閉選擇器 。

通道(Channel)

通道(Channel)可以理解爲數據傳輸的管道。通道與流不同的是,流只是在一個方向上移動(一個流必須是InputStream或者OutputStream的子類),而通道可以用於讀、寫或者同時用於讀寫。當然了所有數據都通過 Buffer 對象來處理。我們永遠不會將字節直接寫入通道中,相反是將數據寫入包含一個或者多個字節的緩衝區。同樣不會直接從通道中讀取字節,而是將數據從通 道讀入緩衝區,再從緩衝區獲取這個字節。

在 NIO 中,提供了多種通道對象,而所有的通道對象都實現了 Channel 接口。它們之間的繼承關係如下圖所示:
在這裏插入圖片描述
爲了保證儘可能清晰的顯示我們關注的點,圖中只顯示了我們關心的Channel。

任何時候讀取數據,都不是直接從通道讀取,而是從通道讀取到緩衝區。所以使用 NIO 讀取數據可 以分爲下面三個步驟:

  1. 從 FileInputStream 獲取 Channel
  2. 創建 Buffer
  3. 將數據從 Channel 讀取到 Buffer 中

下面是一個簡單的使用 NIO 從文件中讀取數據的例子:

public class FileInputDemo {
    public static void main(String[] args) throws Exception {
        FileInputStream fin = new FileInputStream("F://testio/test.txt");
        // 獲取通道
        FileChannel fc = fin.getChannel();
        // 創建緩衝區
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        // 讀取數據到緩衝區
        fc.read(buffer);
        buffer.flip();
        while (buffer.remaining() > 0) {
            byte b = buffer.get();
            System.out.print(((char) b));
        }
        fin.close();
    }
}

下面是一個簡單的使用 NIO 向文件中寫入數據的例子:

public class FileOutputDemo {
    private static final byte message[] = {83, 111, 109, 101, 32, 98, 121, 116, 101, 115, 46};

    public static void main(String[] args) throws Exception {
        FileOutputStream fout = new FileOutputStream("F://testio/test.txt");
        FileChannel fc = fout.getChannel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        for (int i = 0; i < message.length; ++i) {
            buffer.put(message[i]);
        }
        buffer.flip();
        fc.write(buffer);
        fout.close();
    }
}

實現一個單線程NIO

關係圖:
在這裏插入圖片描述
流程圖
在這裏插入圖片描述
服務端代碼:

//同步非阻塞IO模型---NIO服務端(單線程模式)
public class NIOServer {
    //服務端端口號
    private int port = 9999;
    //首先準備兩個東西,一個緩衝區(等待大廳),一個輪詢器(叫號員)。
    //緩衝區,從堆內存分配一個1024大小容量的byte數組作爲數據緩衝區(數據存儲器)
    private ByteBuffer buffer = ByteBuffer.allocate(1024);
    //輪詢器(選擇器),用於檢查一個或多個NIO Channel(通道)的狀態是否處於可讀、可寫
    private Selector selector;
    //創建NIO服務端,port爲服務端端口號
    public NIOServer(int port){
        try {
            this.port = port;
            //創建監聽TCP鏈接的通道並打開,類似BIO中的ServerSocket
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            //爲通道綁定端口,告訴客戶端創建通道的端口(InetSocketAddress類主要作用是封裝端口)
            serverSocketChannel.socket().bind(new InetSocketAddress("localhost",this.port));
            //採用非阻塞模式,NIO是BIO的升級版本,爲了兼容BIO,NIO默認採用阻塞模式
            serverSocketChannel.configureBlocking(false);
            //打開輪詢器(叫號員上崗準備叫號),用於檢查一個或多個NIO Channel(通道)的狀態是否處於可讀、可寫等
            selector = Selector.open();
            //  //將服務端的通道channel註冊到選擇器selector上併爲選擇器設置關心事件(通過Selector監聽Channel時對什麼事件感興趣)
            //SelectionKey.OP_ACCEPT —— 接收連接繼續事件,表示服務器監聽到了客戶連接,服務器可以接收這個連接了
            //SelectionKey.OP_CONNECT —— 連接就緒事件,表示客戶與服務器的連接已經建立成功
            //SelectionKey.OP_READ —— 讀就緒事件,表示通道中已經有了可讀的數據,可以執行讀操作了(通道目前有數據,可以進行讀操作了)
            //SelectionKey.OP_WRITE —— 寫就緒事件,表示已經可以向通道寫數據了(通道目前可以用於寫操作)
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        }catch (IOException e){
            e.printStackTrace();
        }
    }

    //創建監聽,對接收到客戶端的數據進行處理
    public void listen(){
        System.out.println("NIOServer has started,listening on port:" + this.port);
        try {
            //輪詢主線程
            while (true){
                //選擇已經準備就緒的通道(叫號員開始叫號)
                //select()方法返回的int值表示有多少通道已經就緒,是自上次調用select()方法後有多少通道變成就緒狀態。
                //***阻塞點***------至少有一個事件發生 ,否則會阻塞
                int wait = selector.select();
                if (wait == 0){
                    continue;
                }
                //一旦調用select()方法,並且返回值不爲0時,則可以通過調用Selector的selectedKeys()方法來訪問已選擇鍵集合
                //將所有準備就緒的連接存起來
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                //創建迭代器,不斷迭代,就叫做輪詢(在每次爲通道註冊選擇器時都會創建一個SelectionKey)
                //SelectionKey鍵表示了一個特定的通道對象和一個特定的選擇器對象之間的註冊關係
                Iterator<SelectionKey> iter = selectionKeys.iterator();
                //同步就體現在這裏,因爲每一次輪詢只能拿一個key,所以每一次只能處理一種狀態
                while (iter.hasNext()){
                    //每一個key代表一種狀態(每一個號對應一種業務),數據就緒、數據可讀、數據可寫等
                    SelectionKey key = iter.next();
                    //每一次輪詢調用一次proess方法,每次調用只做一件事,同一個時間點只能做一件事
                    proess(key);
                    iter.remove();
                }
            }
        }catch (IOException e){
            e.printStackTrace();
        }
    }

    //對數據具體的處理方法(櫃檯人員開始處理叫到號的人的業務)
    //SelectionKey鍵表示了一個特定的通道對象和一個特定的選擇器對象之間的註冊關係
    private void proess(SelectionKey key) throws IOException{
        RequestHandler requestHandler = new RequestHandler();
        //針對每一種狀態做出相應的反應
        if (key.isAcceptable()){//是否可接收
            System.out.println("進入連接判斷");
            //通過SelectionKey對象獲取其監聽的ServerSocketChannel通道
            ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
            //這裏提現非阻塞,不管數據有沒有準備好,都會有一個狀態的反饋
            //在 ServerSocketChannel 上調用 accept( )方法則會返回 SocketChannel 類型的對象,返回的對象能夠在非阻塞模式下運行
            SocketChannel clientChannel = serverSocketChannel.accept();
            System.out.println("Connection from:" + clientChannel.getRemoteAddress());
            //這裏一定要設置非阻塞
            clientChannel.configureBlocking(false);
            //可接收數據時,將選擇器關心事件改爲可讀
            clientChannel.register(selector,SelectionKey.OP_READ);
        }else if (key.isReadable()){//是否可讀
            //從多路複用器(選擇器/輪詢器)中拿到客戶端的引用,返回該SelectionKey對應的channel
            SocketChannel clientChannel = (SocketChannel) key.channel();
            //讀取通道中一定數量的字節並存入緩衝區數組buffer中,以整數形式返回實際讀取的字節數
            int len = clientChannel.read(buffer);
            if (len > 0){
                //buffer.flip();一定得有,如果沒有,就是從文件最後開始讀取的,當然讀出來的都是byte=0時候的字符。
                //通過buffer.flip();這個語句,就能把buffer的當前位置更改爲buffer緩衝區的第一個位置
                buffer.flip();
                String clientScannerData = new String(buffer.array(),0,len);
                //可讀數據時,將選擇器關心事件改爲可寫
                clientChannel.register(selector,SelectionKey.OP_WRITE);
                //將一個對象或者更多的信息附着到SelectionKey上,這樣就能方便的識別某個給定的通道
                key.attach(clientScannerData);
                System.out.println("Client data:" + clientScannerData + "---Client address: " + clientChannel.getRemoteAddress());
            }
        }else if (key.isWritable()){//是否可寫
            SocketChannel clientChannel = (SocketChannel)key.channel();
            //獲取數據
            String clientScannerData = (String) key.attachment();
            //將緩衝區中的數據存放在byte數組中,數組和緩衝區中任意一方的數據改動都會影響另外一方,然後將數據寫入通道
            clientChannel.write(ByteBuffer.wrap((requestHandler.hendler(clientScannerData)).getBytes()));
            clientChannel.close();
        }
    }

    public static void main(String[] args) {
        new NIOServer(9999).listen();
    }
}

優化線程模型

由上面的示例我們大概可以總結出NIO是怎麼解決掉線程的瓶頸並處理海量連接的:

NIO由原來的阻塞讀寫(佔用線程)變成了單線程輪詢事件,找到可以進行讀寫的網絡描述符進行讀寫。除了事件的輪詢是阻塞的(沒有可乾的事情必須要阻塞),剩餘的I/O操作都是純CPU操作,沒有必要開啓多線程。

並且由於線程的節約,連接數大的時候因爲線程切換帶來的問題也隨之解決,進而爲處理海量連接提供了可能。

單線程處理I/O的效率確實非常高,沒有線程切換,只是拼命的讀、寫、選擇事件。但現在的服務器,一般都是多核處理器,如果能夠利用多核心進行I/O,無疑對效率會有更大的提高。

仔細分析一下我們需要的線程,其實主要包括以下幾種:

  1. 事件分發器,單線程選擇就緒的事件。
  2. I/O處理器,包括connect、read、write等,這種純CPU操作,一般開啓CPU核心個線程就可以。
  3. 業務線程,在處理完I/O後,業務一般還會有自己的業務邏輯,有的還會有其他的阻塞I/O,如DB操作,RPC等。只要有阻塞,就需要單獨的線程。

Java的Selector對於Linux系統來說,有一個致命限制:同一個channel的select不能被併發的調用。因此,如果有多個I/O線程,必須保證:一個socket只能屬於一個IoThread,而一個IoThread可以管理多個socket。

另外連接的處理和讀寫的處理通常可以選擇分開,這樣對於海量連接的註冊和讀寫就可以分發。雖然read()和write()是比較高效無阻塞的函數,但畢竟會佔用CPU,如果面對更高的併發則無能爲力。

下面是我理解的 Java NIO 反應堆(單Reactor多線程模型)的工作原理圖:
在這裏插入圖片描述
注:每個線程的處理流程大概都是讀取數據、解碼、計算處理、編碼、發送響應。

NIO高級主題—Proactor與Reactor

一般情況下,I/O 複用機制需要事件分發器(event dispatcher)。 事件分發器的作用,即將那些讀寫事件源分發給各讀寫事件的處理者,就像送快遞的在樓下喊: 誰誰誰的快遞到了, 快來拿吧!開發人員在開始的時候需要在分發器那裏註冊感興趣的事件,並提供相應的處理者(event handler),或者是回調函數;事件分發器在適當的時候,會將請求的事件分發給這些handler或者回調函數。

涉及到事件分發器的兩種模式稱爲:Reactor和Proactor。 Reactor模式是基於同步I/O的,而Proactor模式是和異步I/O相關的。在Reactor模式中,事件分發器等待某個事件或者可應用或個操作的狀態發生(比如文件描述符可讀寫,或者是socket可讀寫),事件分發器就把這個事件傳給事先註冊的事件處理函數或者回調函數,由後者來做實際的讀寫操作。

而在Proactor模式中,事件處理者(或者代由事件分發器發起)直接發起一個異步讀寫操作(相當於請求),而實際的工作是由操作系統來完成的。發起時,需要提供的參數包括用於存放讀到數據的緩存區、讀的數據大小或用於存放外發數據的緩存區,以及這個請求完後的回調函數等信息。事件分發器得知了這個請求,它默默等待這個請求的完成,然後轉發完成事件給相應的事件處理者或者回調。舉例來說,在Windows上事件處理者投遞了一個異步IO操作(稱爲overlapped技術),事件分發器等IO Complete事件完成。這種異步模式的典型實現是基於操作系統底層異步API的,所以我們可稱之爲“系統級別”的或者“真正意義上”的異步,因爲具體的讀寫是由操作系統代勞的。

二者的差異(以讀操作爲例)

在Reactor中實現讀

  • 註冊讀就緒事件和相應的事件處理器。
  • 事件分發器等待事件。
  • 事件到來,激活分發器,分發器調用事件對應的處理器。
  • 事件處理器完成實際的讀操作,處理讀到的數據,註冊新的事件,然後返還控制權。

在Proactor中實現讀:

  • 處理器發起異步讀操作(注意:操作系統必須支持異步IO)。在這種情況下,處理器無視IO就緒事件,它關注的是完成事件。
  • 事件分發器等待操作完成事件。
  • 在分發器等待過程中,操作系統利用並行的內核線程執行實際的讀操作,並將結果數據存入用戶自定義緩衝區,最後通知事件分發器讀操作完成。
  • 事件分發器呼喚處理器。
  • 事件處理器處理用戶自定義緩衝區中的數據,然後啓動一個新的異步操作,並將控制權返回事件分發器。

可以看出,兩個模式的相同點,都是對某個I/O事件的事件通知(即告訴某個模塊,這個I/O操作可以進行或已經完成)。在結構上,兩者也有相同點,事件分發器負責提交IO操作(異步)、查詢設備是否可操作(同步),然後當條件滿足時,就回調handler;不同點在於,異步情況下(Proactor),當回調handler時,表示I/O操作已經完成;同步情況下(Reactor),回調handler時,表示I/O設備可以進行某個操作(can read 或 can write)。
如果你對Reactor模型和Proactor模型比較感興趣,可以點擊徹底搞懂Reactor模型和Proactor模型這裏不再做過多解釋。

NIO給我們來了什麼

  • 事件驅動模型
  • 避免多線程
  • 單線程處理多任務
  • 非阻塞I/O,I/O讀寫不再阻塞,而是返回0
  • 基於block的傳輸,通常比基於流的傳輸更高效
  • 更高級的IO函數,zero-copy
  • IO多路複用大大提高了Java網絡應用的可伸縮性和實用性

NIO存在的問題

使用NIO != 高性能,當連接數<1000,併發程度不高或者局域網環境下NIO並沒有顯著的性能優勢。NIO並沒有完全屏蔽平臺差異,它仍然是基於各個操作系統的I/O系統實現的,差異仍然存在。使用NIO做網絡編程構建事件驅動模型並不容易,陷阱重重。所以推薦大家使用成熟的NIO框架,如Netty,MINA等。解決了很多NIO的陷阱,並屏蔽了操作系統的差異,有較好的性能和編程模型。

AIO錦上添花

AIO簡介

Java1.7中新增了一些與文件(網絡)I/O相關的一些api。這些API被稱爲NIO.2,或稱爲AIO(Asynchronous I/O)。AIO最大的一個特性就是異步能力,這種能力對socket與文件I/O都起作用。AIO其實是一種在讀寫操作結束之前允許進行其他操作的I/O處理。AIO是對JDK1.4中提出的同步非阻塞I/O(NIO)的進一步增強。

AIO基本原理

Java1.7主要增加了三個新的異步通道和一個用戶處理器接口:

  • AsynchronousFileChannel::用於文件異步讀寫
  • AsynchronousSocketChannel::客戶端異步socket
  • AsynchronousServerSocketChannel: 服務器異步socket
  • CompletionHandler 接口:應用程序向操作系統發起 IO 請求,當完成後處理具體邏輯,否則做 自己該做的事情

“真正”的異步IO需要操作系統更強的支持。在IO多路複用模型中,事件循環將文件句柄的狀態事件通知給用戶線程, 由用戶線程自行讀取數據、處理數據。而在異步IO模型中,當用戶線程收到通知時,數據已經被內核讀取完畢,並放 在了用戶線程指定的緩衝區內,內核在IO完成後通知用戶線程直接使用即可。異步IO模型使用了Proactor設計模式實 現了這一機制,如下圖所示:
在這裏插入圖片描述

AIO初體驗

服務端代碼:

public class AIOServer {
    private final int port;

    public AIOServer(int port) {
        this.port = port;
        listen();
    }

    private void listen() {
        try {
            ExecutorService executorService = Executors.newCachedThreadPool();
            AsynchronousChannelGroup threadGroup = AsynchronousChannelGroup.withCachedThreadPool(executorService, 1);
            final AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(threadGroup);
            server.bind(new InetSocketAddress(port));
            System.out.println("服務已啓動,監聽端口" + port);
            server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
                final ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

                public void completed(AsynchronousSocketChannel result, Object attachment) {
                    System.out.println("IO 操作成功,開始獲取數據");
                    try {
                        buffer.clear();
                        result.read(buffer).get();
                        buffer.flip();
                        result.write(buffer);
                        buffer.flip();
                    } catch (Exception e) {
                        System.out.println(e.toString());
                    } finally {
                        try {
                            result.close();
                            server.accept(null, this);
                        } catch (Exception e) {
                            System.out.println(e.toString());
                        }
                    }
                    System.out.println("操作完成");
                }

                @Override
                public void failed(Throwable exc, Object attachment) {
                    System.out.println("IO 操作是失敗: " + exc);
                }
            });
            try {
                Thread.sleep(Integer.MAX_VALUE);
            } catch (InterruptedException ex) {
                System.out.println(ex);
            }
        } catch (IOException e) {
            System.out.println(e);
        }
    }

    public static void main(String args[]) {
        int port = 8000;
        new AIOServer(port);
    }
}

客戶端代碼:

public class AIOClient {
    private final AsynchronousSocketChannel client;

    public AIOClient() throws Exception {
        client = AsynchronousSocketChannel.open();
    }

    public void connect(String host, int port) throws Exception {
        client.connect(new InetSocketAddress(host, port), null, new CompletionHandler<Void, Void>() {
            @Override
            public void completed(Void result, Void attachment) {
                try {
                    client.write(ByteBuffer.wrap("這是一條測試數據".getBytes())).get();
                    System.out.println("已發送至服務器");
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            }

            @Override
            public void failed(Throwable exc, Void attachment) {
                exc.printStackTrace();
            }
        });
        final ByteBuffer bb = ByteBuffer.allocate(1024);
        client.read(bb, null, new CompletionHandler<Integer, Object>() {
                    @Override
                    public void completed(Integer result, Object attachment) {
                        System.out.println("IO 操作完成" + result);
                        System.out.println("獲取反饋結果" + new String(bb.array()));
                    }

                    @Override
                    public void failed(Throwable exc, Object attachment) {
                        exc.printStackTrace();
                    }
                }
        );
        try {
            Thread.sleep(Integer.MAX_VALUE);
        } catch (InterruptedException ex) {
            System.out.println(ex);
        }
    }

    public static void main(String args[]) throws Exception {
        new AIOClient().connect("localhost", 8000);
    }
}

執行結果:

服務端:
在這裏插入圖片描述
客戶端:
在這裏插入圖片描述
因爲平時AIO用的並不多 ,所以這裏就不詳細講解了,如果有小夥伴對AIO感興趣,可以點擊這裏AIO基礎

各IO模型對比總結

BIO 與 NIO 對比

IO模型 BIO NIO
通信 面向流(鄉村公路) 面向緩衝(高速公路,多路複用技術)
處理 阻塞 IO(多線程) 非阻塞 IO(反應堆 Reactor)
觸發 選擇器(輪詢機制)

最後來一張總結對比表

屬性 同步阻塞 IO(BIO) 僞異步 IO 非阻塞 IO(NIO) 異步 IO(AIO)
客戶端數:IO 線程數 1:1 M:N(M>=N) M:1 M:0
阻塞類型 阻塞 阻塞 非阻塞 非阻塞
同步 同步 同步 同步(多路複用) 異步
API 使用難度 簡單 簡單 複雜 一般
調試難度 簡單 簡單 複雜 複雜
可靠性 非常差
吞吐量

結束語

洋洋灑灑三萬六千多字,與小夥伴們一同在網絡IO演進之路上走了一遭。整個網絡編程體系還是比較龐大的,本文也只是描述了冰山一角,希望能給各位看官一點小小收穫。博主能力有限,只是一個在互聯網摸爬滾打立志要從一個Code Farmer進化爲Senior Architec的小菜鳥,也只能理解到這個層面,未來路還長,大家一起努力、進步!
這篇文章也是本人在CSDN上面發表的第一篇博客,曾經一直想做,現在終於付諸於行動了。這篇文章雖然算不上是一篇優秀的文章,但是它也算對我這一個多星期學習網絡IO的一個小小的總結,更是這段時間熬夜奮戰的心血。文章中如果有不足或錯誤之處,還請小夥伴們指出,忘海涵。
最後送大家三個詞:練習、用心、堅持!

追夢人們,加油!

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