《從 Linux 內(nèi)核角度探秘 JDK MappedByteBuffer(上)》
《從 Linux 內(nèi)核角度探秘 JDK MappedByteBuffer(中)》
4. 零拷貝
關(guān)于零拷貝這個(gè)話題募强,筆者原本不想再聊了读慎,因?yàn)榫W(wǎng)上有太多討論零拷貝的文章了,而且有些寫的真挺不錯(cuò)的,可是大部分文章都在寫 MappedByteBuffer 相較于傳統(tǒng) FileChannel 的優(yōu)勢,但好像很少有人來寫一寫 MappedByteBuffer 的劣勢乳丰,所以筆者這里想寫一點(diǎn)不一樣的,來和大家討論討論 MappedByteBuffer 的劣勢有哪些。
但在開始討論這個(gè)話題之前嘱兼,筆者想了想還是不能免俗,仍然需要把 MappedByteBuffer 和 FileChannel 放在一起從頭到尾對比一下贤徒,基于這個(gè)思路芹壕,我們先來重新簡要梳理一下 FileChannel 和 MappedByteBuffer 讀寫文件的流程。
在之前的文章《從 Linux 內(nèi)核角度探秘 JDK NIO 文件讀寫本質(zhì)》中接奈,由于當(dāng)時(shí)我們還未介紹 DirectByteBuffer 以及 MappedByteBuffer踢涌,所以筆者以 HeapByteBuffer 為例來介紹 FileChannel 讀寫文件的整個(gè)源碼實(shí)現(xiàn)邏輯。
當(dāng)我們使用 HeapByteBuffer 傳入 FileChannel 的 read or write 方法對文件進(jìn)行讀寫時(shí)序宦,JDK 會(huì)首先創(chuàng)建一個(gè)臨時(shí)的 DirectByteBuffer睁壁,對于 FileChannel#read
來說,JDK 在 native 層會(huì)將 read 系統(tǒng)調(diào)用從文件中讀取的內(nèi)容首先存放到這個(gè)臨時(shí)的 DirectByteBuffer 中互捌,然后在拷貝到 HeapByteBuffer 中返回潘明。
對于 FileChannel#write
來說,JDK 會(huì)首先將 HeapByteBuffer 中的待寫入數(shù)據(jù)拷貝到臨時(shí)的 DirectByteBuffer 中秕噪,然后在 native 層通過 write 系統(tǒng)調(diào)用將 DirectByteBuffer 中的數(shù)據(jù)寫入到文件的 page cache 中钳降。
public class IOUtil {
static int read(FileDescriptor fd, ByteBuffer dst, long position,
NativeDispatcher nd)
throws IOException
{
// 如果我們傳入的 dst 是 DirectBuffer,那么直接進(jìn)行文件的讀取
// 將文件內(nèi)容讀取到 dst 中
if (dst instanceof DirectBuffer)
return readIntoNativeBuffer(fd, dst, position, nd);
// 如果我們傳入的 dst 是一個(gè) HeapBuffer腌巾,那么這里就需要?jiǎng)?chuàng)建一個(gè)臨時(shí)的 DirectBuffer
// 在調(diào)用 native 方法底層利用 read or write 系統(tǒng)調(diào)用進(jìn)行文件讀寫的時(shí)候
// 傳入的只能是 DirectBuffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
try {
// 底層通過 read 系統(tǒng)調(diào)用將文件內(nèi)容拷貝到臨時(shí) DirectBuffer 中
int n = readIntoNativeBuffer(fd, bb, position, nd);
if (n > 0)
// 將臨時(shí) DirectBuffer 中的文件內(nèi)容在拷貝到 HeapBuffer 中返回
dst.put(bb);
return n;
}
}
static int write(FileDescriptor fd, ByteBuffer src, long position,
NativeDispatcher nd) throws IOException
{
// 如果傳入的 src 是 DirectBuffer遂填,那么直接將 DirectBuffer 中的內(nèi)容拷貝到文件 page cache 中
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd);
// 如果傳入的 src 是 HeapBuffer,那么這里需要首先創(chuàng)建一個(gè)臨時(shí)的 DirectBuffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
// 首先將 HeapBuffer 中的待寫入內(nèi)容拷貝到臨時(shí)的 DirectBuffer 中
// 隨后通過 write 系統(tǒng)調(diào)用將臨時(shí) DirectBuffer 中的內(nèi)容寫入到文件 page cache 中
int n = writeFromNativeBuffer(fd, bb, position, nd);
return n;
}
}
}
當(dāng)時(shí)有很多讀者朋友給我留言提問說澈蝙,為什么必須要在 DirectByteBuffer 中做一次中轉(zhuǎn)吓坚,直接將 HeapByteBuffer 傳給 native 層不行嗎 ?
答案是肯定不行的灯荧,在本文開頭筆者為大家介紹過 JVM 進(jìn)程的虛擬內(nèi)存空間布局礁击,如下圖所示:
HeapByteBuffer 和 DirectByteBuffer 從本質(zhì)上來說均是 JVM 進(jìn)程地址空間內(nèi)的一段虛擬內(nèi)存,對于 Java 程序來說 HeapByteBuffer 被用來特定表示 JVM 堆中的內(nèi)存逗载,而 DirectByteBuffer 就是一個(gè)普通的 C++ 程序通過 malloc 系統(tǒng)調(diào)用向操作系統(tǒng)申請的一段 Native Memory 位于 JVM 堆之外哆窿。
既然 HeapByteBuffer 是位于 JVM 堆中的內(nèi)存,那么它必然會(huì)受到 GC 的管理撕贞,當(dāng)發(fā)生 GC 的時(shí)候更耻,如果我們選擇的垃圾回收器采用的是 Mark-Copy 或者 Mark-Compact 算法的時(shí)候(Mark-Swap 除外),GC 會(huì)來回移動(dòng)存活的對象捏膨,這就導(dǎo)致了存活的 Java 對象比如這里的 HeapByteBuffer 在 GC 之后它背后的內(nèi)存地址可能已經(jīng)發(fā)生了變化秧均。
而 JVM 中的這些 native 方法是處于 safepoint 之下的食侮,執(zhí)行 native 方法的線程由于是處于 safepoint 中,所以在執(zhí)行 native 方法的過程中可能會(huì)有 GC 的發(fā)生目胡。
如果我們把一個(gè) HeapByteBuffer 傳遞給 native 層進(jìn)行文件讀寫的時(shí)候不巧發(fā)生了 GC锯七,那么 HeapByteBuffer 背后的內(nèi)存地址就會(huì)變化,這樣一來誉己,如果我們在讀取文件的話眉尸,內(nèi)核將會(huì)把文件內(nèi)容拷貝到另一個(gè)內(nèi)存地址中。如果我們在寫入文件的話巨双,內(nèi)核將會(huì)把另一個(gè)內(nèi)存地址中的內(nèi)存寫入到文件的 page cache 中噪猾。
所以我們在通過 native 方法執(zhí)行相關(guān)系統(tǒng)調(diào)用的時(shí)候必須要保證傳入的內(nèi)存地址是不會(huì)變化的,由于 DirectByteBuffer 背后所依賴的 Native Memory 位于 JVM 堆之外筑累,是不會(huì)受到 GC 管理的袱蜡,因此不管發(fā)不發(fā)生 GC,DirectByteBuffer 所引用的這些 Native Memory 地址是不會(huì)發(fā)生變化的慢宗。
所以我們在調(diào)用 native 方法進(jìn)行文件讀寫的時(shí)候需要傳入 DirectByteBuffer坪蚁,如果我們用得是 HeapByteBuffer ,那么就需要一個(gè)臨時(shí)的 DirectByteBuffer 作為中轉(zhuǎn)镜沽。
這時(shí)可能有讀者朋友又會(huì)問了敏晤,我們在使用 HeapByteBuffer 通過 FileChannel#write
對文件進(jìn)行寫入的時(shí)候,首先會(huì)將 HeapByteBuffer 中的內(nèi)容拷貝到臨時(shí)的 DirectByteBuffer 中缅茉,那如果在這個(gè)拷貝的過程中發(fā)生了 GC嘴脾,HeapByteBuffer 背后引用內(nèi)存的地址發(fā)生了變化,那么拷貝到 DirectByteBuffer 中的內(nèi)容仍然是錯(cuò)的啊宾舅。
事實(shí)上在這個(gè)拷貝的過程中是不會(huì)發(fā)生 GC 的统阿,因?yàn)?JVM 這里會(huì)使用 Unsafe#copyMemory
方法來實(shí)現(xiàn) HeapByteBuffer 到 DirectByteBuffer 的拷貝操作,copyMemory 被 JVM 實(shí)現(xiàn)為一個(gè) intrinsic 方法筹我,中間是沒有 safepoint 的,執(zhí)行 copyMemory 的線程由于不在 safepoint 中帆离,所以在拷貝的過程中是不會(huì)發(fā)生 GC 的蔬蕊。
public final class Unsafe {
// intrinsic 方法
public native void copyMemory(Object srcBase, long srcOffset,
Object destBase, long destOffset,
long bytes);
}
在交代完這個(gè)遺留的問題之后,下面我們就以 DirectByteBuffer 為例來重新簡要回顧下傳統(tǒng) FileChannel 對文件的讀寫流程:
當(dāng) JVM 在 native 層使用 read 系統(tǒng)調(diào)用進(jìn)行文件讀取的時(shí)候哥谷,JVM 進(jìn)程會(huì)發(fā)生第一次上下文切換岸夯,從用戶態(tài)轉(zhuǎn)為內(nèi)核態(tài)。
隨后 JVM 進(jìn)程進(jìn)入虛擬文件系統(tǒng)層们妥,在這一層內(nèi)核首先會(huì)查看讀取文件對應(yīng)的 page cache 中是否含有請求的文件數(shù)據(jù)猜扮,如果有,那么直接將文件數(shù)據(jù)拷貝到 DirectByteBuffer 中返回监婶,避免一次磁盤 IO旅赢。并根據(jù)內(nèi)核預(yù)讀算法從磁盤中異步預(yù)讀若干文件數(shù)據(jù)到 page cache 中
如果請求的文件數(shù)據(jù)不在 page cache 中齿桃,則會(huì)進(jìn)入具體的文件系統(tǒng)層,在這一層內(nèi)核會(huì)啟動(dòng)磁盤塊設(shè)備驅(qū)動(dòng)觸發(fā)真正的磁盤 IO煮盼。并根據(jù)內(nèi)核預(yù)讀算法同步預(yù)讀若干文件數(shù)據(jù)短纵。請求的文件數(shù)據(jù)和預(yù)讀的文件數(shù)據(jù)將被一起填充到 page cache 中。
磁盤控制器 DMA 將從磁盤中讀取的數(shù)據(jù)拷貝到頁高速緩存 page cache 中僵控。發(fā)生第一次數(shù)據(jù)拷貝香到。
由于 page cache 是屬于內(nèi)核空間的,不能被 JVM 進(jìn)程直接尋址报破,所以還需要 CPU 將 page cache 中的數(shù)據(jù)拷貝到位于用戶空間的 DirectByteBuffer 中悠就,發(fā)生第二次數(shù)據(jù)拷貝。
最后 JVM 進(jìn)程從系統(tǒng)調(diào)用 read 中返回充易,并從內(nèi)核態(tài)切換回用戶態(tài)梗脾。發(fā)生第二次上下文切換。
從以上過程我們可以看到蔽氨,當(dāng)使用 FileChannel#read
對文件讀取的時(shí)候藐唠,如果文件數(shù)據(jù)在 page cache 中,涉及到的性能開銷點(diǎn)主要有兩次上下文切換鹉究,以及一次 CPU 拷貝宇立。其中上下文切換是主要的性能開銷點(diǎn)。
下面是通過 FileChannel#write
寫入文件的整個(gè)過程:
當(dāng) JVM 在 native 層使用 write 系統(tǒng)調(diào)用進(jìn)行文件寫入的時(shí)候自赔,JVM 進(jìn)程會(huì)發(fā)生第一次上下文切換妈嘹,從用戶態(tài)轉(zhuǎn)為內(nèi)核態(tài)。
進(jìn)入內(nèi)核態(tài)之后绍妨,JVM 進(jìn)程在虛擬文件系統(tǒng)層調(diào)用 vfs_write 觸發(fā)對 page cache 寫入的操作润脸。內(nèi)核調(diào)用 iov_iter_copy_from_user_atomic 函數(shù)將 DirectByteBuffer 中的待寫入數(shù)據(jù)拷貝到 page cache 中。發(fā)生第一次拷貝動(dòng)作( CPU 拷貝)他去。
當(dāng)待寫入數(shù)據(jù)拷貝到 page cache 中時(shí)毙驯,內(nèi)核會(huì)將對應(yīng)的文件頁標(biāo)記為臟頁,內(nèi)核會(huì)根據(jù)一定的閾值判斷是否要對 page cache 中的臟頁進(jìn)行回寫灾测,如果不需要同步回寫爆价,進(jìn)程直接返回。這里發(fā)生第二次上下文切換媳搪。
臟頁回寫又會(huì)根據(jù)臟頁數(shù)量在內(nèi)存中的占比分為:進(jìn)程同步回寫和內(nèi)核異步回寫铭段。當(dāng)臟頁太多了,進(jìn)程自己都看不下去的時(shí)候秦爆,會(huì)同步回寫內(nèi)存中的臟頁序愚,直到回寫完畢才會(huì)返回。在回寫的過程中會(huì)發(fā)生第二次拷貝(DMA 拷貝)等限。
從以上過程我們可以看到爸吮,當(dāng)使用 FileChannel#write
對文件寫入的時(shí)候芬膝,如果不考慮臟頁回寫的情況,單純對于 JVM 這個(gè)進(jìn)程來說涉及到的性能開銷點(diǎn)主要有兩次上下文切換拗胜,以及一次 CPU 拷貝蔗候。其中上下文切換仍然是主要的性能開銷點(diǎn)。
下面我們來看下通過 MappedByteBuffer 對文件進(jìn)行讀寫的過程:
首先我們需要通過 FileChannel#map
將文件的某個(gè)區(qū)域映射到 JVM 進(jìn)程的虛擬內(nèi)存空間中埂软,從而獲得一段文件映射的虛擬內(nèi)存區(qū)域 MappedByteBuffer锈遥。由于底層使用到了 mmap 系統(tǒng)調(diào)用,所以這個(gè)過程也涉及到了兩次上下文切換勘畔。
如上圖所示所灸,當(dāng) MappedByteBuffer 在剛剛映射出來的時(shí)候,它只是進(jìn)程地址空間中的一段虛擬內(nèi)存炫七,其對應(yīng)在進(jìn)程頁表中的頁表項(xiàng)還是空的爬立,背后還沒有映射物理內(nèi)存。此時(shí)映射文件對應(yīng)的 page cache 也是空的万哪,我們要映射的文件內(nèi)容此時(shí)還靜靜地躺在磁盤中侠驯。
當(dāng) JVM 進(jìn)程開始對 MappedByteBuffer 進(jìn)行讀寫的時(shí)候,就會(huì)觸發(fā)缺頁中斷奕巍,內(nèi)核會(huì)將映射的文件內(nèi)容從磁盤中加載到 page cache 中吟策,然后在進(jìn)程頁表中建立 MappedByteBuffer 與 page cache 的映射關(guān)系。由于這里涉及到了缺頁中斷的處理的止,因此也會(huì)有兩次上下文切換的開銷檩坚。
后面 JVM 進(jìn)程對 MappedByteBuffer 的讀寫就相當(dāng)于是直接讀寫 page cache 了赠幕,關(guān)于這一點(diǎn)枫浙,很多讀者朋友會(huì)有這樣的疑問:page cache 是內(nèi)核態(tài)的部分,為什么我們通過用戶態(tài)的 MappedByteBuffer 就可以直接訪問內(nèi)核態(tài)的東西了诅炉?
這里大家不要被內(nèi)核態(tài)這三個(gè)字給唬住了氓润,雖然 page cache 是屬于內(nèi)核部分的赂乐,但其本質(zhì)上還是一塊普通的物理內(nèi)存,想想我們是怎么訪問內(nèi)存的 咖气? 不就是先有一段虛擬內(nèi)存沪猴,然后在申請一段物理內(nèi)存,最后通過進(jìn)程頁表將虛擬內(nèi)存和物理內(nèi)存映射起來么采章,進(jìn)程在訪問虛擬內(nèi)存的時(shí)候,通過頁表找到其映射的物理內(nèi)存地址壶辜,然后直接通過物理內(nèi)存地址訪問物理內(nèi)存悯舟。
回到我們討論的內(nèi)容中,這段虛擬內(nèi)存不就是 MappedByteBuffer 嗎砸民,物理內(nèi)存就是 page cache 啊抵怎,在通過頁表映射起來之后奋救,進(jìn)程在通過 MappedByteBuffer 訪問 page cache 的過程就和訪問普通內(nèi)存的過程是一模一樣的。
也正因?yàn)?MappedByteBuffer 背后映射的物理內(nèi)存是內(nèi)核空間的 page cache反惕,所以它不會(huì)消耗任何用戶空間的物理內(nèi)存(JVM 的堆外內(nèi)存)尝艘,因此也不會(huì)受到 -XX:MaxDirectMemorySize
參數(shù)的限制。
現(xiàn)在我們已經(jīng)清楚了 FileChannel 以及 MappedByteBuffer 進(jìn)行文件讀寫的整個(gè)過程姿染,下面我們就來把兩種文件讀寫方式放在一起來對比一下背亥,但這里有一個(gè)對比的前提:
對于 MappedByteBuffer 來說,我們對比的是其在缺頁處理之后悬赏,讀寫文件的開銷狡汉。
對于 FileChannel 來說闽颇,我們對比的是文件數(shù)據(jù)已經(jīng)存在于 page cache 中的情況下讀寫文件的開銷盾戴。
因?yàn)楣P者認(rèn)為只有基于這個(gè)前提來對比兩者的性能差異才有意義。
對于 FileChannel 來說兵多,無論是通過 read 方法對文件的讀取尖啡,還是通過 write 方法對文件的寫入,它們都需要兩次上下文切換剩膘,以及一次 CPU 拷貝衅斩,其中上下文切換是其主要的性能開銷點(diǎn)。
對于 MappedByteBuffer 來說援雇,由于其背后直接映射的就是 page cache矛渴,讀寫 MappedByteBuffer 本質(zhì)上就是讀寫 page cache,整個(gè)讀寫過程和讀寫普通的內(nèi)存沒有任何區(qū)別惫搏,因此沒有上下文切換的開銷具温,不會(huì)切態(tài),更沒有任何拷貝筐赔。
從上面的對比我們可以看出使用 MappedByteBuffer 來讀寫文件既沒有上下文切換的開銷铣猩,也沒有數(shù)據(jù)拷貝的開銷(可忽略),簡直是完爆 FileChannel茴丰。
既然 MappedByteBuffer 這么屌达皿,那我們何不干脆在所有文件的讀寫場景中全部使用 MappedByteBuffer,這樣豈不省事 贿肩?JDK 為何還保留了 FileChannel 的 read , write 方法呢 峦椰?讓我們來帶著這個(gè)疑問繼續(xù)下面的內(nèi)容~~
5. MappedByteBuffer VS FileChannel
到現(xiàn)在為止,筆者已經(jīng)帶著大家完整的剖析了 mmap汰规,read汤功,write 這些系統(tǒng)調(diào)用在內(nèi)核中的源碼實(shí)現(xiàn),并基于源碼對 MappedByteBuffer 和 FileChannel 兩者進(jìn)行了性能開銷上的對比溜哮。
雖然祭出了源碼滔金,但畢竟還是 talk is cheap色解,本小節(jié)我們就來對兩者進(jìn)行一次 Benchmark,來看一下 MappedByteBuffer 與 FileChannel 對文件讀寫的實(shí)際性能表現(xiàn)如何 餐茵? 是否和我們從源碼中分析的結(jié)果一致科阎。
我們從兩個(gè)方面來對比 MappedByteBuffer 和 FileChannel 的文件讀寫性能:
文件數(shù)據(jù)完全加載到 page cache 中,并且將 page cache 鎖定在內(nèi)存中忿族,不允許 swap锣笨,MappedByteBuffer 不會(huì)有缺頁中斷,F(xiàn)ileChannel 不會(huì)觸發(fā)磁盤 IO 都是直接對 page cache 進(jìn)行讀寫肠阱。
文件數(shù)據(jù)不在 page cache 中票唆,我們加上了 缺頁中斷,磁盤IO屹徘,以及 swap 對文件讀寫的影響走趋。
具體的測試思路是,用 MappedByteBuffer 和 FileChannel 分別以
64B ,128B ,512B ,1K ,2K ,4K ,8K ,32K ,64K ,1M ,32M ,64M ,512M 為單位依次對 1G 大小的文件進(jìn)行讀寫噪伊,從以上兩個(gè)方面對比兩者在不同讀寫單位下的性能表現(xiàn)簿煌。
需要提醒大家的是本小節(jié)中得出的讀寫性能具體數(shù)值是沒有參考價(jià)值的,因?yàn)椴煌浻布h(huán)境下測試得出的具體性能數(shù)值都不一樣鉴吹,值得參考的是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集大小下的讀寫性能趨勢走向姨伟。筆者的軟硬件測試環(huán)境如下:
- 處理器:2.5 GHz 四核Intel Core i7
- 內(nèi)存:16 GB 1600 MHz DDR3
- SSD:APPLE SSD SM0512F
- 操作系統(tǒng):macOS
- JVM:OpenJDK 17
測試代碼:https://github.com/huibinliupush/benchmark , 大家也可以在自己的測試環(huán)境中運(yùn)行一下,然后將跑出的結(jié)果提交到這個(gè)倉庫中豆励。這樣方便大家在不同的測試環(huán)境下對比兩者的文件讀寫性能差異 —— 眾人拾柴火焰高夺荒。
5.1 文件數(shù)據(jù)在 page cache 中
由于這里我們要測試 MappedByteBuffer 和 FileChannel 直接對 page cache 的讀寫性能,所以筆者讓 MappedByteBuffer 良蒸,F(xiàn)ileChannel 只針對同一個(gè)文件進(jìn)行讀寫測試技扼。
在對文件進(jìn)行讀寫之前,首先通過 mlock 系統(tǒng)調(diào)用將文件數(shù)據(jù)提前加載到 page cache 中并主動(dòng)觸發(fā)缺頁處理嫩痰,在進(jìn)程頁表中建立好 MappedByteBuffer 和 page cache 的映射關(guān)系剿吻。最后將 page cache 鎖定在內(nèi)存中不允許 swap。
下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的讀取性能測試:
運(yùn)行結(jié)果如下:
為了直觀的讓大家一眼看出 MappedByteBuffer 和 FileChannel 在對 page cache 讀取的性能差異串纺,筆者根據(jù)上面跑出的性能數(shù)據(jù)繪制成下面這幅柱狀圖丽旅,方便大家觀察兩者的性能趨勢走向。
這里我們可以看出纺棺,MappedByteBuffer 在 4K 之前具有明顯的壓倒性優(yōu)勢榄笙,在 [8K , 32M] 這個(gè)區(qū)間內(nèi),MappedByteBuffer 依然具有優(yōu)勢但已經(jīng)不是十分明顯了祷蝌,從 64M 開始 FileChannel 實(shí)現(xiàn)了一點(diǎn)點(diǎn)反超办斑。
我們可以得到的性能趨勢是,在 [64B, 2K] 這個(gè)單次讀取數(shù)據(jù)量級(jí)范圍內(nèi),MappedByteBuffer 讀取的性能越來越快乡翅,并在 2K 這個(gè)數(shù)據(jù)量級(jí)下達(dá)到了性能最高值,僅消耗了 73 ms罪郊。從 4K 開始讀取性能在一點(diǎn)一點(diǎn)的逐漸下降蠕蚜,并在 64M 這個(gè)數(shù)據(jù)量級(jí)下被 FileChannel 反超。
而 FileChannel 的讀取性能會(huì)隨著數(shù)據(jù)量的增大反而越來越好悔橄,并在某一個(gè)數(shù)據(jù)量級(jí)下性能會(huì)反超 MappedByteBuffer靶累。FileChannel 的最佳讀取性能點(diǎn)是在 64K 處,消耗了 167ms 癣疟。
因此 MappedByteBuffer 適合頻繁讀取小數(shù)據(jù)量的場景挣柬,具體多小,需要大家根據(jù)自己的環(huán)境進(jìn)行測試睛挚,本小節(jié)我們得出的數(shù)據(jù)是 4K 以下邪蛔。
FileChannel 適合大數(shù)據(jù)量的批量讀取場景,具體多大扎狱,還是需要大家根據(jù)自己的環(huán)境進(jìn)行測試侧到,本小節(jié)我們得出的數(shù)據(jù)是 64M 以上。
下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的寫入性能測試:
運(yùn)行結(jié)果如下:
MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的寫入性能的趨勢走向柱狀圖:
這里我們可以看到 MappedByteBuffer 在 8K 之前具有明顯的寫入優(yōu)勢淤击,它的寫入性能趨勢是在 [64B , 8K] 這個(gè)數(shù)據(jù)集方位內(nèi)匠抗,寫入性能隨著數(shù)據(jù)量的增大而越來越快,直到在 8K 這個(gè)數(shù)據(jù)集下達(dá)到了最佳寫入性能污抬。
而在 [32K, 32M] 這個(gè)數(shù)據(jù)集范圍內(nèi)汞贸,MappedByteBuffer 仍然具有優(yōu)勢,但已經(jīng)不是十分明顯了印机,最終在 64M 這個(gè)數(shù)據(jù)集下被 FileChannel 反超矢腻。
和前面的讀取性能趨勢一樣,F(xiàn)ileChannel 的寫入性能也是隨著數(shù)據(jù)量的增大反而越來越好耳贬,最佳的寫入性能是在 64K 處踏堡,僅消耗了 160 ms 。
5.2 文件數(shù)據(jù)不在 page cache 中
在這一小節(jié)中咒劲,我們將缺頁中斷和磁盤 IO 的影響加入進(jìn)來顷蟆,不添加任何的優(yōu)化手段純粹地測一下 MappedByteBuffer 和 FileChannel 對文件讀寫的性能。
為了避免被 page cache 影響腐魂,所以我們需要在每一個(gè)測試數(shù)據(jù)集下帐偎,單獨(dú)分別為 MappedByteBuffer 和 FileChannel 創(chuàng)建各自的測試文件。
下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對文件的讀取性能測試:
運(yùn)行結(jié)果:
從這里我們可以看到蛔屹,在加入了缺頁中斷和磁盤 IO 的影響之后削樊,MappedByteBuffer 在缺頁中斷的影響下平均比之前多出了 500 ms 的開銷。FileChannel 在磁盤 IO 的影響下在 [64B , 512B] 這個(gè)數(shù)據(jù)集范圍內(nèi)比之前平均多出了 1000 ms 的開銷,在 [1K, 512M] 這個(gè)數(shù)據(jù)集范圍內(nèi)比之前平均多出了 100 ms 的開銷漫贞。
在 2K 之前甸箱, MappedByteBuffer 具有明顯的讀取性能優(yōu)勢,最佳的讀取性能出現(xiàn)在 512B 這個(gè)數(shù)據(jù)集下迅脐,從 512B 往后芍殖,MappedByteBuffer 的讀取性能趨勢總體成下降趨勢,并在 4K 這個(gè)地方被 FileChannel 反超谴蔑。
FileChannel 則是在 [64B, 1M] 這個(gè)數(shù)據(jù)集范圍內(nèi)豌骏,讀取性能會(huì)隨著數(shù)據(jù)集的增大而提高,并在 1M 這個(gè)地方達(dá)到了 FileChannel 的最佳讀取性能隐锭,僅消耗了 258 ms窃躲,在 [32M , 512M] 這個(gè)范圍內(nèi) FileChannel 的讀取性能在逐漸下降钦睡,但是比 MappedByteBuffer 的性能高出了一倍蒂窒。
讀到這里大家不禁要問了,理論上來講 MappedByteBuffer 應(yīng)該是完爆 FileChannel 才對啊赎婚,因?yàn)?MappedByteBuffer 沒有系統(tǒng)調(diào)用的開銷刘绣,為什么性能在后面反而被 FileChannel 超越了近一倍之多呢 ?
要明白這個(gè)問題,我們就需要分別把 MappedByteBuffer 和 FileChannel 在讀寫文件時(shí)候所涉及到的性能開銷點(diǎn)一一列舉出來挣输,并對這些性能開銷點(diǎn)進(jìn)行詳細(xì)對比纬凤,這樣答案就有了。
首先 MappedByteBuffer 的主要性能開銷是在缺頁中斷撩嚼,而 FileChannel 的主要開銷是在系統(tǒng)調(diào)用停士,兩者都會(huì)涉及上下文的切換。
FileChannel 在讀寫文件的時(shí)候有磁盤IO完丽,有預(yù)讀恋技。同樣 MappedByteBuffer 的缺頁中斷也有磁盤IO 也有預(yù)讀。目前來看他倆一比一打平逻族。
但別忘了 MappedByteBuffer 是需要進(jìn)程頁表支持的蜻底,在實(shí)際訪問內(nèi)存的過程中會(huì)遇到頁表競爭以及 TLB shootdown 等問題。還有就是 MappedByteBuffer 剛剛被映射出來的時(shí)候聘鳞,其在進(jìn)程頁表中對應(yīng)的各級(jí)頁表以及頁目錄可能都是空的薄辅。所以缺頁中斷這里需要做的一件非常重要的事情就是補(bǔ)齊完善 MappedByteBuffer 在進(jìn)程頁表中對應(yīng)的各級(jí)頁目錄表和頁表,并在頁表項(xiàng)中將 page cache 映射起來抠璃,最后還要刷新 TLB 等硬件緩存站楚。
想更多了解缺頁中斷細(xì)節(jié)的讀者可以看下之前的文章——
《一文聊透 Linux 缺頁異常的處理 —— 圖解 Page Faults》
而 FileChannel 并不會(huì)涉及上面的這些開銷,所以 MappedByteBuffer 的缺頁中斷要比 FileChannel 的系統(tǒng)調(diào)用開銷要大搏嗡,這一點(diǎn)我們可以在上小節(jié)和本小節(jié)的讀寫性能對比中看得出來窿春。
文件數(shù)據(jù)在 page cache 中與不在 page cache 中拉一,MappedByteBuffer 前后的讀取性能平均差了 500 ms,而 FileChannel 前后卻只平均差了 100 ms旧乞。
MappedByteBuffer 的缺頁中斷是平均每 4K 觸發(fā)一次蔚润,而 FileChannel 的系統(tǒng)調(diào)用開銷則是每次都會(huì)觸發(fā)。當(dāng)兩者單次按照小數(shù)據(jù)量讀取 1G 文件的時(shí)候良蛮,MappedByteBuffer 的缺頁中斷較少觸發(fā)抽碌,而 FileChannel 的系統(tǒng)調(diào)用卻在頻繁觸發(fā),所以在這種情況下决瞳,F(xiàn)ileChannel 的系統(tǒng)調(diào)用是主要的性能瓶頸。
這也就解釋了當(dāng)我們在頻繁讀寫小數(shù)據(jù)量的時(shí)候左权,MappedByteBuffer 的性能具有壓倒性優(yōu)勢皮胡。當(dāng)單次讀寫的數(shù)據(jù)量越來越大的時(shí)候,F(xiàn)ileChannel 調(diào)用的次數(shù)就會(huì)越來越少赏迟,這時(shí)候缺頁中斷就會(huì)成為 MappedByteBuffer 的性能瓶頸屡贺,到某一個(gè)點(diǎn)之后,F(xiàn)ileChannel 就會(huì)反超 MappedByteBuffer锌杀。因此當(dāng)我們需要高吞吐量讀寫文件的時(shí)候 FileChannel 反而是最合適的甩栈。
除此之外,內(nèi)核的臟頁回寫也會(huì)對 MappedByteBuffer 以及 FileChannel 的文件寫入性能有非常大的影響糕再,無論是我們在用戶態(tài)中調(diào)用 fsync 或者 msync 主動(dòng)觸發(fā)臟頁回寫還是內(nèi)核通過 pdflush 線程異步臟頁回寫量没,當(dāng)我們使用 MappedByteBuffer 或者 FileChannel 寫入 page cache 的時(shí)候,如果恰巧遇到文件頁的回寫突想,那么寫入操作都會(huì)有非常大的延遲殴蹄,這個(gè)在 MappedByteBuffer 身上體現(xiàn)的更為明顯。
為什么這么說呢 猾担? 我們還是到內(nèi)核源碼中去探尋原因袭灯,先來看臟頁回寫對 FileChannel 的寫入影響。下面是 FileChannel 文件寫入在內(nèi)核中的核心實(shí)現(xiàn):
ssize_t generic_perform_write(struct file *file,
struct iov_iter *i, loff_t pos)
{
// 從 page cache 中獲取要寫入的文件頁并準(zhǔn)備記錄文件元數(shù)據(jù)日志工作
status = a_ops->write_begin(file, mapping, pos, bytes, flags,
&page, &fsdata);
// 將用戶空間緩沖區(qū) DirectByteBuffer 中的數(shù)據(jù)拷貝到 page cache 中的文件頁中
copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
// 將寫入的文件頁標(biāo)記為臟頁并完成文件元數(shù)據(jù)日志的寫入
status = a_ops->write_end(file, mapping, pos, bytes, copied,
page, fsdata);
// 判斷是否需要同步回寫臟頁
balance_dirty_pages_ratelimited(mapping);
}
首先內(nèi)核會(huì)在 write_begin 函數(shù)中通過 grab_cache_page_write_begin 從文件 page cache 中獲取要寫入的文件頁绑嘹。
struct page *grab_cache_page_write_begin(struct address_space *mapping,
pgoff_t index, unsigned flags)
{
struct page *page;
// 在 page cache 中查找寫入數(shù)據(jù)的緩存頁
page = pagecache_get_page(mapping, index, fgp_flags,
mapping_gfp_mask(mapping));
if (page)
wait_for_stable_page(page);
return page;
}
在這里會(huì)調(diào)用一個(gè)非常重要的函數(shù) wait_for_stable_page稽荧,這個(gè)函數(shù)的作用就是判斷當(dāng)前 page cache 中的這個(gè)文件頁是否正在被回寫,如果正在回寫到磁盤工腋,那么當(dāng)前進(jìn)程就會(huì)阻塞直到臟頁回寫完畢姨丈。
/**
* wait_for_stable_page() - wait for writeback to finish, if necessary.
* @page: The page to wait on.
*
* This function determines if the given page is related to a backing device
* that requires page contents to be held stable during writeback. If so, then
* it will wait for any pending writeback to complete.
*/
void wait_for_stable_page(struct page *page)
{
if (bdi_cap_stable_pages_required(inode_to_bdi(page->mapping->host)))
wait_on_page_writeback(page);
}
EXPORT_SYMBOL_GPL(wait_for_stable_page);
等到臟頁回寫完畢之后,進(jìn)程才會(huì)調(diào)用 iov_iter_copy_from_user_atomic 將待寫入數(shù)據(jù)拷貝到 page cache 中夷蚊,最后在 write_end 中調(diào)用 mark_buffer_dirty 將寫入的文件頁標(biāo)記為臟頁构挤。
除了正在回寫的臟頁會(huì)阻塞 FileChannel 的寫入過程之外,如果此時(shí)系統(tǒng)中的臟頁太多了惕鼓,超過了 dirty_ratio
或者 dirty_bytes
等內(nèi)核參數(shù)配置的臟頁比例筋现,那么進(jìn)程就會(huì)同步去回寫臟頁,這也對寫入性能有非常大的影響。
我們接著再來看臟頁回寫對 MappedByteBuffer 的寫入影響矾飞,在開始分析之前一膨,筆者先問大家一個(gè)問題:通過 MappedByteBuffer 寫入 page cache 之后,page cache 中的相應(yīng)文件頁是怎么變臟的 洒沦?
FileChannel 很好理解豹绪,因?yàn)?FileChannel 走的是系統(tǒng)調(diào)用,會(huì)進(jìn)入到文件系統(tǒng)由內(nèi)核進(jìn)行處理申眼,如果寫入文件頁恰好正在回寫時(shí)瞒津,內(nèi)核會(huì)調(diào)用 wait_for_stable_page 阻塞當(dāng)前進(jìn)程。在將數(shù)據(jù)寫入文件頁之后括尸,內(nèi)核又會(huì)調(diào)用 mark_buffer_dirty 將頁面變臟巷蚪。
MappedByteBuffer 就很難理解了,因?yàn)?MappedByteBuffer 不會(huì)走系統(tǒng)調(diào)用濒翻,直接讀寫的就是 page cache屁柏,而 page cache 也只是內(nèi)核在軟件層面上的定義,它的本質(zhì)還是物理內(nèi)存有送。另外臟頁以及臟頁的回寫都是內(nèi)核在軟件層面上定義的概念和行為淌喻。
MappedByteBuffer 直接寫入的是硬件層面的物理內(nèi)存(page cache),硬件哪管你軟件上定義的臟頁以及臟頁回寫啊雀摘,沒有內(nèi)核的參與裸删,那么在通過 MappedByteBuffer 寫入文件頁之后,文件頁是如何變臟的呢 届宠?還有就是 MappedByteBuffer 如何探測到對應(yīng)文件頁正在回寫并阻塞等待呢 烁落?
既然我們涉及到了軟件的概念和行為,那么一定就會(huì)有內(nèi)核的參與豌注,我們回想一下整個(gè) MappedByteBuffer 的生命周期伤塌,唯一一次和內(nèi)核打交道的機(jī)會(huì)就是缺頁中斷,我們看看能不能在缺頁中斷中發(fā)現(xiàn)點(diǎn)什么~
當(dāng) MappedByteBuffer 剛剛被 mmap 映射出來的時(shí)候它還只是一段普通的虛擬內(nèi)存轧铁,背后什么都沒有每聪,其在進(jìn)程頁表中的各級(jí)頁目錄項(xiàng)以及頁表項(xiàng)都還是空的。
當(dāng)我們立即對 MappedByteBuffer 進(jìn)行寫入的時(shí)候就會(huì)發(fā)生缺頁中斷齿风,在缺頁中斷的處理中药薯,內(nèi)核會(huì)在進(jìn)程頁表中補(bǔ)齊與 MappedByteBuffer 映射相關(guān)的各級(jí)頁目錄并在頁表項(xiàng)中與 page cache 進(jìn)行映射。
static vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
// 從 page cache 中讀取文件頁
ret = __do_fault(vmf);
if (vma->vm_ops->page_mkwrite) {
unlock_page(vmf->page);
// 將文件頁變?yōu)榭蓪憼顟B(tài)救斑,并設(shè)置文件頁為臟頁
// 如果文件頁正在回寫童本,那么阻塞等待
tmp = do_page_mkwrite(vmf);
}
}
除此之外,內(nèi)核還會(huì)調(diào)用 do_page_mkwrite 方法將 MappedByteBuffer 對應(yīng)的頁表項(xiàng)變成可寫狀態(tài)脸候,并將與其映射的文件頁立即設(shè)置位臟頁穷娱,如果此時(shí)文件頁正在回寫绑蔫,那么 MappedByteBuffer 在缺頁中斷中也會(huì)阻塞。
int block_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf,
get_block_t get_block)
{
set_page_dirty(page);
wait_for_stable_page(page);
}
這里我們可以看到 MappedByteBuffer 在內(nèi)核中是先變臟然后在對 page cache 進(jìn)行寫入泵额,而 FileChannel 是先寫入 page cache 后在變臟配深。
從此之后,通過 MappedByteBuffer 對 page cache 的寫入就會(huì)變得非常絲滑嫁盲,那么問題來了篓叶,當(dāng) page cache 中的臟頁被內(nèi)核異步回寫之后,內(nèi)核會(huì)把文件頁中的臟頁標(biāo)記清除掉羞秤,那么這時(shí)如果 MappedByteBuffer 對 page cache 寫入缸托,由于不會(huì)發(fā)生缺頁中斷,那么 page cache 中的文件頁如何再次變臟呢 瘾蛋?
內(nèi)核這里的設(shè)計(jì)非常巧妙嗦董,當(dāng)內(nèi)核回寫完臟頁之后,會(huì)調(diào)用 page_mkclean_one 函數(shù)清除文件頁的臟頁標(biāo)記瘦黑,在這里會(huì)首先通過 page_vma_mapped_walk 判斷該文件頁是不是被 mmap 映射到進(jìn)程地址空間的,如果是奇唤,那么說明該文件頁是被 MappedByteBuffer 映射的幸斥。隨后內(nèi)核就會(huì)做一些特殊處理:
通過 pte_wrprotect 對 MappedByteBuffer 在進(jìn)程頁表中對應(yīng)的頁表項(xiàng) pte 進(jìn)行寫保護(hù),變?yōu)橹蛔x權(quán)限咬扇。
通過 pte_mkclean 清除頁表項(xiàng)上的臟頁標(biāo)記甲葬。
static bool page_mkclean_one(struct page *page, struct vm_area_struct *vma,
unsigned long address, void *arg)
{
while (page_vma_mapped_walk(&pvmw)) {
int ret = 0;
address = pvmw.address;
if (pvmw.pte) {
pte_t entry;
entry = ptep_clear_flush(vma, address, pte);
entry = pte_wrprotect(entry);
entry = pte_mkclean(entry);
set_pte_at(vma->vm_mm, address, pte, entry);
}
return true;
}
這樣一來,在臟頁回寫完畢之后懈贺,MappedByteBuffer 在頁表中就變成只讀的了经窖,這一切對用戶態(tài)的我們都是透明的,當(dāng)再次對 MappedByteBuffer 寫入的時(shí)候就不是那么絲滑了梭灿,會(huì)觸發(fā)寫保護(hù)缺頁中斷(我們以為不會(huì)有缺頁中斷画侣,其實(shí)是有的),在寫保護(hù)中斷的處理中堡妒,內(nèi)核會(huì)重新將頁表項(xiàng) pte 變?yōu)榭蓪懪渎遥募摌?biāo)記為臟頁。如果文件頁正在回寫皮迟,缺頁中斷會(huì)阻塞搬泥。如果臟頁積累的太多,這里也會(huì)同步回寫臟頁伏尼。
static vm_fault_t wp_page_shared(struct vm_fault *vmf)
__releases(vmf->ptl)
{
if (vma->vm_ops && vma->vm_ops->page_mkwrite) {
// 設(shè)置頁表項(xiàng)為可寫
// 標(biāo)記文件頁為臟頁
// 如果文件頁正在回寫則阻塞等待
tmp = do_page_mkwrite(vmf);
}
// 判斷是否需要同步回寫臟頁忿檩,
fault_dirty_shared_page(vma, vmf->page);
return VM_FAULT_WRITE;
}
所以并不是對 MappedByteBuffer 調(diào)用 mlock 之后就萬事大吉了,在遇到臟頁回寫的時(shí)候爆阶,MappedByteBuffer 依然會(huì)發(fā)生寫保護(hù)類型的缺頁中斷燥透。在缺頁中斷處理中會(huì)等待臟頁的回寫沙咏,并且還可能會(huì)發(fā)生臟頁的同步回寫。這對 MappedByteBuffer 的寫入性能會(huì)有非常大的影響兽掰。
在明白這些問題之后芭碍,下面我們繼續(xù)來看 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對文件的寫入性能測試:
運(yùn)行結(jié)果:
在筆者的測試環(huán)境中,我們看到 MappedByteBuffer 在對文件的寫入性能一路碾壓 FileChannel孽尽,并沒有出現(xiàn)被 FileChannel 反超的情況窖壕。但我們看到 MappedByteBuffer 從 4K 開始寫入性能是在逐漸下降的,而 FileChannel 的寫入性能卻在一路升高杉女。
根據(jù)上面的分析瞻讽,我們可以推斷出,后面隨著數(shù)據(jù)量的增大熏挎,由于 MappedByteBuffer 缺頁中斷瓶頸的影響速勇,在 512M 后面某一個(gè)數(shù)據(jù)集下,F(xiàn)ileChannel 的寫入性能最終是會(huì)超過 MappedByteBuffer 的坎拐。
在本小節(jié)的開頭烦磁,筆者就強(qiáng)調(diào)了,本小節(jié)值得參考的是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集大小下的讀寫性能趨勢走向哼勇,而不是具體的性能數(shù)值都伪。
6. MappedByteBuffer 在 RocketMQ 中的應(yīng)用
在 RocketMQ 的消息存儲(chǔ)架構(gòu)模型中有三個(gè)非常核心的文件,它們分別是:CommitLog积担,ConsumeQueue陨晶,IndexFile。其中 CommitLog 是消息真正存儲(chǔ)的地方帝璧,而 ConsumeQueue 和 IndexFile 都是根據(jù) CommitLog 生成的消息索引文件先誉,它們包含了消息在 CommitLog 文件中的真實(shí)物理偏移。
6.1 CommitLog
當(dāng) Producer 將消息發(fā)送到 Broker 之后的烁,RocketMQ 會(huì)根據(jù)消息的序列化協(xié)議將消息持久化到 CommitLog 文件中褐耳,一旦消息被刷到磁盤中,Producer 發(fā)送給 Broker 的消息就不會(huì)丟失了撮躁。CommitLog 文件存儲(chǔ)的主體是消息的 body 以及相關(guān)的元數(shù)據(jù)漱病,CommitLog 并不會(huì)區(qū)分消息的 Topic。也就是說在同一 Broker 實(shí)例中把曼,所有 Topic 下的消息都會(huì)被順序的寫入 CommitLog 文件混合存儲(chǔ)杨帽。
CommitLog 文件的默認(rèn)大小為 1G,存儲(chǔ)路徑:/{storePathRootDir}/store/commitlog/{fileName}
嗤军。文件的命名規(guī)則為 CommitLog 文件中存儲(chǔ)消息的最小物理偏移注盈,當(dāng)一個(gè) CommitLog 文件被寫滿之后,RocketMQ 就會(huì)創(chuàng)建一個(gè)新的 CommitLog 文件叙赚。
比如老客,第一個(gè) CommitLog 文件會(huì)命名為 00000000000000000000
僚饭,文件名一共 20 位,左邊補(bǔ)零胧砰,剩余為消息在文件中的最小物理偏移鳍鸵,文件大小為 1G,表示第一個(gè) CommitLog 文件中消息的最小物理偏移為 0 尉间。
當(dāng)?shù)谝粋€(gè) CommitLog 文件被寫滿之后偿乖,第二個(gè) CommitLog 文件就會(huì)被命名為 00000000001073741824
(1G = 1073741824),表示第二個(gè) CommitLog 文件中消息的最小物理偏移為 1073741824哲嘲。后面第三個(gè)贪薪,第四個(gè) CommitLog 文件的命名規(guī)則都是一樣的,以此類推眠副。
單個(gè) Broker 實(shí)例下的每條消息的物理偏移是全局唯一的画切,而 CommitLog 文件的命名規(guī)則是根據(jù)消息的物理偏移依次遞增的,所以給定一個(gè)消息的物理偏移囱怕,通過二分查找就能很快的定位到存儲(chǔ)該消息的具體 CommitLog 文件霍弹。
6.2 ConsumeQueue
現(xiàn)在消息的存儲(chǔ)解決了,但是消息的消費(fèi)卻成了難題娃弓,因?yàn)閱蝹€(gè) Broker 實(shí)例下的所有 Topic 消息都是混合存儲(chǔ)在 CommitLog 中庞萍,而 Consumer 是基于訂閱的 Topic 進(jìn)行消費(fèi)的,這樣一來忘闻,Consumer 想要消費(fèi)具體 Topic 下的消息,就需要根據(jù) Topic 來遍歷 CommitLog 檢索消息恋博,這樣效率是非常低下的齐佳。
因此就有必要為 Consumer 消費(fèi)消息專門建立一個(gè)索引文件,這個(gè)索引文件就是 ConsumeQueue 债沮,ConsumeQueue 可以看做是基于 Topic 的 CommitLog 索引文件 炼吴。
每個(gè) Topic 下邊包含多個(gè) MessageQueue,該 Topic 下的所有消息會(huì)均勻的分布在各個(gè) MessageQueue 中疫衩,有點(diǎn)像 Kafka 里的 Partition 概念硅蹦。Producer 在向 Broker 發(fā)送消息的時(shí)候會(huì)指定該消息所屬的 MessageQueue。每個(gè) MessageQueue 下邊會(huì)有多個(gè) ConsumeQueue 文件闷煤,用于存儲(chǔ)該隊(duì)列中的消息在 CommitLog 中的索引童芹。
ConsumeQueue 文件的存儲(chǔ)路徑結(jié)構(gòu)為:Topic/MessageQueue/ConsumeQueue
,具體的存儲(chǔ)路徑是:/{storePathRootDir}/store/consumequeue/{topic}/{queueId}/{fileName}
鲤拿,單個(gè) ConsumeQueue 文件可以存儲(chǔ) 30 萬條消息索引假褪,每條消息索引占用 20 個(gè)字節(jié),分別是:消息在 CommitLog 中的物理偏移(8字節(jié))近顷,消息的長度(4字節(jié))生音,消息 tag 的 hashcode(8字節(jié))宁否。每個(gè) ConsumeQueue 文件大小約為 5.72M(30萬 * 20 = 600 萬字節(jié))。
ConsumeQueue 文件的命名規(guī)則是消息索引在文件中的最小物理偏移缀遍,比如慕匠,每個(gè) MessageQueue 下第一個(gè) ConsumeQueue 文件會(huì)被命名為 00000000000000000000
,文件大小為 5.72M。當(dāng)?shù)谝粋€(gè)文件寫滿之后域醇,就會(huì)創(chuàng)建第二個(gè) ConsumeQueue 文件台谊,命名為 00000000000006000000
。這樣依次類推歹苦。
RocketMQ 會(huì)啟動(dòng)一個(gè)叫做 ReputMessageService 的后臺(tái)線程青伤,每隔 1ms 執(zhí)行一次,負(fù)責(zé)不停地從 CommitLog 中構(gòu)建消息索引并寫入到 ConsumeQueue 文件殴瘦。而消息的索引一旦被構(gòu)建到 ConsumeQueue 文件中之后狠角,Consumer 就可以看到了。
消息索引在 ConsumeQueue 文件中的物理偏移我們稱之為消息的邏輯偏移蚪腋,ConsumerGroup 中保存的消費(fèi)進(jìn)度就是這個(gè)邏輯偏移丰歌,當(dāng) ConsumerGroup 根據(jù)當(dāng)前保存的消費(fèi)進(jìn)度從 Broker 中拉取消息的時(shí)候,RocketMQ 就是先根據(jù)消息的這個(gè)邏輯偏移通過二分查找定位到消息索引所在的具體 ConsumeQueue 文件屉凯,然后從 ConsumeQueue 文件中讀取消息索引立帖,而消息索引中保存了該消息在 CommitLog 中的物理偏移,最后根據(jù)這個(gè)物理偏移從 CommitLog 中讀取出具體的消息內(nèi)容悠砚。
6.3 IndexFile
IndexFile 也是一種消息索引文件晓勇,同樣也是由后臺(tái)線程 ReputMessageService 來構(gòu)建的,不同的是 IndexFile 是根據(jù) CommitLog 中存儲(chǔ)的消息 key 以及消息的存儲(chǔ)時(shí)間來構(gòu)建的消息索引文件灌旧,這樣我們就可以通過消息 key 或者消息生產(chǎn)的時(shí)間來查找消息了绑咱。
IndexFile 索引文件可以看做是一個(gè)哈希表的結(jié)構(gòu),其中包含了 500 萬個(gè)哈希槽(hashSlot)枢泰,每個(gè)哈希槽占用 4 個(gè)字節(jié)描融,用來指向一個(gè)鏈表。在構(gòu)建 IndexFile 的時(shí)候衡蚂,會(huì)計(jì)算每一個(gè)消息 key 的 hashcode窿克,然后通過 hashcode % hashSlotNum
定位哈希槽,如果遇到哈希槽沖突毛甲,就會(huì)將沖突的消息索引采用頭插法插入到哈希槽指向的鏈表中年叮,這樣可以保證最新生產(chǎn)出來的消息位于鏈表的最前面。
消息索引就存放在各個(gè)哈希槽指向的這個(gè)鏈表中玻募,按照消息的生產(chǎn)時(shí)間從近到遠(yuǎn)依次排列谋右。一個(gè) IndexFile 可以容納 2000W 條消息索引,每條消息索引占用 20 個(gè)字節(jié)补箍,分別是:消息 key 的 hashcode (4字節(jié))改执,消息在 CommitLog 中的物理偏移 Physical Offset (8字節(jié))磁浇,Time Diff(4字節(jié))窘问,Next Index Pos(4字節(jié))用于指向該消息索引在哈希鏈表中的下一個(gè)消息索引读规。這里的 Time Diff 指的是消息的存儲(chǔ)時(shí)間與 beginTimestamp 的差值按声,而 beginTimestamp 表示的是 IndexFile 中所有消息的最小存儲(chǔ)時(shí)間。
除此之外终蒂,在 IndexFile 的開頭會(huì)有一個(gè) 40 字節(jié)大小的 indexHeader 頭部蜂林,用于保存文件中關(guān)于消息索引的一些統(tǒng)計(jì)信息:
8 字節(jié)的 beginTimestamp 表示 IndexFile 中消息的最小存儲(chǔ)時(shí)間
8 字節(jié)的 endTimestamp 表示 IndexFile 中消息的最大存儲(chǔ)時(shí)間
8 字節(jié)的 beginPhyoffset 表示 IndexFile 中消息在 CommitLog 中的最小物理偏移
8 字節(jié)的 endPhyoffset 表示 IndexFile 中消息在 CommitLog 中的最大物理偏移
4 字節(jié)的 hashSlotcount 表示 IndexFile 中當(dāng)前用到的哈希槽個(gè)數(shù)。
4 字節(jié)的 indexCount 表示 IndexFile 中目前保存的消息索引條數(shù)拇泣。
單個(gè) IndexFile 的總大小為 :40 字節(jié)的 Header + 500 萬 * 4 字節(jié)的哈希槽 + 2000 萬 * 20 字節(jié)的消息索引 = 400 M
噪叙。IndexFile 的命名規(guī)則是用創(chuàng)建文件時(shí)候的當(dāng)前時(shí)間戳,存儲(chǔ)路徑為:/{storePathRootDir}/store/index/{fileName}
霉翔。
我們首先會(huì)根據(jù)消息的生產(chǎn)時(shí)間通過二分查找的方式定位具體的 IndexFile睁蕾,在通過消息 key 的 hashcode 定位到具體的消息索引,從消息索引中拿到 Physical Offset债朵,最后在 CommitLog 中定位到具體的消息內(nèi)容子眶。
6.4 文件預(yù)熱
RocketMQ 對于 CommitLog,ConsumeQueue序芦,IndexFile 等文件的讀寫都是通過 MappedByteBuffer 來進(jìn)行的臭杰,因此 RocketMQ 專門定義了一個(gè)用于描述內(nèi)存文件映射的模型 —— MappedFile,其中封裝了針對內(nèi)存映射文件的所有操作谚中。比如渴杆,文件的預(yù)熱,文件的讀寫宪塔,文件的回寫等操作将塑。
public class DefaultMappedFile extends AbstractMappedFile {
protected FileChannel fileChannel;
protected MappedByteBuffer mappedByteBuffer;
private void init(final String fileName, final int fileSize) throws IOException {
this.fileName = fileName;
this.fileSize = fileSize;
this.file = new File(fileName);
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
}
}
通過 fileChannel.map
映射出來的 mappedByteBuffer 只是一段虛擬內(nèi)存,背后并未與任何物理內(nèi)存發(fā)生關(guān)聯(lián)(文件的 page cache), 后續(xù)在讀寫這段 mappedByteBuffer 的時(shí)候就會(huì)產(chǎn)生缺頁中斷的開銷蝌麸,對文件的讀寫性能產(chǎn)生比較大的影響。
所以 RocketMQ 為了最大化文件讀寫的性能而提供了文件預(yù)熱的功能艾疟,文件預(yù)熱在默認(rèn)情況下是關(guān)閉的来吩,如果需要可以在 Broker 的配置文件中開啟 warmMapedFileEnable。
warmMapedFileEnable=true
當(dāng) warmMapedFileEnable 開啟之后蔽莱,RocketMQ 在初始化完 MappedFile 之后弟疆,就會(huì)調(diào)用 warmMappedFile 函數(shù)對文件進(jìn)行預(yù)熱:
對 mappedByteBuffer 這段虛擬內(nèi)存范圍內(nèi)的虛擬內(nèi)存按照內(nèi)存頁為單位,逐個(gè)觸發(fā)缺頁中斷盗冷,目的是提前講映射文件的內(nèi)容加載到 page cache 中怠苔,并在進(jìn)程頁表中建立好 mappedByteBuffer 與 page cache 的映射關(guān)系。
使用前面介紹的 mlock 系統(tǒng)調(diào)用將 mappedByteBuffer 背后映射的 page cache 鎖定在內(nèi)存中仪糖,不允許內(nèi)核 swap柑司。
使用 madvise 系統(tǒng)調(diào)用再次觸發(fā)一次預(yù)讀迫肖,感覺這里完全沒必要調(diào)用 madvise,甚至也沒必要進(jìn)行步驟 1攒驰。只調(diào)用 mlock 就可以了蟆湖,因?yàn)閮?nèi)核在執(zhí)行 mlock 的過程中步驟 1 和步驟 3 的事情就都順便做了。不清楚 RocketMQ 這里為什么要有這么多重復(fù)的不必要?jiǎng)幼鞑7啵赡苁菫榱思嫒莶煌牟僮飨到y(tǒng)以及不同版本的內(nèi)核吧隅津,這里我們就不深入去探究了。
public void warmMappedFile(FlushDiskType type, int pages) {
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
for (long i = 0, j = 0; i < this.fileSize; i += DefaultMappedFile.OS_PAGE_SIZE, j++) {
byteBuffer.put((int) i, (byte) 0);
}
this.mlock();
}
public void mlock() {
final long address = ((DirectBuffer) (this.mappedByteBuffer)).address();
Pointer pointer = new Pointer(address);
{
int ret = LibC.INSTANCE.mlock(pointer, new NativeLong(this.fileSize));
}
{
int ret = LibC.INSTANCE.madvise(pointer, new NativeLong(this.fileSize), LibC.MADV_WILLNEED);
}
}
6.5 讀寫分離
再對文件進(jìn)行預(yù)熱之后劲室,后續(xù)對 mappedByteBuffer 的讀寫就是直接讀寫 page cache 了伦仍,整個(gè)過程沒有系統(tǒng)調(diào)用也沒有數(shù)據(jù)拷貝的開銷,經(jīng)過本文第五小節(jié)的分析我們知道 mappedByteBuffer 非常適合頻繁小數(shù)據(jù)量的文件讀寫場景很洋,而 RocketMQ 主要處理的是業(yè)務(wù)消息充蓝,通常這些業(yè)務(wù)消息不會(huì)很大,所以 RocketMQ 選擇 mappedByteBuffer 來讀寫文件實(shí)在是太合適了蹲缠。
但是如果我們通過 mappedByteBuffer 來高頻地不斷向 CommitLog 寫入消息的話棺克, page cache 中的臟頁比例就會(huì)越來越大,而 page cache 回寫臟頁的時(shí)機(jī)是由內(nèi)核來控制的线定,當(dāng)臟頁積累到一定程度娜谊,內(nèi)核就會(huì)啟動(dòng) pdflush 線程來將 page cache 中的臟頁回寫到磁盤中。
雖然現(xiàn)在 page cache 已經(jīng)被我們 mlock 住了斤讥,但是我們在用戶態(tài)無法控制臟頁的回寫纱皆,當(dāng)臟頁回寫完畢之后,我們通過 mappedByteBuffer 寫入文件時(shí)仍然會(huì)觸發(fā)寫保護(hù)缺頁中斷芭商。這樣也會(huì)加大 mappedByteBuffer 的寫入延遲派草,產(chǎn)生性能毛刺。
為了避免這種寫入毛刺的產(chǎn)生铛楣,RocketMQ 引入了讀寫分離的機(jī)制近迁,默認(rèn)是關(guān)閉的,可以通過 transientStorePoolEnable
開啟簸州。
transientStorePoolEnable=true
在開啟讀寫分離之后鉴竭,RocketMQ 會(huì)初始化一個(gè)堆外內(nèi)存池 transientStorePool,隨后從這個(gè)堆外內(nèi)存池中獲取一個(gè) DirectByteBuffer(writeBuffer)來初始化 MappedFile岸浑。
public class DefaultMappedFile extends AbstractMappedFile {
/**
* Message will put to here first, and then reput to FileChannel if writeBuffer is not null.
*/
protected ByteBuffer writeBuffer = null;
protected TransientStorePool transientStorePool = null;
@Override
public void init(final String fileName, final int fileSize,
final TransientStorePool transientStorePool) throws IOException {
init(fileName, fileSize);
// 用于暫存數(shù)據(jù)的 directBuffer
this.writeBuffer = transientStorePool.borrowBuffer();
// 堆外內(nèi)存池
this.transientStorePool = transientStorePool;
}
}
后續(xù) Broker 再對 CommitLog 寫入消息的時(shí)候搏存,首先會(huì)寫到 writeBuffer 中,因?yàn)?writeBuffer 只是一段普通的堆外內(nèi)存矢洲,不會(huì)涉及到臟頁回寫璧眠,因此 CommitLog 的寫入過程就會(huì)非常平滑,不會(huì)有性能毛刺。而從 CommitLog 讀取消息的時(shí)候仍然是通過 mappedByteBuffer 進(jìn)行责静。
public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb,
PutMessageContext putMessageContext) {
// 開啟讀寫分離之后獲取到的是 writeBuffer,否則獲取 mappedByteBuffer
ByteBuffer byteBuffer = appendMessageBuffer().slice();
byteBuffer.position(currentPos);
// 將消息寫入到 byteBuffer 中
result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos,
(MessageExtBatch) messageExt, putMessageContext);
}
protected ByteBuffer appendMessageBuffer() {
return writeBuffer != null ? writeBuffer : this.mappedByteBuffer;
}
消息數(shù)據(jù)現(xiàn)在只是暫存在 writeBuffer 中袁滥,當(dāng)積攢的數(shù)據(jù)超過了 16K(可通過 commitCommitLogLeastPages 配置),或者消息在 writeBuffer 中停留時(shí)間超過了 200 ms(可通過 commitCommitLogThoroughInterval 配置)泰演。
private int commitCommitLogThoroughInterval = 200;
private int commitCommitLogLeastPages = 4
protected boolean isAbleToCommit(final int commitLeastPages) {
if (commitLeastPages > 0) {
// writeBuffer 中積攢的數(shù)據(jù)超過了 16 k呻拌,開始 commit
return ((write / OS_PAGE_SIZE) - (commit / OS_PAGE_SIZE)) >= commitLeastPages;
}
return write > commit;
}
那么 RocketMQ 就會(huì)將 writeBuffer 中的消息數(shù)據(jù)通過 FileChannel 一次性批量異步寫入到 page cache 中。
public int commit(final int commitLeastPages) {
if (this.isAbleToCommit(commitLeastPages)) {
this.fileChannel.write(byteBuffer);
}
}
既然 RocketMQ 在讀寫分離模式下設(shè)計(jì)的是通過 FileChannel 來批量寫入消息睦焕,那么就需要考慮 FileChannel 的最佳寫入性能點(diǎn)藐握,這里 RocketMQ 選擇了 16K,而我們在本文第五小節(jié)中測試的 FileChannel 最佳寫入性能點(diǎn)也差不多是在 32K 附近垃喊,而且寫入性能是要比 MappedByteBuffer 高很多的猾普。
6.6 文件刷盤
無論是通過 MappedByteBuffer 還是 FileChannel 對文件進(jìn)行寫入,當(dāng)系統(tǒng)中的臟頁積累到一定量的時(shí)候本谜,都會(huì)對其寫入文件的性能造成非常大的影響初家。另外臟頁不及時(shí)回寫還會(huì)造成數(shù)據(jù)丟失的風(fēng)險(xiǎn)。
因此為了避免數(shù)據(jù)丟失的風(fēng)險(xiǎn)以及對寫入性能的影響乌助,當(dāng)臟頁在 page cache 中積累到 16K 或者臟頁在 page cache 中停留時(shí)間超過 10s 的時(shí)候溜在,RocketMQ 就會(huì)通過 force 方法將臟頁回寫到磁盤中。
private int flushCommitLogLeastPages = 4;
private int flushCommitLogThoroughInterval = 1000 * 10;
private boolean isAbleToFlush(final int flushLeastPages) {
if (flushLeastPages > 0) {
return ((write / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE)) >= flushLeastPages;
}
return write > flush;
}
public int flush(final int flushLeastPages) {
if (this.isAbleToFlush(flushLeastPages)) {
if (writeBuffer != null || this.fileChannel.position() != 0) {
this.fileChannel.force(false);
} else {
this.mappedByteBuffer.force();
}
}
}
總結(jié)
本文從 OS 內(nèi)核他托,JVM 掖肋,中間件應(yīng)用三個(gè)視角帶著大家全面深入地拆解了一下關(guān)于 MappedByteBuffer 的方方面面,在文章的開始赏参,我們先是在 OS 內(nèi)核的視角下志笼,分別從私有文件映射,共享文件映射兩個(gè)方面把篓,介紹了 MappedByteBuffer 的映射過程以及缺頁處理纫溃。還原了 MappedByteBuffer 最為本質(zhì)的面貌。
在此基礎(chǔ)之上韧掩,我們來到了 JVM 的視角紊浩,介紹了 JDK 如何對系統(tǒng)調(diào)用 mmap 進(jìn)行一步一步的封裝,并介紹了很多映射的細(xì)節(jié)疗锐,比如經(jīng)常被誤解的 System,gc 之后到底發(fā)生了什么坊谁,真的是無法預(yù)測嗎 ?
隨后筆者接著為大家介紹了和 MappedByteBuffer 相關(guān)的幾個(gè)系統(tǒng)調(diào)用:madvise , mlock , msync窒悔,并詳細(xì)的分析了他們在內(nèi)核中的源碼實(shí)現(xiàn)。
最后筆者從映射文件數(shù)據(jù)在與不在 page cache 中這兩個(gè)角度敌买,詳細(xì)對比了 MappedByteBuffer 與 FileChannel 在文件讀寫上的性能差異简珠,并從內(nèi)核的角度分析了具體導(dǎo)致兩者性能差異的原因。
在文章的結(jié)尾,筆者以 RocketMQ 為例聋庵,介紹了 MappedByteBuffer 在中間件中的應(yīng)用膘融。好了,今天的內(nèi)容就到這里祭玉,我們下篇文章見~~~