- Java程序員面試筆試寶典(第2版)
- 何昊等編著
- 4797字
- 2022-06-17 16:00:55
2.4 NIO
在NIO(Nonblocking IO,非阻塞IO)出現(xiàn)之前,Java是通過傳統(tǒng)的Socket來實(shí)現(xiàn)基本的網(wǎng)絡(luò)通信功能的。以服務(wù)端為例,其實(shí)現(xiàn)基本流程如圖2-3所示。

圖2-3 Socket使用流程
如果客戶端還沒有對(duì)服務(wù)端發(fā)起連接請(qǐng)求,那么accept就會(huì)阻塞[阻塞指的是暫停一個(gè)線程的執(zhí)行以等待某個(gè)條件發(fā)生(例如某資源就緒)]。如果連接成功,當(dāng)數(shù)據(jù)還沒有準(zhǔn)備好的時(shí)候,對(duì)read的調(diào)用同樣會(huì)阻塞。當(dāng)要處理多個(gè)連接的時(shí)候,就需要采用多線程的方式,由于每個(gè)線程都擁有自己的棧空間,而且由于阻塞會(huì)導(dǎo)致大量線程進(jìn)行上下文切換,使得程序的運(yùn)行效率非常低下。因此在J2SE1.4中引入了NIO來解決這個(gè)問題。
NIO通過Selector、Channels和Buffers來實(shí)現(xiàn)非阻塞的IO操作。NIO是指New I/O,既然有New I/O,那么就會(huì)有Old I/O,Old I/O是指基于流的I/O方法。NIO是在Java 1.4中被納入JDK中的,它最主要的特點(diǎn)是,提供了基于Selector的異步網(wǎng)絡(luò)I/O,使得一個(gè)線程可以管理多個(gè)連接。下面給出基于NIO處理多個(gè)連接的結(jié)構(gòu)圖,如圖2-4所示。

圖2-4 NIO結(jié)構(gòu)圖
在介紹NIO的原理之前,首先介紹幾個(gè)重要的概念:Channel(通道)、Buffer(緩沖區(qū))和Selector(選擇器)。
(1)Channel(通道)
為了更容易地理解什么是Channel,這里以InputStream為例來介紹什么是Channel。傳統(tǒng)的IO中經(jīng)常使用下面的代碼來讀取文件(此處忽略異常處理):


InputStream其實(shí)就是一個(gè)用來讀取文件的通道。只不過InputStrem是一個(gè)單向的通道,只能用來讀取數(shù)據(jù)。而NIO中的Channel是一個(gè)雙向的通道,不僅能讀取數(shù)據(jù),而且還能寫入數(shù)據(jù)。
(2)Buffer(緩沖區(qū))
在上面的示例代碼中,InputStream把讀取到的數(shù)據(jù)放在了byte數(shù)組中,如果用OutputStream寫數(shù)據(jù),那么也可以把byte數(shù)組中的數(shù)據(jù)寫到文件中。而在NIO中,數(shù)據(jù)只能被寫到Buffer中,同理讀取的數(shù)據(jù)也只能放在Buffer中,由此可見Buffer是Channel用來讀寫數(shù)據(jù)的非常重要的一個(gè)工具。
(3)Selector(選擇器)
Selector是NIO中最重要的部分,是實(shí)現(xiàn)一個(gè)線程管理多個(gè)連接的關(guān)鍵,它的作用就是輪詢所有被注冊(cè)的Channel,一旦發(fā)現(xiàn)Channel上被注冊(cè)的事件發(fā)生,就可以對(duì)這個(gè)事件進(jìn)行處理。
2.4.1 Buffer
在Java NIO中,Buffer主要的作用就是與Channel進(jìn)行交互。它本質(zhì)上是一塊可讀寫數(shù)據(jù)的內(nèi)存,這塊內(nèi)存中有很多可以存儲(chǔ)byte、int、char等的小單元。這塊內(nèi)存被包裝成NIO Buffer對(duì)象,并提供了一組方法,來簡(jiǎn)化數(shù)據(jù)的讀寫。在Java NIO中,核心的Buffer有7類,如圖2-5所示。

圖2-5 Buffer的類圖
為了更好地理解上面四個(gè)步驟,下面將重點(diǎn)介紹Buffer中幾個(gè)非常重要的屬性:capacity、position和limit。
1)capacity用來表示Buffer的容量,也就是剛開始申請(qǐng)的Buffer的大小。
2)position表示下一次讀(寫)的位置。
在寫數(shù)據(jù)到Buffer中時(shí),position表示當(dāng)前可寫的位置。初始的position值為0。當(dāng)寫入一個(gè)數(shù)據(jù)(例如int或short)到Buffer后,position會(huì)向前移動(dòng)到下一個(gè)可插入數(shù)據(jù)的Buffer單元。position最大的值為capacity-1。
在讀取數(shù)據(jù)時(shí),也是從某個(gè)位置開始讀。當(dāng)從Buffer的position處讀取數(shù)據(jù)完成時(shí),position也會(huì)從向前位置移動(dòng)到下一個(gè)可讀的位置。
buffer從寫入模式變?yōu)樽x取模式時(shí),position會(huì)歸零,每次讀取后,position向后移動(dòng)。
3)limit表示本次讀(寫)的極限位置。
在寫入數(shù)據(jù)時(shí),limit表示最多能往Buffer里寫入多少數(shù)據(jù),它等同于buffer的容量。
在讀取數(shù)據(jù)時(shí),limit表示最多能讀到多少數(shù)據(jù),也就是說position移動(dòng)到limit時(shí)讀操作會(huì)停止。它的值等同于寫模式下position的位置。
為了更容易地理解這三個(gè)屬性之間的關(guān)系,下面通過圖2-6來說明。
從上圖可以看出,在寫模式中,position表示下一個(gè)可寫入位置,一旦切換到讀模式,position就會(huì)置0(可以從Buffer最開始的地方讀數(shù)據(jù)),而此時(shí)這個(gè)Buffer的limit就是在讀模式下的position,因?yàn)樵趐osition之后是沒有數(shù)據(jù)的。

圖2-6 Buffer的內(nèi)部原理
在理解了Buffer的內(nèi)部實(shí)現(xiàn)原理后,下面重點(diǎn)介紹如何使用Buffer。
(1)申請(qǐng)Buffer
在使用Buffer前必須先申請(qǐng)一塊固定大小的內(nèi)存空間來供Buffer使用,這個(gè)工作可以通過Buffer類提供的allocate()方法來實(shí)現(xiàn)。例如:

(2)向Buffer中寫數(shù)據(jù)
可以通過Buffer的put方法來寫入數(shù)據(jù),也可以通過Channel向Buffer中寫數(shù)據(jù),例如:

(3)讀寫模式的轉(zhuǎn)換
Buffer的flip()方法用來把Buffer從寫模式轉(zhuǎn)換為讀模式,flip方法的底層實(shí)現(xiàn)原理為:把position置0,并把Buffer的limit設(shè)置為當(dāng)前的position值。
(4)從Buffer中讀取數(shù)據(jù)
與寫數(shù)據(jù)類似,讀數(shù)據(jù)也有兩種方式,分別為:通過Buffer的get方法讀取,或從buffer中讀取數(shù)據(jù)到Channel中。例如:

當(dāng)完成數(shù)據(jù)的讀取后,需要調(diào)用clear()或compact()方法來清空Buffer,從而實(shí)現(xiàn)Buffer的復(fù)用。這兩個(gè)方法的實(shí)現(xiàn)原理為:clear()方法會(huì)把position置0,把limit設(shè)置為capacity;由此可見,如果Buffer中還有未讀的數(shù)據(jù),那么clear()方法也會(huì)清理這部分?jǐn)?shù)據(jù)。如果想保留這部分未讀的數(shù)據(jù),那么就需要調(diào)用compact()方法。下面以IntBuffer為例介紹compact()方法的實(shí)現(xiàn)原理:
將緩沖區(qū)當(dāng)前位置和界限之間的int(如果有)復(fù)制到緩沖區(qū)的開始處。即將索引p=position()處的int復(fù)制到索引0處,將索引p+1處的int復(fù)制到索引1處,依此類推,直到將索引limit()-1處的int復(fù)制到索引n=limit()-1-p處。然后將緩沖區(qū)的位置設(shè)置為n+1,并將其界限設(shè)置為其容量。如果已定義了標(biāo)記,那么丟棄它。
(5)重復(fù)讀取數(shù)據(jù)
Buffer還有另外一個(gè)重要的重復(fù)讀取數(shù)據(jù)的方法:rewind(),它的實(shí)現(xiàn)原理如下:只把position的值置0,而limit保持不變,使用rewind()方法可以實(shí)現(xiàn)對(duì)Buffer中的數(shù)據(jù)進(jìn)行重復(fù)的讀取。
由此可見在NIO中使用Buffer的時(shí)候,通常都需要遵循如下4個(gè)步驟:
1)向Buffer中寫入數(shù)據(jù)。
2)調(diào)用flip()方法把Buffer從寫模式切換到讀模式。
3)從Buffer中讀取數(shù)據(jù)。
4)調(diào)用clear()方法或compact()方法來清空Buffer。
(6)標(biāo)記與復(fù)位。
Buffer中還有兩個(gè)非常重要的方法:mark()和reset()。mark()方法用來標(biāo)記當(dāng)前的position,一旦標(biāo)記完成,在任何時(shí)刻都可以使用reset()方法來把position恢復(fù)到標(biāo)記的值。
2.4.2 Channel
在NIO中,數(shù)據(jù)的讀寫都是通過Channel(通道)來實(shí)現(xiàn)的。Channel與傳統(tǒng)的“流”非常類似,只不過Channel不能直接訪問數(shù)據(jù),而只能與Buffer進(jìn)行交互,也就是說Channel只能通過buffer來實(shí)現(xiàn)數(shù)據(jù)的讀寫。如圖2-7所示。

圖2-7 Channel與Buffer的關(guān)系
雖然通道與流有很多相似的地方,但是它們也有很多區(qū)別,下面主要介紹3個(gè)區(qū)別:
1)通道是雙向的,既可以讀也可以寫。但是大部分流都是單向的,只能讀或者寫。
2)通道可以實(shí)現(xiàn)異步的讀寫,大部分流只支持同步的讀寫。
3)通道的讀寫只能通過Buffer來完成。
在Java語(yǔ)言中,主要有以下4個(gè)常見的Channel的實(shí)現(xiàn):
1)FileChannel:用來讀寫文件;
2)DatagramChannel:用來對(duì)UDP的數(shù)據(jù)進(jìn)行讀寫;
3)SocketChannel:用來對(duì)TCP的數(shù)據(jù)進(jìn)行讀寫,一般用作客戶端實(shí)現(xiàn);
4)ServerSocketChannel:用來監(jiān)聽TCP的連接請(qǐng)求,然后針對(duì)每個(gè)請(qǐng)求會(huì)創(chuàng)建一個(gè)SocketChannel,一般被用作服務(wù)器實(shí)現(xiàn)。
下面通過一個(gè)例子來介紹FileChannel的使用方法:



程序的運(yùn)行結(jié)果為:

2.4.3 Selector
Selector表示選擇器或者多路復(fù)用器。它主要的功能為輪詢檢查多個(gè)通道的狀態(tài),判斷通道注冊(cè)的事件是否發(fā)生,也就是說判斷通道是否可讀或可寫。然后根據(jù)發(fā)生事件的類型對(duì)這個(gè)通道做出對(duì)應(yīng)的響應(yīng)。由此可見,一個(gè)Selector完全可以用來管理多個(gè)連接,由此大大提高了系統(tǒng)的性能。這一節(jié)將重點(diǎn)介紹Selector的使用方法。
(1)創(chuàng)建Selector
Selector的創(chuàng)建非常簡(jiǎn)單,只需要調(diào)用Selector的靜態(tài)方法open就可以創(chuàng)建一個(gè)Selector,示例代碼如下所示:

一旦Selector被創(chuàng)建出來,接下來就需要把感興趣的Channel的事件注冊(cè)給Selector了。
(2)注冊(cè)Channel的事件到Selector
由于Selector需要輪詢多個(gè)Channel,因此注冊(cè)的Channel必須是非阻塞的。在注冊(cè)前需要使用下面的代碼來把channel注冊(cè)為非阻塞的。

配置完成后就可以使用下面的代碼來注冊(cè)感興趣的事件了:

需要注意的是,只有繼承了SelectableChannel或AbstractSelectableChannel的類才有configureBlocking這個(gè)方法。常用的SocketChannel和ServerSocketChannel都是繼承自AbstractSelectableChannel的,因此它們都有configureBlocking方法,可以注冊(cè)到Selector上。
register方法用來向給定的選擇器注冊(cè)此通道,并返回一個(gè)選擇鍵。
第一個(gè)參數(shù)表示要向其注冊(cè)此通道的選擇器;第二個(gè)參數(shù)表示的是感興趣的鍵的可用操作集,鍵的取值有下面四種或者是它們的組合(SelectionKey.OP_READ|SelectionKey.OP_WRITE):

(3)SelectionKey
向Selector注冊(cè)Channel的時(shí)候,register方法會(huì)返回一個(gè)SelectionKey的對(duì)象,這個(gè)對(duì)象表示了一個(gè)特定的通道對(duì)象和一個(gè)特定的選擇器對(duì)象之間的注冊(cè)關(guān)系。它主要包含如下的一些屬性:

1)interest集合。interest集合表示Selector對(duì)這個(gè)通道感興趣的事件的集合,通常會(huì)使用位操作來判斷Selector對(duì)哪些事件感興趣,如下例所示:

2)ready集合。ready集合是通道已經(jīng)準(zhǔn)備就緒的操作的集合。在一次選擇(Selection)之后,會(huì)首先訪問這個(gè)ready集合。可以使用位操作來檢查某一個(gè)事件是否就緒。在實(shí)際編程中,經(jīng)常使用下面的方法來判斷事件是否就緒:

3)附加對(duì)象。可以把一個(gè)對(duì)象或者更多信息附著到SelectionKey上,這樣就能方便的識(shí)別某個(gè)給定的通道,有兩種方法來給SelectionKey添加附加對(duì)象:

(4)使用Selector選擇Channel
如果對(duì)Selector注冊(cè)了一個(gè)或多個(gè)通道,那么就可以使用select方法來獲取那些準(zhǔn)備就緒的通道(如果對(duì)讀事件感興趣,那么會(huì)返回讀就緒的通道;如果對(duì)寫事件感興趣,那么會(huì)獲取寫就緒的通道)。select方法主要有下面三種重載方式:
1)select():選擇一組鍵,其相應(yīng)的通道已為I/O操作準(zhǔn)備就緒。此方法執(zhí)行處于阻塞模式的選擇操作。僅在至少選擇一個(gè)通道、調(diào)用此選擇器的wakeup方法,或者當(dāng)前的線程已中斷(以先到者為準(zhǔn))后此方法才返回。
2)select(long timeout):此方法執(zhí)行處于阻塞模式的選擇操作。僅在至少選擇一個(gè)通道、調(diào)用此選擇器的wakeup方法、當(dāng)前的線程已中斷,或者給定的超時(shí)期滿(以先到者為準(zhǔn))后此方法才返回。
3)int selectNow():此方法執(zhí)行非阻塞的選擇操作。如果自從前一次選擇操作后,沒有通道變成可選擇的,那么此方法直接返回零。
一旦select()方法的返回值表示有通道就緒了,此時(shí)就可以通過selector的selectedKeys()方法來獲取那些就緒的通道。示例代碼如下所示:


下面給出一個(gè)Selector簡(jiǎn)單的使用示例:
1)服務(wù)端代碼:


2)客戶端代碼:


2.4.4 AIO
從上面的介紹可以看出BIO使用同步阻塞的方式工作的,而NIO則使用的是異步阻塞的方式。對(duì)于NIO而言,它最重要的作用是當(dāng)一個(gè)連接創(chuàng)建后,不需要對(duì)應(yīng)一個(gè)線程,這個(gè)連接會(huì)被注冊(cè)到多路復(fù)用器上面,所以所有的連接只需要一個(gè)線程就可以管理,當(dāng)這個(gè)線程中的多路復(fù)用器進(jìn)行輪詢的時(shí)候,發(fā)現(xiàn)連接上有請(qǐng)求的話,才開啟一個(gè)線程進(jìn)行處理,也就是一個(gè)請(qǐng)求一個(gè)線程模式。
在NIO的處理方式中,當(dāng)一個(gè)請(qǐng)求來的話,開啟線程進(jìn)行處理,但是它仍然需要使用阻塞的方式讀取數(shù)據(jù),顯然在這種情況下這個(gè)線程就被阻塞了,在高并發(fā)的環(huán)境下,也會(huì)有一定的性能的問題。造成這個(gè)問題的主要原因就是NIO仍然使用了同步的IO。
AIO是對(duì)NIO的改進(jìn)(所以AIO又稱NIO.2),它是基于Proactor模型實(shí)現(xiàn)的。
在IO讀寫的時(shí)候,如果想把IO請(qǐng)求與讀寫操作分離調(diào)配進(jìn)行,那么就需要用到事件分離器。根據(jù)處理機(jī)制的不同,事件分離器又分為:同步的Reactor和異步的Proactor。為了更好地理解AIO與NIO的區(qū)別,下面首先簡(jiǎn)要介紹一下Reactor模型與Proactor模型的區(qū)別:
Reactor模型
它的工作原理為(以讀操作為例):
1)應(yīng)用程序在事件分離器上注冊(cè)“讀就緒事件”與“讀就緒事件處理器”;
2)事件分離器會(huì)等待讀就緒事件發(fā)生;
3)一旦讀就緒事件發(fā)生,事件分離器就會(huì)被激活,分離器就會(huì)調(diào)用“讀就緒事件處理器”;
4)此時(shí)讀就緒處理器就知道有數(shù)據(jù)可以讀了,然后開始讀取數(shù)據(jù),把讀到的數(shù)據(jù)提交程序使用。
Proactor模型
1)應(yīng)用程序在事件分離器上注冊(cè)“讀完成事件”和“讀完成事件處理器”,并向操作系統(tǒng)發(fā)出異步讀請(qǐng)求;
2)事件分離器會(huì)等待操作系統(tǒng)完成數(shù)據(jù)讀取;
3)在操作系統(tǒng)完成數(shù)據(jù)的讀取并將結(jié)果數(shù)據(jù)存入用戶自定義緩沖區(qū)后會(huì)通知事件分離器讀操作完成;
4)事件分離器監(jiān)聽到“讀完成事件”后會(huì)激活“讀完成事件處理器”;
5)讀完成事件處理器此時(shí)就可以把讀取到的數(shù)據(jù)提供給應(yīng)用程序使用。
由此可以看出它們的主要區(qū)別為:在Reactor模型中,應(yīng)用程序需要負(fù)責(zé)數(shù)據(jù)的讀取操作;而在Proactor模型中,應(yīng)用程序不需要負(fù)責(zé)讀取數(shù)據(jù)。由此可以看出,AIO的處理流程如下所示:
1)每個(gè)socket連接在事件分離器注冊(cè)“IO完成事件”和“IO完成事件處理器”;
2)應(yīng)用程序需要進(jìn)行IO操作時(shí),會(huì)向分離器發(fā)出IO請(qǐng)求并把所需的Buffer區(qū)域告訴分離器,分離器則會(huì)通知操作系統(tǒng)進(jìn)行IO操作;
3)操作系統(tǒng)則嘗試IO操作,等操作完成后會(huì)通知分離器;
4)分離器檢測(cè)到IO完成事件后,就激活I(lǐng)O完成事件處理器,處理器會(huì)通知應(yīng)用程序,接著應(yīng)用程序就可以直接從Buffer區(qū)進(jìn)行數(shù)據(jù)的讀寫。
在AIO socket編程中,服務(wù)端通道是AsynchronousServerSocketChannel,這個(gè)類提供了一個(gè)open()靜態(tài)工廠,一個(gè)bind()方法用于綁定服務(wù)端IP地址(還有端口號(hào)),另外還提供了accept()用于接收用戶連接請(qǐng)求。在客戶端使用的通道是AsynchronousSocketChannel,這個(gè)通道除了提供open靜態(tài)工廠方法外,還提供了read和write方法。
在AIO編程中,當(dāng)應(yīng)用程序發(fā)出一個(gè)事件(accept、read或write等)后需要指定事件處理類(也就是回調(diào)函數(shù)),AIO中使用的事件處理類是CompletionHandler<V,A>,這個(gè)接口有如下兩個(gè)方法:分別在異步操作成功和失敗時(shí)被回調(diào)。

下面給出一個(gè)簡(jiǎn)單的AIO的使用示例,在實(shí)例中服務(wù)器端只是簡(jiǎn)單地回顯客戶端發(fā)送的數(shù)據(jù)。
服務(wù)端代碼:


客戶端代碼:


- TypeScript入門與實(shí)戰(zhàn)
- Julia機(jī)器學(xué)習(xí)核心編程:人人可用的高性能科學(xué)計(jì)算
- Hadoop+Spark大數(shù)據(jù)分析實(shí)戰(zhàn)
- 實(shí)戰(zhàn)Java高并發(fā)程序設(shè)計(jì)(第3版)
- Node.js Design Patterns
- Unity 2D Game Development Cookbook
- 計(jì)算機(jī)應(yīng)用基礎(chǔ)教程(Windows 7+Office 2010)
- 分布式數(shù)據(jù)庫(kù)原理、架構(gòu)與實(shí)踐
- Hadoop大數(shù)據(jù)分析技術(shù)
- Tableau Dashboard Cookbook
- Python 快速入門(第3版)
- RESTful Web API Design with Node.js
- Java EE基礎(chǔ)實(shí)用教程
- 軟件再工程:優(yōu)化現(xiàn)有軟件系統(tǒng)的方法與最佳實(shí)踐
- ServiceDesk Plus 8.x Essentials