java NIO之MappedByteBuffer
前言
java io操作中通常採用BufferedReader,BufferedInputStream等帶緩衝的IO類處理大檔案,不過java nio中引入了一種基於MappedByteBuffer操作大檔案的方式,其讀寫效能極高,本文會介紹其效能如此高的內部實現原理。
記憶體管理
在深入MappedByteBuffer之前,先看看計算機記憶體管理的幾個術語:
- MMC:CPU的記憶體管理單元。
- 實體記憶體:即記憶體條的記憶體空間。
- 虛擬記憶體:計算機系統記憶體管理的一種技術。它使得應用程式認為它擁有連續的可用的記憶體(一個連續完整的地址空間),而實際上,它通常是被分隔成多個實體記憶體碎片,還有部分暫時儲存在外部磁碟儲存器上,在需要時進行資料交換。
- 頁面檔案:作業系統反映構建並使用虛擬記憶體的硬碟空間大小而建立的檔案,在windows下,即pagefile.sys檔案,其存在意味著實體記憶體被佔滿後,將暫時不用的資料移動到硬碟上。
- 缺頁中斷:當程式試圖訪問已對映在虛擬地址空間中但未被載入至實體記憶體的一個分頁時,由MMC發出的中斷。如果作業系統判斷此次訪問是有效的,則嘗試將相關的頁從虛擬記憶體檔案中載入實體記憶體。
為什麼會有虛擬記憶體和實體記憶體的區別?
如果正在執行的一個程序,它所需的記憶體是有可能大於記憶體條容量之和的,如記憶體條是256M,程式卻要建立一個2G的資料區,那麼所有資料不可能都載入到記憶體(實體記憶體),必然有資料要放到其他介質中(比如硬碟),待程序需要訪問那部分資料時,再排程進入實體記憶體。
什麼是虛擬記憶體地址和實體記憶體地址?
假設你的計算機是32位,那麼它的地址匯流排是32位的,也就是它可以定址00xFFFFFFFF(4G)的地址空間,但如果你的計算機只有256M的實體記憶體0x0x0FFFFFFF(256M),同時你的程序產生了一個不在這256M地址空間中的地址,那麼計算機該如何處理呢?回答這個問題前,先說明計算機的記憶體分頁機制。
計算機會對虛擬記憶體地址空間(32位為4G)進行分頁產生頁(page),對實體記憶體地址空間(假設256M)進行分頁產生頁幀(page frame),頁和頁幀的大小一樣,所以虛擬記憶體頁的個數勢必要大於實體記憶體頁幀的個數。在計算機上有一個頁表(page table),就是對映虛擬記憶體頁到實體記憶體頁的,更確切的說是頁號到頁幀號的對映,而且是一對一的對映。
問題來了,虛擬記憶體頁的個數 > 實體記憶體頁幀的個數,豈不是有些虛擬記憶體頁的地址永遠沒有對應的實體記憶體地址空間?不是的,作業系統是這樣處理的。作業系統有個頁面失效(page fault)功能。作業系統找到一個最少使用的頁幀,使之失效,並把它寫入磁碟,隨後把需要訪問的頁放到頁幀中,並修改頁表中的對映,保證了所有的頁都會被排程。
現在來看看什麼是虛擬記憶體地址和實體記憶體地址:
- 虛擬記憶體地址:由頁號(與頁表中的頁號關聯)和偏移量(頁的小大,即這個頁能存多少資料)組成。
舉個例子,有一個虛擬地址它的頁號是4,偏移量是20,那麼他的定址過程是這樣的:首先到頁表中找到頁號4對應的頁幀號(比如為8),如果頁不在記憶體中,則用失效機制調入頁,接著把頁幀號和偏移量傳給MMC組成一個物理上真正存在的地址,最後就是訪問實體記憶體的資料了。
MappedByteBuffer是什麼
從繼承結構上看,MappedByteBuffer繼承自ByteBuffer,內部維護了一個邏輯地址address。
示例
通過MappedByteBuffer讀取檔案
public class MappedByteBufferTest {
public static void main(String[] args) {
File file = new File("D://data.txt");
long len = file.length();
byte[] ds = new byte[(int) len];
try {
MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r")
.getChannel()
.map(FileChannel.MapMode.READ_ONLY, 0, len);
for (int offset = 0; offset < len; offset++) {
byte b = mappedByteBuffer.get();
ds[offset] = b;
}
Scanner scan = new Scanner(new ByteArrayInputStream(ds)).useDelimiter(" ");
while (scan.hasNext()) {
System.out.print(scan.next() + " ");
}
} catch (IOException e) {}
}
}
map過程
FileChannel提供了map方法把檔案對映到虛擬記憶體,通常情況可以對映整個檔案,如果檔案比較大,可以進行分段對映。
- FileChannel中的幾個變數:
- MapMode mode:記憶體映像檔案訪問的方式,共三種:
- MapMode.READ_ONLY:只讀,試圖修改得到的緩衝區將導致丟擲異常。
- MapMode.READ_WRITE:讀/寫,對得到的緩衝區的更改最終將寫入檔案;但該更改對對映到同一檔案的其他程式不一定是可見的。
- MapMode.PRIVATE:私用,可讀可寫,但是修改的內容不會寫入檔案,只是buffer自身的改變,這種能力稱之為”copy on write”。
- position:檔案對映時的起始位置。
- allocationGranularity:Memory allocation size for mapping buffers,通過native函式initIDs初始化。
- MapMode mode:記憶體映像檔案訪問的方式,共三種:
接下去通過分析原始碼,瞭解一下map過程的內部實現。
- 通過RandomAccessFile獲取FileChannel。
public final FileChannel getChannel() {
synchronized (this) {
if (channel == null) {
channel = FileChannelImpl.open(fd, path, true, rw, this);
}
return channel;
}
}
上述實現可以看出,由於synchronized ,只有一個執行緒能夠初始化FileChannel。
- 通過FileChannel.map方法,把檔案對映到虛擬記憶體,並返回邏輯地址address,實現如下:
**只保留了核心程式碼**
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize,
addr + pagePosition,
mfd,
um);
} else {
return Util.newMappedByteBuffer(isize,
addr + pagePosition,
mfd,
um);
}
}
上述程式碼可以看出,最終map通過native函式map0完成檔案的對映工作。
1. 如果第一次檔案對映導致OOM,則手動觸發垃圾回收,休眠100ms後再次嘗試對映,如果失敗,則丟擲異常。
2. 通過newMappedByteBuffer方法初始化MappedByteBuffer例項,不過其最終返回的是DirectByteBuffer的例項,實現如下:
static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd, Runnable unmapper) {
MappedByteBuffer dbb;
if (directByteBufferConstructor == null)
initDBBConstructor();
dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
new Object[] { new Integer(size),
new Long(addr),
fd,
unmapper }
return dbb;
}
// 訪問許可權
private static void initDBBConstructor() {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
Class<?> cl = Class.forName("java.nio.DirectByteBuffer");
Constructor<?> ctor = cl.getDeclaredConstructor(
new Class<?>[] { int.class,
long.class,
FileDescriptor.class,
Runnable.class });
ctor.setAccessible(true);
directByteBufferConstructor = ctor;
}});
}
由於FileChannelImpl和DirectByteBuffer不在同一個包中,所以有許可權訪問問題,通過AccessController類獲取DirectByteBuffer的構造器進行例項化。
DirectByteBuffer是MappedByteBuffer的一個子類,其實現了對記憶體的直接操作。
get過程
MappedByteBuffer的get方法最終通過DirectByteBuffer.get方法實現的。
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
public byte get(int i) {
return ((unsafe.getByte(ix(checkIndex(i)))));
}
private long ix(int i) {
return address + (i << 0);
}
map0()函式返回一個地址address,這樣就無需呼叫read或write方法對檔案進行讀寫,通過address就能夠操作檔案。底層採用unsafe.getByte方法,通過(address + 偏移量)獲取指定記憶體的資料。
- 第一次訪問address所指向的記憶體區域,導致缺頁中斷,中斷響應函式會在交換區中查詢相對應的頁面,如果找不到(也就是該檔案從來沒有被讀入記憶體的情況),則從硬碟上將檔案指定頁讀取到實體記憶體中(非jvm堆記憶體)。
- 如果在拷貝資料時,發現物理記憶體不夠用,則會通過虛擬記憶體機制(swap)將暫時不用的物理頁面交換到硬碟的虛擬記憶體中。
效能分析
從程式碼層面上看,從硬碟上將檔案讀入記憶體,都要經過檔案系統進行資料拷貝,並且資料拷貝操作是由檔案系統和硬體驅動實現的,理論上來說,拷貝資料的效率是一樣的。
但是通過記憶體對映的方法訪問硬碟上的檔案,效率要比read和write系統呼叫高,這是為什麼?
- read()是系統呼叫,首先將檔案從硬碟拷貝到核心空間的一個緩衝區,再將這些資料拷貝到使用者空間,實際上進行了兩次資料拷貝;
- map()也是系統呼叫,但沒有進行資料拷貝,當缺頁中斷髮生時,直接將檔案從硬碟拷貝到使用者空間,只進行了一次資料拷貝。
所以,採用記憶體對映的讀寫效率要比傳統的read/write效能高。
總結
- MappedByteBuffer使用虛擬記憶體,因此分配(map)的記憶體大小不受JVM的-Xmx引數限制,但是也是有大小限制的。
- 如果當檔案超出1.5G限制時,可以通過position引數重新map檔案後面的內容。
- MappedByteBuffer在處理大檔案時的確效能很高,但也存在一些問題,如記憶體佔用、檔案關閉不確定,被其開啟的檔案只有在垃圾回收的才會被關閉,而且這個時間點是不確定的。
javadoc中也提到:A mapped byte buffer and the file mapping that it represents remain valid until the buffer itself is garbage-collected.*