JAVA NIO 記憶體映射(轉載)

來源:互聯網
上載者:User

標籤:link   ted   額外   redo   死迴圈   open   技術   nload   final   

原文地址:http://blog.csdn.net/fcbayernmunchen/article/details/8635427

 

 

Java類庫中的NIO包相對於IO 包來說有一個新功能是記憶體對應檔,日常編程中並不是經常用到,但是在處理大檔案時是比較理想的提高效率的手段。本文我主要想結合作業系統中(OS)相關方面的知識介紹一下原理。

 

在傳統的檔案IO操作中,我們都是叫用作業系統提供的底層標準IO系統調用函數 read()、write() ,此時調用此函數的進程(在JAVA中即java進程)由當前的使用者態切換到核心態,然後OS的核心代碼負責將相應的檔案資料讀取到核心的IO緩衝區,然後再把資料從核心IO緩衝區拷貝到進程的私人地址空間中去,這樣便完成了一次IO操作。至於為什麼要多此一舉搞一個核心IO緩衝區把原本只需一次拷貝資料的事情搞成需要2次資料拷貝呢? 我想學過作業系統或者電腦系統結構的人都知道,這麼做是為了減少磁碟的IO操作,為了提高效能而考慮的,因為我們的程式訪問一般都帶有局部性,也就是所謂的局部性原理,在這裡主要是指的空間局部性,即我們訪問了檔案的某一段資料,那麼接下去很可能還會訪問接下去的一段資料,由於磁碟IO操作的速度比直接存取記憶體慢了好幾個數量級,所以OS根據局部性原理會在一次 read()系統調用過程中預讀更多的檔案資料緩衝在核心IO緩衝區中,當繼續訪問的檔案資料在緩衝區中時便直接拷貝資料到進程私人空間,避免了再次的低效率磁碟IO操作。在JAVA中當我們採用IO包下的檔案操作流,如:

 

[java] view plain copy

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

[java] view plain copy

in.read();

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

 

[java] view plain copy

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

[java] view plain copy

BufferedInputStream buf_in = new BufferedInputStream(in);

[java] view plain copy

buf_in.read();

這樣一來,我們每一次 buf_in.read() 時候,BufferedInputStream 會根據情況自動為我們預讀更多的位元組資料到它自己維護的一個內部位元組數組緩衝區中,這樣我們便可以減少系統調用次數,從而達到其緩衝區的目的。所以要明確的一點是 BufferedInputStream 的作用不是減少 磁碟IO操作次數(這個OS已經幫我們做了),而是通過減少系統調用次數來提高效能的。同理 BufferedOuputStream , BufferedReader/Writer 也是一樣的。在 C語言的函數庫中也有類似的實現,如 fread(),這個函數就是 c語言中的緩衝IO,作用與BufferedInputStream()相同.

 

這裡簡單的引用下JDK6 中 BufferedInputStream 的源碼驗證下:

 

 

 

[java] view plain copy

public

class BufferedInputStream extends FilterInputStream {

 

private static int defaultBufferSize = 8192;

 

/**

* The internal buffer array where the data is stored. When necessary,

* it may be replaced by another array of

* a different size.

*/

protected volatile byte buf[];

/**

* The index one greater than the index of the last valid byte in

* the buffer.

* This value is always

* in the range <code>0</code> through <code>buf.length</code>;

* elements <code>buf[0]</code> through <code>buf[count-1]

* </code>contain buffered input data obtained

* from the underlying input stream.

*/

protected int count;

 

/**

* The current position in the buffer. This is the index of the next

* character to be read from the <code>buf</code> array.

* <p>

* This value is always in the range <code>0</code>

* through <code>count</code>. If it is less

* than <code>count</code>, then <code>buf[pos]</code>

* is the next byte to be supplied as input;

* if it is equal to <code>count</code>, then

* the next <code>read</code> or <code>skip</code>

* operation will require more bytes to be

* read from the contained input stream.

*

* @see java.io.BufferedInputStream#buf

*/

protected int pos;

 

/* 這裡省略去 N 多代碼 ------>> */

 

/**

* See

* the general contract of the <code>read</code>

* method of <code>InputStream</code>.

*

* @return the next byte of data, or <code>-1</code> if the end of the

* stream is reached.

* @exception IOException if this input stream has been closed by

* invoking its {@link #close()} method,

* or an I/O error occurs.

* @see java.io.FilterInputStream#in

*/

public synchronized int read() throws IOException {

if (pos >= count) {

fill();

if (pos >= count)

return -1;

}

return getBufIfOpen()[pos++] & 0xff;

}

 

 

我們可以看到,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作業系統為例子,看:

 

 

此圖為 linux 2.X 中的進程虛擬儲存空間,即進程的虛擬位址空間,如果你的機子是 32 位,那麼就有 2^32 = 4G的虛擬位址空間,我們可以看到圖中有一塊地區: "Memory mapped region for shared libraries" ,這段地區就是在記憶體對應檔的時候將某一段的虛擬位址和檔案對象的某一部分建立起映射關係,此時並沒有拷貝資料到記憶體中去,而是當進程代碼第一次引用這段代碼內的虛擬位址時,觸發了缺頁異常,這時候OS根據映射關係直接將檔案的相關部分資料拷貝到進程的使用者私人空間中去,當有操作第N頁資料的時候重複這樣的OS頁面發送器操作。注意啦,原來記憶體對應檔的效率比標準IO高的重要原因就是因為少了把資料拷貝到OS核心緩衝區這一步(可能還少了native堆中轉這一步)。

 

java中提供了3種記憶體映射模式,即:唯讀(readonly)、讀寫(read_write)、專用(private) ,對於 唯讀模式來說,如果程式試圖進行寫操作,則會拋出ReadOnlyBufferException異常;第二種的讀寫入模式表明了通過記憶體對應檔的方式寫或修改檔案內容的話是會立刻反映到磁碟檔案中去的,別的進程如果共用了同一個對應檔,那麼也會立即看到變化!而不是像標準IO那樣每個進程有各自的核心緩衝區,比如JAVA代碼中,沒有執行 IO輸出資料流的 flush() 或者 close() 操作,那麼對檔案的修改不會更新到磁碟去,除非進程運行結束;最後一種專用模式採用的是OS的"寫時拷貝"原則,即在沒有發生寫操作的情況下,多個進程之間都是共用檔案的同一塊實體記憶體(進程各自的虛擬位址指向同一片物理地址),一旦某個進程進行寫操作,那麼將會把受影響的檔案資料單獨拷貝一份到進程的私人緩衝區中,不會反映到物理檔案中去。

 

 

 

在java NIO中可以很容易的建立一塊記憶體映射地區,代碼如下:

 

File file = new File("E:\\download\\office2007pro.chs.ISO");

FileInputStream in = new FileInputStream(file);

FileChannel channel = in.getChannel();

MappedByteBuffer buff = channel.map(FileChannel.MapMode.READ_ONLY, 0,channel.size());

 

這裡建立了一個唯讀模式的記憶體對應檔地區,接下來我就來測試下與普通NIO中的通道操作相比效能上的優勢,先看如下代碼:

 

public class IOTest {

static final int BUFFER_SIZE = 1024;

 

public static void main(String[] args) throws Exception {

 

File file = new File("F:\\aa.pdf");

FileInputStream in = new FileInputStream(file);

FileChannel channel = in.getChannel();

MappedByteBuffer buff = channel.map(FileChannel.MapMode.READ_ONLY, 0,

channel.size());

 

byte[] b = new byte[1024];

int len = (int) file.length();

 

long begin = System.currentTimeMillis();

 

for (int offset = 0; offset < len; offset += 1024) {

 

if (len - offset > BUFFER_SIZE) {

buff.get(b);

} else {

buff.get(new byte[len - offset]);

}

}

 

long end = System.currentTimeMillis();

System.out.println("time is:" + (end - begin));

 

}

}

輸出為 63,即通過記憶體對應檔的方式讀取 86M多的檔案只需要78毫秒,我現在改為普通NIO的通道操作看下:

 

 

 

File file = new File("F:\\liq.pdf");

FileInputStream in = new FileInputStream(file);

FileChannel channel = in.getChannel();

ByteBuffer buff = ByteBuffer.allocate(1024);

 

long begin = System.currentTimeMillis();

while (channel.read(buff) != -1) {

buff.flip();

buff.clear();

}

long end = System.currentTimeMillis();

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堆大小控制:

 

[java] view plain copy

public static void main(String[] args) {

ByteBuffer.allocateDirect(1024*1024*100); // 100MB

}

我們設定了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,

 

public static void main(String[] args) {

for(int i=0;i<20000;i++) {

ByteBuffer.allocateDirect(1024*100); //100K

}

}

 

輸出結果如下:

 

[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堆上分配記憶體地區的情況:

 

[java] view plain copy

public static void main(String[] args) {

r(int i=0;i<10000;i++) {

ByteBuffer.allocate(1024*100); //100K

 

}

 

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來操作前面 記憶體映射和基本通道操作的例子,來看看直接記憶體操作的話,程式的效能如何:

 

File file = new File("F:\\liq.pdf");

FileInputStream in = new FileInputStream(file);

FileChannel channel = in.getChannel();

ByteBuffer buff = ByteBuffer.allocateDirect(1024);

 

long begin = System.currentTimeMillis();

while (channel.read(buff) != -1) {

buff.flip();

buff.clear();

}

long end = System.currentTimeMillis();

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 堆中的很多記憶體處於空閑狀態。

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

JAVA NIO 記憶體映射(轉載)

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.