☕【Java深層系列】「併發程式設計系列」深入分析和研究MappedByteBuffer的實現原理和開發指南
前言介紹
在Java程式語言中,操作檔案IO的時候,通常採用BufferedReader,BufferedInputStream等帶緩衝的IO類處理大檔案,不過java nio中引入了一種基於MappedByteBuffer操作大檔案的方式,其讀寫效能極高,比起bio的模型處理方式,它大大的加大了支援解析讀取檔案的數量和空間。
OS的記憶體管理
記憶體層面的技術名詞概念
- MMU:CPU的記憶體管理單元。
- 實體記憶體:即記憶體條的記憶體空間。
- 虛擬記憶體:計算機系統記憶體管理的一種技術。它使得應用程式認為它擁有連續的可用的記憶體(一個連續完整的地址空間),而實際上,它通常是被分隔成多個實體記憶體碎片,還有部分暫時儲存在外部磁碟儲存器上,在需要時進行資料交換。
- 頁面檔案:作業系統反映構建並使用虛擬記憶體的硬碟空間大小而建立的檔案,在windows下,即pagefile.sys檔案,其存在意味著實體記憶體被佔滿後,將暫時不用的資料移動到硬碟上。
- 缺頁中斷:當程式試圖訪問已對映在虛擬地址空間中但未被載入至實體記憶體的一個分頁時,由MMC發出的中斷。如果作業系統判斷此次訪問是有效的,則嘗試將相關的頁從虛擬記憶體檔案中載入實體記憶體。
虛擬記憶體和實體記憶體
正在執行的一個程序,它所需的記憶體是有可能大於記憶體條容量之和的,如記憶體條是256M,程式卻要建立一個2G的資料區,那麼所有資料不可能都載入到記憶體(實體記憶體),必然有資料要放到其他介質中(比如硬碟),待程序需要訪問那部分資料時,再排程進入實體記憶體,而這種場景下,被排程到硬碟的資源空間所佔用的儲存,我們便將他理解為虛擬記憶體。
MappedByteBuffer
從大體上講一下MappedByteBuffer 究竟是什麼。從繼承結構上來講,MappedByteBuffer 繼承自 ByteBuffer,所以,ByteBuffer 有的能力它全有;像變動 position 和 limit 指標啦、包裝一個其他種類Buffer的檢視啦,內部維護了一個邏輯地址address。
“MappedByteBuffer” 會提升速度,變快
-
為什麼快?因為它使用 direct buffer 的方式讀寫檔案內容,這種方式的學名叫做記憶體對映。這種方式直接呼叫系統底層的快取,沒有 JVM 和系統之間的複製操作,所以效率大大的提高了。而且由於它這麼快,還可以用它來在程序(或執行緒)間傳遞訊息,基本上能達到和 “共享記憶體頁” 相同的作用,只不過它是依託實體檔案來執行的。
-
還有就是它可以讓讀寫那些太大而不能放進記憶體中的檔案。實現假定整個檔案都放在記憶體中(實際上,大檔案放在記憶體和虛擬記憶體中),基本上都可以將它當作一個特別大的陣列來訪問,這樣極大的簡化了對於大檔案的修改等操作。
MappedByteBuffer的案例用法
FileChannel 提供了 map 方法來把檔案對映為 MappedByteBuffer: MappedByteBuffer map(int mode,long position,long size); 可以把檔案的從 position 開始的 size 大小的區域對映為 MappedByteBuffer,mode 指出了可訪問該記憶體映像檔案的方式,共有三種,分別為:
- MapMode.READ_ONLY(只讀): 試圖修改得到的緩衝區將導致丟擲 ReadOnlyBufferException。
- MapMode.READ_WRITE(讀 / 寫): 對得到的緩衝區的更改最終將寫入檔案;但該更改對對映到同一檔案的其他程式不一定是可見的(無處不在的 “一致性問題” 又出現了)。
- MapMode.PRIVATE(專用): 可讀可寫, 但是修改的內容不會寫入檔案, 只是 buffer 自身的改變,這種能力稱之為”copy on write”
MappedByteBuffer較之ByteBuffer新增的三個方法
- fore() 緩衝區是 READ_WRITE 模式下,此方法對緩衝區內容的修改強行寫入檔案
- load() 將緩衝區的內容載入記憶體,並返回該緩衝區的引用
- isLoaded() 如果緩衝區的內容在實體記憶體中,則返回真,否則返回假
採用FileChannel構建相關的MappedByteBuffer
//一個byte佔1B,所以共向檔案中存128M的資料
int length = 0x8FFFFFF;
try (FileChannel channel = FileChannel.open(Paths.get("src/c.txt"),
StandardOpenOption.READ, StandardOpenOption.WRITE);) {
MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, length);
for(int i=0;i<length;i++) {
mapBuffer.put((byte)0);
}
for(int i = length/2;i<length/2+4;i++) {
//像陣列一樣訪問
System.out.println(mapBuffer.get(i));
}
}
實現相關的讀寫檔案的對比處理
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
public class TestMappedByteBuffer {
private static int length = 0x2FFFFFFF;//1G
private abstract static class Tester {
private String name;
public Tester(String name) {
this.name = name;
}
public void runTest() {
System.out.print(name + ": ");
long start = System.currentTimeMillis();
test();
System.out.println(System.currentTimeMillis()-start+" ms");
}
public abstract void test();
}
private static Tester[] testers = {
new Tester("Stream RW") {
public void test() {
try (FileInputStream fis = new FileInputStream(
"src/a.txt");
DataInputStream dis = new DataInputStream(fis);
FileOutputStream fos = new FileOutputStream(
"src/a.txt");
DataOutputStream dos = new DataOutputStream(fos);) {
byte b = (byte)0;
for(int i=0;i<length;i++) {
dos.writeByte(b);
dos.flush();
}
while (dis.read()!= -1) {
}
} catch (IOException e) {
e.printStackTrace();
}
}
},
new Tester("Mapped RW") {
public void test() {
try (FileChannel channel = FileChannel.open(Paths.get("src/b.txt"),
StandardOpenOption.READ, StandardOpenOption.WRITE);) {
MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, length);
for(int i=0;i<length;i++) {
mapBuffer.put((byte)0);
}
mapBuffer.flip();
while(mapBuffer.hasRemaining()) {
mapBuffer.get();
}
} catch (IOException e) {
e.printStackTrace();
}
}
},
new Tester("Mapped PRIVATE") {
public void test() {
try (FileChannel channel = FileChannel.open(Paths.get("src/c.txt"),
StandardOpenOption.READ, StandardOpenOption.WRITE);) {
MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.PRIVATE, 0, length);
for(int i=0;i<length;i++) {
mapBuffer.put((byte)0);
}
mapBuffer.flip();
while(mapBuffer.hasRemaining()) {
mapBuffer.get();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
};
public static void main(String[] args) {
for(Tester tester:testers) {
tester.runTest();
}
}
}
測試結果
-
Stream RW->用傳統流的方式,最慢,應該是由於用的資料量是 1G,無法全部讀入記憶體,所以它根本無法完成測試。
-
MapMode.READ_WRITE,它的速度每次差別較大,在 0.6s 和 8s 之間波動,而且很不穩定。
-
MapMode.PRIVATE就穩得出奇,一直是 1.1s 到 1.2s 之間。
無論是哪個速度都是十分驚人的,但是 MappedByteBuffer 也有不足,就是在資料量很小的時候,表現比較糟糕,那是因為 direct buffer 的初始化時間較長,所以建議大家只有在資料量較大的時候,在用 MappedByteBuffer。
map過程
FileChannel提供了map方法把檔案對映到虛擬記憶體,通常情況可以對映整個檔案,如果檔案比較大,可以進行分段對映。
FileChannel中的幾個變數:
- MapMode mode:記憶體映像檔案訪問的方式,也就是上面說的三種方式。
- position:檔案對映時的起始位置。
- allocationGranularity:Memory allocation size for mapping buffers,通過native函式initIDs初始化。
接下去通過分析原始碼,瞭解一下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效能高。
採用RandomAccessFile構建相關的MappedByteBuffer
通過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) {}
}
}
總結
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.*