JAVA NIO之淺談內存映射文件原理與DirectMemory

轉載自:http://blog.csdn.net/fcbayernmunchen/article/details/8635427java

 Java類庫中的NIO包相對於IO 包來講有一個新功能是內存映射文件,平常編程中並非常常用到,可是在處理大文件時是比較理想的提升效率的手段。本文我主要想結合操做系統中(OS)相關方面的知識介紹一下原理。linux

   在傳統的文件IO操做中,咱們都是調用操做系統提供的底層標準IO系統調用函數  read()、write() ,此時調用此函數的進程(在JAVA中即java進程)由當前的用戶態切換到內核態,而後OS的內核代碼負責將相應的文件數據讀取到內核的IO緩衝區,而後再把數據從內核IO緩衝區拷貝到進程的私有地址空間中去,這樣便完成了一次IO操做。至於爲何要畫蛇添足搞一個內核IO緩衝區把本來只需一次拷貝數據的事情搞成須要2次數據拷貝呢? 我想學過操做系統或者計算機系統結構的人都知道,這麼作是爲了減小磁盤的IO操做,爲了提升性能而考慮的,由於咱們的程序訪問通常都帶有局部性,也就是所謂的局部性原理,在這裏主要是指的空間局部性,即咱們訪問了文件的某一段數據,那麼接下去極可能還會訪問接下去的一段數據,因爲磁盤IO操做的速度比直接訪問內存慢了好幾個數量級,因此OS根據局部性原理會在一次 read()系統調用過程當中預讀更多的文件數據緩存在內核IO緩衝區中,當繼續訪問的文件數據在緩衝區中時便直接拷貝數據到進程私有空間,避免了再次的低效率磁盤IO操做。在JAVA中當咱們採用IO包下的文件操做流,如:編程

FileInputStream in = new FileInputStream("D:\\java.txt");  
in.read();  

  JAVA虛擬機內部便會調用OS底層的 read()系統調用完成操做,如上所述,在第二次調用 in.read()的時候可能就是從內核緩衝區直接返回數據了(可能還有通過 native堆作一次中轉,由於這些函數都被聲明爲 native,即本地平臺相關,因此可能在C代碼中有作一次中轉,如 win32中是經過 C代碼從OS讀取數據,而後再傳給JVM內存)。既然如此,JAVA的IO包中爲啥還要提供一個 BufferedInputStream 類來做爲緩衝區呢。關鍵在於四個字,"系統調用"!當讀取OS內核緩衝區數據的時候,便發起了一次系統調用操做(經過native的C函數調用),而系統調用的代價相對來講是比較高的,涉及到進程用戶態和內核態的上下文切換等一系列操做,因此咱們常常採用以下的包裝:windows

FileInputStream in = new FileInputStream("D:\\java.txt"); 
BufferedInputStream buf_in = new BufferedInputStream(in); 
buf_in.read();

這樣一來,咱們每一次 buf_in.read() 時候,BufferedInputStream 會根據狀況自動爲咱們預讀更多的字節數據到它本身維護的一個內部字節數組緩衝區中,這樣咱們即可以減小系統調用次數,從而達到其緩衝區的目的。因此要明確的一點是 BufferedInputStream 的做用不是減小 磁盤IO操做次數(這個OS已經幫咱們作了),而是經過減小系統調用次數來提升性能的。同理 BufferedOuputStream , BufferedReader/Writer 也是同樣的。在 C語言的函數庫中也有相似的實現,如 fread(),這個函數就是 C語言中的緩衝IO,做用與BufferedInputStream()相同.數組

    這裏簡單的引用下JDK6 中 BufferedInputStream 的源碼驗證下:緩存

 1 public  
 2 class BufferedInputStream extends FilterInputStream {  
 3   
 4     private static int defaultBufferSize = 8192;  
 5   
 6     /** 
 7      * The internal buffer array where the data is stored. When necessary, 
 8      * it may be replaced by another array of 
 9      * a different size. 
10      */  
11     protected volatile byte buf[];  
12   /** 
13      * The index one greater than the index of the last valid byte in  
14      * the buffer.  
15      * This value is always 
16      * in the range <code>0</code> through <code>buf.length</code>; 
17      * elements <code>buf[0]</code>  through <code>buf[count-1] 
18      * </code>contain buffered input data obtained 
19      * from the underlying  input stream. 
20      */  
21     protected int count;  
22   
23     /** 
24      * The current position in the buffer. This is the index of the next  
25      * character to be read from the <code>buf</code> array.  
26      * <p> 
27      * This value is always in the range <code>0</code> 
28      * through <code>count</code>. If it is less 
29      * than <code>count</code>, then  <code>buf[pos]</code> 
30      * is the next byte to be supplied as input; 
31      * if it is equal to <code>count</code>, then 
32      * the  next <code>read</code> or <code>skip</code> 
33      * operation will require more bytes to be 
34      * read from the contained  input stream. 
35      * 
36      * @see     java.io.BufferedInputStream#buf 
37      */  
38     protected int pos;  
39   
40  /* 這裏省略去 N 多代碼 ------>>  */  
41   
42   /** 
43      * See 
44      * the general contract of the <code>read</code> 
45      * method of <code>InputStream</code>. 
46      * 
47      * @return     the next byte of data, or <code>-1</code> if the end of the 
48      *             stream is reached. 
49      * @exception  IOException  if this input stream has been closed by 
50      *              invoking its {@link #close()} method, 
51      *              or an I/O error occurs.  
52      * @see        java.io.FilterInputStream#in 
53      */  
54     public synchronized int read() throws IOException {  
55     if (pos >= count) {  
56         fill();  
57         if (pos >= count)  
58         return -1;  
59     }  
60     return getBufIfOpen()[pos++] & 0xff;  
61     } 

 咱們能夠看到,BufferedInputStream 內部維護着一個 字節數組 byte[] buf 來實現緩衝區的功能,咱們調用的  buf_in.read() 方法在返回數據以前有作一個 if 判斷,若是 buf 數組的當前索引不在有效的索引範圍以內,即 if 條件成立, buf 字段維護的緩衝區已經不夠了,這時候會調用 內部的 fill() 方法進行填充,而fill()會預讀更多的數據到 buf 數組緩衝區中去,而後再返回當前字節數據,若是 if 條件不成立便直接從 buf緩衝區數組返回數據了。其中getBufIfOpen()返回的就是 buf字段的引用。順便說下,源碼中的 buf 字段聲明爲  protected volatile byte buf[];  主要是爲了經過 volatile 關鍵字保證 buf數組在多線程併發環境中的內存可見性.多線程

   和 Java NIO 的內存映射無關的部分說了這麼多篇幅,主要是爲了作個鋪墊,這樣才能創建起一個知識體系,以便更好的理解內存映射文件的優勢。併發

   內存映射文件和以前說的 標準IO操做最大的不一樣之處就在於它雖然最終也是要從磁盤讀取數據,可是它並不須要將數據讀取到OS內核緩衝區,而是直接將進程的用戶私有地址空間中的一部分區域與文件對象創建起映射關係,就好像直接從內存中讀、寫文件同樣,速度固然快了。爲了說清楚這個,咱們以 Linux操做系統爲例子,看下圖:app

   

 此圖爲 Linux 2.X 中的進程虛擬存儲器,即進程的虛擬地址空間,若是你的機子是 32 位,那麼就有  2^32 = 4G的虛擬地址空間,咱們能夠看到圖中有一塊區域: 「Memory mapped region for shared libraries」 ,這段區域就是在內存映射文件的時候將某一段的虛擬地址和文件對象的某一部分創建起映射關係,此時並無拷貝數據到內存中去,而是當進程代碼第一次引用這段代碼內的虛擬地址時,觸發了缺頁異常,這時候OS根據映射關係直接將文件的相關部分數據拷貝到進程的用戶私有空間中去,當有操做第N頁數據的時候重複這樣的OS頁面調度程序操做。注意啦,原來內存映射文件的效率比標準IO高的重要緣由就是由於少了把數據拷貝到OS內核緩衝區這一步(可能還少了native堆中轉這一步)。less

   java中提供了3種內存映射模式,即:只讀(readonly)、讀寫(read_write)、專用(private) ,對於  只讀模式來講,若是程序試圖進行寫操做,則會拋出ReadOnlyBufferException異常;第二種的讀寫模式代表了經過內存映射文件的方式寫或修改文件內容的話是會馬上反映到磁盤文件中去的,別的進程若是共享了同一個映射文件,那麼也會當即看到變化!而不是像標準IO那樣每一個進程有各自的內核緩衝區,好比JAVA代碼中,沒有執行 IO輸出流的 flush() 或者  close() 操做,那麼對文件的修改不會更新到磁盤去,除非進程運行結束;最後一種專用模式採用的是OS的「寫時拷貝」原則,即在沒有發生寫操做的狀況下,多個進程之間都是共享文件的同一塊物理內存(進程各自的虛擬地址指向同一片物理地址),一旦某個進程進行寫操做,那麼將會把受影響的文件數據單獨拷貝一份到進程的私有緩衝區中,不會反映到物理文件中去。

 

   在JAVA NIO中能夠很容易的建立一塊內存映射區域,代碼以下:

1 File file = new File("E:\\download\\office2007pro.chs.ISO");  
2 FileInputStream in = new FileInputStream(file);  
3 FileChannel channel = in.getChannel();  
4 MappedByteBuffer buff = channel.map(FileChannel.MapMode.READ_ONLY, 0,channel.size());

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

 1 public class IOTest {  
 2     static final int BUFFER_SIZE = 1024;  
 3   
 4     public static void main(String[] args) throws Exception {  
 5   
 6         File file = new File("F:\\aa.pdf");  
 7         FileInputStream in = new FileInputStream(file);  
 8         FileChannel channel = in.getChannel();  
 9         MappedByteBuffer buff = channel.map(FileChannel.MapMode.READ_ONLY, 0,  
10                 channel.size());  
11   
12         byte[] b = new byte[1024];  
13         int len = (int) file.length();  
14   
15         long begin = System.currentTimeMillis();  
16   
17         for (int offset = 0; offset < len; offset += 1024) {  
18   
19             if (len - offset > BUFFER_SIZE) {  
20                 buff.get(b);  
21             } else {  
22                 buff.get(new byte[len - offset]);  
23             }  
24         }  
25   
26         long end = System.currentTimeMillis();  
27         System.out.println("time is:" + (end - begin));  
28   
29     }  
30 } 

輸出爲 63,即經過內存映射文件的方式讀取 86M多的文件只須要78毫秒,我如今改成普通NIO的通道操做看下:

 1 File file = new File("F:\\liq.pdf");  
 2 FileInputStream in = new FileInputStream(file);  
 3 FileChannel channel = in.getChannel();  
 4 ByteBuffer buff = ByteBuffer.allocate(1024);   
 5   
 6 long begin = System.currentTimeMillis();  
 7 while (channel.read(buff) != -1) {  
 8     buff.flip();  
 9     buff.clear();  
10 }  
11 long end = System.currentTimeMillis();  
12 System.out.println("time is:" + (end - begin));  

輸出爲 468毫秒,幾乎是 6 倍的差距,文件越大,差距便越大。因此內存映射文件特別適合於對大文件的操做,JAVA中的限制是最大不得超過 Integer.MAX_VALUE,即2G左右,不過咱們能夠經過分次映射文件(channel.map)的不一樣部分來達到操做整個文件的目的。

   按照jdk文檔的官方說法,內存映射文件屬於JVM中的直接緩衝區,還能夠經過 ByteBuffer.allocateDirect() ,即DirectMemory的方式來建立直接緩衝區。他們相比基礎的 IO操做來講就是少了中間緩衝區的數據拷貝開銷。同時他們屬於JVM堆外內存,不受JVM堆內存大小的限制。

 

   其中 DirectMemory 默認的大小是等同於JVM最大堆,理論上說受限於 進程的虛擬地址空間大小,好比 32位的windows上,每一個進程有4G的虛擬空間除去 2G爲OS內核保留外,再減去 JVM堆的最大值,剩餘的纔是DirectMemory大小。經過 設置 JVM參數 -Xmx64M,即JVM最大堆爲64M,而後執行如下程序能夠證實DirectMemory不受JVM堆大小控制:

1 public static void main(String[] args) {       
2     ByteBuffer.allocateDirect(1024*1024*100); // 100MB  
3 }  

 咱們設置了JVM堆 64M限制,而後在 直接內存上分配了 100MB空間,程序執行後直接報錯:Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory。接着我設置 -Xmx200M,程序正常結束。而後我修改配置: -Xmx64M  -XX:MaxDirectMemorySize=200M,程序正常結束。所以得出結論: 直接內存DirectMemory的大小默認爲 -Xmx 的JVM堆的最大值,可是並不受其限制,而是由JVM參數 MaxDirectMemorySize單獨控制。接下來咱們來證實直接內存不是分配在JVM堆中。咱們先執行如下程序,並設置 JVM參數 -XX:+PrintGC,

1 public static void main(String[] args) {         
2  for(int i=0;i<20000;i++) {  
3            ByteBuffer.allocateDirect(1024*100);  //100K  
4       }  
5   } 

 輸出結果以下:

     [GC 1371K->1328K(61312K), 0.0070033 secs]
     [Full GC 1328K->1297K(61312K), 0.0329592 secs]
     [GC 3029K->2481K(61312K), 0.0037401 secs]
     [Full GC 2481K->2435K(61312K), 0.0102255 secs]

   咱們看到這裏執行 GC的次數較少,可是觸發了 兩次 Full GC,緣由在於直接內存不受 GC(新生代的Minor GC)影響,只有當執行老年代的 Full GC時候纔會順便回收直接內存!而直接內存是經過存儲在JVM堆中的DirectByteBuffer對象來引用的,因此當衆多的DirectByteBuffer對象重新生代被送入老年代後才觸發了 full gc。

 再看直接在JVM堆上分配內存區域的狀況:

1 public static void main(String[] args) {         
2     for(int i=0;i<10000;i++) {  
3           ByteBuffer.allocate(1024*100);  //100K  
4     }
5 }  

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

   
        [GC 16023K->224K(61312K), 0.0012432 secs]
        [GC 16211K->192K(77376K), 0.0006917 secs]
        [GC 32242K->176K(77376K), 0.0010613 secs]
        [GC 32225K->224K(109504K), 0.0005539 secs]
        [GC 64423K->192K(109504K), 0.0006151 secs]
        [GC 64376K->192K(171392K), 0.0004968 secs]
        [GC 128646K->204K(171392K), 0.0007423 secs]
        [GC 128646K->204K(299968K), 0.0002067 secs]
        [GC 257190K->204K(299968K), 0.0003862 secs]
        [GC 257193K->204K(287680K), 0.0001718 secs]
        [GC 245103K->204K(276480K), 0.0001994 secs]
        [GC 233662K->204K(265344K), 0.0001828 secs]
        [GC 222782K->172K(255232K), 0.0001998 secs]
        [GC 212374K->172K(245120K), 0.0002217 secs]

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


   我想提個疑問,NIO中的DirectMemory和內存文件映射同屬於直接緩衝區,可是前者和 -Xmx和-XX:MaxDirectMemorySize有關,然後者徹底沒有JVM參數能夠影響和控制,這讓我不由懷疑二者的直接緩衝區是否相同,前者指的是 JAVA進程中的 native堆,即涉及底層平臺如 win32的dll 部分,由於 C語言中的 malloc()分配的內存就屬於 native堆,不屬於 JVM堆,這也是DirectMemory能在一些場景中顯著提升性能的緣由,由於它避免了在 native堆和jvm堆之間數據的來回複製;然後者則是沒有通過 native堆,是由 JAVA進程直接創建起 某一段虛擬地址空間和文件對象的關聯映射關係,參見 Linux虛擬存儲器圖中的 「Memory mapped region for shared libraries」  區域,因此內存映射文件的區域並不在JVM GC的回收範圍內,由於它自己就不屬於堆區,卸載這部分區域只能經過系統調用 unmap()來實現 (Linux)中,而 JAVA API 只提供了 FileChannel.map 的形式建立內存映射區域,卻沒有提供對應的 unmap(),讓人十分費解,致使要卸載這部分區域比較麻煩。

 

 最後再試試經過 DirectMemory來操做前面 內存映射和基本通道操做的例子,來看看直接內存操做的話,程序的性能如何:

   

 1 File file = new File("F:\\liq.pdf");  
 2 FileInputStream in = new FileInputStream(file);  
 3 FileChannel channel = in.getChannel();  
 4 ByteBuffer buff = ByteBuffer.allocateDirect(1024);   
 5   
 6 long begin = System.currentTimeMillis();  
 7 while (channel.read(buff) != -1) {  
 8     buff.flip();  
 9     buff.clear();  
10 }  
11 long end = System.currentTimeMillis();  
12 System.out.println("time is:" + (end - begin));

程序輸出爲 312毫秒,看來比普通的NIO通道操做(468毫秒)來的快,可是比 mmap 內存映射的 63秒差距太多了,我想應該不至於吧,經過修改;ByteBuffer buff = ByteBuffer.allocateDirect(1024);  爲 ByteBuffer buff = ByteBuffer.allocateDirect((int)file.length()),即一次性分配整個文件長度大小的堆外內存,最終輸出爲 78毫秒,由此能夠得出兩個結論:1.堆外內存的分配耗時比較大.   2.仍是比mmap內存映射來得慢,都不要說經過mmap讀取數據的時候還涉及缺頁異常、頁面調度的系統調用了,看來內存映射文件確實NB啊,這還只是 86M的文件,若是上 G 的大小呢?

  最後一點爲 DirectMemory的內存只有在 JVM執行 full gc 的時候纔會被回收,那麼若是在其上分配過大的內存空間,那麼也將出現 OutofMemoryError,即使 JVM 堆中的不少內存處於空閒狀態。

  

  原本只想寫點內存映射部分,可是寫着寫着涉及進來的知識多了點,邊界很差把控啊。。。

 

  尼瑪,都是3月8號凌晨快2點了,不過想一想總比之前玩 拳皇遊戲 熬夜來的好吧,寫完收工,趕忙睡覺去。。。

  

   我想補充下額外的一個知識點,關於 JVM堆大小的設置是不受限於物理內存,而是受限於虛擬內存空間大小,理論上來講是進程的虛擬地址空間大小,可是實際上咱們的虛擬內存空間是有限制的,通常windows上默認在C盤,大小爲物理內存的2倍左右。我作了個實驗:我機子是 64位的win7,那麼理論上說進程虛擬空間是幾乎無限大,物理內存爲4G,而我設置 -Xms5000M, 即在啓動JAVA程序的時候一次性申請到超過物理內存大小的5000M內存,程序正常啓動,而當我加到 -Xms8000M的時候就報OOM錯誤了,而後我修改增長 win7的虛擬內存,程序又正常啓動了,說明 -Xms 受限於虛擬內存的大小。我設置-Xms5000M,即超過了4G物理內存,並在一個死循環中不斷建立對象,並保證不會被GC回收。程序運行一會後整個電腦幾乎死機狀態,即卡住了,反映很慢很慢,推測是發生了系統顛簸,即頻繁的頁面調度置換致使,說明 -Xms -Xmx不是侷限於物理內存的大小,而是綜合虛擬內存了,JVM會根據電腦虛擬內存的設置來控制。

相關文章
相關標籤/搜索