深入分析 I/O 的工作機(jī)制
點(diǎn)擊上方“程序員大白”,選擇“星標(biāo)”公眾號(hào)
重磅干貨,第一時(shí)間送達(dá)

不管是磁盤還是網(wǎng)絡(luò)傳輸,最小的存儲(chǔ)單元都是字節(jié),而不是字符,所以I/O操作的都是字節(jié)而不是字符。但是我們的程序中通常操作的數(shù)據(jù)都是字符形式的。
基于字節(jié)的I/O操作接口輸入和輸出分別是InputStream和OutputStream
寫字符的I/O操作接口涉及的是write(char[] buf, int off, int len)
讀字符的I/O操作是read(char[] buf, int off, int len)
字節(jié)與字符的轉(zhuǎn)化接口
數(shù)據(jù)持久化或網(wǎng)絡(luò)傳輸都是以字節(jié)進(jìn)行的,所以必須要有字符到字節(jié)或者字節(jié)到字符的轉(zhuǎn)化。
幾種訪問文件的方式
讀取和寫入文件I/O操作都調(diào)用操作系統(tǒng)提供的接口,因?yàn)榇疟P設(shè)備是由操作系統(tǒng)管理的,應(yīng)用程序要訪問物理設(shè)備只能通過系統(tǒng)調(diào)用的方式來工作。
只要是系統(tǒng)調(diào)用就可能存在內(nèi)核空間地址和用戶空間地址切換的問題,這是操作系統(tǒng)為了保護(hù)系統(tǒng)本身的運(yùn)行安全而將內(nèi)核程序運(yùn)行使用的內(nèi)存空間和用戶程序運(yùn)行的內(nèi)存空間隔離造成的。這樣雖然保證了內(nèi)核程序運(yùn)行的安全性,但是也必然存在數(shù)據(jù)可能需要從內(nèi)核空間向用戶空間復(fù)制的問題。
如果遇到非常耗時(shí)的操作,如磁盤I/O,數(shù)據(jù)從磁盤復(fù)制到內(nèi)核空間,然后又從內(nèi)核空間復(fù)制到用戶空間,將會(huì)非常緩慢。這時(shí)操作系統(tǒng)為了加速I/O訪問,在內(nèi)核空間使用緩存機(jī)制,也就是將從磁盤讀取的文件按照一定的組織方式進(jìn)行緩存。
標(biāo)準(zhǔn)訪問文件方式
當(dāng)應(yīng)用程序調(diào)用read()接口時(shí),操作系統(tǒng)檢查內(nèi)核的告訴緩存中有沒有需要的數(shù)據(jù)。如果已經(jīng)緩存了,那么就直接從緩存中返回;如果沒有,從磁盤中讀取,然后緩存在操作系統(tǒng)的緩存中。
當(dāng)應(yīng)用程序調(diào)用write()接口時(shí),將數(shù)據(jù)從用戶地址空間復(fù)制到內(nèi)核地址空間的緩存中。這時(shí),對(duì)用戶程序來說,寫操作就已經(jīng)完成了,至于什么時(shí)候再寫到磁盤中是有操作系統(tǒng)決定的,除非顯式地調(diào)用sync同步命令
直接I/O方式
應(yīng)用程序直接訪問磁盤數(shù)據(jù),而不經(jīng)過操作系統(tǒng)內(nèi)核數(shù)據(jù)緩沖區(qū),這樣做的目的就是減少一次從內(nèi)核緩沖區(qū)到用戶程序緩存的數(shù)據(jù)復(fù)制。
這種訪問文件的方式通常是在對(duì)數(shù)據(jù)的緩存管理由應(yīng)用程序?qū)崿F(xiàn)的數(shù)據(jù)庫管理程序中。(如數(shù)據(jù)庫管理系統(tǒng)中,系統(tǒng)明確地知道應(yīng)該緩存哪些數(shù)據(jù),應(yīng)該失效哪些數(shù)據(jù),還可以對(duì)一些熱點(diǎn)數(shù)據(jù)做預(yù)加載,提前將熱點(diǎn)數(shù)據(jù)加載到內(nèi)存,可以加速數(shù)據(jù)的訪問效率;而操作系統(tǒng)并不知道哪些是熱點(diǎn)數(shù)據(jù),只是簡(jiǎn)單地緩存最近一次從磁盤讀取的數(shù)據(jù))
缺點(diǎn):如果訪問的數(shù)據(jù)不在應(yīng)用程序緩存中,那么每次數(shù)據(jù)都會(huì)直接從磁盤加載。這種直接加載會(huì)非常緩慢。
同步訪問文件方式
數(shù)據(jù)的讀取和寫入都是同步操作的,它與標(biāo)準(zhǔn)訪問文件方式不同的是,只有當(dāng)數(shù)據(jù)被成功寫到磁盤時(shí)才返回給應(yīng)用程序成功標(biāo)志。
這種訪問文件方式性能比較差,只有在一些對(duì)數(shù)據(jù)安全性要求比較高的場(chǎng)景中才會(huì)使用,而且通常這種操作方式的硬件都是定制的。
異步訪問文件方式
當(dāng)訪問數(shù)據(jù)的線程發(fā)出請(qǐng)求之后,線程會(huì)接著去處理其他事情,而不是阻塞等待,當(dāng)請(qǐng)求的數(shù)據(jù)返回后繼續(xù)處理下面的操作。這種訪問文件的方式可以明顯地提高應(yīng)用程序的效率,但是不會(huì)改變?cè)L問文件的效率。
內(nèi)存映射方式
內(nèi)存映射方式是指操作系統(tǒng)將內(nèi)存中的某一塊區(qū)域與磁盤中的文件關(guān)聯(lián)起來,當(dāng)要訪問內(nèi)存中一段數(shù)據(jù)時(shí),轉(zhuǎn)換為訪問文件的某一段數(shù)據(jù)。這種方式的目的同樣是減少數(shù)據(jù)從內(nèi)核空間緩存到用戶空間緩存的數(shù)據(jù)復(fù)制操作,因?yàn)檫@兩個(gè)空間的數(shù)據(jù)是共享的。
Java訪問磁盤文件
數(shù)據(jù)在磁盤中的唯一最小描述就是文件,也就是說上層應(yīng)用程序只能通過文件來操作磁盤上的數(shù)據(jù),文件也是操作系統(tǒng)和磁盤驅(qū)動(dòng)器交互的最小單元。
Java中通常的File并不代表一個(gè)真實(shí)存在的文件對(duì)象,當(dāng)你指定一個(gè)路徑描述符時(shí),它就會(huì)返回一個(gè)代表這個(gè)路徑的一個(gè)虛擬對(duì)象,這個(gè)可能是一個(gè)真實(shí)存在的文件或者是一個(gè)包含多個(gè)文件的目錄。
如何從磁盤讀取一段文本字符:
當(dāng)傳入一個(gè)文件路徑時(shí),將會(huì)根據(jù)這個(gè)路徑創(chuàng)建一個(gè)File對(duì)象來標(biāo)識(shí)這個(gè)文件,然后根據(jù)這個(gè)File對(duì)象創(chuàng)建真正讀取文件的操作對(duì)象,這時(shí)將會(huì)真正創(chuàng)建一個(gè)關(guān)聯(lián)真實(shí)存在的磁盤文件的文件描述符FileDescriptor,通過這個(gè)對(duì)象可以直接控制這個(gè)磁盤文件。
由于我們需要讀取的是字符格式,所以需要StreamDecoder類將byte解碼為char格式。
Java序列化
Java序列化就是將一個(gè)對(duì)象轉(zhuǎn)化成一串二進(jìn)制表示的字節(jié)數(shù)組,通過保存或轉(zhuǎn)移這些字節(jié)數(shù)據(jù)來達(dá)到持久化的目的。需要持久化,對(duì)象必須繼承java.io.Serializable接口。
反序列化則是相反的過程,將這個(gè)字節(jié)數(shù)組再重新構(gòu)造成對(duì)象。
網(wǎng)絡(luò)I/O工作機(jī)制
TCP狀態(tài)轉(zhuǎn)化

1、CLOSED:起始點(diǎn),在超時(shí)或者連接關(guān)閉時(shí)進(jìn)入此狀態(tài)
2、LISTEN:Server端在等待連接時(shí)的狀態(tài),Server端為此要調(diào)用Scok
影響網(wǎng)絡(luò)傳輸?shù)囊蛩?/span>
將一份數(shù)據(jù)從一個(gè)地方正確地傳輸?shù)搅硪粋€(gè)地方所需要的時(shí)間我們稱為響應(yīng)時(shí)間。影響這個(gè)響應(yīng)時(shí)間的因素有很多。
網(wǎng)絡(luò)帶寬
傳輸距離
TCP擁塞控制
TCP傳輸是一個(gè)停-等-停-等協(xié)議,傳輸放和接受方的步調(diào)要一致,要達(dá)到這個(gè)步調(diào)一致就要通過擁塞控制來調(diào)節(jié)。TCP在傳輸時(shí)會(huì)設(shè)定一個(gè)窗口(BDP,Brandwidth Delay Product),這個(gè)窗口的大小是由帶寬和RTT(Round-Trip Time,數(shù)據(jù)在兩端的來回時(shí)間,也就是響應(yīng)時(shí)間)決定的。計(jì)算的公式是帶寬(b/s)?*?RTT(s)。通過這個(gè)值可以得出理論上最優(yōu)的TCP緩沖區(qū)的大小。
Java Socket的工作機(jī)制
Socket描述計(jì)算機(jī)之間完成相互通信的一種抽象功能。
打個(gè)比方,可以把Socket比作兩個(gè)城市之間的交通工具,有了它,就可以在城市之間來回穿梭了、交通工具有多種,每種交通工具也有相應(yīng)的交通規(guī)則。Socket也一樣,也有多種。大部分情況我們使用的是基于TCP/IP的流套接字,它是一種穩(wěn)定的通信協(xié)議。

主機(jī)A的應(yīng)用程序要能和主機(jī)B的應(yīng)用程序通信,必須通過Socket建立連接,而建立Socket連接必須由底層TCP/IP協(xié)議來建立TCP連接。建立TCP連接需要底層IP協(xié)議來尋址網(wǎng)絡(luò)中的主機(jī)。網(wǎng)絡(luò)層使用的IP協(xié)議可以幫助我們根據(jù)IP地址來找到目標(biāo)主機(jī),但是一臺(tái)主機(jī)上可能運(yùn)行著多個(gè)應(yīng)用程序,如何才能與指定的應(yīng)用程序通信就要通過TCP或UDP的地址,也就是端口號(hào)來指定了。
建立通信鏈路
當(dāng)客戶端要與服務(wù)端通信時(shí),客戶端首先要?jiǎng)?chuàng)建一個(gè)Socket實(shí)例,操作系統(tǒng)將為這個(gè)Socket實(shí)例分配一個(gè)沒有被使用的本地端口號(hào),并創(chuàng)建一個(gè)包含本地和遠(yuǎn)程地址和端口號(hào)的套接字?jǐn)?shù)據(jù)結(jié)構(gòu),這個(gè)數(shù)據(jù)結(jié)構(gòu)將一直保存在系統(tǒng)中直到這個(gè)連接關(guān)閉。
在創(chuàng)建Socket實(shí)例的構(gòu)造函數(shù)正確返回之前,將要進(jìn)行TCP的三次握手協(xié)議,TCP握手協(xié)議完成后,Socket實(shí)例對(duì)象將創(chuàng)建完成,否則將拋出IOException錯(cuò)誤。
數(shù)據(jù)傳輸
當(dāng)連接已經(jīng)建立成功,服務(wù)端和客戶端都會(huì)擁有一個(gè)Socket實(shí)例,每個(gè)Socket實(shí)例都有一個(gè)InputStream和OutputStream,并通過這兩個(gè)對(duì)象來交換數(shù)據(jù)。
當(dāng)創(chuàng)建Socket對(duì)象時(shí),操作系統(tǒng)將會(huì)為InputStream和OutputStream分別分配一定大小的緩存區(qū),數(shù)據(jù)的寫入和讀取都是通過這個(gè)緩存區(qū)完成的。
寫入端將數(shù)據(jù)寫到OutputStream對(duì)應(yīng)的SendQ隊(duì)列中,當(dāng)隊(duì)列填滿時(shí),數(shù)據(jù)將被轉(zhuǎn)移到另一端InputStream的RecvQ隊(duì)列中,如果這時(shí)RecvQ已經(jīng)滿了,那么OutputStream的write方法將會(huì)阻塞知道RecvQ隊(duì)列有足夠的空間容納SendQ發(fā)送的數(shù)據(jù)。
NIO的工作方式
BIO帶來的挑戰(zhàn)
BIO即阻塞IO,不管是磁盤IO還是網(wǎng)絡(luò)IO,數(shù)據(jù)在寫入OutputStream或者從InputStream讀取時(shí)都有可能會(huì)阻塞,一旦有阻塞,線程將會(huì)失去CPU的使用權(quán)。
NIO的工作機(jī)制

這里的Channel可以比作某種具體的交通工具,如汽車或高鐵;
而Selector可以比作一個(gè)車站的車輛運(yùn)行調(diào)度系統(tǒng),它將負(fù)責(zé)監(jiān)控每輛車的當(dāng)前運(yùn)行狀態(tài),是已經(jīng)出站,還是在路上的。也就是它可以輪訓(xùn)每個(gè)Channel的狀態(tài)。
Buffer可以比作車上的座位。信息已經(jīng)封裝在了Socket里面,對(duì)你是透明的。
public void selector() throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.socket().bind(new InetSocketAddress(8080));
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
while (it.hasNext()) {
SelectionKey key = (SelectionKey) it.next();
if ((key.readOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel();
SocketChannel sc = ssChannel.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
it.remove();
}
}
}
}
調(diào)用
Selector的靜態(tài)工廠創(chuàng)建一個(gè)選擇器創(chuàng)建一個(gè)服務(wù)端的
Channel,綁定到一個(gè)Socket對(duì)象,并把這個(gè)通信信道注冊(cè)到選擇器上,把這個(gè)通信信道設(shè)置為非阻塞模式然后就可以調(diào)用
Selector的selectedKeys方法來檢查已經(jīng)注冊(cè)在這個(gè)選擇器上的所有通信信道是否有需要的事件發(fā)生。如果有某個(gè)事件發(fā)生,將會(huì)返回所有的
SelectionKey,通過這個(gè)對(duì)象Channel方法就可以取得這個(gè)通信信道對(duì)象,從而可以讀取通信的數(shù)據(jù)而這里讀取的數(shù)據(jù)是
Buffer,這個(gè)Buffer是我們可以控制的緩沖器
Selector可以同時(shí)監(jiān)聽一組通信信道(Channel)上的IO狀態(tài),前提是這個(gè)Selector已經(jīng)注冊(cè)到這些通信信道中。選擇器
Selector可以調(diào)用select()方法檢查已經(jīng)注冊(cè)的通信信道上IO是否已經(jīng)準(zhǔn)備好,如果沒有一個(gè)信道IO狀態(tài)有變化,那么select方法會(huì)阻塞等待或在超時(shí)后返回0。如果有多個(gè)信道有數(shù)據(jù),那么將會(huì)把這些數(shù)據(jù)分配到對(duì)應(yīng)的數(shù)據(jù)
Buffer中。所以關(guān)鍵的地方是,有一個(gè)線程來處理所有連接的數(shù)據(jù)交互,每個(gè)連接的數(shù)據(jù)交互都不是阻塞方式,所以可以同時(shí)處理大量的連接請(qǐng)求。
Buffer的工作方式
Selector檢測(cè)到通信信道IO有數(shù)據(jù)傳輸時(shí),通過select()取得SocketChannel,將數(shù)據(jù)讀取或?qū)懭?/span>Buffer緩沖區(qū)。
Buffer可以簡(jiǎn)單地理解為一組基本數(shù)據(jù)類型的元素列表,它通過幾個(gè)變量來保存這個(gè)數(shù)據(jù)的當(dāng)前位置狀態(tài),也就是有四個(gè)索引。
capacity:緩沖區(qū)數(shù)組的總長(zhǎng)度position:下一個(gè)要操作的數(shù)據(jù)元素的位置limit:緩沖區(qū)數(shù)組中不可操作的下一個(gè)元素的位置,limit<=capacitymark:用于記錄當(dāng)前position的前一個(gè)位置或者默認(rèn)是0

我們通過ByteBuffer.allocate(11)方法創(chuàng)建一個(gè)11個(gè)byte的數(shù)組緩沖區(qū),初始狀態(tài)時(shí),position為0,capactiy和limit默認(rèn)都是數(shù)組長(zhǎng)度。
當(dāng)我們寫入5個(gè)字節(jié)時(shí),位置變化如下:

這時(shí),我們需要將緩沖區(qū)的5個(gè)字節(jié)數(shù)據(jù)寫入Channel通信信道,所以我們調(diào)用byteBuffer.flip()方法

這時(shí),底層操作系統(tǒng)就可以從緩沖區(qū)中正確讀取這5個(gè)字節(jié)數(shù)據(jù),并發(fā)送出去了。在下一次寫數(shù)據(jù)之前,我們?cè)僬{(diào)一下clear()方法,緩沖區(qū)的索引狀態(tài)又回到初始位置。
當(dāng)我們調(diào)用mark()時(shí),它將記錄當(dāng)前position的前一個(gè)位置,當(dāng)我們調(diào)用reset時(shí),position將恢復(fù)mark記錄下來的值。
通過Channel獲取的IO數(shù)據(jù)首先要經(jīng)過操作系統(tǒng)的Socket緩沖區(qū)再將數(shù)據(jù)復(fù)制到Buffer中,這個(gè)操作系統(tǒng)緩沖區(qū)就是底層的TCP協(xié)議關(guān)聯(lián)的RecvQ或者SendQ隊(duì)列。
從操作系統(tǒng)緩沖區(qū)到用戶緩沖區(qū)復(fù)制數(shù)據(jù)比較耗性能,Buffer提供了另外一種直接操作操作系統(tǒng)緩沖區(qū)的方式,即ByteBuffer.allocateDirector(size),這個(gè)方法返回的DirectByteBuffer就是與底層存儲(chǔ)空間關(guān)聯(lián)的緩沖區(qū),它通過Native代碼操作非JVM堆的內(nèi)存空間。
NIO的數(shù)據(jù)訪問方式
FileChannel.transferXXX
FileChannel.transferXXX與傳統(tǒng)的訪問文件方式相比可以減少數(shù)據(jù)從內(nèi)核到用戶空間的復(fù)制,數(shù)據(jù)直接在內(nèi)核空間中移動(dòng)。


FileChannel.map
將文件按照一定大小映射為內(nèi)存區(qū)域,當(dāng)程序訪問這個(gè)內(nèi)存區(qū)域時(shí)將直接操作這個(gè)文件數(shù)據(jù),這種方式省去了數(shù)據(jù)從內(nèi)核空間向用戶空間復(fù)制的損耗。這種方式適合對(duì)大文件的只讀性操作,如大文件的MD5校驗(yàn)。
適配器模式裝飾器模式的區(qū)別
適配器模式的意義是要將一個(gè)接口轉(zhuǎn)變成另外一個(gè)接口,它的目的是通過改變接口來達(dá)到重復(fù)使用的目的
裝飾器模式不是要改變被裝飾對(duì)象的接口,而是要保持原有的接口,但是增強(qiáng)原有對(duì)象的功能,或者改變?cè)袑?duì)象的處理方式而提升性能。
source: //nathanchen.github.io/14588873943004.html
推薦閱讀
關(guān)于程序員大白
程序員大白是一群哈工大,東北大學(xué),西湖大學(xué)和上海交通大學(xué)的碩士博士運(yùn)營(yíng)維護(hù)的號(hào),大家樂于分享高質(zhì)量文章,喜歡總結(jié)知識(shí),歡迎關(guān)注[程序員大白],大家一起學(xué)習(xí)進(jìn)步!

