Java 中的內(nèi)存映射 mmap
1、mmap 基礎(chǔ)概念
mmap 是一種內(nèi)存映射文件的方法,即將一個(gè)文件映射到進(jìn)程的地址空間,實(shí)現(xiàn)文件磁盤地址和一段進(jìn)程虛擬地址的映射。實(shí)現(xiàn)這樣的映射關(guān)系后,進(jìn)程就可以采用指針的方式讀寫操作這一段內(nèi)存,而系統(tǒng)會(huì)自動(dòng)回寫臟頁(yè)到對(duì)應(yīng)的文件磁盤上,即完成了對(duì)文件的操作而不必再調(diào)用 read,write 等系統(tǒng)調(diào)用函數(shù)。相反,內(nèi)核空間對(duì)這段區(qū)域的修改也直接反映用戶空間,從而可以實(shí)現(xiàn)不同進(jìn)程間的文件共享。
mmap工作原理:

操作系統(tǒng)提供了這么一系列 mmap 的配套函數(shù)
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset); int munmap( void * addr, size_t len); int msync( void *addr, size_t len, int flags);
2、Java 中的 mmap
Java 中原生讀寫方式大概可以被分為三種:普通 IO,FileChannel(文件通道),mmap(內(nèi)存映射)。區(qū)分他們也很簡(jiǎn)單,例如 FileWriter,FileReader 存在于 java.io 包中,他們屬于普通 IO;FileChannel 存在于 java.nio 包中,也是 Java 最常用的文件操作類;而今天的主角 mmap,則是由 FileChannel 調(diào)用 map 方法衍生出來(lái)的一種特殊讀寫文件的方式,被稱之為內(nèi)存映射。
mmap 的使用方式:
FileChannel fileChannel = new RandomAccessFile(new File("db.data"), "rw").getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, filechannel.size();
MappedByteBuffer便是 Java 中的 mmap 操作類。
// 寫 byte[] data = new byte[4]; int position = 8; // 從當(dāng)前 mmap 指針的位置寫入 4b 的數(shù)據(jù) mappedByteBuffer.put(data); // 指定 position 寫入 4b 的數(shù)據(jù) MappedByteBuffer subBuffer = mappedByteBuffer.slice(); subBuffer.position(position); subBuffer.put(data); // 讀 byte[] data = new byte[4]; int position = 8; // 從當(dāng)前 mmap 指針的位置讀取 4b 的數(shù)據(jù) mappedByteBuffer.get(data); // 指定 position 讀取 4b 的數(shù)據(jù) MappedByteBuffer subBuffer = mappedByteBuffer.slice(); subBuffer.position(position); subBuffer.get(data);
3、mmap 不是銀彈
促使我寫這一篇文章的一大動(dòng)力,來(lái)自于網(wǎng)絡(luò)中很多關(guān)于 mmap 錯(cuò)誤的認(rèn)知。初識(shí) mmap,很多文章提到 mmap 適用于處理大文件的場(chǎng)景,現(xiàn)在回過(guò)頭看,其實(shí)這種觀點(diǎn)是非?;奶频?,希望通過(guò)此文能夠澄清 mmap 本來(lái)的面貌。
FileChannel 與 mmap 同時(shí)存在,大概率說(shuō)明兩者都有其合適的使用場(chǎng)景,而事實(shí)也的確如此。在看待二者時(shí),可以將其看待成實(shí)現(xiàn)文件 IO 的兩種工具,工具本身沒(méi)有好壞,主要還是看使用場(chǎng)景。
4、mmap vs FileChannel
這一節(jié),詳細(xì)介紹一下 FileChannel 和 mmap 在進(jìn)行文件 IO 的一些異同點(diǎn)。
4.1 pageCache
FileChannel 和 mmap 的讀寫都經(jīng)過(guò) pageCache,或者更準(zhǔn)確的說(shuō)法是通過(guò) vmstat 觀測(cè)到的 cache 這一部分內(nèi)存,而非用戶空間的內(nèi)存。
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 4622324 40736 351384 0 0 0 0 2503 200 50 1 50 0 0
至于說(shuō) mmap 映射的這部分內(nèi)存能不能稱之為 pageCache,我并沒(méi)有去調(diào)研過(guò),不過(guò)在操作系統(tǒng)看來(lái),他們并沒(méi)有太多的區(qū)別,這部分 cache 都是內(nèi)核在控制。后面本文也統(tǒng)一稱 mmap 出來(lái)的內(nèi)存為 pageCache。
4.2 缺頁(yè)中斷
對(duì) Linux 文件 IO 有基礎(chǔ)認(rèn)識(shí)的讀者,可能對(duì)缺頁(yè)中斷這個(gè)概念也不會(huì)太陌生。mmap 和 FileChannel 都以缺頁(yè)中斷的方式,進(jìn)行文件讀寫。
以 mmap 讀取 1G 文件為例, fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, _GB); 進(jìn)行映射是一個(gè)消耗極少的操作,此時(shí)并不意味著 1G 的文件被讀進(jìn)了 pageCache。只有通過(guò)以下方式,才能夠確保文件被讀進(jìn) pageCache。
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
MappedByteBuffer map = fileChannel.map(MapMode.READ_WRITE, 0, _GB);
for (int i = 0; i < _GB; i += _4kb) {
temp += map.get(i);
}
關(guān)于內(nèi)存對(duì)齊的細(xì)節(jié)在這里就不拓展了,可以詳見(jiàn) java.nio.MappedByteBuffer#load 方法,load 方法也是通過(guò)按頁(yè)訪問(wèn)的方式觸發(fā)中斷
如下是 pageCache 逐漸增長(zhǎng)的過(guò)程,共計(jì)約增長(zhǎng)了 1.034G,說(shuō)明文件內(nèi)容此刻已全部 load。
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 0 0 4824640 1056 207912 0 0 0 0 2374 195 50 0 50 0 0
2 1 0 4605300 2676 411892 0 0 205256 0 3481 1759 52 2 34 12 0
2 1 0 4432560 2676 584308 0 0 172032 0 2655 346 50 1 25 24 0
2 1 0 4255080 2684 761104 0 0 176400 0 2754 380 50 1 19 29 0
2 3 0 4086528 2688 929420 0 0 167940 40 2699 327 50 1 25 24 0
2 2 0 3909232 2692 1106300 0 0 176520 4 2810 377 50 1 23 26 0
2 2 0 3736432 2692 1278856 0 0 172172 0 2980 361 50 1 17 31 0
3 0 0 3722064 2840 1292776 0 0 14036 0 2757 392 50 1 29 21 0
2 0 0 3721784 2840 1292892 0 0 116 0 2621 283 50 1 50 0 0
2 0 0 3721996 2840 1292892 0 0 0 0 2478 237 50 0 50 0 0
兩個(gè)細(xì)節(jié):
mmap 映射的過(guò)程可以理解為一個(gè)懶加載, 只有 get() 時(shí)才會(huì)觸發(fā)缺頁(yè)中斷
預(yù)讀大小是有操作系統(tǒng)算法決定的,可以默認(rèn)當(dāng)作 4kb,即如果希望懶加載變成實(shí)時(shí)加載,需要按照 step=4kb 進(jìn)行一次遍歷
而 FileChannel 缺頁(yè)中斷的原理也與之相同,都需要借助 PageCache 做一層跳板,完成文件的讀寫。
4.3 內(nèi)存拷貝次數(shù)
很多言論認(rèn)為 mmap 相比 FileChannel 少一次復(fù)制,我個(gè)人覺(jué)得還是需要區(qū)分場(chǎng)景。
例如需求是從文件首地址讀取一個(gè) int,兩者所經(jīng)過(guò)的鏈路其實(shí)是一致的:SSD -> pageCache -> 應(yīng)用內(nèi)存,mmap 并不會(huì)少拷貝一次。
但如果需求是維護(hù)一個(gè) 100M 的復(fù)用 buffer,且涉及到文件 IO,mmap 直接就可以當(dāng)做是 100M 的 buffer 來(lái)用,而不用在進(jìn)程的內(nèi)存(用戶空間)中再維護(hù)一個(gè) 100M 的緩沖。
4.4 用戶態(tài)與內(nèi)核態(tài)
用戶態(tài)和內(nèi)核態(tài):

操作系統(tǒng)出于安全考慮,將一些底層的能力進(jìn)行了封裝,提供了系統(tǒng)調(diào)用(system call)給用戶使用。這里就涉及到“用戶態(tài)”和“內(nèi)核態(tài)”的切換問(wèn)題,私認(rèn)為這里也是很多人概念理解模糊的重災(zāi)區(qū),我在此梳理下個(gè)人的認(rèn)知,如有錯(cuò)誤也歡迎指正。
先看 FileChannel,下面兩段代碼,你認(rèn)為誰(shuí)更快?
// 方法一: 4kb 刷盤
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_4kb);
for (int i = 0; i < _4kb; i++) {
byteBuffer.put((byte)0);
}
for (int i = 0; i < _GB; i += _4kb) {
byteBuffer.position(0);
byteBuffer.limit(_4kb);
fileChannel.write(byteBuffer);
}
// 方法二: 單字節(jié)刷盤
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1);
byteBuffer.put((byte)0);
for (int i = 0; i < _GB; i ++) {
byteBuffer.position(0);
byteBuffer.limit(1);
fileChannel.write(byteBuffer);
}
使用方法一:4kb 緩沖刷盤(常規(guī)操作),在我的測(cè)試機(jī)器上只需要 1.2s 就寫完了 1G。而不使用任何緩沖的方法二,幾乎是直接卡死,文件增長(zhǎng)速度非常緩慢,在等待了 5 分鐘還沒(méi)寫完后,中斷了測(cè)試。
使用寫入緩沖區(qū)是一個(gè)非常經(jīng)典的優(yōu)化技巧,用戶只需要設(shè)置 4kb 整數(shù)倍的寫入緩沖區(qū),聚合小數(shù)據(jù)的寫入,就可以使得數(shù)據(jù)從 pageCache 刷盤時(shí),盡可能是 4kb 的整數(shù)倍,避免寫入放大問(wèn)題。但這不是這一節(jié)的重點(diǎn),大家有沒(méi)有想過(guò),pageCache 其實(shí)本身也是一層緩沖,實(shí)際寫入 1byte 并不是同步刷盤的,相當(dāng)于寫入了內(nèi)存,pageCache 刷盤由操作系統(tǒng)自己決策。那為什么方法二這么慢呢? 主要就在于 filechannel 的 read/write 底層相關(guān)聯(lián)的系統(tǒng)調(diào)用,是需要切換內(nèi)核態(tài)和用戶態(tài)的,注意,這里跟內(nèi)存拷貝沒(méi)有任何關(guān)系,導(dǎo)致態(tài)切換的根本原因是 read/write 關(guān)聯(lián)的系統(tǒng)調(diào)用本身 。方法二比方法一多切換了 4096 倍,態(tài)的切換成為了瓶頸,導(dǎo)致耗時(shí)嚴(yán)重。
階段總結(jié)一下重點(diǎn),在 DRAM 中設(shè)置用戶寫入緩沖區(qū)這一行為有兩個(gè)意義:
- 方便做 4kb 對(duì)齊,ssd 刷盤友好
- 減少用戶態(tài)和內(nèi)核態(tài)的切換次數(shù),cpu 友好
但 mmap 不同,其底層提供的映射能力不涉及到切換內(nèi)核態(tài)和用戶態(tài),注意,這里跟內(nèi)存拷貝還是沒(méi)有任何關(guān)系,導(dǎo)致態(tài)不發(fā)生切換的根本原因是 mmap 關(guān)聯(lián)的系統(tǒng)調(diào)用本身。驗(yàn)證這一點(diǎn),也非常容易,我們使用 mmap 實(shí)現(xiàn)方法二來(lái)看看速度如何:
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
MappedByteBuffer map = fileChannel.map(MapMode.READ_WRITE, 0, _GB);
for (int i = 0; i < _GB; i++) {
map.put((byte)0);
}
在我的測(cè)試機(jī)器上,花費(fèi)了 3s,它比 FileChannel + 4kb 緩沖寫要慢,但遠(yuǎn)比 FileChannel 寫單字節(jié)快。
這里也解釋了我之前文章《文件 IO 操作的一些最佳實(shí)踐》中一個(gè)疑問(wèn):"一次寫入很小量數(shù)據(jù)的場(chǎng)景使用 mmap 會(huì)比 fileChannel 快的多“,其背后的原理就和上述例子一樣,在小數(shù)據(jù)量下,瓶頸不在于 IO,而在于 用戶態(tài)和內(nèi)核態(tài)的切換 。
5、mmap 細(xì)節(jié)補(bǔ)充
5.1 copy on write 模式
我們注意到 public abstract MappedByteBuffer map(MapMode mode,long position, long size) 的第一個(gè)參數(shù),MapMode 其實(shí)有三個(gè)值,在網(wǎng)絡(luò)沖浪的時(shí)候,也幾乎沒(méi)有找到講解 MapMode 的文章。MapMode 有三個(gè)枚舉值 READ_WRITE 、 READ_ONLY 、 PRIVATE ,大多數(shù)時(shí)候使用的可能是 READ_WRITE ,而 READ_ONLY 不過(guò)是限制了 WRITE 而已,很容易理解,但這個(gè) PRIVATE 身上似乎有一層神秘的面紗。
實(shí)際上 PRIVATE 模式正是 mmap 的 copy on write 模式,當(dāng)使用 MapMode.PRIVATE 去映射文件時(shí),你會(huì)獲得以下的特性:
- 其他任何方式對(duì)文件的修改,會(huì)直接反映在當(dāng)前 mmap 映射中。
private mmap之后自身的 put 行為,會(huì)觸發(fā)復(fù)制,形成自己的副本,任何修改不會(huì)會(huì)刷到文件中,也不再感知該文件該頁(yè)的改動(dòng)。
俗稱:copy on write。
這有什么用呢?重點(diǎn)就在于任何修改都不會(huì)回刷文件。其一,你可以獲得一個(gè)文件副本,如果你正好有這個(gè)需求,直接可以使用 PRIVATE 模式去進(jìn)行映射,其二,令人有點(diǎn)小激動(dòng)的場(chǎng)景,你獲得了一塊真正的 PageCache,不用擔(dān)心它會(huì)被操作系統(tǒng)刷盤造成 overhead。假設(shè)你的機(jī)器配置如下:機(jī)器內(nèi)存 9G,JVM 參數(shù)設(shè)置為 6G,堆外限制為 2G,那剩下的 1G 只能被內(nèi)核態(tài)使用,如果想被用戶態(tài)的程序利用起來(lái),就可以使用 mmap 的 copy on write 模式,這不會(huì)占用你的堆內(nèi)內(nèi)存或者堆外內(nèi)存。
5.2 回收 mmap 內(nèi)存
更正之前博文關(guān)于 mmap 內(nèi)存回收的一個(gè)錯(cuò)誤說(shuō)法,回收 mmap 很簡(jiǎn)單
((DirectBuffer) mmap).cleaner().clean();
mmap 的生命中簡(jiǎn)單可以分為:map(映射),get/load (缺頁(yè)中斷),clean(回收)。一個(gè)實(shí)用的技巧是動(dòng)態(tài)分配的內(nèi)存映射區(qū)域,在讀取過(guò)后,可以異步回收掉。
6、mmap 使用場(chǎng)景
使用 mmap 處理小數(shù)據(jù)的頻繁讀寫
如果 IO 非常頻繁,數(shù)據(jù)卻非常小,推薦使用 mmap,以避免 FileChannel 導(dǎo)致的切態(tài)問(wèn)題。例如索引文件的追加寫。
6.1 mmap 緩存
當(dāng)使用 FileChannel 進(jìn)行文件讀寫時(shí),往往需要一塊寫入緩存以達(dá)到聚合的目的,最常使用的是堆內(nèi)/堆外內(nèi)存,但他們都有一個(gè)問(wèn)題,即當(dāng)進(jìn)程掛掉后,堆內(nèi)/堆外內(nèi)存會(huì)立刻丟失,這一部分沒(méi)有落盤的數(shù)據(jù)也就丟了。而使用 mmap 作為緩存,會(huì)直接存儲(chǔ)在 pageCache 中,不會(huì)導(dǎo)致數(shù)據(jù)丟失,盡管這只能規(guī)避進(jìn)程被 kill 這種情況,無(wú)法規(guī)避掉電。
6.2 小文件的讀寫
恰恰和網(wǎng)傳的很多言論相反,mmap 由于其不切態(tài)的特性,特別適合順序讀寫,但由于 sun.nio.ch.FileChannelImpl#map(MapMode mode, long position, long size) 中 size 的限制,只能傳遞一個(gè) int 值,所以,單次 map 單個(gè)文件的長(zhǎng)度不能超過(guò) 2G,如果將 2G 作為文件大 or 小的閾值,那么小于 2G 的文件使用 mmap 來(lái)讀寫一般來(lái)說(shuō)是有優(yōu)勢(shì)的。在 RocketMQ 中也利用了這一點(diǎn),為了能夠方便的使用 mmap,將 commitLog 的大小按照 1G 來(lái)進(jìn)行切分。對(duì)的,忘記說(shuō)了,RocketMQ 等消息隊(duì)列一直在使用 mmap。
6.3 cpu 緊俏下的讀寫
在大多數(shù)場(chǎng)景下,FileChannel 和讀寫緩沖的組合相比 mmap 要占據(jù)優(yōu)勢(shì),或者說(shuō)不分伯仲,但在 cpu 緊俏下的讀寫,使用 mmap 進(jìn)行讀寫往往能起到優(yōu)化的效果,它的根據(jù)是 mmap 不會(huì)出現(xiàn)用戶態(tài)和內(nèi)核態(tài)的切換,導(dǎo)致 cpu 的不堪重負(fù)(但這樣承擔(dān)起動(dòng)態(tài)映射與異步回收內(nèi)存的開銷)。
6.4 特殊軟硬件因素
例如持久化內(nèi)存 Pmem、不同代數(shù)的 SSD、不同主頻的 CPU、不同核數(shù)的 CPU、不同的文件系統(tǒng)、文件系統(tǒng)的掛載方式...等等因素都會(huì)影響 mmap 和 filechannel read/write 的快慢,因?yàn)樗麄儗?duì)應(yīng)的系統(tǒng)調(diào)用是不同的。只有 benchmark 過(guò)后,方知快慢。
到此這篇關(guān)于Java 中的內(nèi)存映射 mmap的文章就介紹到這了,更多相關(guān)Java 中的內(nèi)存映射內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
java實(shí)現(xiàn)動(dòng)態(tài)代理方法淺析
這篇文章主要介紹了java實(shí)現(xiàn)動(dòng)態(tài)代理方法淺析,很實(shí)用的功能,需要的朋友可以參考下2014-08-08
SpringBoot整合RedisTemplate實(shí)現(xiàn)緩存信息監(jiān)控的步驟
這篇文章主要介紹了SpringBoot整合RedisTemplate實(shí)現(xiàn)緩存信息監(jiān)控,一步一步的實(shí)現(xiàn)?Springboot?整合?Redis?來(lái)存儲(chǔ)數(shù)據(jù),讀取數(shù)據(jù),需要的朋友可以參考下2022-01-01
Java?C++題解leetcode672燈泡開關(guān)示例
這篇文章主要為大家介紹了Java?C++題解leetcode672燈泡開關(guān)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-09-09
關(guān)于springboot2.4跨域配置問(wèn)題
這篇文章主要介紹了springboot2.4跨域配置的方法,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-07-07
SpringBoot3整合Mybatis完整版實(shí)例
本文詳細(xì)介紹了SpringBoot3整合MyBatis的完整步驟,包括添加數(shù)據(jù)庫(kù)驅(qū)動(dòng)和MyBatis依賴、配置數(shù)據(jù)源和MyBatis、創(chuàng)建表和Bean類、編寫Mapper接口和XML文件、創(chuàng)建Controller類以及配置掃描包,通過(guò)這些步驟,可以實(shí)現(xiàn)SpringBoot3與MyBatis的成功整合,并進(jìn)行功能測(cè)試2025-01-01
分布式調(diào)度XXL-Job整合Springboot2.X實(shí)戰(zhàn)操作過(guò)程(推薦)
這篇文章主要介紹了分布式調(diào)度XXL-Job整合Springboot2.X實(shí)戰(zhàn)操作,包括定時(shí)任務(wù)的使用場(chǎng)景和常見(jiàn)的定時(shí)任務(wù),通過(guò)本文學(xué)習(xí)幫助大家該選擇哪個(gè)分布式任務(wù)調(diào)度平臺(tái),對(duì)此文感興趣的朋友一起看看吧2022-04-04
mybatis調(diào)用存儲(chǔ)過(guò)程的實(shí)例代碼
這篇文章主要介紹了mybatis調(diào)用存儲(chǔ)過(guò)程的實(shí)例,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2017-10-10
SpringBoot使用開發(fā)環(huán)境application.properties問(wèn)題
這篇文章主要介紹了SpringBoot使用開發(fā)環(huán)境application.properties問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-07-07
DoytoQuery中的關(guān)聯(lián)查詢方案示例詳解
這篇文章主要為大家介紹了DoytoQuery中的關(guān)聯(lián)查詢方案示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12

