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