Java NIO (圖解+秒懂+史上最全)


文章很長,而且持續更新,建議收藏起來,慢慢讀!瘋狂創客圈總目錄 博客園版 為您奉上珍貴的學習資源 :

免費贈送 :《尼恩Java面試寶典》 持續更新+ 史上最全 + 面試必備 2000頁+ 面試必備 + 大廠必備 +漲薪必備
免費贈送 經典圖書:《Java高並發核心編程(卷1)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高並發核心編程(卷2)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:《Java高並發核心編程(卷3)加強版》 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 經典圖書:尼恩Java面試寶典 最新版 面試必備 + 大廠必備 +漲薪必備 加尼恩免費領
免費贈送 資源寶庫: Java 必備 百度網盤資源大合集 價值>10000元 加尼恩領取


特別說明:本文所屬書籍已經更新啦,最新內容以書籍為准(書籍也免費送哦)

下面的內容,來自於《Java高並發核心編程 卷1加強版》一書,此書的最新電子版,已經免費贈送,大家找尼恩領取即可。

而且,《Java高並發核心編程卷1》的電子書,會不斷優化和迭代。最新一輪的迭代,增加了 消息驅動IO模型的內容,這是之前沒有的,使得在 Java NIO 底層原理這塊,書的內容變得非常全面。
另外,如果出現內容需要更新,到處要更新的話,工作量會很大,所以后續的更新,都會統一到電子書哦。

核心基礎:Java NIO核心詳解

高性能的Java通信,絕對離不開Java
NIO組件,現在主流的技術框架或中間件服務器,都使用了Java NIO組件,譬如Tomcat、Jetty、Netty。

學習和掌握Java NIO組件,已經不是一項加分技能,而是一項必備技能。

不管是面試,還是實際開發,作為Java“攻城獅”(工程師的諧音),都必須掌握NIO的原理和開發實踐技能。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

NIO的起源

NIO技術是怎么來的?為啥需要這個技術呢?先給出一份在Java NIO出來之前,服務器端同步阻塞I/O處理(也就是BIO,Blocking I/O)的參考代碼:


class ConnectionPerThreadWithPool implements Runnable
{
    public void run()
    {
        //線程池
        //注意,生產環境不能這么用,具體請參考《java高並發核心編程卷2》
        ExecutorService executor = Executors.newFixedThreadPool(100);
        try
        {
            //服務器監聽socket
            ServerSocket serverSocket =
                    new ServerSocket(NioDemoConfig.SOCKET_SERVER_PORT);
           //主線程死循環, 等待新連接到來
            while (!Thread.interrupted())
            {
                Socket socket = serverSocket.accept();
                //接收一個連接后,為socket連接,新建一個專屬的處理器對象
                Handler handler = new Handler(socket);
                //創建新線程來handle
                //或者,使用線程池來處理
                new Thread(handler).start();
            }

        } catch (IOException ex)
        { /* 處理異常 */ }
    }

    static class Handler implements Runnable
    {
        final Socket socket;
        Handler(Socket s)
        {
            socket = s;
        }
        public void run()
        {
            //死循環處理讀寫事件
            boolean ioCompleted=false;
            while (!ioCompleted)
            {
                try
                {
                    byte[] input = new byte[NioDemoConfig.SERVER_BUFFER_SIZE];
                    /* 讀取數據 */
                    socket.getInputStream().read(input);
                    // 如果讀取到結束標志
                    // ioCompleted= true
                    // socket.close();

                    /* 處理業務邏輯,獲取處理結果 */
                    byte[] output = null;
                    /* 寫入結果 */
                    socket.getOutputStream().write(output);
                } catch (IOException ex)
                { /*處理異常*/ }
            }
        }
    }
}

以上示例代碼中,對於每一個新的網絡連接,都通過線程池分配給一個專門線程去負責IO處理。每個線程都獨自處理自己負責的socket連接的輸入和輸出。當然,服務器的監聽線程也是獨立的,任何的socket連接的輸入和輸出處理,不會阻塞到后面新socket連接的監聽和建立,這樣,服務器的吞吐量就得到了提升。早期版本的Tomcat服務器,就是這樣實現的。

這是一個經典的每連接每線程的模型——Connection Per Thread模式。這種模型,在活動連接數不是特別高(小於單機1000)的情況下,這種模型是比較不錯的,可以讓每一個連接專注於自己的I/O並且編程模型簡單,也不用過多考慮系統的過載、限流等問題。此模型往往會結合線程池使用,線程池本身就是一個天然的漏斗,可以緩沖一些系統處理不了的連接或請求。

不過,這個模型最本質的問題在於,嚴重依賴於線程。但線程是很”貴”的資源,主要表現在:

  • 1 線程的創建和銷毀成本很高,線程的創建和銷毀都需要通過重量級的系統調用去完成。

  • 2.線程本身占用較大內存,像Java的線程的棧內存,一般至少分配512K~1M的空間,如果系統中的線程數過千,整個JVM的內存將被耗用1G。

  • 3.線程的切換成本是很高的。操作系統發生線程切換的時候,需要保留線程的上下文,然后執行系統調用。過多的線程頻繁切換帶來的后果是,可能執行線程切換的時間甚至會大於線程執行的時間,這時候帶來的表現往往是系統CPU sy值特別高(超過20%以上)的情況,導致系統幾乎陷入不可用的狀態。

說 明

CPU利用率為CPU在用戶進程、內核、中斷處理、IO等待以及空閑時間五個部分使用百分比。人們往往通過五個部分的各種組合,用來分析CPU消耗情況的關鍵指標。CPU sy值表示內核線程處理所占的百分比。

如果使用linux 的top命令去查看當前系統的資源,會輸出下面的一些指標:

top - 23:22:02 up 5:47, 1 user, load average: 0.00, 0.00, 0.00
Tasks: 107 total, 1 running, 106 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.3%us, 0.3%sy, 0.0%ni, 99.3%id, 0.0%wa, 0.0%hi, 0.0%si,0.0%st
Mem: 1017464k total, 359292k used, 658172k free, 56748k buffers
Swap: 2064376k total, 0k used, 2064376k free, 106200k cached

這里關注的是輸出信息的第三行,其中:0.4%us表示用戶進程所占的百分比;0.3%sy表示內核線程處理所占的百分比;0.0%ni表示被nice命令改變優先級的任務所占的百分比;99.3%id表示CPU空閑時間所占的百分比;0.0%wa表示等待IO所占的百分比;0.0%hi表示硬件中斷所占的百分比,0.0%si表示為軟件中斷所占的百分比。

所以,當 CPU sy 值高時,表示系統調用耗費了較多的 CPU,對於 Java 應用程序而言,造成這種現象的主要原因是啟動的線程比較多,並且這些線程多數都處於不斷的等待(例如鎖等待狀態)和執行狀態的變化過程中,這就導致了操作系統要不斷的調度這些線程,切換執行。

  • 4.容易造成鋸齒狀的系統負載。因為系統負載是用活動線程數或CPU核心數,一旦線程數量高但外部網絡環境不是很穩定,就很容易造成大量請求同時到來,從而激活大量阻塞線程從而使系統負載壓力過大。

說 明

系統負載(System
Load),指當前正在被CPU執行和等待被CPU執行的進程數目總和,是反映系統忙閑程度的重要指標。當load值低於CPU數目時,表示CPU有空閑,資源存在浪費;當load值高於CPU數目時,表示進程在排隊等待CPU,表示系統資源不足,影響應用程序的執行性能。

總之,當面對十萬甚至百萬級連接的時候,傳統的BIO模型是無能為力的。

但是,高並發的需求卻越來越普通,隨着移動端應用的興起和各種網絡游戲的盛行,百萬級長連接日趨普遍,此時,必然需要一種更高效的I/O處理組件——這就是Java的NIO編程組件。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

Java NIO簡介

在1.4版本之前,JavaIO類庫是阻塞式IO;從1.4版本開始,引進了新的異步IO庫,被稱為Java New IO類庫,簡稱為Java NIO。

Java NIO類庫的目標,就是要讓Java支持非阻塞IO,基於這個原因,更多的人喜歡稱Java NIO為非阻塞IO(Non-Block IO),稱“老的”阻塞式Java IO為OIO(Old IO)。總體上說,NIO彌補了原來面向流的OIO同步阻塞的不足,它為標准Java代碼提供了高速的、面向緩沖區的IO。

Java NIO類庫包含以下三個核心組件:

  • Channel(通道)

  • Buffer(緩沖區)

  • Selector(選擇器)

如果理解了第2章的四種IO模型,大家一眼就能識別出來,Java NIO,屬於第三種模型—— IO 多路復用模型。只不過,Java
NIO組件提供了統一的應用開發API,為大家屏蔽了底層的操作系統的差異。

后面的章節,我們會對以上的三個Java NIO的核心組件,展開詳細介紹。先來看看Java的NIO和OIO的簡單對比。

NIO和OIO的對比

在Java中,NIO和OIO的區別,主要體現在三個方面:

(1)OIO是面向流(Stream Oriented)的,NIO是面向緩沖區(Buffer Oriented)的。

問題是:什么是面向流,什么是面向緩沖區呢?

在面向流的OIO操作中,IO的 read() 操作總是以流式的方式順序地從一個流(Stream)中讀取一個或多個字節,因此,我們不能隨意地改變讀取指針的位置,也不能前后移動流中的數據。

而NIO中引入了Channel(通道)和Buffer(緩沖區)的概念。面向緩沖區的讀取和寫入,都是與Buffer進行交互。用戶程序只需要從通道中讀取數據到緩沖區中,或將數據從緩沖區中寫入到通道中。NIO不像OIO那樣是順序操作,可以隨意地讀取Buffer中任意位置的數據,可以隨意修改Buffer中任意位置的數據。

(2)OIO的操作是阻塞的,而NIO的操作是非阻塞的。

OIO的操作是阻塞的,當一個線程調用read() 或 write()時,該線程被阻塞,直到有一些數據被讀取,或數據完全寫入。該線程在此期間不能再干任何事情了。例如,我們調用一個read方法讀取一個文件的內容,那么調用read的線程會被阻塞住,直到read操作完成。

NIO如何做到非阻塞的呢?當我們調用read方法時,系統底層已經把數據准備好了,應用程序只需要從通道把數據復制到Buffer(緩沖區)就行;如果沒有數據,當前線程可以去干別的事情,不需要進行阻塞等待。

NIO的非阻塞是如何做到的呢?

其實在上一章,答案已經揭曉了,根本原因是:NIO使用了通道和通道的IO多路復用技術。

(3)OIO沒有選擇器(Selector)概念,而NIO有選擇器的概念。

NIO技術的實現,是基於底層的IO多路復用技術實現的,比如在Windows中需要select多路復用組件的支持,在Linux系統中需要select/poll/epoll多路復用組件的支持。所以NIO的需要底層操作系統提供支持。而OIO不需要用到選擇器。

通道(Channel)

前面提到,Java NIO類庫包含以下三個核心組件:

  • Channel(通道)

  • Buffer(緩沖區)

  • Selector(選擇器)

首先說一下Channel,國內大多翻譯成“通道”。Channel的角色和OIO中的Stream(流)是差不多的。在OIO中,同一個網絡連接會關聯到兩個流:一個輸入流(Input Stream),另一個輸出流(Output Stream),Java應用程序通過這兩個流,不斷地進行輸入和輸出的操作。

在NIO中,一個網絡連接使用一個通道表示,所有的NIO的IO操作都是通過連接通道完成的。一個通道類似於OIO中的兩個流的結合體,既可以從通道讀取數據,也可以向通道寫入數據。

在這里插入圖片描述

Channel和Stream的一個顯著的不同是:Stream是單向的,譬如InputStream是單向的只讀流,OutputStream是單向的只寫流;而Channel是雙向的,既可以用來進行讀操作,又可以用來進行寫操作。

NIO中的Channel的主要實現有:

  • 1.FileChannel 用於文件IO操作

  • 2.DatagramChannel 用於UDP的IO操作

  • 3.SocketChannel 用於TCP的傳輸操作

  • 4.ServerSocketChannel 用於TCP連接監聽操作

選擇器(Selector)

首先,回顧一個前面介紹的基礎知識,什么是IO多路復用模型?

IO多路復用指的是一個進程/線程可以同時監視多個文件描述符(含socket連接),一旦其中的一個或者多個文件描述符可讀或者可寫,該監聽進程/線程能夠進行IO事件的查詢。

在Java應用層面,如何實現對多個文件描述符的監視呢?

需要用到一個非常重要的Java NIO組件——Selector 選擇器。Selector選擇器可以理解為一個IO事件的監聽與查詢器。通過選擇器,一個線程可以查詢多個通道的IO事件的就緒狀態。

在介紹Selector選擇器之前,首先介紹一下這個前置的概念:IO事件。

什么是IO事件呢?

表示通道某種IO操作已經就緒、或者說已經做好了准備。

例如,如果一個新Channel鏈接建立成功了,就會在Server Socket Channel上發生一個IO事件,代表一個新連接一個准備好,這個IO事件叫做“接收就緒”事件。

再例如,一個Channel通道如果有數據可讀,就會發生一個IO事件,代表該連接數據已經准備好,這個IO事件叫做“讀就緒”事件。

Java NIO將NIO事件進行了簡化,只定義了四個事件,這四種事件用SelectionKey的四個常量來表示:

  • SelectionKey.OP_CONNECT

  • SelectionKey.OP_ACCEPT

  • SelectionKey.OP_READ

  • SelectionKey.OP_WRITE

說 明

各個操作系統定義的IO事件,復雜得多,Java NIO 底層完成了操作系統IO事件,到Java NIO 事件的映射。這部分底層原理比較深奧,如果有興趣,可以去看我的視頻。

在大家了解了IO事件之后,再回頭來看Selector選擇器。Selector的本質,就是去查詢這些IO就緒事件,所以,它的名稱就叫做
Selector查詢者。

從編程實現維度來說,IO多路復用編程的第一步,是把通道注冊到選擇器中,第二步則是通過選擇器所提供的事件查詢(select)方法,這些注冊的通道是否有已經就緒的IO事件(例如可讀、可寫、網絡連接完成等)。

由於一個選擇器只需要一個線程進行監控,所以,我們可以很簡單地使用一個線程,通過選擇器去管理多個連接通道。
在這里插入圖片描述

與OIO相比,NIO使用選擇器的最大優勢:系統開銷小,系統不必為每一個網絡連接(文件描述符)創建進程/線程,從而大大減小了系統的開銷。

總之,通過Java NIO可以達到一個線程負責多個連接通道的IO處理,這是非常高效的。這種高效,恰恰就來自於Java的選擇器組件Selector以及其底層的操作系統IO多路復用技術的支持。

緩沖區(Buffer)

應用程序與通道(Channel)主要的交互,主要是進行數據的read讀取和write寫入。為了完成NIO的非阻塞讀寫操作,NIO為大家准備了第三個重要的組件——NIO Buffer(NIO緩沖區)。

Buffer顧名思義:緩沖區,實際上是一個容器,一個連續數組。Channel提供從文件、網絡讀取數據的渠道,但是讀寫的數據都必須經過Buffer。

在這里插入圖片描述

所謂通道的讀取,就是將數據從通道讀取到緩沖區中;所謂通道的寫入,就是將數據從緩沖區中寫入到通道中。緩沖區的使用,是面向流進行讀寫操作的OIO所沒有的,也是NIO非阻塞的重要前提和基礎之一。

接下來筆者從緩沖區開始,為大家詳細介紹NIO的Buffer(緩沖區)、Channel(通道)、Selector(選擇器)三大核心組件。

詳解NIO Buffer類及其屬性

NIO的Buffer(緩沖區)本質上是一個內存塊,既可以寫入數據,也可以從中讀取數據。Java NIO中代表緩沖區的Buffer類是一個抽象類,位於java.nio包中。

NIO的Buffer的內部是一個內存塊(數組),此類與普通的內存塊(Java數組)不同的是:NIO Buffer對象,提供了一組比較有效的方法,用來進行寫入和讀取的交替訪問。

說 明

Buffer類是一個非線程安全類。

Buffer類

Buffer類是一個抽象類,對應於Java的主要數據類型,在NIO中有8種緩沖區類,分別如下:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedByteBuffer。

前7種Buffer類型,覆蓋了能在IO中傳輸的所有的Java基本數據類型。第8種類型MappedByteBuffer是專門用於內存映射的一種ByteBuffer類型。不同的Buffer子類,其能操作的數據類型能夠通過名稱進行判斷,比如IntBuffer只能操作Integer類型的對象。

實際上,使用最多的還是ByteBuffer二進制字節緩沖區類型,后面會看到。

Buffer類的重要屬性

Buffer的子類會擁有一塊內存,作為數據的讀寫緩沖區,但是讀寫緩沖區並沒有定義在Buffer基類,而是定義在具體的子類中。如ByteBuf子類就擁有一個byte[]類型的數組成員final byte[] hb,作為自己的讀寫緩沖區,數組的元素類型與Buffer子類的操作類型相互對應。

說 明

在本書的上一個版本中,這里的內容為:Buffer內部有一個byte[]類型的數組作為數據的讀寫緩沖區。咋看上去沒有什么錯誤,實際上是這個結論是錯誤的。具體原因:作為讀寫緩沖區的數組,並沒有定義在Buffer類中,而是定義在各具體子類中。
感謝社群小伙伴 @炬,是他發現了這個藏得比較隱蔽的編寫錯誤。

為了記錄讀寫的狀態和位置,Buffer類額外提供了一些重要的屬性,其中有以下三個重要的成員屬性:

  • capacity(容量)

  • position(讀寫位置)

  • limit(讀寫的限制)

接下來對以上三個成員屬性,進行比較詳細的介紹。

  1. capacity屬性

Buffer類的capacity屬性,表示內部容量的大小。一旦寫入的對象數量超過了capacity容量,緩沖區就滿了,不能再寫入了。

Buffer類的capacity屬性一旦初始化,就不能再改變。原因是什么呢?Buffer類的對象在初始化時,會按照capacity分配內部數組的內存,在數組內存分配好之后,它的大小當然就不能改變了。

前面講到,Buffer類是一個抽象類,Java不能直接用來新建對象。在具體使用的時候,必須使用Buffer的某個子類,例如DoubleBuffer子類,該子類能寫入的數據類型是double類型,如果在創建實例時其capacity是100,那么我們最多可以寫入100個double類型的數據。

說 明

capacity容量並不是指內部的內存塊byte[]數組的字節數量,而是指能寫入的數據對象的最大限制數量。

  1. position屬性

Buffer類的position屬性,表示當前的位置。position屬性的值與緩沖區的讀寫模式有關。在不同的模式下,position屬性值的含義是不同的,在緩沖區進行讀寫的模式改變時,position值會進行相應的調整。

在寫入模式下,position的值變化規則如下:

(1)在剛進入到寫入模式時,position值為0,表示當前的寫入位置為從頭開始。

(2)每當一個數據寫到緩沖區之后,position會向后移動到下一個可寫的位置。

(3)初始的position值為0,最大可寫值為limit–1。當position值達到limit時,緩沖區就已經無空間可寫了。

在讀模式下,position的值變化規則如下:

(1)當緩沖區剛開始進入到讀取模式時,position會被重置為0。

(2)當從緩沖區讀取時,也是從position位置開始讀。讀取數據后,position向前移動到下一個可讀的位置。

(3)在讀模式下,limit表示可以讀上限。position的最大值,為最大可讀上限limit,當position達到limit時,表明緩沖區已經無數據可讀。

Buffer的讀寫模式具體如何切換呢?當新建了一個緩沖區實例時,緩沖區處於寫入模式,這時是可以寫數據的。在數據寫入完成后,如果要從緩沖區讀取數據,這就要進行模式的切換,可以使用(即調用)flip翻轉方法,將緩沖區變成讀取模式。

在從寫入模式到讀取模式的flip翻轉過程中,position和limit屬性值會進行調整,具體的規則是:

(1)limit屬性被設置成寫入模式時的position值,表示可以讀取的最大數據位置;

(2)position由原來的寫入位置,變成新的可讀位置,也就是0,表示可以從頭開始讀。

  1. limit屬性

Buffer類的limit屬性,表示可以寫入或者讀取的最大上限,其屬性值的具體含義,也與緩沖區的讀寫模式有關,在不同的模式下,limit的值的含義是不同的,具體分為以下兩種情況:

(1)在寫入模式下,limit屬性值的含義為可以寫入的數據最大上限。在剛進入到寫入模式時,limit的值會被設置成緩沖區的capacity容量值,表示可以一直將緩沖區的容量寫滿。

(2)在讀取模式下,limit的值含義為最多能從緩沖區中讀取到多少數據。

一般來說,在進行緩沖區操作時,是先寫入然后再讀取的。當緩沖區寫入完成后,就可以開始從Buffer讀取數據,可以使用flip翻轉方法,這時,limit的值也會進行調整。具體如何調整呢?將寫入模式下的position值,設置成讀取模式下的limit值,也就是說,將之前寫入的最大數量,作為可以讀取的上限值。

Buffer在flip翻轉時的屬性值調整,主要涉及position、limit兩個屬性,但是這種調整比較微妙,不是太好理解,下面是一個簡單例子:

首先,創建緩沖區。新創建的緩沖區處於寫入模式,其position值為0,limit值為最大容量capacity。

然后,向緩沖區寫數據。每寫入一個數據,position向后面移動一個位置,也就是position的值加1。這里假定寫入了5個數,當寫入完成后,position的值為5。

最后,使用flip方法將緩沖區切換到讀模式。limit的值,先會被設置成寫入模式時的position值,所以新的limit值是5,表示可以讀取的最大上限是5。之后調整position值,新的position會被重置為0,表示可以從0開始讀。

緩沖區切換到讀模式后,就可以從緩沖區讀取數據了,一直到緩沖區的數據讀取完畢。

除了以上capacity(容量)、position(讀寫位置)、limit(讀寫的限制)三個重要屬性之外,Buffer還有一個比較重要的標記屬性:mark(標記)屬性。該屬性的大致作用為:在緩沖區操作過程當中,可以將當前的position的值臨時存入mark屬性中;需要的時候,可以再從mark中取出暫存的標記值,恢復到position屬性中,重新從position位置開始處理。

Buffer的4個屬性小結

除了capacity(容量)、position(讀寫位置)、limit(讀寫的限制)三個重要屬性,第4個屬性mark(標記)比較簡單,該屬性是一個暫存屬性,用於暫存position的值,方便后面的重復使用。

下面用一個表格總結一下 Buffer類的4個重要屬性,參見表3-1。

表3-1 Buffer四個重要屬性的取值說明

屬性 說明
capacity 容量,即可以容納的最大數據量;在緩沖區創建時設置並且不能改變
limit 上限,緩沖區中當前的數據量
position 位置,緩沖區中下一個要被讀或寫的元素的索引
mark 調用mark()方法來設置mark=position,再調用reset()可以讓position恢復到mark標記的位置,即position=mark

詳解NIO Buffer類的重要方法

本小節將詳細介紹Buffer類常用的幾個方法,包含Buffer實例創建、對Buffer實例的寫入、讀取、重復讀、標記和重置等。

allocate()創建緩沖區

在使用Buffer(緩沖區)實例之前,我們首先需要獲取Buffer子類的實例對象,並且分配內存空間。如果需要獲取一個Buffer實例對象,並不是使用子類的構造器來創建一個實例對象,而是調用子類的allocate()方法。

下面的程序片段,演示如何獲取一個整型的Buffer實例對象,代碼如下:

package com.crazymakercircle.bufferDemo;
import com.crazymakercircle.util.Logger;
import java.nio.IntBuffer;

public class UseBuffer
{
	//一個整型的Buffer靜態變量
	static IntBuffer intBuffer = null;
	public static void allocateTest()
	{
//創建了一個Intbuffer實例對象
		intBuffer = IntBuffer.allocate(20); 
		Logger.debug("------------after allocate------------------");
		Logger.debug("position=" + intBuffer.position());
		Logger.debug("limit=" + intBuffer.limit());
		Logger.debug("capacity=" + intBuffer.capacity());
	}
    //...省略其他代碼
}

例子中,IntBuffer是具體的Buffer子類,通過調用IntBuffer.allocate(20),創建了一個Intbuffer實例對象,並且分配了20*4個字節的內存空間。運行程序之后,通過程序的輸出結果,我們可以查看一個新建緩沖區實例對象的主要屬性值,如下所示:

allocatTest \|\> ------------after allocate------------------
allocatTest \|\> position=0
allocatTest \|\> limit=20
allocatTest \|\> capacity=20

從上面的運行結果,可以看出:一個緩沖區在新建后,處於寫入的模式,position屬性(代表寫入位置)的值為0,緩沖區的capacity容量值也是初始化時allocate方法的參數值(這里是20),而limit最大可寫上限值也為的allocate方法的初始化參數值。

put()寫入到緩沖區

在調用allocate方法分配內存、返回了實例對象后,緩沖區實例對象處於寫模式,可以寫入對象,而如果要寫入對象到緩沖區,需要調用put方法。put方法很簡單,只有一個參數,即為所需要寫入的對象。只不過,寫入的數據類型要求與緩沖區的類型保持一致。

接着前面的例子,向剛剛創建的intBuffer緩存實例對象中,寫入的5個整數,代碼如下:

package com.crazymakercircle.bufferDemo;
…省略import
public class UseBuffer
{
	//一個整型的Buffer靜態變量
	static IntBuffer intBuffer = null;
    //...省略了創建緩沖區的代碼,具體查看前面小節的內容和隨書源碼
	    public static void putTest()
    {
        for (int i = 0; i < 5; i++)
        {
		  	  //寫入一個整數到緩沖區
            intBuffer.put(i);
        }
		
        //輸出緩沖區的主要屬性值
        Logger.debug("------------after putTest------------------");
        Logger.debug("position=" + intBuffer.position());
        Logger.debug("limit=" + intBuffer.limit());
        Logger.debug("capacity=" + intBuffer.capacity());
    }
    //...省略其他代碼
}


寫入5個元素后,同樣輸出緩沖區的主要屬性值,輸出的結果如下:

putTest |>  ------------after putTest------------------ 
putTest |>  position=5 
putTest |>  limit=20 
putTest |>  capacity=20

從結果可以看到,寫入了5個元素之后,緩沖區的position屬性值變成了5,所以指向了第6個(從0開始的)可以進行寫入的元素位置。而limit最大可寫上限、capacity最大容量兩個屬性的值,都沒有發生變化。

flip()翻轉

向緩沖區寫入數據之后,是否可以直接從緩沖區中讀取數據呢?呵呵,不能。為什么呢?這時緩沖區還處於寫模式,如果需要讀取數據,還需要將緩沖區轉換成讀模式。flip()翻轉方法是Buffer類提供的一個模式轉變的重要方法,它的作用就是將寫入模式翻轉成讀取模式。

接着前面的例子,演示一下flip()方法的使用:


package com.crazymakercircle.bufferDemo;
…省略import
public class UseBuffer
{
	//一個整型的Buffer靜態變量
	static IntBuffer intBuffer = null;
  //...省略了緩沖區的創建、寫入數據的代碼,具體查看前面小節的內容和隨書源碼
	public static void flipTest()
	{
			//翻轉緩沖區,從寫入模式翻轉成讀取模式			
			intBuffer.flip();
			//輸出緩沖區的主要屬性值			
			Logger.info("------------after flip ------------------");
			Logger.info("position=" + intBuffer.position());
			Logger.info("limit=" + intBuffer.limit());
				Logger.info("capacity=" + intBuffer.capacity());
		}
    //...省略其他代碼
}

在調用flip方法進行緩沖區的模式翻轉之后,通過程序的輸出內容可以看到,緩沖區的屬性有了奇妙的變化,具體如下:

flipTest |>  ------------after flipTest ------------------ 
flipTest |>  position=0 
flipTest |>  limit=5 
flipTest |>  capacity=20 

調用flip方法后,新模式下可讀上限limit的值,變成了之前寫入模式下的position屬性值,也就是5;而新的讀取模式下的position值,簡單粗暴地變成了0,表示從頭開始讀取。

對flip()方法的從寫入到讀取轉換的規則,再一次詳細的介紹如下:

(1)首先,設置可讀上限limit的屬性值。將寫入模式下的緩沖區中內容的最后寫入位置position值,作為讀取模式下的limit上限值。

(2)其次,把讀的起始位置position的值設為0,表示從頭開始讀。

(3)最后,清除之前的mark標記,因為mark保存的是寫入模式下的臨時位置,發生模式翻轉后,如果繼續使用舊的mark標記,會造成位置混亂。

有關上面的三步,其實可以查看Buffer.flip()方法的源代碼,具體代碼如下:

public final Buffer flip() {
    limit = position;  //設置可讀的長度上限limit,設置為寫入模式下的position值
    position = 0;       //把讀的起始位置position的值設為0,表示從頭開始讀
 mark = UNSET_MARK;  // 清除之前的mark標記
    return this;
}

當然,新的問題來了:在讀取完成后,如何再一次將緩沖區切換成寫入模式呢?答案是:可以調用Buffer.clear()
清空或者Buffer.compact()壓縮方法,它們可以將緩沖區轉換為寫模式。總體的Buffer模式轉換,大致如圖3-1所示。

圖3-1 緩沖區讀寫模式的轉換

get()從緩沖區讀取

使用調用flip方法將緩沖區切換成讀取模式之后,就可以開始從緩沖區中進行數據讀取了。讀取數據的方法很簡單,可以調用get方法每次從position的位置讀取一個數據,並且進行相應的緩沖區屬性的調整。

接着前面flip的使用實例,演示一下緩沖區的讀取操作,代碼如下:

package com.crazymakercircle.bufferDemo;
…省略import
public class UseBuffer
{
  //一個整型的Buffer靜態變量
  static IntBuffer intBuffer = null;
  //...省略了緩沖區的創建、寫入、翻轉的代碼,具體查看前面小節的內容和隨書源碼

  public static void getTest()
  {
  //先讀2個數據
    for (int i = 0; i\< 2; i++)
    {
      int j = intBuffer.get();
      Logger.info("j = " + j);
    }
//輸出緩沖區的主要屬性值
Logger.info("---------after get 2 int --------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());

//再讀3個數據

for (int i = 0; i\< 3; i++)
{
int j = intBuffer.get();
Logger.info("j = " + j);
}
//輸出緩沖區的主要屬性值
Logger.info("---------after get 3 int ---------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());
}
//...
}
//...省略其他代碼
}

以上代碼調用get方法從緩沖實例中先讀取2個,再讀取3個元素,運行后,輸出的結果如下:

getTest \|\> ------------after get 2 int ------------------
getTest \|\> position=2
getTest \|\> limit=5
getTest \|\> capacity=20
getTest \|\> ------------after get 3 int ------------------
getTest \|\> position=5
getTest \|\> limit=5
getTest \|\> capacity=20

從程序的輸出結果,我們可以看到,讀取操作會改變可讀位置position的屬性值,而limit可讀上限值並不會改變。在position值和limit的值相等時,表示所有數據讀取完成,position指向了一個沒有數據的元素位置,已經不能再讀了。此時再讀,會拋出BufferUnderflowException異常。

那么,在讀完之后是否可以立即對緩沖區進行數據寫入呢?答案是不能。現在還處於讀取模式,我們必須調用Buffer.clear()或Buffer.compact()方法,即清空或者壓縮緩沖區,將緩沖區切換成寫入模式,讓其重新可寫。

此外還有一個問題:緩沖區是不是可以重復讀呢?答案是可以的,既可以通過倒帶方法rewind()去完成,也可以通過mark(
)和reset( )兩個方法組合實現。

rewind()倒帶

已經讀完的數據,如果需要再讀一遍,可以調用rewind()方法。rewind()也叫倒帶,就像播放磁帶一樣倒回去,再重新播放。

接着前面的示例代碼,繼續rewind方法使用的演示,示例代碼如下:

package com.crazymakercircle.bufferDemo;
…省略import
public class UseBuffer
{
//一個整型的Buffer靜態變量
static IntBuffer intBuffer = null;

//...省略了緩沖區的寫入和讀取等代碼,具體查看前面小節的內容和隨書源碼
public static void rewindTest() {

//倒帶
intBuffer.rewind();

//輸出緩沖區屬性
Logger.info("------------after rewind ------------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());

}

//...省略其他代碼

}

這個范例程序的執行結果如下:

rewindTest \|\> ------------after rewind ------------------
rewindTest \|\> position=0
rewindTest \|\> limit=5
rewindTest \|\> capacity=20
rewind

()方法,主要是調整了緩沖區的position屬性與mark標記屬性,具體的調整規則如下:

(1)position重置為0,所以可以重讀緩沖區中的所有數據;

(2)limit保持不變,數據量還是一樣的,仍然表示能從緩沖區中讀取的元素數量;

(3)mark標記被清理,表示之前的臨時位置不能再用了。

從JDK中可以查閱到Buffer.rewind()方法的源代碼,具體如下:

public final Buffer rewind() {
  position = 0;//重置為0,所以可以重讀緩沖區中的所有數據
  mark = -1; // mark標記被清理,表示之前的臨時位置不能再用了
  return this;
}

通過源代碼,我們可以看到rewind()方法與flip()很相似,區別在於:倒帶方法rewind()不會影響limit屬性值;而翻轉方法flip()會重設limit屬性值。

在rewind倒帶之后,就可以再一次讀取,重復讀取的示例代碼如下:

package com.crazymakercircle.bufferDemo;
…省略import
public class UseBuffer
{
//一個整型的Buffer靜態變量
static IntBuffer intBuffer = null;

//...省略了緩沖區的寫入和讀取、倒帶等代碼,具體查看前面小節的內容和隨書源碼
public static void reRead() {
for (int i = 0; i\< 5; i++) {
if (i == 2) {
//臨時保存,標記一下第3個位置
intBuffer.mark();
}

//讀取元素
int j = intBuffer.get();
Logger.info("j = " + j);
}

//輸出緩沖區的屬性值
Logger.info("------------after reRead------------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());

}

//...省略其他代碼

}

這段代碼,和前面的讀取示例代碼基本相同,只是增加了一個mark調用。大家可以通過隨書源碼工程執行以上代碼並觀察輸出結果,具體的輸出與前面的類似,這里不做贅述。

mark( )和reset( )

mark( )和reset(
)兩個方法是成套使用的:Buffer.mark()方法將當前position的值保存起來,放在mark屬性中,讓mark屬性記住這個臨時位置;之后,可以調用Buffer.reset()方法將mark的值恢復到position中。

說 明

Buffer.mark()和Buffer.reset()兩個方法都涉及到mark屬性的使用。mark()方法與mark屬性,二者的名字雖然相同,但是一個是Buffer類的成員方法,另一個是Buffer類的成員屬性,不能混淆。

例如,可以在前面重復讀取的示例代碼中,在讀到第3個元素(i為2時)時,可以調用mark()方法,把當前位置position的值保存到mark屬性中,這時mark屬性的值為2。

然后,就可以調用reset(
)方法,將mark屬性的值恢復到position中,這樣就可以從位置2(第三個元素)開始重復讀取。

繼續接着前面重復讀取的代碼,進行mark( )方法和reset( )方法的示例演示,代碼如下:

package com.crazymakercircle.bufferDemo;
…省略import
public class UseBuffer
{
//一個整型的Buffer靜態變量
static IntBuffer intBuffer = null;

//...省略了緩沖區的倒帶、重復讀取等代碼,具體查看前面小節的內容和隨書源碼
//演示前提:
//在前面的reRead()演示方法中,已經通過mark()方法,暫存了position值

public static void afterReset() {
Logger.info("------------after reset------------------");
//把前面保存在mark中的值恢復到position
intBuffer.reset();

//輸出緩沖區的屬性值
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());

//讀取並且輸出元素
for (int i =2; i\< 5; i++) {
int j = intBuffer.get();
Logger.info("j = " + j);
}

}

//...省略其他代碼

}

在上面的代碼中,首先調用reset()把mark中的值恢復到position中,因此讀取的位置position就是2,表示可以再次開始從第3個元素開始讀取數據。上面的程序代碼的輸出結果是:

afterReset \|\> ------------after reset------------------
afterReset \|\> position=2
afterReset \|\> limit=5
afterReset \|\> capacity=20
afterReset \|\> j = 2
afterReset \|\> j = 3
afterReset \|\> j = 4

調用reset方法之后,position的值為2,此時去讀取緩沖區,輸出了后面的三個元素為2、3、4。

clear( )清空緩沖區

在讀取模式下,調用clear()方法將緩沖區切換為寫入模式。此方法的作用:

(1)會將position清零;

(2)limit設置為capacity最大容量值,可以一直寫入,直到緩沖區寫滿。

接着上面的實例,演示一下clear( )方法的使用,大致的代碼如下:

package com.crazymakercircle.bufferDemo;
…省略import
public class UseBuffer
{
//一個整型的Buffer靜態變量
static IntBuffer intBuffer = null;

//...省略了緩沖區的創建、寫入、讀取等代碼,具體查看前面小節的內容和隨書源碼

public static void clearDemo() {
Logger.info("------------after clear------------------");

//清空緩沖區,進入寫入模式
intBuffer.clear();

//輸出緩沖區的屬性值

Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());

}

//...省略其他代碼

}

這個程序運行之后,結果如下:

main \|\>清空
clearDemo \|\> ------------after clear------------------
clearDemo \|\> position=0
clearDemo \|\> limit=20
clearDemo \|\> capacity=20

在緩沖區處於讀取模式時,調用clear(),緩沖區會被切換成寫入模式。調用clear()之后,我們可以看到清空了position(寫入的起始位置)的值,其值被設置為0,並且limit值(寫入的上限)為最大容量。

使用Buffer類的基本步驟

總體來說,使用Java NIO Buffer類的基本步驟如下:

(1)使用創建子類實例對象的allocate( )方法,創建一個Buffer類的實例對象。

(2)調用put( )方法,將數據寫入到緩沖區中。

(3)寫入完成后,在開始讀取數據前,調用Buffer.flip(
)方法,將緩沖區轉換為讀模式。

(4)調用get( )方法,可以從緩沖區中讀取數據。

(5)讀取完成后,調用Buffer.clear()方法或Buffer.compact()方法,將緩沖區轉換為寫入模式,可以繼續寫入。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

詳解NIO Channel(通道)類

前面提到,Java
NIO中,一個socket連接使用一個Channel(通道)來表示。然而,從更廣泛的層面來說,一個通道可以表示一個底層的文件描述符,例如硬件設備、文件、網絡連接等。然而,遠遠不止如此,除了可以對應到底層文件描述符。所以,文件描述符相對應,Java
NIO的通道可以更加細化。例如,對應不同的網絡傳輸協議類型,在Java中都有不同的NIO
Channel(通道)實現。

Channel(通道)的主要類型

這里不對Java
NIO全部通道類型進行過多的描述,僅僅聚焦於介紹其中最為重要的四種Channel(通道)實現:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。

對於以上四種通道,說明如下:

(1)FileChannel文件通道,用於文件的數據讀寫;

(2)SocketChannel套接字通道,用於Socket套接字TCP連接的數據讀寫;

(3)ServerSocketChannel服務器套接字通道(或服務器監聽通道),允許我們監聽TCP連接請求,為每個監聽到的請求,創建一個SocketChannel套接字通道;

(4)DatagramChannel數據報通道,用於UDP協議的數據讀寫。

這個四種通道,涵蓋了文件IO、TCP網絡、UDP
IO三類基礎IO讀寫操作。下面從通道的獲取、讀取、寫入、關閉四個重要的操作入手,對四種通道進行簡單的介紹。

FileChannel文件通道

FileChannel是專門操作文件的通道。通過FileChannel,既可以從一個文件中讀取數據,也可以將數據寫入到文件中。特別申明一下,FileChannel為阻塞模式,不能設置為非阻塞模式。

下面分別介紹:FileChannel的獲取、讀取、寫入、關閉四個操作。

  1. 獲取FileChannel通道

可以通過文件的輸入流、輸出流獲取FileChannel文件通道,示例如下:

//創建一個文件輸入流
FileInputStream fis = new FileInputStream(srcFile);

//獲取文件流的通道
FileChannel inChannel = fis.getChannel();

//創建一個文件輸出流
FileOutputStream fos = new FileOutputStream(destFile);

//獲取文件流的通道
FileChannel outchannel = fos.getChannel();

也可以通過RandomAccessFile文件隨機訪問類,獲取FileChannel文件通道實例,代碼如下:

// 創建RandomAccessFile隨機訪問對象
RandomAccessFile rFile = new RandomAccessFile("filename.txt","rw");

//獲取文件流的通道(可讀可寫)
FileChannel channel = rFile.getChannel();

2. 讀取FileChannel通道

在大部分應用場景,從通道讀取數據都會調用通道的int
read(ByteBufferbuf)方法,它從通道讀取到數據寫入到ByteBuffer緩沖區,並且返回讀取到的數據量。
RandomAccessFile aFile = new RandomAccessFile(fileName, "rw");

//獲取通道(可讀可寫)
FileChannel channel=aFile.getChannel();

//獲取一個字節緩沖區
ByteBuffer buf = ByteBuffer.allocate(CAPACITY);
int length = -1;

//調用通道的read方法,讀取數據並買入字節類型的緩沖區
while ((length = channel.read(buf)) != -1) {
//……省略buf中的數據處理

}

說明:以上代碼channel.read(buf)雖然是讀取通道的數據,對於通道來說是讀取模式,但是對於ByteBuffer緩沖區來說則是寫入數據,這時,ByteBuffer緩沖區處於寫入模式。

說 明

以上代碼中channel.read(buf)讀取通道的數據時,雖然對於通道來說是讀取模式,但是對於ByteBuffer緩沖區來說則是寫入數據,這時,ByteBuffer緩沖區處於寫入模式。

  1. 寫入FileChannel通道

寫入數據到通道,在大部分應用場景,都會調用通道的write(ByteBuffer)方法,此方法的參數是一個ByteBuffer緩沖區實例,是待寫數據的來源。

write(ByteBuffer)方法的作用,是從ByteBuffer緩沖區中讀取數據,然后寫入到通道自身,而返回值是寫入成功的字節數。

//如果buf處於寫入模式(如剛寫完數據),需要flip翻轉buf,使其變成讀取模式
buf.flip();
int outlength = 0;

//調用write方法,將buf的數據寫入通道
while ((outlength = outchannel.write(buf)) != 0) {
  System.out.println("寫入的字節數:" + outlength);
}

在以上的outchannel.write(buf)調用中,對於入參buf實例來說,需要從其中讀取數據寫入到outchannel通道中,所以入參buf必須處於讀取模式,不能處於寫入模式。

4.關閉通道

當通道使用完成后,必須將其關閉。關閉非常簡單,調用close( )方法即可。

//關閉通道
channel.close( );

5.強制刷新到磁盤

在將緩沖區寫入通道時,出於性能原因,操作系統不可能每次都實時將寫入數據落地(或刷新)到磁盤,完成最終的數據保存。

如果在將緩沖數據寫入通道時,需要保證數據能落地寫入到磁盤,可以在寫入后調用一下FileChannel的force()方法。

//強制刷新到磁盤
channel.force(true);

使用FileChannel完成文件復制的實踐案例

下面是一個簡單的實戰案例:使用文件通道復制文件。其具體的功能是:使用FileChannel文件通道,將原文件復制一份,把原文中的數據都復制到目標文件中。完整代碼如下:

package com.crazymakercircle.iodemo.fileDemos;

//...省略import的類,具體請參見源代碼工程

public class FileNIOCopyDemo {

public static void main(String[] args) {

//演示復制資源文件

nioCopyResouceFile();

}

/**
* 復制兩個資源目錄下的文件
*/

public static void nioCopyResouceFile() {

//源

String sourcePath = NioDemoConfig.FILE\_RESOURCE\_SRC\_PATH;

String srcPath = IOUtil.getResourcePath(sourcePath);

Logger.info("srcPath=" + srcPath);

//目標

String destPath = NioDemoConfig.FILE\_RESOURCE\_DEST\_PATH;

String destDecodePath = IOUtil.builderResourcePath(destPath);

Logger.info("destDecodePath=" + destDecodePath);

//復制文件

nioCopyFile(srcDecodePath, destDecodePath);

}

/**
* nio方式復制文件
* \@param srcPath 源路徑
* \@param destPath 目標路徑
*/

>   public static void nioCopyFile(String srcPath, String destPath){

File srcFile = new File(srcPath);

File destFile = new File(destPath);

try {

//如果目標文件不存在,則新建

if (!destFile.exists()) {

destFile.createNewFile();

}

long startTime = System.currentTimeMillis();

FileInputStream fis = null;

FileOutputStream fos = null;

FileChannel inChannel = null; //輸入通道

FileChannel outchannel = null; //輸出通道

try {

fis = new FileInputStream(srcFile);

fos = new FileOutputStream(destFile);

inChannel = fis.getChannel();

outchannel = fos.getChannel();

int length = -1;

//新建buf,處於寫入模式

ByteBufferbuf = ByteBuffer.allocate(1024);

//從輸入通道讀取到buf

while ((length = inChannel.read(buf)) != -1) {

//buf第一次模式切換:翻轉buf,從寫入模式變成讀取模式

buf.flip();

int outlength = 0;

//將buf寫入到輸出的通道

while ((outlength = outchannel.write(buf)) != 0) {

System.out.println("寫入的字節數:" + outlength);

}

//buf第二次模式切換:清除buf,變成寫入模式

buf.clear();

}

//強制刷新到磁盤

outchannel.force(true);

} finally {

//關閉所有的可關閉對象

IOUtil.closeQuietly(outchannel);

IOUtil.closeQuietly(fos);

IOUtil.closeQuietly(inChannel);

IOUtil.closeQuietly(fis);

}

long endTime = System.currentTimeMillis();

Logger.info("base復制毫秒數:" + (endTime - startTime));

} catch (IOException e) {

e.printStackTrace();

}

}

除了FileChannel的通道操作外,還需要注意代碼執行過程中隱藏的ByteBuffer的模式切換。由於新建的ByteBuffer是寫入模式,才可作為inChannel.read(ByteBuffer)方法的參數,inChannel.read(…)方法將從通道inChannel讀到的數據寫入到ByteBuffer。然后,需要調用緩沖區的flip方法,將ByteBuffer從寫入模式切換成讀取模式,才能作為outchannel.write(ByteBuffer)方法的參數,以便從ByteBuffer讀取數據,最終寫入到outchannel輸出通道。

完成一次復制之后,在進入下一次復制前,還要進行一次緩沖區的模式切換。此時,需要將通過clear方法將Buffer切換成寫入模式,才能進入下一次的復制。所以,在示例代碼中,每一輪外層的while循環,都需要兩次ByteBuffer模式切換:第一次模式切換時,翻轉buf,變成讀取模式;第二次模式切換時,清除buf,變成寫入模式。

上面的示例代碼,主要的目的在於:演示文件通道以及字節緩沖區的使用。然而,作為文件復制的程序來說,以上實戰代碼的效率不是最高的。更高效的文件復制,可以調用文件通道的transferFrom方法。具體的代碼,可以參見源代碼工程中的FileNIOFastCopyDemo類,完整源文件的路徑為:

com.crazymakercircle.iodemo.fileDemos.FileNIOFastCopyDemo

請大家在隨書源碼工程中自行運行和學習以上代碼,這里不做贅述。

SocketChannel套接字通道

在NIO中,涉及網絡連接的通道有兩個:一個是SocketChannel負責連接的數據傳輸,另一個是ServerSocketChannel負責連接的監聽。其中,NIO中的SocketChannel傳輸通道,與OIO中的Socket類對應;NIO中的ServerSocketChannel監聽通道,對應於OIO中的ServerSocket類。

ServerSocketChannel僅僅應用於服務器端,而SocketChannel則同時處於服務器端和客戶端,所以,對應於一個連接,兩端都有一個負責傳輸的SocketChannel傳輸通道。

無論是ServerSocketChannel,還是SocketChannel,都支持阻塞和非阻塞兩種模式。如何進行模式的設置呢?調用configureBlocking方法,具體如下:

(1)socketChannel.configureBlocking(false)設置為非阻塞模式。

(2)socketChannel.configureBlocking(true)設置為阻塞模式。

在阻塞模式下,SocketChannel通道的connect連接、read讀、write寫操作,都是同步的和阻塞式的,在效率上與Java舊的OIO的面向流的阻塞式讀寫操作相同。因此,在這里不介紹阻塞模式下的通道的具體操作。在非阻塞模式下,通道的操作是異步、高效率的,這也是相對於傳統的OIO的優勢所在。下面僅僅詳細介紹在非阻塞模式下通道的打開、讀寫和關閉操作等操作。

  1. 獲取SocketChannel傳輸通道

在客戶端,先通過SocketChannel靜態方法open()獲得一個套接字傳輸通道;然后,將socket套接字設置為非阻塞模式;最后,通過connect()實例方法,對服務器的IP和端口發起連接。

//獲得一個套接字傳輸通道
SocketChannel socketChannel = SocketChannel.open();

//設置為非阻塞模式
socketChannel.configureBlocking(false);

//對服務器的IP和端口發起連接
socketChannel.connect(new InetSocketAddress("127.0.0.1",80));

非阻塞情況下,與服務器的連接可能還沒有真正建立,socketChannel.connect方法就返回了,因此需要不斷地自旋,檢查當前是否是連接到了主機:

while(! socketChannel.finishConnect() ){
//不斷地自旋、等待,或者做一些其他的事情……

}

在服務器端,如何獲取與客戶端對應的傳輸套接字呢?

在連接建立的事件到來時,服務器端的ServerSocketChannel能成功地查詢出這個新連接事件,並且通過調用服務器端ServerSocketChannel監聽套接字的accept()方法,來獲取新連接的套接字通道:

//新連接事件到來,首先通過事件,獲取服務器監聽通道
ServerSocketChannel server = (ServerSocketChannel) key.channel();

//獲取新連接的套接字通道
SocketChannel socketChannel = server.**accept**();

//設置為非阻塞模式
socketChannel.configureBlocking(false);

說 明

NIO套接字通道,主要用於非阻塞的傳輸場景。所以,基本上都需要調用通道的configureBlocking(false)方法,將通道從阻塞模式切換為非阻塞模式。

  1. 讀取SocketChannel傳輸通道

當SocketChannel傳輸通道可讀時,可以從SocketChannel讀取數據,具體方法與前面的文件通道讀取方法是相同的。調用read方法,將數據讀入緩沖區ByteBuffer。

ByteBufferbuf = ByteBuffer.allocate(1024);
int bytesRead = socketChannel.read(buf);

在讀取時,因為是異步的,因此我們必須檢查read的返回值,以便判斷當前是否讀取到了數據。read()方法的返回值是讀取的字節數,如果返回-1,那么表示讀取到對方的輸出結束標志,對方已經輸出結束,准備關閉連接。實際上,通過read方法讀數據,本身是很簡單的,比較困難的是,在非阻塞模式下,如何知道通道何時是可讀的呢?這就需要用到NIO的新組件——Selector通道選擇器,稍后介紹。

  1. 寫入到SocketChannel傳輸通道

和前面的把數據寫入到FileChannel文件通道一樣,大部分應用場景都會調用通道的int
write(ByteBufferbuf)方法。

//寫入前需要讀取緩沖區,要求ByteBuffer是讀取模式
buffer.flip();

socketChannel.write(buffer);
  1. 關閉SocketChannel傳輸通道

在關閉SocketChannel傳輸通道前,如果傳輸通道用來寫入數據,則建議調用一次shutdownOutput()終止輸出方法,向對方發送一個輸出的結束標志(-1)。然后調用socketChannel.close()方法,關閉套接字連接。

//調用終止輸出方法,向對方發送一個輸出的結束標志
socketChannel.shutdownOutput();

//關閉套接字連接
IOUtil.closeQuietly(socketChannel);

使用SocketChannel發送文件的實踐案例

下面的實踐案例是使用FileChannel文件通道讀取本地文件內容,然后在客戶端使用SocketChannel套接字通道,把文件信息和文件內容發送到服務器。客戶端的完整代碼如下:

package com.crazymakercircle.iodemo.socketDemos;

//...

public class NioSendClient {

private Charset charset = Charset.forName("UTF-8");

/\*\*

\* 向服務器端傳輸文件

\*/

public void sendFile()

{

try

{

String sourcePath = NioDemoConfig.SOCKET\_SEND\_FILE;

String srcPath = IOUtil.getResourcePath(sourcePath);

Logger.debug("srcPath=" + srcPath);

String destFile = NioDemoConfig.SOCKET\_RECEIVE\_FILE;

Logger.debug("destFile=" + destFile);

File file = new File(srcPath);

if (!file.exists())

{

Logger.debug("文件不存在");

return;

}

FileChannel fileChannel =

new FileInputStream(file).getChannel();

SocketChannel socketChannel =

SocketChannel.open();

socketChannel.socket().connect(

new InetSocketAddress("127.0.0.1",18899));

socketChannel.configureBlocking(false);

Logger.debug("Client 成功連接服務端");

while (!socketChannel.finishConnect())

{

//不斷的自旋、等待,或者做一些其他的事情

}

//發送文件名稱和長度

ByteBuffer buffer =

sengFileNameAndLength(destFile, file, socketChannel);

//發送文件內容

int length =

sendContent(file, fileChannel, socketChannel, buffer);

if (length == -1)

{

IOUtil.closeQuietly(fileChannel);

socketChannel.shutdownOutput();

IOUtil.closeQuietly(socketChannel);

}

Logger.debug("======== 文件傳輸成功 ========");

} catch (Exception e)

{

e.printStackTrace();

}

}

//方法:發送文件內容

public int sendContent(File file, FileChannel fileChannel,

SocketChannel socketChannel,

ByteBuffer buffer) throws IOException

{

//發送文件內容

Logger.debug("開始傳輸文件");

int length = 0;

long progress = 0;

while ((length = fileChannel.read(buffer)) \> 0)

{

buffer.flip();

socketChannel.write(buffer);

buffer.clear();

progress += length;

Logger.debug("\| " + (100 \* progress / file.length()) + "% \|");

}

return length;

}

//方法:發送文件名稱和長度

public ByteBuffer sengFileNameAndLength(String destFile,

File file,

SocketChannel socketChannel) throws IOException

{

//發送文件名稱

ByteBuffer fileNameByteBuffer = charset.encode(destFile);

ByteBuffer buffer =

ByteBuffer.allocate(NioDemoConfig.SEND\_BUFFER\_SIZE);

//發送文件名稱長度

int fileNameLen = fileNameByteBuffer.capacity();

buffer.putInt(fileNameLen);

buffer.flip();

socketChannel.write(buffer);

buffer.clear();

Logger.info("Client 文件名稱長度發送完成:", fileNameLen);

//發送文件名稱

socketChannel.write(fileNameByteBuffer);

Logger.info("Client 文件名稱發送完成:", destFile);

//發送文件長度

buffer.putLong(file.length());

buffer.flip();

socketChannel.write(buffer);

buffer.clear();

Logger.info("Client 文件長度發送完成:", file.length());

return buffer;

}

}

以上代碼中的文件發送過程:首先發送文件名稱(不帶路徑)和文件長度,然后是發送文件內容。代碼中的配置項,如服務器的IP、服務器端口、待發送的源文件名稱(帶路徑)、遠程的目標文件名稱等配置信息,都是從system.properties配置文件中讀取的,通過自定義的NioDemoConfig配置類來完成配置。

在運行以上客戶端的程序之前,需要先運行服務器端的程序。服務器端的類與客戶端的源代碼在同一個包下,類名為NioReceiveServer,具體參見源代碼工程,我們稍后再詳細介紹這個類。

DatagramChannel數據報通道

在Java中使用UDP協議傳輸數據,比TCP協議更加簡單。和Socket套接字的TCP傳輸協議不同,UDP協議不是面向連接的協議。使用UDP協議時,只要知道服務器的IP和端口,就可以直接向對方發送數據。在Java
NIO中,使用DatagramChannel數據報通道來處理UDP協議的數據傳輸。

  1. 獲取DatagramChannel數據報通道

獲取數據報通道的方式很簡單,調用DatagramChannel類的open靜態方法即可。然后調用configureBlocking(false)方法,設置成非阻塞模式。

//獲取DatagramChannel數據報通道

DatagramChannel channel = DatagramChannel.open();

//設置為非阻塞模式

datagramChannel.configureBlocking(false);

如果需要接收數據,還需要調用bind方法綁定一個數據報的監聽端口,具體如下:

//調用bind方法綁定一個數據報的監聽端口
channel.socket().bind(new InetSocketAddress(18080));
  1. 讀取DatagramChannel數據報通道數據

當DatagramChannel通道可讀時,可以從DatagramChannel讀取數據。和前面的SocketChannel讀取方式不同,這里不調用read方法,而是調用receive(ByteBufferbuf)方法將數據從DatagramChannel讀入,再寫入到ByteBuffer緩沖區中。

//創建緩沖區

ByteBuffer buf = ByteBuffer.allocate(1024);

//從DatagramChannel讀入,再寫入到ByteBuffer緩沖區

SocketAddress clientAddr= datagramChannel.receive(buf);

通道讀取receive(ByteBufferbuf)方法雖然讀取了數據到buf緩沖區,但是其返回值是SocketAddress類型,表示返回發送端的連接地址(包括IP和端口)。通過receive方法讀取數據非常簡單,但是,在非阻塞模式下,如何知道DatagramChannel通道何時是可讀的呢?和SocketChannel一樣,同樣需要用到NIO的新組件—Selector通道選擇器,稍后介紹。

  1. 寫入DatagramChannel數據報通道

向DatagramChannel發送數據,和向SocketChannel通道發送數據的方法也是不同的。這里不是調用write方法,而是調用send方法。示例代碼如下:

//把緩沖區翻轉到讀取模式

buffer.flip();

//調用send方法,把數據發送到目標IP+端口

dChannel.send(buffer, new InetSocketAddress("127.0.0.1",18899));

//清空緩沖區,切換到寫入模式

buffer.clear();

由於UDP是面向非連接的協議,因此,在調用send方法發送數據的時候,需要指定接收方的地址(IP和端口)。

4. 關閉DatagramChannel數據報通道

這個比較簡單,直接調用close()方法,即可關閉數據報通道。

//簡單關閉即可

dChannel.close();

使用DatagramChannel數據包通道發送數據的實踐案例

下面是一個使用DatagramChannel數據包通到發送數據的客戶端示例程序代碼。其功能是:獲取用戶的輸入數據,通過DatagramChannel數據報通道,將數據發送到遠程的服務器。客戶端的完整程序代碼如下:

package com.crazymakercircle.iodemo.udpDemos;

//...

public class UDPClient {

public void send() throws IOException {

//獲取DatagramChannel數據報通道

DatagramChannel dChannel = DatagramChannel.open();

//設置為非阻塞

dChannel.configureBlocking(false);

ByteBuffer buffer =

ByteBuffer.allocate(NioDemoConfig.SEND\_BUFFER\_SIZE);

Scanner scanner = new Scanner(System.in);

Print.tcfo("UDP客戶端啟動成功!");

Print.tcfo("請輸入發送內容:");

while (scanner.hasNext()) {

String next = scanner.next();

buffer.put((Dateutil.getNow() + " \>\>" + next).getBytes());

buffer.flip();

//通過DatagramChannel數據報通道發送數據

dChannel.send(buffer,

new InetSocketAddress("127.0.0.1",18899));

buffer.clear();

}

//操作四:關閉DatagramChannel數據報通道

dChannel.close();

}

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

new UDPClient().send();

}

}

通過示例程序代碼可以看出,在客戶端使DatagramChannel數據報通道發送數據,比起在客戶端使用套接字SocketChannel發送數據,簡單很多。

接下來看看在服務器端應該如何使用DatagramChannel數據包通道接收數據呢?

下面貼出服務器端通過DatagramChannel數據包通道接收數據的程序代碼,可能大家目前不一定可以看懂,因為代碼中用到了Selector選擇器,但是不要緊,下一個小節就介紹它。

服務器端的接收功能是:通過DatagramChannel數據報通道,綁定一個服務器地址(IP+端口),接收客戶端發送過來的UDP數據報。服務器端的完整代碼如下:

package com.crazymakercircle.iodemo.udpDemos;

//...

public class UDPServer {

public void receive() throws IOException {

//獲取DatagramChannel數據報通道

DatagramChannel datagramChannel = DatagramChannel.open();

//設置為非阻塞模式

datagramChannel.configureBlocking(false);

//綁定監聽地址

datagramChannel.bind(

>   new InetSocketAddress("127.0.0.1",18899));

Print.tcfo("UDP服務器啟動成功!");

//開啟一個通道選擇器

Selector selector = Selector.open();

//將通道注冊到選擇器

datagramChannel.register(selector, SelectionKey.OP\_READ);

//通過選擇器,查詢IO事件

while (selector.select() \> 0) {

Iterator\<SelectionKey\> iterator =

selector.selectedKeys().iterator();

ByteBuffer buffer =

ByteBuffer.allocate(NioDemoConfig.SEND\_BUFFER\_SIZE);

//迭代IO事件

while (iterator.hasNext()) {

SelectionKeyselectionKey = iterator.next();

//可讀事件,有數據到來

if (selectionKey.isReadable()) {

//讀取DatagramChannel數據報通道的數據

SocketAddress client =

>   datagramChannel.receive(buffer);

buffer.flip();

Print.tcfo(

>   new String(buffer.array(), 0, buffer.limit()));

buffer.clear();

}

}

iterator.remove();

}

//關閉選擇器和通道

selector.close();

datagramChannel.close();

}

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

new UDPServer().receive();

}

}

在服務器端,首先調用了bind方法綁定datagramChannel的監聽端口。當數據到來后,調用了receive方法,從datagramChannel數據包通道接收數據,再寫入到ByteBuffer緩沖區中。

在服務器端代碼中,為了監控數據的到來,使用了Selector選擇器。什么是選擇器?如何使用選擇器呢?欲知后事如何,請聽下節分解。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲

詳解NIO Selector選擇器

Java
NIO的三大核心組件:Channel(通道)、Buffer(緩沖區)、Selector(選擇器)。其中通道和緩沖區,二者的聯系也比較密切:數據總是從通道讀到緩沖區內,或者從緩沖區寫入到通道中。

至此,前面兩個組件已經介紹完畢,下面迎來了最后一個非常重要的角色——選擇器(Selector)。

選擇器以及注冊

選擇器(Selector)是什么呢?選擇器和通道的關系又是什么?

簡單地說:選擇器的使命是完成IO的多路復用,其主要工作是通道的注冊、監聽、事件查詢。一個通道代表一條連接通路,通過選擇器可以同時監控多個通道的IO(輸入輸出)狀況。選擇器和通道的關系,是監控和被監控的關系。

選擇器提供了獨特的API方法,能夠選出(select)所監控的通道已經發生了哪些IO事件,包括讀寫就緒的IO操作事件。

在NIO編程中,一般是一個單線程處理一個選擇器,一個選擇器可以監控很多通道。所以,通過選擇器,一個單線程可以處理數百、數千、數萬、甚至更多的通道。在極端情況下(數萬個連接),只用一個線程就可以處理所有的通道,這樣會大量地減少線程之間上下文切換的開銷。

通道和選擇器之間的關聯,通過register(注冊)的方式完成。調用通道的Channel.register(Selector
sel,int
ops)方法,可以將通道實例注冊到一個選擇器中。register方法有兩個參數:第一個參數,指定通道注冊到的選擇器實例;第二個參數,指定選擇器要監控的IO事件類型。

可供選擇器監控的通道IO事件類型,包括以下四種:

(1)可讀:SelectionKey.OP_READ

(2)可寫:SelectionKey.OP_WRITE

(3)連接:SelectionKey.OP_CONNECT

(4)接收:SelectionKey.OP_ACCEPT

以上的事件類型常量定義在SelectionKey類中。如果選擇器要監控通道的多種事件,可以用“按位或”運算符來實現。例如,同時監控可讀和可寫IO事件:

//監控通道的多種事件,用“按位或”運算符來實現

int key = SelectionKey.OP\_READ \| SelectionKey.OP\_WRITE ;

什么是IO事件呢?

這個概念容易混淆,這里特別說明一下。這里的IO事件不是對通道的IO操作,而是通道處於某個IO操作的就緒狀態,表示通道具備執行某個IO操作的條件。比方說某個SocketChannel傳輸通道,如果完成了和對端的三次握手過程,則會發生“連接就緒”(OP_CONNECT)的事件。再比方說某個ServerSocketChannel服務器連接監聽通道,在監聽到一個新連接的到來時,則會發生“接收就緒”(OP_ACCEPT)的事件。還比方說,一個SocketChannel通道有數據可讀,則會發生“讀就緒”(OP_READ)事件;一個等待寫入數據的SocketChannel通道,會發生寫就緒(OP_WRITE)事件。

說 明

Socket連接事件的核心原理,和TCP連接的建立過程有關。關於TCP協議的核心原理和連接建立時三次握手和四次揮手知識,請參閱本書后面的有關TCP協議原理的部分內容。

SelectableChannel可選擇通道

並不是所有的通道,都是可以被選擇器監控或選擇的。比方說,FileChannel文件通道就不能被選擇器復用。判斷一個通道能否被選擇器監控或選擇,有一個前提:判斷它是否繼承了抽象類SelectableChannel(可選擇通道),如果是則可以被選擇,否則不能。

簡單地說,一條通道若能被選擇,必須繼承SelectableChannel類。

SelectableChannel類,是何方神聖呢?它提供了實現通道的可選擇性所需要的公共方法。Java
NIO中所有網絡鏈接Socket套接字通道,都繼承了SelectableChannel類,都是可選擇的。而FileChannel文件通道,並沒有繼承SelectableChannel,因此不是可選擇通道。

SelectionKey選擇鍵

通道和選擇器的監控關系注冊成功后,就可以選擇就緒事件。具體的選擇工作,和調用選擇器Selector的select(
)方法來完成。通過select方法,選擇器可以不斷地選擇通道中所發生操作的就緒狀態,返回注冊過的感興趣的那些IO事件。換句話說,一旦在通道中發生了某些IO事件(就緒狀態達成),並且是在選擇器中注冊過的IO事件,就會被選擇器選中,並放入SelectionKey選擇鍵的集合中。

這里出現一個新的概念——SelectionKey選擇鍵。SelectionKey選擇鍵是什么呢?簡單地說,SelectionKey選擇鍵就是那些被選擇器選中的IO事件。前面講到,一個IO事件發生(就緒狀態達成)后,如果之前在選擇器中注冊過,就會被選擇器選中,並放入SelectionKey選擇鍵集合中;如果之前沒有注冊過,即使發生了IO事件,也不會被選擇器選中。SelectionKey選擇鍵和IO的關系,可以簡單地理解為:選擇鍵,就是被選中了的IO事件。

在實際編程時,選擇鍵的功能是很強大的。通過SelectionKey選擇鍵,不僅僅可以獲得通道的IO事件類型,比方說SelectionKey.OP_READ;還可以獲得發生IO事件所在的通道;另外,也可以獲得選出選擇鍵的選擇器實例。

選擇器使用流程

使用選擇器,主要有以下三步:

(1)獲取選擇器實例;

(2)將通道注冊到選擇器中;

(3)輪詢感興趣的IO就緒事件(選擇鍵集合)。

第一步:獲取選擇器實例。選擇器實例是通過調用靜態工廠方法open()來獲取的,具體如下:

//調用靜態工廠方法open()來獲取Selector實例
Selector selector = Selector.open();

Selector選擇器的類方法open(
)的內部,是向選擇器SPI(SelectorProvider)發出請求,通過默認的SelectorProvider(選擇器提供者)對象,獲取一個新的選擇器實例。Java中SPI全稱為(Service
Provider
Interface,服務提供者接口),是JDK的一種可以擴展的服務提供和發現機制。Java通過SPI的方式,提供選擇器的默認實現版本。也就是說,其他的服務提供商可以通過SPI的方式,提供定制化版本的選擇器的動態替換或者擴展。

第二步:將通道注冊到選擇器實例。要實現選擇器管理通道,需要將通道注冊到相應的選擇器上,簡單的示例代碼如下:

// 2.獲取通道
ServerSocketChannelserverSocketChannel = ServerSocketChannel.open();
// 3.設置為非阻塞
serverSocketChannel.configureBlocking(false);
// 4.綁定連接
serverSocketChannel.bind(new InetSocketAddress(18899));
// 5.將通道注冊到選擇器上,並制定監聽事件為:“接收連接”事件
serverSocketChannel.register(selector,SelectionKey.OP\_ACCEPT);

上面通過調用通道的register(…)方法,將ServerSocketChannel通道注冊到了一個選擇器上。當然,在注冊之前,首先需要准備好通道。

這里需要注意:注冊到選擇器的通道,必須處於非阻塞模式下,否則將拋出IllegalBlockingModeException異常。這意味着,FileChannel文件通道不能與選擇器一起使用,因為FileChannel文件通道只有阻塞模式,不能切換到非阻塞模式;而Socket套接字相關的所有通道都可以。

其次,還需要注意:一個通道,並不一定要支持所有的四種IO事件。例如服務器監聽通道ServerSocketChannel,僅僅支持Accept(接收到新連接)IO事件;而傳輸通道SocketChannel則不同,該類型通道不支持Accept類型的IO事件。

如何判斷通道支持哪些事件呢?可以在注冊之前,可以通過通道的validOps()方法,來獲取該通道所有支持的IO事件集合。

第三步:選出感興趣的IO就緒事件(選擇鍵集合)。通過Selector選擇器的select()方法,選出已經注冊的、已經就緒的IO事件,並且保存到SelectionKey選擇鍵集合中。SelectionKey集合保存在選擇器實例內部,其元素為SelectionKey類型實例。調用選擇器的selectedKeys()方法,可以取得選擇鍵集合。

接下來,需要迭代集合的每一個選擇鍵,根據具體IO事件類型,執行對應的業務操作。大致的處理流程如下:

//輪詢,選擇感興趣的IO就緒事件(選擇鍵集合)

while (selector.select() \> 0) {

Set selectedKeys = selector.selectedKeys();

Iterator keyIterator = selectedKeys.iterator();

while(keyIterator.hasNext()) {

SelectionKey key = keyIterator.next();

//根據具體的IO事件類型,執行對應的業務操作

if(key.isAcceptable()) {

// IO事件:ServerSocketChannel服務器監聽通道有新連接

} else if (key.isConnectable()) {

// IO事件:傳輸通道連接成功

} else if (key.isReadable()) {

// IO事件:傳輸通道可讀

} else if (key.isWritable()) {

// IO事件:傳輸通道可寫

}

//處理完成后,移除選擇鍵

keyIterator.remove();

}

}

處理完成后,需要將選擇鍵從這個SelectionKey集合中移除,防止下一次循環的時候,被重復的處理。SelectionKey集合不能添加元素,如果試圖向SelectionKey選擇鍵集合中添加元素,則將拋出java.lang.UnsupportedOperationException異常。

用於選擇就緒的IO事件的select()方法,有多個重載的實現版本,具體如下:

(1)select():阻塞調用,一直到至少有一個通道發生了注冊的IO事件。

(2)select(long timeout):和select()一樣,但最長阻塞時間為timeout指定的毫秒數。

(3)selectNow():非阻塞,不管有沒有IO事件,都會立刻返回。

select()方法的返回值的是整數類型(int),表示發生了IO事件的數量。更准確地說,是從上一次select到這一次select之間,有多少通道發生了IO事件,更加准確地說,是指發生了選擇器感興趣(注冊過)的IO事件數。

使用NIO實現Discard服務器的實踐案例

Discard服務器的功能很簡單:僅僅讀取客戶端通道的輸入數據,讀取完成后直接關閉客戶端通道;並且讀取到的數據直接拋棄掉(Discard)。Discard服務器足夠簡單明了,作為第一個學習NIO的通信實例,較有參考價值。

下面的Discard服務器代碼,其中將選擇器使用流程中的步驟進行了進一步細化:

package com.crazymakercircle.iodemo.NioDiscard;

//...

public class NioDiscardServer {

public static void startServer() throws IOException {

// 1.獲取選擇器

Selector selector = Selector.open();

// 2.獲取通道

ServerSocketChannel serverSocketChannel =

>   ServerSocketChannel.open();

// 3.設置為非阻塞

serverSocketChannel.configureBlocking(false);

// 4.綁定連接

serverSocketChannel.bind(newInetSocketAddress(18899));

Logger.info("服務器啟動成功");

// 5.將通道注冊的“接收新連接”IO事件,注冊到選擇器上

serverSocketChannel.register(selector,

>   SelectionKey.OP\_ACCEPT);

// 6.輪詢感興趣的IO就緒事件(選擇鍵集合)

while (selector.select() \> 0) {

// 7.獲取選擇鍵集合

Iterator\<SelectionKey\> selectedKeys =

selector.selectedKeys().iterator();

while (selectedKeys.hasNext()) {

// 8.獲取單個的選擇鍵,並處理

SelectionKey selectedKey = selectedKeys.next();

// 9.判斷key是具體的什么事件

if (selectedKey.isAcceptable()) {

// 10.若選擇鍵的IO事件是“連接就緒”事件,就獲取客戶端連接

SocketChannel socketChannel =

>   serverSocketChannel.accept();

// 11.將新連接切換為非阻塞模式

socketChannel.configureBlocking(false);

// 12.將該新連接的通道的可讀事件,注冊到選擇器上

socketChannel.register(selector,

>   SelectionKey.OP\_READ);

} else if (selectedKey.isReadable()) {

// 13.若選擇鍵的IO事件是“可讀”事件, 讀取數據

SocketChannelsocketChannel =

(SocketChannel) selectedKey.channel();

// 14.讀取數據,然后丟棄

ByteBufferbyteBuffer = ByteBuffer.allocate(1024);

int length = 0;

while ((length =

>   socketChannel.read(byteBuffer)) \>0)

{

byteBuffer.flip();

Logger.info(new String(byteBuffer.array(), 0, length));

byteBuffer.clear();

}

socketChannel.close();

}

// 15.移除選擇鍵

selectedKeys.remove();

}

}

// 16.關閉連接

serverSocketChannel.close();

}

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

startServer();

}

}

實現DiscardServer丟棄服務一共分為16步,其中第7到第15步是循環執行的,不斷查詢選擇感興趣的IO事件到選擇鍵集合中,然后通過selector.selectedKeys()獲取該選擇鍵集合,並且進行迭代處理。在事件處理過程中,對於新建立的socketChannel客戶端傳輸通道,也要注冊到同一個選擇器上,這樣就能使用同一個選擇線程,不斷地對所有的注冊通道進行選擇鍵的查詢。

在DiscardServer程序中,涉及到兩次選擇器注冊:一次是注冊serverChannel服務器通道;另一次,注冊接收到的socketChannel客戶端傳輸通道。前者serverChannel服務器通道所注冊的,是新連接的IO事件SelectionKey.OP_ACCEPT;后者客戶端傳輸通道socketChannel所注冊的,是可讀IO事件SelectionKey.OP_READ。

注冊完成后如果有事件發生,也就是DiscardServer在對選擇鍵進行處理時,通過對類型進行判斷,然后進行相應的處理:

(1)如果是SelectionKey.OP_ACCEPT新連接事件類型,代表serverChannel服務器通道接收到新的客戶端連接,發生了新連接事件,則通過服務器通道的accept方法,獲取新的socketChannel傳輸通道,並且將新通道注冊到選擇器;

(2)如果是SelectionKey.OP_READ可讀事件類型,代表某個客戶端通道有數據可讀,則讀取選擇鍵中socketChannel傳輸通道的數據,進行業務處理,這里是直接丟棄數據。

客戶端的DiscardClient代碼,則更為簡單。客戶端首先建立到服務器的連接,發送一些簡單的數據,然后直接關閉連接。代碼如下:

package com.crazymakercircle.iodemo.NioDiscard;

//...

public class NioDiscardClient {

public static void startClient() throws IOException {

InetSocketAddress address =

>   new InetSocketAddress("127.0.0.1",18899);

// 1.獲取通道

SocketChannel socketChannel = SocketChannel.open(address);

// 2.切換成非阻塞模式

socketChannel.configureBlocking(false);

//不斷地自旋、等待連接完成,或者做一些其他的事情

while (!socketChannel.finishConnect()) {

}

Logger.info("客戶端連接成功");

// 3.分配指定大小的緩沖區

ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

byteBuffer.put("hello world".getBytes());

byteBuffer.flip();

//發送到服務器

socketChannel.write(byteBuffer);

socketChannel.shutdownOutput();

socketChannel.close();

}

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

startClient();

}

}

說 明

如果需要執行整個Discard演示程序,首先要執行前面的NioDiscardServer
服務器端程序,然后才能執行本客戶端程序。

通過Discard服務器的開發實踐,大家對NIO
Selector(選擇)的使用流程,應該了解得非常清楚了。下面來看一個稍微復雜一點的案例:在服務器端接收文件和內容。

使用SocketChannel在服務器端接收文件的實踐案例

本示例演示文件的接收,是服務器端的程序。和前面介紹的文件發送的SocketChannel客戶端程序是相互配合使用的。由於在服務器端,需要用到選擇器,所以,一直到此處完成了選擇器的知識介紹之后,才姍姍來遲開始介紹NIO文件傳輸的Socket服務器端程序。服務器端接收文件的示例代碼如下所示:

package com.crazymakercircle.iodemo.socketDemos;

...省略import

/**
* 文件傳輸Server端
* Created by 尼恩\@ 瘋狂創客圈
*/

public class NioReceiveServer
{
//接受文件路徑
private static final String RECEIVE\_PATH =

>   NioDemoConfig.SOCKET\_RECEIVE\_PATH;

private Charset charset = Charset.forName("UTF-8");
/**
* 服務器端保存的客戶端對象,對應一個客戶端文件
*/
static class Client
{

//文件名稱
String fileName;
//長度
long fileLength;
//開始傳輸的時間
long startTime;
//客戶端的地址
InetSocketAddress remoteAddress;
//輸出的文件通道
FileChannel outChannel;
//接收長度

long receiveLength;

public boolean isFinished()
{
return receiveLength \>= fileLength;
}
}

private ByteBuffer buffer
= ByteBuffer.allocate(NioDemoConfig.SERVER\_BUFFER\_SIZE);

//使用Map保存每個客戶端傳輸

>   //當OP\_READ通道可讀時,根據channel找到對應的對象

Map\<SelectableChannel, Client\> clientMap =

>   new HashMap\<SelectableChannel, Client\>();

public void startServer() throws IOException
{
// 1、獲取Selector選擇器
Selector selector = Selector.open();
// 2、獲取通道

ServerSocketChannel serverChannel =

>   ServerSocketChannel.open();

ServerSocket serverSocket = serverChannel.socket();

// 3.設置為非阻塞
serverChannel.configureBlocking(false);

// 4、綁定連接
InetSocketAddress address
= new InetSocketAddress(18899);
serverSocket.bind(address);

// 5、將通道注冊到選擇器上,並注冊的IO事件為:“接收新連接”
serverChannel.register(selector, SelectionKey.OP\_ACCEPT);
Print.tcfo("serverChannel is linstening...");

// 6、輪詢感興趣的I/O就緒事件(選擇鍵集合)
while (selector.select() \> 0)
{

// 7、獲取選擇鍵集合
Iterator\<SelectionKey\> it =

>   selector.selectedKeys().iterator();

while (it.hasNext())
{

// 8、獲取單個的選擇鍵,並處理
SelectionKey key = it.next();

// 9、判斷key是具體的什么事件,是否為新連接事件
if (key.isAcceptable())
{

// 10、若接受的事件是“新連接”事件,就獲取客戶端新連接
ServerSocketChannel server =

>   (ServerSocketChannel) key.channel();

SocketChannel socketChannel = server.accept();
if (socketChannel == null) continue;

// 11、客戶端新連接,切換為非阻塞模式
socketChannel.configureBlocking(false);

// 12、將客戶端新連接通道注冊到selector選擇器上
SelectionKey selectionKey =
socketChannel.register(selector, SelectionKey.OP\_READ);

// 余下為業務處理
Client client = new Client();

client.remoteAddress =

>   (InetSocketAddress) socketChannel.getRemoteAddress();

clientMap.put(socketChannel, client);

Logger.debug(socketChannel.getRemoteAddress() + "連接成功...");

} else if (key.isReadable())
{
processData(key);
}

// NIO的特點只會累加,已選擇的鍵的集合不會刪除
// 如果不刪除,下一次又會被select函數選中
it.remove();
}
}
}

/**
* 處理客戶端傳輸過來的數據
*/

private void processData(SelectionKey key) throws IOException
{
Client client = clientMap.get(key.channel());
SocketChannel socketChannel = (SocketChannel) key.channel();
int num = 0;
try
{
buffer.clear();
while ((num = socketChannel.read(buffer)) \> 0)
{
buffer.flip();

//客戶端發送過來的,首先處理文件名
if (null == client.fileName)
{
if (buffer.capacity() \< 4)
{
continue;
}

int fileNameLen = buffer.getInt();
byte[] fileNameBytes = new byte[fileNameLen];
buffer.get(fileNameBytes);

// 文件名

String fileName = new String(fileNameBytes, charset);
File directory = new File(RECEIVE\_PATH);
if (!directory.exists())
{
directory.mkdir();
}

Logger.info("NIO 傳輸目標dir:", directory);
client.fileName = fileName;
String fullName =
directory.getAbsolutePath() + File.separatorChar + fileName;

Logger.info("NIO 傳輸目標文件:", fullName);
File file = new File(fullName.trim());
if (!file.exists())
{
file.createNewFile();
}

FileChannel fileChannel =

>   new FileOutputStream(file).getChannel();

client.outChannel = fileChannel;
if (buffer.capacity() \< 8)
{
continue;
}

// 文件長度
long fileLength = buffer.getLong();
client.fileLength = fileLength;
client.startTime = System.currentTimeMillis();
Logger.debug("NIO 傳輸開始:");
client.receiveLength += buffer.capacity();

if (buffer.capacity() \> 0)
{
// 寫入文件
client.outChannel.write(buffer);
}

if (client.isFinished())
{
finished(key, client);
}
buffer.clear();
}

//客戶端發送過來的,最后是文件內容

else
{
client.receiveLength += buffer.capacity();

// 寫入文件
client.outChannel.write(buffer);
if (client.isFinished())
{
finished(key, client);
}

buffer.clear();
}
}

key.cancel();

} catch (IOException e)
{
key.cancel();
e.printStackTrace();
return;
}

// 調用close為-1 到達末尾
if (num == -1)
{
finished(key, client);
buffer.clear();
}
}

private void finished(SelectionKey key, Client client)
{
IOUtil.closeQuietly(client.outChannel);
Logger.info("上傳完畢");
key.cancel();
Logger.debug("文件接收成功,File Name:" + client.fileName);
Logger.debug(" Size:" +

>   IOUtil.getFormatFileSize(client.fileLength));

long endTime = System.currentTimeMillis();
Logger.debug("NIO IO 傳輸毫秒數:" +

>   (endTime - client.startTime));

}

/**
* 入口
*/

public static void main(String[] args) throws Exception
{
  NioReceiveServer server = new NioReceiveServer();
  server.startServer();
}

}

由於客戶端每次傳輸文件,都會分為多次傳輸:

(1)首先傳入文件名稱;

(2)其次是文件大小;

(3)然后是文件內容。

對應於每一個客戶端socketChannel,創建一個Client客戶端對象,用於保存客戶端狀態,分別保存文件名、文件大小和寫入的目標文件通道outChannel。

socketChannel和Client對象之間是一對一的對應關系:建立連接的時候,以鍵值對的形式保存Client實例在map中,其中socketChannel作為鍵(Key),Client對象作為值(Value)。當socketChannel傳輸通道有數據可讀時,通過選擇鍵key.channel()方法,取出IO事件所在socketChannel通道。然后通過socketChannel通道,從map中取到對應的Client對象。

接收到數據時,如果文件名為空,先處理文件名稱,並把文件名保存到Client對象,同時創建服務器上的目標文件;接下來再讀到數據,說明接收到了文件大小,把文件大小保存到Client對象;接下來再接到數據,說明是文件內容了,則寫入Client對象的outChannel文件通道中,直到數據讀取完畢。

運行方式:啟動這個NioReceiveServer服務器程序后,再啟動前面介紹的客戶端程序NioSendClient,即可以完成文件的傳輸。

由於NIO傳輸是非阻塞的、異步的,所以,在傳輸過程中會出現“粘包”和“半包”問題。正因為這個原因,無論是前面NIO文件傳輸實例、還是Discard服務器程序,都會在傳輸過程中的出現異常現象(偶現)。由於以上的實例,在生產過程中不會使用,僅僅是為了大家學習NIO的知識,所以,沒有為了解決“粘包”和“半包”問題而將代碼編寫得很復雜。

說 明

很多小伙伴在{瘋狂創客圈}社群的交流群反饋:在執行以上實例時,傳輸過程中會出現異常現象,會發生部分內容傳輸出錯。其實並不是程序問題,而是傳輸過程中發生了“粘包”和“半包”問題。后面的章節,會專門介紹“粘包”和“半包”問題以及其根本性的解決方案。

本章小結

在編程難度上,Java NIO編程的難度比同步阻塞Java
OIO編程大很多。請注意,前面的實踐案例,是比較簡單的,並不是復雜的通信程序,但是仍然會看到“粘包”和“拆包”等問題。如果加上這些問題,代碼將會更加復雜。

與Java OIO相比,Java NIO編程大致的特點如下:

(1)在NIO中,服務器接收新連接的工作,是異步進行的。不像Java的OIO那樣,服務器監聽連接,是同步的、阻塞的。NIO可以通過選擇器(也可以說成:多路復用器),后續不斷地輪詢選擇器的選擇鍵集合,選擇新到來的連接。

(2)在NIO中,SocketChannel傳輸通道的讀寫操作都是異步的。如果沒有可讀寫的數據,負責IO通信的線程不會同步等待。這樣,線程就可以處理其他連接的通道;不需要像OIO那樣,線程一直阻塞,等待所負責的連接可用為止。

(3)在NIO中,一個選擇器線程可以同時處理成千上萬的客戶端連接,性能不會隨着客戶端的增加而線性下降。

總之,有了Linux底層的epoll支持,以及Java NIO
Selector選擇器等等應用層IO復用技術,Java程序從而可以實現IO通信的高TPS、高並發,使服務器具備並發數十萬、數百萬的連接能力。Java的NIO技術非常適合用於高性能、高負載的網絡服務器。鼎鼎大名的通信服務器中間件Netty,就是基於Java的NIO技術實現的。

當然,Java
NIO技術僅僅是基礎,如果要實現通信的高性能和高並發,還離不開高效率的設計模式。下一章將開始為大家介紹高性能服務必備的設計模式:Reactor反應器模式。

說明:本文會以pdf格式持續更新,更多最新尼恩3高pdf筆記,請從下面的鏈接獲取:語雀 或者 碼雲


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM