JAVA IO 以及 NIO 理解

轉載:http://www.cnblogs.com/hapjin/p/5736188.html

由於Netty,瞭解了一些異步IO的知識,JAVA裏面NIO就是原來的IO的一個補充,本文主要記錄下在JAVA中IO的底層實現原理,以及對Zerocopy技術介紹。

IO,其實意味着:數據不停地搬入搬出緩衝區而已(使用了緩衝區)。比如,用戶程序發起讀操作,導致“ syscall read ”系統調用,就會把數據搬入到 一個buffer中;用戶發起寫操作,導致 “syscall write ”系統調用,將會把一個 buffer 中的數據 搬出去(發送到網絡中 or 寫入到磁盤文件)

上面的過程看似簡單,但是底層操作系統具體如何實現以及實現的細節就非常複雜了。正是因爲實現方式不同,有針對普通情況下的文件傳輸(暫且稱普通IO吧),也有針對大文件傳輸或者批量大數據傳輸的實現方式,比如zerocopy技術。

先來看一張普通的IO處理的流程圖:

 

整個IO過程的流程如下:

1)程序員寫代碼創建一個緩衝區(這個緩衝區是用戶緩衝區):哈哈。然後在一個while循環裏面調用read()方法讀數據(觸發"syscall read"系統調用)

byte[] b = new byte[4096];

while((read = inputStream.read(b))>=0) {
        total = total + read;
            // other code....
        }

2)當執行到read()方法時,其實底層是發生了很多操作的:

①內核給磁盤控制器發命令說:我要讀磁盤上的某某塊磁盤塊上的數據。--kernel issuing a command to the disk controller hardware to fetch the data from disk.

②在DMA的控制下,把磁盤上的數據讀入到內核緩衝區。--The disk controller writes the data directly into a kernel memory buffer by DMA

③內核把數據從內核緩衝區複製到用戶緩衝區。--kernel copies the data from the temporary buffer in kernel space

這裏的用戶緩衝區應該就是我們寫的代碼中 new 的 byte[] 數組。

 

從上面的步驟中可以分析出什麼?

ⓐ對於操作系統而言,JVM只是一個用戶進程,處於用戶態空間中。而處於用戶態空間的進程是不能直接操作底層的硬件的。而IO操作就需要操作底層的硬件,比如磁盤。因此,IO操作必須得藉助內核的幫助才能完成(中斷,trap),即:會有用戶態到內核態的切換。

ⓑ我們寫代碼 new byte[] 數組時,一般是都是“隨意” 創建一個“任意大小”的數組。比如,new byte[128]、new byte[1024]、new byte[4096]....

但是,對於磁盤塊的讀取而言,每次訪問磁盤讀數據時,並不是讀任意大小的數據的,而是:每次讀一個磁盤塊或者若干個磁盤塊(這是因爲訪問磁盤操作代價是很大的,而且我們也相信局部性原理) 因此,就需要有一個“中間緩衝區”--即內核緩衝區。先把數據從磁盤讀到內核緩衝區中,然後再把數據從內核緩衝區搬到用戶緩衝區。

這也是爲什麼我們總感覺到第一次read操作很慢,而後續的read操作卻很快的原因吧。因爲,對於後續的read操作而言,它所需要讀的數據很可能已經在內核緩衝區了,此時只需將內核緩衝區中的數據拷貝到用戶緩衝區即可,並未涉及到底層的讀取磁盤操作,當然就快了。

The kernel tries to cache and/or prefetch data, so the data being requested by the process may already be available in kernel space.
If so, the data requested by the process is copied out.
If the data isn
't available, the process is suspended while the kernel goes about bringing the data into memory.

如果數據不可用,process將會被掛起,並需要等待內核從磁盤上把數據取到內核緩衝區中。

 

那我們可能會說:DMA爲什麼不直接將磁盤上的數據讀入到用戶緩衝區呢?一方面是 ⓑ中提到的內核緩衝區作爲一箇中間緩衝區。用來“適配”用戶緩衝區的“任意大小”和每次讀磁盤塊的固定大小。另一方面則是,用戶緩衝區位於用戶態空間,而DMA讀取數據這種操作涉及到底層的硬件,硬件一般是不能直接訪問用戶態空間的(OS的原因吧)

綜上,由於DMA不能直接訪問用戶空間(用戶緩衝區),普通IO操作需要將數據來回地在 用戶緩衝區 和 內核緩衝區移動,這在一定程序上影響了IO的速度。那有沒有相應的解決方案呢?

那就是直接內存映射IO,也即JAVA NIO中提到的內存映射文件,或者說 直接內存....總之,它們表達的意思都差不多。示例圖如下:

從上圖可以看出:內核空間的 buffer 與 用戶空間的 buffer 都映射到同一塊 物理內存區域。

它的主要特點如下:

①對文件的操作不需要再發read 或者 write 系統調用了---The user process sees the file data asmemory, so there is no need to issue read() or write() system calls.

②當用戶進程訪問“內存映射文件”地址時,自動產生缺頁錯誤,然後由底層的OS負責將磁盤上的數據送到內存。關於頁式存儲管理,可參考:內存分配與內存管理的一些理解

As the user process touches the mapped memory space, page faults will be generated automatically to bring in the file data from disk. 
If the user modifies the mapped memory space, the affected page is automatically marked as dirty and will be subsequently
flushed to disk to update the file.

 

這就是是JAVA NIO中提到的內存映射緩衝區(Memory-Mapped-Buffer)它類似於JAVA NIO中的直接緩衝區(Directed Buffer)。MemoryMappedBuffer可以通過java.nio.channels.FileChannel.java(通道)的 map方法創建。

使用內存映射緩衝區來操作文件,它比普通的IO操作讀文件要快得多。甚至比使用文件通道(FileChannel)操作文件 還要快。因爲,使用內存映射緩衝區操作文件時,沒有顯示的系統調用(read,write),而且OS還會自動緩存一些文件頁(memory page)

 

zerocopy技術介紹

看完了上面的IO操作的底層實現過程,再來了解zerocopy技術就很easy了。IBM有一篇名爲《Efficient data transfer through zero copy》的論文對zerocopy做了完整的介紹。感覺非常好,下面就基於這篇文來記錄下自己的一些理解。

 

zerocopy技術的目標就是提高IO密集型JAVA應用程序的性能。在本文的前面部分介紹了:IO操作需要數據頻繁地在內核緩衝區和用戶緩衝區之間拷貝,而zerocopy技術可以減少這種拷貝的次數,同時也降低了上下文切換(用戶態與內核態之間的切換)的次數。

比如,大多數WEB應用程序執行的一項操作就是:接受用戶請求--->從本地磁盤讀數據--->數據進入內核緩衝區--->用戶緩衝區--->內核緩衝區--->用戶緩衝區--->socket發送

數據每次在內核緩衝區與用戶緩衝區之間的拷貝會消耗CPU以及內存的帶寬。而zerocopy有效減少了這種拷貝次數。

Each time data traverses the user-kernel boundary, it must be copied, which consumes CPU cycles and memory bandwidth.
Fortunately, you can eliminate these copies through a technique called—appropriately enough —zero copy

 

那它是怎麼做到的呢?

我們知道,JVM(JAVA虛擬機)爲JAVA語言提供了跨平臺的一致性,屏蔽了底層操作系統的具體實現細節,因此,JAVA語言也很難直接使用底層操作系統提供的一些“奇技淫巧”。

而要實現zerocopy,首先得有操作系統的支持。其次,JDK類庫也要提供相應的接口支持。幸運的是,自JDK1.4以來,JDK提供了對NIO的支持,通過java.nio.channels.FileChannel類的transferTo()方法可以直接將字節傳送到可寫的通道中(Writable Channel),並不需要將字節送入用戶程序空間(用戶緩衝區)

You can use the transferTo()method to transfer bytes directly from the channel on which it is invoked to 
another writable byte channel, without requiring data to flow through the application

 

下面就來詳細分析一下經典的web服務器(比如文件服務器)乾的活:從磁盤中中讀文件,並把文件通過網絡(socket)發送給Client。

File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

從代碼上看,就是兩步操作。第一步:將文件讀入buf;第二步:將 buf 中的數據通過socket發送出去。但是,這兩步操作需要四次上下文切換(用戶態與內核態之間的切換) 和 四次拷貝操作才能完成。

①第一次上下文切換髮生在 read()方法執行,表示服務器要去磁盤上讀文件了,這會導致一個 sys_read()的系統調用。此時由用戶態切換到內核態,完成的動作是:DMA把磁盤上的數據讀入到內核緩衝區中(這也是第一次拷貝)。

②第二次上下文切換髮生在read()方法的返回(這也說明read()是一個阻塞調用),表示數據已經成功從磁盤上讀到內核緩衝區了。此時,由內核態返回到用戶態,完成的動作是:將內核緩衝區中的數據拷貝到用戶緩衝區(這是第二次拷貝)。

③第三次上下文切換髮生在 send()方法執行,表示服務器準備把數據發送出去了。此時,由用戶態切換到內核態,完成的動作是:將用戶緩衝區中的數據拷貝到內核緩衝區(這是第三次拷貝)

④第四次上下文切換髮生在 send()方法的返回【這裏的send()方法可以異步返回,所謂異步返回就是:線程執行了send()之後立即從send()返回,剩下的數據拷貝及發送就交給底層操作系統實現了】。此時,由內核態返回到用戶態,完成的動作是:將內核緩衝區中的數據送到 protocol engine.(這是第四次拷貝

這裏對 protocol engine不是太瞭解,但是從上面的示例圖來看:它是NIC(NetWork Interface Card) buffer。網卡的buffer???

 

下面這段話,非常值得一讀:這裏再一次提到了爲什麼需要內核緩衝區。

複製代碼
Use of the intermediate kernel buffer (rather than a direct transfer of the data
into the user buffer)might seem inefficient. But intermediate kernel buffers were 
introduced into the process to improve performance. Using the intermediate 
buffer on the read side allows the kernel buffer to act as a "readahead cache" 
when the application hasn't asked for as much data as the kernel buffer holds.
This significantly improves performance when the requested data amount is less
than the kernel buffer size. The intermediate buffer on the write side allows the write to complete asynchronously.
複製代碼

一個核心觀點就是:內核緩衝區提高了性能。咦?是不是很奇怪?因爲前面一直說正是因爲引入了內核緩衝區(中間緩衝區),使得數據來回地拷貝,降低了效率。

那先來看看,它爲什麼說內核緩衝區提高了性能。

對於讀操作而言,內核緩衝區就相當於一個“readahead cache”,當用戶程序一次只需要讀一小部分數據時,首先操作系統從磁盤上讀一大塊數據到內核緩衝區,用戶程序只取走了一小部分( 我可以只 new 了一個 128B的byte數組啊! new byte[128])。當用戶程序下一次再讀數據,就可以直接從內核緩衝區中取了,操作系統就不需要再次訪問磁盤啦!因爲用戶要讀的數據已經在內核緩衝區啦!這也是前面提到的:爲什麼後續的讀操作(read()方法調用)要明顯地比第一次快的原因。從這個角度而言,內核緩衝區確實提高了讀操作的性能。

再來看寫操作:可以做到 “異步寫”(write asynchronously)。也即:wirte(dest[]) 時,用戶程序告訴操作系統,把dest[]數組中的內容寫到XX文件中去,於是write方法就返回了。操作系統則在後臺默默地把用戶緩衝區中的內容(dest[])拷貝到內核緩衝區,再把內核緩衝區中的數據寫入磁盤。那麼,只要內核緩衝區未滿,用戶的write操作就可以很快地返回。這應該就是異步刷盤策略吧。

(其實,到這裏。以前一個糾結的問題就是同步IO,異步IO,阻塞IO,非阻塞IO之間的區別已經沒有太大的意義了。這些概念,只是針對的看問題的角度不一樣而已。阻塞、非阻塞是針對線程自身而言;同步、異步是針對線程以及影響它的外部事件而言....)【更加完美、精闢的解釋可以參考這個系列的文章:系統間通信(3)——IO通信模型和JAVA實踐 上篇

 

既然,你把內核緩衝區說得這麼強大和完美,那還要 zerocopy幹嘛啊???

Unfortunately, this approach itself can become a performance bottleneck if the size of the data requested 
is considerably larger than the kernel buffer size. The data gets copied multiple times among the disk, kernel buffer,
and user buffer before it is finally delivered to the application.
Zero copy improves performance by eliminating these redundant data copies.

終於輪到zerocopy粉墨登場了。當需要傳輸的數據遠遠大於內核緩衝區的大小時,內核緩衝區就會成爲瓶頸。這也是爲什麼zerocopy技術合適大文件傳輸的原因。內核緩衝區爲啥成爲了瓶頸?---我想,很大的一個原因是它已經起不到“緩衝”的功能了,畢竟傳輸的數據量太大了。

 

下面來看看zerocopy技術是如何來處理文件傳輸的。

當 transferTo()方法 被調用時,由用戶態切換到內核態。完成的動作是:DMA將數據從磁盤讀入 Read buffer中(第一次數據拷貝)。然後,還是在內核空間中,將數據從Read buffer 拷貝到 Socket buffer(第二次數據拷貝),最終再將數據從 Socket buffer 拷貝到 NIC buffer(第三次數據拷貝)。然後,再從內核態返回到用戶態

上面整個過程就只涉及到了:三次數據拷貝和二次上下文切換。感覺也才減少了一次數據拷貝嘛。但這裏已經不涉及用戶空間的緩衝區了。

三次數據拷貝中,也只有一次拷貝需要到CPU的干預。(第2次拷貝),而前面的傳統數據拷貝需要四次且有三次拷貝需要CPU的干預。

This is an improvement: we've reduced the number of context switches from four to two and reduced the number of data copies
from four to three (only one of which involves the CPU)

 

如果說zerocopy技術只能完成到這步,那也就 just so so 了。

We can further reduce the data duplication done by the kernel if the underlying network interface card supports 
gather operations. In Linux kernels 2.4 and later, the socket buffer descriptor was modified to accommodate this requirement.
This approach not only reduces multiple context switches but also eliminates the duplicated data copies that
require CPU involvement.

也就是說,如果底層的網絡硬件以及操作系統支持,還可以進一步減少數據拷貝次數 以及 CPU干預次數。

從上圖看出:這裏一共只有兩次拷貝 和 兩次上下文切換。而且這兩次拷貝都是DMA copy,並不需要CPU干預(嚴謹一點的話就是不完全需要吧.)。

整個過程如下:

用戶程序執行 transferTo()方法,導致一次系統調用,從用戶態切換到內核態。完成的動作是:DMA將數據從磁盤中拷貝到Read buffer

用一個描述符標記此次待傳輸數據的地址以及長度,DMA直接把數據從Read buffer 傳輸到 NIC buffer。數據拷貝過程都不用CPU干預了。

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