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

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映射文件,使隨機讀寫文件和讀寫記憶體相似的速度。

參考資料

//www.linuxjournal.com/article/6345

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