天天看點

☕【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仍然持有源檔案的句柄,檔案處于不可删除狀态。

官方并沒有給出釋放句柄的操作,不過可以嘗試一下的方式:

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

拷貝一個檔案,在拷貝完成之後将源檔案删除 使用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/

極限就是為了超越而存在的