☕【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完成文件的映射工作。

  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性能高。

採用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.*

參考資料

//blog.csdn.net/qq_41969879/article/details/81629469