【Java難點攻克】「NIO和內存映射性能提升系列」徹底透析NIO底層的內存映射機制原理與Direct Memory的關係

NIO與內存映射文件

Java類庫中的NIO包相對於IO包來說有一個新功能就是 【內存映射文件】,在業務層面的日常開發過程中並不是經常會使用,但是一旦在處理大文件時是比較理想的提高效率的手段,之前已經在基於API和開發實戰角度介紹了相關的大文件讀取以及NIO操作的實現,而本文主要想結合操作系統(OS)底層中相關方面的內容進行分析原理,夯實大家對IO模型及操作系統相關的底層知識體系。

下圖就是Java應用程序以及操作系統OS內核的調用關係圖:

我們會針對於操作系統與應用程序之間建立的關係去分析IO處理底層機制。

傳統的IO技術

在傳統的文件IO操作中, 我們都是調用操作系統提供的底層標準IO系統調用函數read()、write() , 此時調用此函數的進程(Java進程) 由當前的用戶態切換到內核態, 然後OS的內核代碼負責將相應的文件數據讀取到內核的IO緩衝區,然後再把數據從內核IO緩衝區拷貝到進程的私有地址空間中去,這樣便完成了一次IO操作,如下圖所示。

程序的局部性原理

爲什麼需要內核IO緩衝區

至於爲什麼要多此一舉搞一個內核IO緩衝區把原本只需一次拷貝數據的事情搞成需要2次數據拷貝呢?

IO拷貝的預先局部拷貝

爲了減少磁盤的IO操作,爲了提高性能而考慮的,因爲我們的程序訪問一般都帶有局部性,也就是所謂的局部性原理,在這裏主要是指的空間局部性,即我們訪問了文件的某一段數據,那麼接下去很可能還會訪問接下去的一段數據,由於磁盤IO操作的速度比直接訪問內存慢了好幾個數量級,所以OS根據局部性原理會在一次read(系統調用過程中預讀更多的文件數據緩存在內核IO緩衝區中, 當繼續訪問的文件數據在緩衝區中時便直接拷貝數據到進程私有空間, 避免了再次的低效率磁盤IO操作。

應用程序IO操作實例

在Java中當我們採用IO包下的文件操作流,如:

FileInputStream in=new FileInputStream("/usr/text") ;
in.read();

JAVA虛擬機內部便會調用OS底層的read()系統調用完成操作, 在第二次調用read()的時候很可能就是從內核緩衝區直接返回數據了,此外還有可能需要經過native堆做一次中轉,因爲這些函數都被聲明爲native, 即本地方法, 所以可能在C語言中有做一次中轉, 如win32/win64中就是通過C語言從OS讀取數據, 然後再傳給JVM內存 。

系統調用與應用程序

既然如此, Java-IO包中爲啥還要提供一個BufferedInputStream類來作爲緩衝區呢,關鍵在於四個字, "系統調用",當讀取OS內核緩衝區數據的時候, 便發起了一次系統調用操作(通過native的C函數調用),而系統調用的代價相對來說是比較高的,涉及到進程用戶態和內核態的上下文切換等一系列操作,所以我們經常採用如下的包裝:

File ln put Stream in=new FileInputStream("/user/txt") ;
BufferedInputStream buf in=new BufferedInputStream(in) ;
buf in.read();
通過Buffer減少系統調用次數(經常被理解錯誤)
  • 有了Buffer緩存,我們每一次in.read() 時候, BufferedInputStream會根據情況自動爲我們預讀更多的字節數據到它自己維護的一個內部字節數組緩衝區中,這樣我們便可以減少系統調用次數, 從而達到其緩衝區的目的。

  • 所以要明確的一點是BufferedInputStream的作用不是減少磁盤IO操作次數,因爲操作系統OS已經幫我們完成了,而是通過減少系統調用次數來提高性能的。

  • 同理BufferedOuputStream, BufferedReader/Writer也是一樣的。在C語言的函數庫中也有類似的實現, 如,fread()是C語言中的緩衝IO, 與BufferedInputStream()相同.

BufferedInputStream分析

從上面的源碼我們可以看到,BufferedInputStream內部維護着一個字節數組byte[] buf來實現緩衝區的功能,調用的buf的in.read()方法在返回數據之前有做一個if判斷, 如果buf數組的當前索引不在有效的索引範圍之內, 即if條件成立, buf字段維護的緩衝區已經不夠了, 這時候會調用內部的fill()方法進行填充, 而fill()會預讀更多的數據到buf數組緩衝區中去, 然後再返回當前字節數據, 如果if條件不成立便直接從buf緩衝區數組返回數據了。

BufferedInputStream的buff的可見性

對於read方法中的getBufIfOpen()返回的就是buf字段的引用,源碼中的buf字段聲明爲
protected volatile byte buf;主要是爲了通過volatile關鍵字保證buf數組在多線程併發環
境中的內存可見性.

內存映射文件的實現

內存映射文件和之前說的標準IO操作最大的不同之處就在於它雖然最終也是要從磁盤讀取數據,但是它並不需要將數據讀取到OS內核緩衝區,而是直接將進程的用戶私有地址空間中的一部分區域與文件對象建立起映射關係,就好像直接從內存中讀、寫文件一樣,速度當然快了,如下圖所示。

Linux中的進程虛擬存儲器, 即進程的虛擬地址空間, 如果你的機子是32位,那麼就有2^32=4G的虛擬地址空間,我們可以看到圖中有一塊區域:

“Memory mapped region for shared libraries”

這段區域就是在內存映射文件的時候將某一段的虛擬地址和文件對象的某一部分建立起映射關係,此時並沒有拷貝數據到內存中去,而是當進程代碼第一次引用這段代碼內的虛擬地址時,觸發了缺頁異常,這時候OS根據映射關係直接將文件的相關部分數據拷貝到進程的用戶私有空間中去,當有操作第N頁數據的時候重複這樣的OS頁面調度程序操作。

內存映射文件的優點

內存映射文件的效率比標準IO高的重要原因就是因爲少了把數據拷貝到OS內核緩衝區這一步(可能還少了native堆中轉這一步) 。

Java中提供了3種內存映射模式:只讀(readonly) 、讀寫(read_write) 、專用(private) 。

只讀(readonly) 模式

對於只讀模式來說,如果程序試圖進行寫操作,則會拋出Readonly Buffer Exception異常。

read_write模式

NIO的read_write模式

read_write讀寫模式表明了通過內存映射文件的方式寫或修改文件內容的話是會立刻反映到磁盤文件中去的,別的進程如果共享了同一個映射文件,那麼也會立即看到變化!

標準IO的read_write模式

標準IO那樣每個進程有各自的內核緩衝區, 比如Java代碼中, 沒有執行IO輸出流的flush()或者close()操作, 那麼對文件的修改不會更新到磁盤去, 除非進程運行結束。

專用模式

專用模式採用的是OS的“寫時拷貝”原則,即在沒有發生寫操作的情況下,多個進程之間都是共享文件的同一塊物理內存(進程各自的虛擬地址指向同一片物理地址),一旦某個進程進行寫操作,那麼將會把受影響的文件數據單獨拷貝一份到進程的私有緩衝區中,不會反映到物理文件中去。

File file=new File("/usr/txt") ;
FileInputStream in=new FileInputStream(file) ;
File Channel channel=in.getChannel();
MappedByteBuffer buff=channel.map(File Channel.Map Mode.READ_ONLY, 0, channel.size 0) ;

這裏創建了一個只讀模式的內存映射文件區域, 接下來我就來測試下與普通NIO中的通道操作相比性能上的優勢,先看如下代碼:

輸出爲63,即通過內存映射文件的方式讀取86M多的文件只需要78毫秒,我現在改爲普通
NIO的通道操作看下:

File file=new File("/usr/txt") ;
FileInputStream in=new FileInputStream(file) ;
File Channel channel=in.getChannel();
ByteBuffer buff=ByteBuffer.allocate(1024) ;
long begin=System.currentTimeMillis();
while(channel.read(buff) !=-1) {
	buff.flip 0;
	buff.clear 0;
	long end=System.currentTimeMillis();
	System.out.print In("time is:"+(end-begin) ) ;

輸出爲468毫秒,幾乎是6倍的差距,文件越大,差距便越大。

內存映射的使用場景

內存映射特別適合於對大文件的操作, JAVA中的限制是最大不得超過Integer.MAXVALUE, 即2G左右, 不過我們可以通過分次映射文件(channel.map) 的不同部分來達到操作整個文件的目的。

內存映射的優勢特點

內存映射屬於JVM中的直接緩衝區, 還可以通過ByteBuffer.allocateDirect, 即Direct Memory的方式來創建直接緩衝區。

相比基礎的IO操作來說就是少了中間緩衝區的數據拷貝開銷,同時他們屬於JVM堆外內存, 不受JVM堆內存大小的限制。

其中Direct Memory默認的大小是等同於JVM最大堆, 理論上說受限於進程的虛擬地址空間大小, 比如32位的windows上, 每個進程有4G的虛擬空間除去2G爲OS內核保留外, 再減去JVM堆的最大值, 剩餘的纔是Direct Memory大小。

通過設置JVM參數-Xmx64M, 即JVM最大堆爲64M, 然後執行以下程序可以證明Direct Memory不受JVM堆大小控制:

public static void main(String args) {
	ByteBuffer.allocateDirect(1024*1024*100) ; //100MB
}

輸出結果如下:

[GC1371K->1328K(61312K) , 0.0070033secs][Full GC1328K->1297K(61312K),0.0329592secs]
[GC3029K->2481K(61312K) , 0.0037401secs][Full GC2481K->2435K(61312K) , 0.0102255secs]

看到這裏執行GC的次數較少, 但是觸發了兩次Full GC, 原因在於直接內存不受GC(新生代的Minor GC) 影響, 只有當執行老年代的Full GC時候纔會順便回收直接內存!而直接內存是通過存儲在JVM堆中的Direct ByteBuffer對象來引用的, 所以當衆多的Direct ByteBuffer對象從新生代被送入老年代後才觸發了full gc。再看直接在JVM堆上分配內存區域的情況:

public static void main(String~args) {
	for(inti=0; i<10000; i++) {
		ByteBuffer.allocate(1024*100) ; //100K
	}
}

ByteBuffer.allocate意味着直接在JVM堆上分配內存, 所以受新生代的Minor GC影響, 輸出如下:

[GC16023K->224K(61312K) , 0.0012432secs][GC16211K->192K(77376K) , 0.0006917secs][GC32242K->176K(77376K) , 0.0010613secs][GC32225K->224K(109504K) , 0.0005539secs][GC64423K->192K(109504K) , 0.0006151secs][GC64376K->192K(171392K, 0.0004968secs][GC128646K->204K(171392K) , 0.0007423secs][GC128646K->204K(299968K) , 0.0002067secs][GC257190K->204K(299968K) , 0.0003862secs][GC257193K->204K(287680K) , 0.0001718secs][GC245103K->204K(276480K) , 0.0001994secs][GC233662K->204K(265344K) , 0.0001828secs][GC222782K->172K(255232K) , 0.0001998secs][GC212374K->172K(245120K) , 0.0002217secs]

可以看到, 由於直接在JVM堆上分配內存, 所以觸發了多次GC, 且不會觸及Full GC, 因爲對象根本沒機會進入老年代。

Direct Memory和內存映射

NIO中的Direct Memory和內存文件映射同屬於直接緩衝區, 但是前者和-Xmx和-XX:MaxDirectMemorySize有關, 而後者完全沒有JVM參數可以影響和控制,這讓我不禁懷疑兩者的直接緩衝區是否相同。

Direct Memory

Direct Memory指的是JAVA進程中的native堆, 即涉及底層平臺如win 32的dII部分, 因爲C語言中的malloc) 分配的內存就屬於native堆, 不屬於JVM堆,這也是Direct Memory能在一些場景中顯著提高性能的原因, 因爲它避免了在native堆和jvm堆之間數據的來回複製;

內存映射

內存映射則是沒有經過native堆, 是由JAVA進程直接建立起某一段虛擬地址空間和文件對象的關聯映射關係, 參見Linux虛擬存儲器圖中的“Memory mapped region for shared libraries”區域, 所以內存映射文件的區域並不在JVM GC的回收範圍內, 因爲它本身就不屬於堆區, 卸載這部分區域只能通過系統調用unmap()來實現(Linux)中, 而JAVA API只提供了FileChannel.map的形式創建內存映射區域, 卻沒有提供對應的unmap(), 讓人十分費解, 導致要卸載這部分區域比較麻煩。

Direct Memory和內存映射結合所實現的案例

通過Direct Memory來操作前面內存映射和基本通道操作的例子, 來看看直接內存操作的話,程序的性能如何:

File file=new File("/usr/txt") ;
FileInputStream in=new FilelnputStream(file) ;
FileChannel channel=in.getChannel 0;
ByteBuffer buff=ByteBuffer.allocateDirect(1024) ;
long begin=System.currentTimeMillis(;
while(channel.read(buff) !=-1) {
	buff.flip();
	buff.clear();
	long end=System.currentTimeMillis();
	System.out.printIn("time is:"+(end-begin) );
}

程序輸出爲312毫秒, 看來比普通的NIO通道操作(468毫秒) 來的快, 但是比mmap內存映射的63秒差距太多了, 通過修改; ByteBuffer buff=ByteBuffer.allocateDirect(1024) ;

ByteBuffer buff=ByteBuffer.allocateDirect((in) file.length 0) , 即一次性分配整個文件長度大小的堆外內存,最終輸出爲78毫秒,由此可以得出堆外內存的分配耗時比較大,還是比mmap內存映射來得慢。

Direct Memory的內存回收(非常重要總結)

最後一點爲Direct Memory的內存只有在JVM執行full gc的時候纔會被回收, 那麼如果在其上分配過大的內存空間, 那麼也將出現OOM, 即便JVM堆中的很多內存處於空閒狀態。

JVM堆內存的限制範圍

關於JVM堆大小的設置是不受限於物理內存, 而是受限於虛擬內存空間大小,理論上來說是進程的虛擬地址空間大小,但是實際上我們的虛擬內存空間是有限制的, 一般windows上默認在C盤, 大小爲物理內存的2倍左右。

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