MappedByteBuffer 詳解(圖解+秒懂+史上最全)
MappedByteBuffer(圖解+秒懂+史上最全)
java nio中引入了一種基於MappedByteBuffer操作大檔案的方式,其讀寫效能極高,本文會介紹其效能如此高的內部實現原理。
記憶體管理
在深入MappedByteBuffer之前,先看看計算機記憶體管理的幾個術語:
- MMU: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組成一個物理上真正存在的地址,最後就是訪問實體記憶體的資料了。
Java中基礎MMap的使用
MappedByteBuffer是什麼?從繼承結構上看,MappedByteBuffer繼承自ByteBuffer,內部維護了一個邏輯地址address。
將共享記憶體和磁碟檔案建立聯絡的是檔案通道類:FileChannel。
該類的加入是JDK為了統一對外部裝置(檔案、網路介面等)的訪問方法,並且加強了多執行緒對同一檔案進行存取的安全性。
這裡只是用它來建立共享記憶體用,它建立了共享記憶體和磁碟檔案之間的一個通道。
FileChannel提供了map方法把檔案對映到虛擬記憶體,通常情況可以對映整個檔案,如果檔案比較大,可以進行分段對映。
大致的步驟:
-
首先通過 RandomAccessFile獲取檔案通道。
-
然後,通過channel進行記憶體對映,獲取一個虛擬記憶體區域VMA
//通過RandomAccessFile獲取FileChannel。
try (FileChannel channel = new RandomAccessFile(decodePath, "rw").getChannel();) {
//通過channel進行記憶體對映,獲取一個虛擬記憶體區域VMA
MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.PRIVATE, 0, length);
....
channel.map方法的引數:
- 對映型別
MapMode mode:記憶體映像檔案訪問的方式,FileChannel中的幾個常量定義,共三種:
- MapMode.READ_ONLY:只讀,試圖修改得到的緩衝區將導致丟擲異常。
- MapMode.READ_WRITE:讀/寫,對得到的緩衝區的更改最終將寫入檔案;但該更改對對映到同一檔案的其他程式不一定是可見的。
- MapMode.PRIVATE:私用,可讀可寫,但是修改的內容不會寫入檔案,只是buffer自身的改變,這種能力稱之為”copy on write”。
- position:檔案對映時的起始位置。
- length:對映區的長度。長度單位為位元組。長度單位為位元組
示例1:通過MappedByteBuffer讀取檔案
package com.crazymakercircle.iodemo.fileDemos;
import com.crazymakercircle.NioDemoConfig;
import com.crazymakercircle.util.IOUtil;
import com.crazymakercircle.util.Logger;
import java.io.*;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
/**
* Created by 尼恩@ 瘋創客圈
*/
public class FileMmapDemo {
/**
* 演示程式的入口函式
*
* @param args
*/
public static void main(String[] args) {
doMmapDemo();
}
/**
* 讀取
*/
public static void doMmapDemo() {
String sourcePath = NioDemoConfig.MMAP_FILE_RESOURCE_SRC_PATH;
String decodePath = IOUtil.getResourcePath(sourcePath);
Logger.debug("decodePath=" + decodePath);
mmapWriteFile(decodePath);
}
/**
* 讀取檔案內容並輸出
*
* @param fileName 檔名
*/
public static void mmapWriteFile(String fileName) {
//向檔案中存1M的資料
int length = 1024;//
try (FileChannel channel = new RandomAccessFile(fileName, "rw").getChannel();) {
//一個整數4個位元組
MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, length);
for (int i = 0; i < length; i++) {
mapBuffer.put((byte) (Integer.valueOf('a') + i % 26));
}
for (int i = 0; i < length; i++) {
if (i % 50 == 0) System.out.println("");
//像陣列一樣訪問
System.out.print((char) mapBuffer.get(i));
}
mapBuffer.force();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
輸出的結果
decodePath=/E:/refer/crazydemo/netty_redis_zookeeper_source_code/NioDemos/target/classes//mmap.demo.log
abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx
yzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuv
wxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrst
uvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr
stuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnop
qrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmn
opqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl
mnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghij
klmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefgh
ijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdef
ghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd
efghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzab
cdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz
abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx
yzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuv
wxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrst
uvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr
stuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnop
qrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmn
opqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl
mnopqrstuvwxyzabcdefghijDisconnected from the target VM, address: '127.0.0.1:50970', transport: 'socket'
Process finished with exit code 0
示例2:通過MappedByteBuffer讀取私用對映
私用,可讀可寫,但是修改的內容不會寫入檔案,只是buffer自身的改變,這種能力稱之為”copy on write”。
/**
* 讀取檔案內容並輸出
*
*/
public static void mmapPrivate() {
String sourcePath = NioDemoConfig.MMAP_FILE_RESOURCE_SRC_PATH;
String decodePath = IOUtil.getResourcePath(sourcePath);
Logger.debug("decodePath=" + decodePath);
//向檔案中存1M的資料
int length = 1024;//
try (FileChannel channel = new RandomAccessFile(decodePath, "rw").getChannel();) {
//一個整數4個位元組
MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.PRIVATE, 0, length);
for (int i = 0; i < length; i++) {
mapBuffer.put((byte) (Integer.valueOf('a') + i % 26));
}
for (int i = 0; i < length; i++) {
if (i % 50 == 0) System.out.println("");
//像陣列一樣訪問
System.out.print((char) mapBuffer.get(i));
}
mapBuffer.force();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
執行程式,可以看到檔案並沒有寫入的內容。
例項3:通過MMap共享記憶體
共享記憶體對應應用開發的意義
對熟知UNIX系統應用開發的程式設計師來說,IPC(InterProcess Communication)機制是非常熟悉的,
IPC基本包括共享記憶體、訊號燈操作、訊息佇列、訊號處理等部分,是開發應用中非常重要的必不可少的工具。
在所有的IPC中, 其中共享記憶體是關鍵,對於資料共享、系統快速查詢、動態配置、減少資源耗費等均有獨到的優點。
對應UNIX系統來說,共享記憶體分為一般普通共享記憶體和檔案對映共享記憶體兩種,而對應 Windows,實際上只有映像檔案共享記憶體一種。
所以java應用中也是隻能建立映像檔案共享記憶體。
Java中的共享記憶體場景
在java語言中,基本上沒有提及共享記憶體這個概念,但是,在某一些應用中,共享記憶體確實非常有用。
例如採用java語言的分散式應用系統中,存在著大量的分散式共享物件,很多時候需要查詢這些物件的狀態,以檢視系統是否執行正常或者瞭解這些物件的目前的一些統計資料和狀態。
如果採用網路通訊的方式,顯然會增加應用的額外負擔,也增加了一些不必要的應用程式設計。
而如果採用共享記憶體的方式,則可以直接通過共享記憶體檢視物件的狀態資料和統計資料,從而減少了一些不必要的麻煩。
共享記憶體的使用有如下幾個特點:
- 可以被多個程序開啟訪問;
- 讀寫操作的程序在執行讀寫操作時其他程序不能進行寫操作;
- 多個程序可以交替對某一共享記憶體執行寫操作;
- 一個程序執行了記憶體的寫操作後,不影響其他程序對該記憶體的訪問。同時其他程序對更新後的記憶體具有可見性。
- 在程序執行寫操作時如果異常退出,對其他程序寫操作禁止應自動解除。
- 相對共享檔案,資料訪問的方便性和效率有
共享記憶體在java中的實現
在jdk1.4中提供的類MappedByteBuffer為我們實現共享記憶體提供了較好的方法。
該緩衝區實際上是一個磁碟檔案的記憶體映像。二者的變化將保持同步,即記憶體資料發生變化會立刻反映到磁碟檔案中,這樣會有效的保證共享記憶體的實現。
將共享記憶體和磁碟檔案建立聯絡的是檔案通道類:FileChannel。
該類的加入是JDK為了統一對外部裝置(檔案、網路介面等)的訪問方法,並且加強了多執行緒對同一檔案進行存取的安全性。
這裡只是用它來建立共享記憶體用,它建立了共享記憶體和磁碟檔案之間的一個通道。
開啟一個檔案建立一個檔案通道可以用RandomAccessFile類中的方法getChannel。
該方法將直接返回一個檔案通道。
該檔案通道由於對應的檔案設為隨機存取檔案,一方面可以進行讀寫兩種操作,另一方面使用它不會破壞映像檔案的內容(如果用FileOutputStream直接開啟一個映像檔案會將該檔案的大小置為0,當然資料會全部丟失)。
為什麼用 FileOutputStream和FileInputStream則不能理想的實現共享記憶體的要求呢?
因為這兩個類同時實現自由的讀寫操作要困難得多。
如何保障寫入的互斥性
由於只有一個檔案能擁有寫的許可權,可以通過分散式鎖的方式,保障排他性。
如果在同一個機器上有一種簡單的互斥方式:
- 採用檔案鎖的方式。
共享記憶體在java中的應用的參考程式碼
package com.crazymakercircle.iodemo.sharemem;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.util.Properties;
import com.crazymakercircle.NioDemoConfig;
import com.crazymakercircle.util.IOUtil;
/**
* 共享記憶體操作類
*/
public class ShareMemory {
String sourcePath = NioDemoConfig.MEM_SHARE_RESOURCE_SRC_PATH;
String decodePath = IOUtil.getResourcePath(sourcePath);
int fsize = 1024; //檔案的實際大小
MappedByteBuffer mapBuf = null; //定義共享記憶體緩衝區
FileChannel fc = null; //定義相應的檔案通道
FileLock fl = null; //定義檔案區域鎖定的標記。
Properties p = null;
RandomAccessFile randomAccessFile = null; //定義一個隨機存取檔案物件
public ShareMemory() {
try {
// 獲得一個只讀的隨機存取檔案物件 "rw" 開啟以便讀取和寫入。如果該檔案尚不存在,則嘗試建立該檔案。
randomAccessFile = new RandomAccessFile(decodePath, "rw");
//獲取相應的檔案通道
fc = randomAccessFile.getChannel();
//將此通道的檔案區域直接對映到記憶體中。
mapBuf = fc.map(FileChannel.MapMode.READ_WRITE, 0, fsize);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* @param pos 鎖定區域開始的位置;必須為非負數
* @param len 鎖定區域的大小;必須為非負數
* @param buff 寫入的資料
* @return
*/
public synchronized int write(int pos, int len, byte[] buff) {
if (pos >= fsize || pos + len >= fsize) {
return 0;
}
//定義檔案區域鎖定的標記。
FileLock fl = null;
try {
//獲取此通道的檔案給定區域上的鎖定。
fl = fc.lock(pos, len, false);
if (fl != null) {
mapBuf.position(pos);
ByteBuffer bf1 = ByteBuffer.wrap(buff);
mapBuf.put(bf1);
//釋放此鎖定。
fl.release();
return len;
}
} catch (Exception e) {
if (fl != null) {
try {
fl.release();
} catch (IOException e1) {
System.out.println(e1.toString());
}
}
return 0;
}
return 0;
}
/**
* @param pos 鎖定區域開始的位置;必須為非負數
* @param len 鎖定區域的大小;必須為非負數
* @param buff 要取的資料
* @return
*/
public synchronized int read(int pos, int len, byte[] buff) {
if (pos >= fsize) {
return 0;
}
//定義檔案區域鎖定的標記。
FileLock fl = null;
try {
fl = fc.lock(pos, len, false);
if (fl != null) {
//System.out.println( "pos="+pos );
mapBuf.position(pos);
if (mapBuf.remaining() < len) {
len = mapBuf.remaining();
}
if (len > 0) {
mapBuf.get(buff, 0, len);
}
fl.release();
return len;
}
} catch (Exception e) {
if (fl != null) {
try {
fl.release();
} catch (IOException e1) {
System.out.println(e1.toString());
}
}
return 0;
}
return 0;
}
/**
* 完成,關閉相關操作
*/
protected void finalize() throws Throwable {
if (fc != null) {
try {
fc.close();
} catch (IOException e) {
System.out.println(e.toString());
}
fc = null;
}
if (randomAccessFile != null) {
try {
randomAccessFile.close();
} catch (IOException e) {
System.out.println(e.toString());
}
randomAccessFile = null;
}
mapBuf = null;
}
/**
* 關閉共享記憶體操作
*/
public synchronized void closeSMFile() {
if (fc != null) {
try {
fc.close();
} catch (IOException e) {
System.out.println(e.toString());
}
fc = null;
}
if (randomAccessFile != null) {
try {
randomAccessFile.close();
} catch (IOException e) {
System.out.println(e.toString());
}
randomAccessFile = null;
}
mapBuf = null;
}
}
map過程核心原理
接下去通過分析原始碼,瞭解一下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完成檔案的對映工作。
- 如果第一次檔案對映導致OOM,則手動觸發垃圾回收,休眠100ms後再次嘗試對映,如果失敗,則丟擲異常。
- 通過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.