☕【Java深層系列】「技術盲區」讓我們一起去挑戰一下如何讀取一個較大或者超大的檔案資料!

浩宇天尚發表於2021-12-18

Java的檔案IO流處理方式

Java MappedByteBuffer & FileChannel & RandomAccessFile & FileXXXputStream 的讀寫。

Java的檔案IO讀取介紹

Java在JDK 1.4引入了ByteBuffer等NIO相關的類,使得 Java 程式設計師可以拋棄基於 Stream ,從而使用基於 Block 的方式讀寫檔案,java io操作中通常採用BufferedReader,BufferedInputStream等帶緩衝的IO類處理大檔案,不過java nio中引入了一種基於MappedByteBuffer操作大檔案的方式,其讀寫效能極高,本文會介紹其效能如此高的內部實現原理,分析一下到底是 FileChannel 快還是 MappedByteBuffer 塊。

此外,JDK 還引入了 IO 效能優化之王—— 零拷貝 sendFile 和 mmap。但他們的效能究竟怎麼樣? 和 RandomAccessFile 比起來,快多少? 什麼情況下快?

Java的檔案IO流技術痛點

如果我們要做超大檔案的讀寫(2G以上)。使用傳統的流讀寫,很有可能記憶體會直接爆了,幾乎不可能完成。

MappedByteBuffer

MappedByteBuffer的一個能力就是它可以讓我們讀寫那些因為太大而不能放進記憶體中的檔案。有了它,我們就可以假定整個檔案都放在記憶體中(實際上,大檔案放在記憶體和虛擬記憶體中),基本上都可以將它當作一個特別大的陣列來訪問,這樣極大的簡化了對於大檔案的修改等操作。

MappedByteBuffer的技術原理

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)功能。

作業系統找到一個最少使用的頁幀(LFU),使之失效,並把它寫入磁碟,隨後把需要訪問的頁放到頁幀中,並修改頁表中的對映,保證了所有的頁都會被排程。

虛擬記憶體地址和實體記憶體地址

虛擬記憶體地址:由頁號(與頁表中的頁號關聯)和偏移量(頁的小大,即這個頁能存多少資料)組成。

虛擬記憶體轉換到實體記憶體的過程

舉個例子,有一個虛擬地址它的頁號是4,偏移量是20,那麼他的定址過程是這樣的:首先到頁表中找到頁號4對應的頁幀號(比如為8),如果頁不在記憶體中,則用失效機制調入頁,接著把頁幀號和偏移量傳給MMU組成一個物理上真正存在的地址,最後就是訪問實體記憶體的資料了。

總結說明

對大多數作業系統來說,做記憶體檔案對映都是一個昂貴的操作。所以MappedByteBuffer適用於對大檔案的讀寫。對於小檔案直接用普通的讀寫就好了。

使用MappedByteBuffer案例

MappedByteBuffer繼承自ByteBuffer,擁有變動position和limit指標啦、包裝一個其他種類Buffer的檢視啦,你可以把整個檔案(不管檔案有多大)看成是一個ByteBuffer。

  • java.lang.Object
  • java.nio.Buffer
  • java.nio.ByteBuffer
  • java.nio.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存在的問題

使用MappedByteBuffer整個過程非常快,對映的位元組緩衝區是通過FileChannel.map 方法建立的,對映的位元組緩衝區和它所表示的檔案對映關係在該緩衝區本身成為垃圾回收緩衝區之前一直保持有效。

官方解釋

The buffer and the mapping that it represents will remain valid until the buffer itself is garbage-collected.A mapping, once established, is not dependent upon the file channel that was used to create it. Closing the channel, in particular, has no effect upon the validity of the mapping.

這就可能一些問題,主要就是記憶體佔用和檔案關閉等不確定問題。被MappedByteBuffer開啟的檔案只有在垃圾收集時才會被關閉,而這個點是不確定的。

比如說,先用MappedByteBuffer map到一個原始檔。進行復制操作。結束後想刪掉原始檔。刪除是會失敗的,主要原因是變數MappedByteBuffer仍然持有原始檔的控制程式碼,檔案處於不可刪除狀態。

官方並沒有給出釋放控制程式碼的操作,不過可以嘗試一下的方式:

實際需求案例場景

拷貝一個檔案,在拷貝完成之後將原始檔刪除 使用MappedByteBuffer 進行操作
但是MappedByteBuffer和它和他相關聯的資源 在垃圾回收之前一直保持有效 但是MappedByteBuffer儲存著對原始檔的引用 ,因此刪除原始檔失敗。

	public static void copyFileAndRemoveResource()  {
		File source = null;
		File dest = null;
		MappedByteBuffer buf = null;
		try {
			source = new File("D:\\eee.txt");
			dest = new File("C:\\eee.txt");
		} catch (NullPointerException e) {
			e.printStackTrace();
		}
		try (FileChannel in = new FileInputStream(source).getChannel();
				FileChannel out = new FileOutputStream(dest).getChannel();) {
			long size = in.size();
			buf = in.map(FileChannel.MapMode.READ_ONLY, 0, size);
			out.write(buf);
			buf.force();// 將此緩衝區所做的內容更改強制寫入包含對映檔案的儲存裝置中。
			System.out.println("檔案複製完成!");
			// System.gc();
			// 同時關閉檔案通道和釋放MappedByteBuffer才能成功
			in.close();//如果在關閉之前拋異常也不怕,因為使用了try-with-resource
			// 強制釋放MappedByteBuffer資源
			clean(buf);
			// 檔案複製完成後,刪除原始檔
			/*
			 * source.delete() 刪除用此抽象路徑名所表示的檔案或目錄,如果該路徑表示的是一個目錄 則該目錄必須為空資料夾才可以刪除
			 * 注意:使用java.nio.file.Files的delete方法能告訴你為什麼會刪除失敗
			 * 所以儘量使用Files.delete(Paths.get(pathName));來替代File物件的delete
			 * System.out.println(source.delete() == true ? "刪除成功!" : "刪除失敗!");
			 */
			Files.delete(Paths.get("D:\\eee.txt"));
			System.out.println("刪除成功!");
		} catch (Exception e) {
			e.printStackTrace();
		} 
	public static void clean(final MappedByteBuffer buffer) throws Exception {
		if (buffer == null) {
			return;
		}
		buffer.force();
		AccessController.doPrivileged(new PrivilegedAction<Object>() {//Privileged特權
			@Override
			public Object run() {
				try {
					// System.out.println(buffer.getClass().getName());
					Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
					getCleanerMethod.setAccessible(true);
					sun.misc.Cleaner cleaner = (sun.misc.Cleaner) getCleanerMethod.invoke(buffer, new Object[0]);
					cleaner.clean();
				} catch (Exception e) {
					e.printStackTrace();
				}
				return null;
			}
		});
		/*
		 * 
		 * 在MyEclipse中編寫Java程式碼時,用到了Cleaner,import sun.misc.Cleaner;可是Eclipse提示:
		 * Access restriction: The type Cleaner is not accessible due to
		 * restriction on required library *\rt.jar Access restriction : The
		 * constructor Cleaner() is not accessible due to restriction on
		 * required library *\rt.jar
		 * 
		 * 解決方案1(推薦): 只需要在project build path中先移除JRE System Library,再新增庫JRE
		 * System Library,重新編譯後就一切正常了。 解決方案2: Windows -> Preferences -> Java ->
		 * Compiler -> Errors/Warnings -> Deprecated and trstricted API ->
		 * Forbidden reference (access rules): -> change to warning
		 */
	}
}

其實講到這裡該問題的解決辦法已然清晰明瞭了——就是在刪除索引檔案的同時還取消對應的記憶體對映,刪除mapped物件。

不過令人遺憾的是,Java並沒有特別好的解決方案——令人有些驚訝的是,Java沒有為MappedByteBuffer提供unmap的方法,該方法甚至要等到Java 10才會被引入 ,DirectByteBufferR類是不是一個公有類class DirectByteBufferR extends DirectByteBuffer implements DirectBuffer 使用預設訪問修飾符

不過Java倒是提供了內部的“臨時”解決方案——DirectByteBufferR.cleaner().clean() 切記這只是臨時方法。

  • 畢竟該類在Java9中就正式被隱藏了,而且也不是所有JVM廠商都有這個類。
  • 還有一個解決辦法就是顯式呼叫System.gc(),讓gc趕在cache失效前就進行回收。
  • 不過坦率地說,這個方法弊端更多:首先顯式呼叫GC是強烈不被推薦使用的,其次很多生產環境甚至禁用了顯式GC呼叫,所以這個辦法最終沒有被當做這個bug的解決方案。
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初始化。

利用 IO 零拷貝的 MQ 們

Java 世界有很多 MQ:ActiveMQ,kafka,RocketMQ,去哪兒 MQ,而他們則是 Java 世界使用 NIO 零拷貝的大戶。

然而,他們的效能卻大相同,拋開其他的因素,例如網路傳輸方式,資料結構設計,檔案儲存方式,我們僅僅討論 Broker 端對檔案的讀寫,看看他們有什麼不同。

總結的各個 MQ 使用的檔案讀寫方式。

  • kafka:record 的讀寫都是基於 FileChannel。index 讀寫基於 MMAP。

  • RocketMQ:讀盤基於 MMAP,寫盤預設使用 MMAP,可通過修改配置,配置成 FileChannel,原因是作者想避免 PageCache 的鎖競爭,通過兩層架構實現讀寫分離。

  • QMQ: 去哪兒 MQ,讀盤使用 MMAP,寫盤使用 FileChannel。

  • ActiveMQ 5.15: 讀寫全部都是基於 RandomAccessFile,這也是我們拋棄 ActiveMQ 的原因。

MMAP 眾所周知,基於 OS 的 mmap 的記憶體對映技術,通過MMU對映檔案,使隨機讀寫檔案和讀寫記憶體相似的速度。

參考資料

https://www.linuxjournal.com/article/6345

http://thinkinjava.cn/2019/05/12/2019/05-12-java-nio/

相關文章