什么是 I/O
I/O 是機(jī)器獲取和交換信息的主要渠道,而流是完成 I/O 操作的主要方式。
在計(jì)算機(jī)中,流是一種信息的轉(zhuǎn)換。流是有序的,因此相對于某一機(jī)器或者應(yīng)用程序而言, 通常把機(jī)器或者應(yīng)用程序接收外界的信息稱為輸入流(InputStream),從機(jī)器或者應(yīng) 用程序向外輸出的信息稱為輸出流(OutputStream),合稱為輸入 / 輸出流(I/O Streams)。
機(jī)器間或程序間在進(jìn)行信息交換或者數(shù)據(jù)交換時(shí),總是先將對象或數(shù)據(jù)轉(zhuǎn)換為某種形式的流,再通過流的傳輸,到達(dá)指定機(jī)器或程序后,再將流轉(zhuǎn)換為對象數(shù)據(jù)。因此,流就可以被看作是一種數(shù)據(jù)的載體,通過它可以實(shí)現(xiàn)數(shù)據(jù)交換和傳輸。
Java 的 I/O 操作類在包 java.io 下,其中 InputStream、OutputStream 以及 Reader、 Writer 類是 I/O 包中的 4 個(gè)基本類,它們分別處理字節(jié)流和字符流。
不管是文件讀寫還是網(wǎng)絡(luò)發(fā)送接收,信息的最小存儲(chǔ)單元都是字 節(jié),那為什么 I/O 流操作要分為字節(jié)流操作和字符流操作呢?
字符到字節(jié)必須經(jīng)過轉(zhuǎn)碼,這個(gè)過程非常耗時(shí),如果我們不知道編碼類型就很容易出現(xiàn)亂碼問題。所以 I/O 流提供了一個(gè)直接操作字符的接口,方便我們平時(shí)對字符進(jìn)行流操作。下面我們就分別了解下“字節(jié)流”和“字符流”。
字節(jié)流
InputStream/OutputStream 是字節(jié)流的抽象類,這兩個(gè)抽象類又派生出了若干子類,不同的子類分別處理不同的操作類型。如果是文件的讀寫操作,就使用FileInputStream/FileOutputStream;如果是數(shù)組的讀寫操作,就使用ByteArrayInputStream/ByteArrayOutputStream;如果是普通字符串的讀寫操作,就使用 BufferedInputStream/BufferedOutputStream。字符流
Reader/Writer 是字符流的抽象類,這兩個(gè)抽象類也派生出了若干子類,不同的子類分別處理不同的操作類型
傳統(tǒng) I/O 的性能問題
I/O 操作分為磁盤 I/O 操作和網(wǎng)絡(luò) I/O 操作。前者是從磁盤中讀取數(shù)據(jù)源輸入到內(nèi)存中,之后將讀取的信息持久化輸出在物理磁盤上;后者是從網(wǎng)絡(luò)中讀取信息輸入到內(nèi)存,最終將信息輸出到網(wǎng)絡(luò)中。但不管是磁盤 I/O 還是網(wǎng)絡(luò) I/O,在傳統(tǒng) I/O 中都存在嚴(yán)重的性能問題。
- 多次內(nèi)存復(fù)制
在傳統(tǒng) I/O 中,通過 InputStream 從源數(shù)據(jù)中讀取數(shù)據(jù)流輸入到緩沖區(qū)里,通過OutputStream 將數(shù)據(jù)輸出到外部設(shè)備(包括磁盤、網(wǎng)絡(luò))。
JVM 會(huì)發(fā)出 read() 系統(tǒng)調(diào)用,并通過 read 系統(tǒng)調(diào)用向內(nèi)核發(fā)起讀請求;
內(nèi)核向硬件發(fā)送讀指令,并等待讀就緒;
內(nèi)核把將要讀取的數(shù)據(jù)復(fù)制到指向的內(nèi)核緩存中;
操作系統(tǒng)內(nèi)核將數(shù)據(jù)復(fù)制到用戶空間緩沖區(qū),然后 read 系統(tǒng)調(diào)用返回。
在這個(gè)過程中,數(shù)據(jù)先從外部設(shè)備復(fù)制到內(nèi)核空間,再從內(nèi)核空間復(fù)制到用戶空間,發(fā)生了兩次內(nèi)存復(fù)制操作。這種操作會(huì)導(dǎo)致不必要的數(shù)據(jù)拷貝和上下文切換,從而降低 I/O的性能。
- 阻塞
在傳統(tǒng) I/O 中,InputStream 的 read() 是一個(gè) while 循環(huán)操作,它會(huì)一直等待數(shù)據(jù)讀取,直到數(shù)據(jù)就緒才會(huì)返回。這就意味著如果沒有數(shù)據(jù)就緒,這個(gè)讀取操作將會(huì)一直被掛起,用戶線程將會(huì)處于阻塞狀態(tài)。
在少量連接請求的情況下,使用這種方式?jīng)]有問題,響應(yīng)速度也很高。但在發(fā)生大量連接請求時(shí),就需要?jiǎng)?chuàng)建大量監(jiān)聽線程,這時(shí)如果線程沒有數(shù)據(jù)就緒就會(huì)被掛起,然后進(jìn)入阻塞狀態(tài)。一旦發(fā)生線程阻塞,這些線程將會(huì)不斷地?fù)寠Z CPU 資源,從而導(dǎo)致大量的 CPU 上下文切換,增加系統(tǒng)的性能開銷。
如何優(yōu)化 I/O 操作
面對以上兩個(gè)性能問題,不僅編程語言對此做了優(yōu)化,各個(gè)操作系統(tǒng)也進(jìn)一步優(yōu)化了 I/O。JDK1.4 發(fā)布了 java.nio 包(new I/O 的縮寫),NIO 的發(fā)布優(yōu)化了內(nèi)存復(fù)制以及阻塞導(dǎo)致的嚴(yán)重性能問題。JDK1.7 又發(fā)布了 NIO2,提出了從操作系統(tǒng)層面實(shí)現(xiàn)的異步 I/O。
- 使用緩沖區(qū)優(yōu)化讀寫流操作
在傳統(tǒng) I/O 中,提供了基于流的 I/O 實(shí)現(xiàn),即 InputStream 和 OutputStream,這種基于流的實(shí)現(xiàn)以字節(jié)為單位處理數(shù)據(jù)。
NIO 與傳統(tǒng) I/O 不同,它是基于塊(Block)的,它以塊為基本單位處理數(shù)據(jù)。在 NIO 中,最為重要的兩個(gè)組件是緩沖區(qū)(Buffer)和通道(Channel)。Buffer 是一塊連續(xù)的內(nèi) 存塊,是 NIO 讀寫數(shù)據(jù)的中轉(zhuǎn)地。Channel 表示緩沖數(shù)據(jù)的源頭或者目的地,它用于讀取緩沖或者寫入數(shù)據(jù),是訪問緩沖的接口。
傳統(tǒng) I/O 和 NIO 的最大區(qū)別就是傳統(tǒng) I/O 是面向流,NIO 是面向 Buffer。Buffer 可以將文件一次性讀入內(nèi)存再做后續(xù)處理,而傳統(tǒng)的方式是邊讀文件邊處理數(shù)據(jù)。雖然傳統(tǒng) I/O后面也使用了緩沖塊,例如 BufferedInputStream,但仍然不能和 NIO 相媲美。使用 NIO 替代傳統(tǒng) I/O 操作,可以提升系統(tǒng)的整體性能,效果立竿見影。
- 使用 DirectBuffer 減少內(nèi)存復(fù)制
NIO 的 Buffer 除了做了緩沖塊優(yōu)化之外,還提供了一個(gè)可以直接訪問物理內(nèi)存的類 DirectBuffer。普通的 Buffer 分配的是 JVM 堆內(nèi)存,而 DirectBuffer 是直接分配物理內(nèi)存。
數(shù)據(jù)要輸出到外部設(shè)備,必須先從用戶空間復(fù)制到內(nèi)核空間,再復(fù)制到輸出設(shè)備, 而 DirectBuffer 則是直接將步驟簡化為從內(nèi)核空間復(fù)制到外部設(shè)備,減少了數(shù)據(jù)拷貝。
由于 DirectBuffer 申請的是非 JVM 的物理內(nèi)存,所以創(chuàng)建和銷毀的代價(jià)很高。DirectBuffer 申請的內(nèi)存并不是直接由 JVM 負(fù)責(zé)垃圾回收,但在 DirectBuffer 包裝類被回收時(shí),會(huì)通過 Java Reference 機(jī)制來釋放該內(nèi)存塊。
- 避免阻塞,優(yōu)化 I/O 操作
NIO 很多人也稱之為 Non-block I/O,即非阻塞 I/O,因?yàn)檫@樣叫,更能體現(xiàn)它的特點(diǎn)。
傳統(tǒng)的 I/O 即使使用了緩沖塊,依然存在阻塞問題。由于線程池線程數(shù)量有限,一旦發(fā)生 大量并發(fā)請求,超過最大數(shù)量的線程就只能等待,直到線程池中有空閑的線程可以被復(fù)用。
而對 Socket 的輸入流進(jìn)行讀取時(shí),讀取流會(huì)一直阻塞,直到發(fā)生以下三種情況的任意一種才會(huì)解除阻塞:
有數(shù)據(jù)可讀;
連接釋放;
空指針或 I/O 異常。
阻塞問題,就是傳統(tǒng) I/O 最大的弊端。NIO 發(fā)布后,通道和多路復(fù)用器這兩個(gè)基本組件實(shí) 現(xiàn)了 NIO 的非阻塞。
通道(Channel)
傳統(tǒng) I/O 的數(shù)據(jù)讀取和寫入是從用戶空間到內(nèi)核空間來回復(fù)制,而內(nèi)核 空間的數(shù)據(jù)是通過操作系統(tǒng)層面的 I/O 接口從磁盤讀取或?qū)懭搿?/p>
在應(yīng)用程序調(diào)用操作系統(tǒng) I/O 接口時(shí),是由 CPU 完成分配,這種方式最大的問題 是“發(fā)生大量 I/O 請求時(shí),非常消耗 CPU“;之后,操作系統(tǒng)引入了 DMA(直接存儲(chǔ)器 存儲(chǔ)),內(nèi)核空間與磁盤之間的存取完全由 DMA 負(fù)責(zé),但這種方式依然需要向 CPU 申請權(quán)限,且需要借助 DMA 總線來完成數(shù)據(jù)的復(fù)制操作,如果 DMA 總線過多,就會(huì)造成總線沖突。
通道的出現(xiàn)解決了以上問題,Channel 有自己的處理器,可以完成內(nèi)核空間和磁盤之間的 I/O 操作。
在 NIO 中,我們讀取和寫入數(shù)據(jù)都要通過 Channel,由于 Channel 是雙向的,所以讀、寫可以同時(shí)進(jìn)行。
多路復(fù)用器(Selector)
Selector 是 Java NIO 編程的基礎(chǔ)。用于檢查一個(gè)或多個(gè) NIO Channel 的狀態(tài)是否處于可讀、可寫。
Selector 是基于事件驅(qū)動(dòng)實(shí)現(xiàn)的,我們可以在 Selector 中注冊 accpet、read 監(jiān)聽事件,Selector 會(huì)不斷輪詢注冊在其上的 Channel,如果某個(gè) Channel 上面發(fā)生監(jiān)聽事件,這個(gè) Channel 就處于就緒狀態(tài),然后進(jìn)行 I/O 操作。
一個(gè)線程使用一個(gè) Selector,通過輪詢的方式,可以監(jiān)聽多個(gè) Channel 上的事件???以在注冊 Channel 時(shí)設(shè)置該通道為非阻塞,當(dāng) Channel 上沒有 I/O 操作時(shí),該線程就不 會(huì)一直等待了,而是會(huì)不斷輪詢所有 Channel,從而避免發(fā)生阻塞。
目前操作系統(tǒng)的 I/O 多路復(fù)用機(jī)制都使用了 epoll,相比傳統(tǒng)的 select 機(jī)制,epoll 沒有最大連接句柄 1024 的限制。所以 Selector 在理論上可以輪詢成千上萬的客戶端。