本文基于 Linux 內(nèi)核 5.4 版本進行討論
自上篇文章《從 Linux 內(nèi)核角度探秘 JDK MappedByteBuffer》 發(fā)布之后,很多讀者朋友私信我說,文章的信息量太大了,其中很多章節(jié)介紹的內(nèi)容都是大家非常想要了解,并且是頻繁被搜索的內(nèi)容,所以根據(jù)讀者朋友的建議,筆者決定將一些重要的章節(jié)內(nèi)容獨立出來,更好的方便大家檢索。
關(guān)于 MappedByteBuffer 和 FileChannel 的話題,網(wǎng)上有很多,但大部分都在討論 MappedByteBuffer 相較于傳統(tǒng) FileChannel 的優(yōu)勢,但好像很少有人來寫一寫 MappedByteBuffer 的劣勢,所以筆者這里想寫一點不一樣的,來和大家討論討論 MappedByteBuffer 的劣勢有哪些。
但在開始討論這個話題之前,筆者想了想還是不能免俗,仍然需要把 MappedByteBuffer 和 FileChannel 放在一起從頭到尾對比一下,基于這個思路,我們先來重新簡要梳理一下 FileChannel 和 MappedByteBuffer 讀寫文件的流程。
1. FileChannel 讀寫文件過程
在之前的文章《從 Linux 內(nèi)核角度探秘 JDK NIO 文件讀寫本質(zhì)》中,由于當時我們還未介紹 DirectByteBuffer 以及 MappedByteBuffer,所以筆者以 HeapByteBuffer 為例來介紹 FileChannel 讀寫文件的整個源碼實現(xiàn)邏輯。
當我們使用 HeapByteBuffer 傳入 FileChannel 的 read or write 方法對文件進行讀寫時,JDK 會首先創(chuàng)建一個臨時的 DirectByteBuffer,對于 FileChannel#read
來說,JDK 在 native 層會將 read 系統(tǒng)調(diào)用從文件中讀取的內(nèi)容首先存放到這個臨時的 DirectByteBuffer 中,然后在拷貝到 HeapByteBuffer 中返回。
對于 FileChannel#write
來說,JDK 會首先將 HeapByteBuffer 中的待寫入數(shù)據(jù)拷貝到臨時的 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,那么直接進行文件的讀取
// 將文件內(nèi)容讀取到 dst 中
if (dst instanceof DirectBuffer)
return readIntoNativeBuffer(fd, dst, position, nd);
// 如果我們傳入的 dst 是一個 HeapBuffer,那么這里就需要創(chuàng)建一個臨時的 DirectBuffer
// 在調(diào)用 native 方法底層利用 read or write 系統(tǒng)調(diào)用進行文件讀寫的時候
// 傳入的只能是 DirectBuffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
try {
// 底層通過 read 系統(tǒng)調(diào)用將文件內(nèi)容拷貝到臨時 DirectBuffer 中
int n = readIntoNativeBuffer(fd, bb, position, nd);
if (n > 0)
// 將臨時 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)建一個臨時的 DirectBuffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
// 首先將 HeapBuffer 中的待寫入內(nèi)容拷貝到臨時的 DirectBuffer 中
// 隨后通過 write 系統(tǒng)調(diào)用將臨時 DirectBuffer 中的內(nèi)容寫入到文件 page cache 中
int n = writeFromNativeBuffer(fd, bb, position, nd);
return n;
}
}
}
當時有很多讀者朋友給我留言提問說,為什么必須要在 DirectByteBuffer 中做一次中轉(zhuǎn),直接將 HeapByteBuffer 傳給 native 層不行嗎 ?
答案是肯定不行的,在本文開頭筆者為大家介紹過 JVM 進程的虛擬內(nèi)存空間布局,如下圖所示:
HeapByteBuffer 和 DirectByteBuffer 從本質(zhì)上來說均是 JVM 進程地址空間內(nèi)的一段虛擬內(nèi)存,對于 Java 程序來說 HeapByteBuffer 被用來特定表示 JVM 堆中的內(nèi)存,而 DirectByteBuffer 就是一個普通的 C++ 程序通過 malloc 系統(tǒng)調(diào)用向操作系統(tǒng)申請的一段 Native Memory 位于 JVM 堆之外。
既然 HeapByteBuffer 是位于 JVM 堆中的內(nèi)存,那么它必然會受到 GC 的管理,當發(fā)生 GC 的時候,如果我們選擇的垃圾回收器采用的是 Mark-Copy 或者 Mark-Compact 算法的時候(Mark-Swap 除外),GC 會來回移動存活的對象,這就導(dǎo)致了存活的 Java 對象比如這里的 HeapByteBuffer 在 GC 之后它背后的內(nèi)存地址可能已經(jīng)發(fā)生了變化。
而 JVM 中的這些 native 方法是處于 safepoint 之下的,執(zhí)行 native 方法的線程由于是處于 safepoint 中,所以在執(zhí)行 native 方法的過程中可能會有 GC 的發(fā)生。
如果我們把一個 HeapByteBuffer 傳遞給 native 層進行文件讀寫的時候不巧發(fā)生了 GC,那么 HeapByteBuffer 背后的內(nèi)存地址就會變化,這樣一來,如果我們在讀取文件的話,內(nèi)核將會把文件內(nèi)容拷貝到另一個內(nèi)存地址中。如果我們在寫入文件的話,內(nèi)核將會把另一個內(nèi)存地址中的內(nèi)存寫入到文件的 page cache 中。
所以我們在通過 native 方法執(zhí)行相關(guān)系統(tǒng)調(diào)用的時候必須要保證傳入的內(nèi)存地址是不會變化的,由于 DirectByteBuffer 背后所依賴的 Native Memory 位于 JVM 堆之外,是不會受到 GC 管理的,因此不管發(fā)不發(fā)生 GC,DirectByteBuffer 所引用的這些 Native Memory 地址是不會發(fā)生變化的。
所以我們在調(diào)用 native 方法進行文件讀寫的時候需要傳入 DirectByteBuffer,如果我們用得是 HeapByteBuffer ,那么就需要一個臨時的 DirectByteBuffer 作為中轉(zhuǎn)。
這時可能有讀者朋友又會問了,我們在使用 HeapByteBuffer 通過 FileChannel#write
對文件進行寫入的時候,首先會將 HeapByteBuffer 中的內(nèi)容拷貝到臨時的 DirectByteBuffer 中,那如果在這個拷貝的過程中發(fā)生了 GC,HeapByteBuffer 背后引用內(nèi)存的地址發(fā)生了變化,那么拷貝到 DirectByteBuffer 中的內(nèi)容仍然是錯的啊。
事實上在這個拷貝的過程中是不會發(fā)生 GC 的,因為 JVM 這里會使用 Unsafe#copyMemory
方法來實現(xiàn) HeapByteBuffer 到 DirectByteBuffer 的拷貝操作,copyMemory 被 JVM 實現(xiàn)為一個 intrinsic 方法,中間是沒有 safepoint 的,執(zhí)行 copyMemory 的線程由于不在 safepoint 中,所以在拷貝的過程中是不會發(fā)生 GC 的。
public final class Unsafe {
// intrinsic 方法
public native void copyMemory(Object srcBase, long srcOffset,
Object destBase, long destOffset,
long bytes);
}
在交代完這個遺留的問題之后,下面我們就以 DirectByteBuffer 為例來重新簡要回顧下傳統(tǒng) FileChannel 對文件的讀寫流程:
-
當 JVM 在 native 層使用 read 系統(tǒng)調(diào)用進行文件讀取的時候,JVM 進程會發(fā)生第一次上下文切換,從用戶態(tài)轉(zhuǎn)為內(nèi)核態(tài)。
-
隨后 JVM 進程進入虛擬文件系統(tǒng)層,在這一層內(nèi)核首先會查看讀取文件對應(yīng)的 page cache 中是否含有請求的文件數(shù)據(jù),如果有,那么直接將文件數(shù)據(jù)拷貝到 DirectByteBuffer 中返回,避免一次磁盤 IO。并根據(jù)內(nèi)核預(yù)讀算法從磁盤中異步預(yù)讀若干文件數(shù)據(jù)到 page cache 中
-
如果請求的文件數(shù)據(jù)不在 page cache 中,則會進入具體的文件系統(tǒng)層,在這一層內(nèi)核會啟動磁盤塊設(shè)備驅(qū)動觸發(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 進程直接尋址,所以還需要 CPU 將 page cache 中的數(shù)據(jù)拷貝到位于用戶空間的 DirectByteBuffer 中,發(fā)生第二次數(shù)據(jù)拷貝。
-
最后 JVM 進程從系統(tǒng)調(diào)用 read 中返回,并從內(nèi)核態(tài)切換回用戶態(tài)。發(fā)生第二次上下文切換。
從以上過程我們可以看到,當使用 FileChannel#read
對文件讀取的時候,如果文件數(shù)據(jù)在 page cache 中,涉及到的性能開銷點主要有兩次上下文切換,以及一次 CPU 拷貝。其中上下文切換是主要的性能開銷點。
下面是通過 FileChannel#write
寫入文件的整個過程:
-
當 JVM 在 native 層使用 write 系統(tǒng)調(diào)用進行文件寫入的時候,JVM 進程會發(fā)生第一次上下文切換,從用戶態(tài)轉(zhuǎn)為內(nèi)核態(tài)。
-
進入內(nèi)核態(tài)之后,JVM 進程在虛擬文件系統(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ā)生第一次拷貝動作( CPU 拷貝)。
-
當待寫入數(shù)據(jù)拷貝到 page cache 中時,內(nèi)核會將對應(yīng)的文件頁標記為臟頁,內(nèi)核會根據(jù)一定的閾值判斷是否要對 page cache 中的臟頁進行回寫,如果不需要同步回寫,進程直接返回。這里發(fā)生第二次上下文切換。
-
臟頁回寫又會根據(jù)臟頁數(shù)量在內(nèi)存中的占比分為:進程同步回寫和內(nèi)核異步回寫。當臟頁太多了,進程自己都看不下去的時候,會同步回寫內(nèi)存中的臟頁,直到回寫完畢才會返回。在回寫的過程中會發(fā)生第二次拷貝(DMA 拷貝)。
從以上過程我們可以看到,當使用 FileChannel#write
對文件寫入的時候,如果不考慮臟頁回寫的情況,單純對于 JVM 這個進程來說涉及到的性能開銷點主要有兩次上下文切換,以及一次 CPU 拷貝。其中上下文切換仍然是主要的性能開銷點。
2. MappedByteBuffer 讀寫文件過程
下面我們來看下通過 MappedByteBuffer 對文件進行讀寫的過程:
首先我們需要通過 FileChannel#map
將文件的某個區(qū)域映射到 JVM 進程的虛擬內(nèi)存空間中,從而獲得一段文件映射的虛擬內(nèi)存區(qū)域 MappedByteBuffer。由于底層使用到了 mmap 系統(tǒng)調(diào)用,所以這個過程也涉及到了兩次上下文切換。
如上圖所示,當 MappedByteBuffer 在剛剛映射出來的時候,它只是進程地址空間中的一段虛擬內(nèi)存,其對應(yīng)在進程頁表中的頁表項還是空的,背后還沒有映射物理內(nèi)存。此時映射文件對應(yīng)的 page cache 也是空的,我們要映射的文件內(nèi)容此時還靜靜地躺在磁盤中。
當 JVM 進程開始對 MappedByteBuffer 進行讀寫的時候,就會觸發(fā)缺頁中斷,內(nèi)核會將映射的文件內(nèi)容從磁盤中加載到 page cache 中,然后在進程頁表中建立 MappedByteBuffer 與 page cache 的映射關(guān)系。由于這里涉及到了缺頁中斷的處理,因此也會有兩次上下文切換的開銷。
后面 JVM 進程對 MappedByteBuffer 的讀寫就相當于是直接讀寫 page cache 了,關(guān)于這一點,很多讀者朋友會有這樣的疑問:page cache 是內(nèi)核態(tài)的部分,為什么我們通過用戶態(tài)的 MappedByteBuffer 就可以直接訪問內(nèi)核態(tài)的東西了?
這里大家不要被內(nèi)核態(tài)這三個字給唬住了,雖然 page cache 是屬于內(nèi)核部分的,但其本質(zhì)上還是一塊普通的物理內(nèi)存,想想我們是怎么訪問內(nèi)存的 ? 不就是先有一段虛擬內(nèi)存,然后在申請一段物理內(nèi)存,最后通過進程頁表將虛擬內(nèi)存和物理內(nèi)存映射起來么,進程在訪問虛擬內(nèi)存的時候,通過頁表找到其映射的物理內(nèi)存地址,然后直接通過物理內(nèi)存地址訪問物理內(nèi)存。
回到我們討論的內(nèi)容中,這段虛擬內(nèi)存不就是 MappedByteBuffer 嗎,物理內(nèi)存就是 page cache 啊,在通過頁表映射起來之后,進程在通過 MappedByteBuffer 訪問 page cache 的過程就和訪問普通內(nèi)存的過程是一模一樣的。
也正因為 MappedByteBuffer 背后映射的物理內(nèi)存是內(nèi)核空間的 page cache,所以它不會消耗任何用戶空間的物理內(nèi)存(JVM 的堆外內(nèi)存),因此也不會受到 -XX:MaxDirectMemorySize
參數(shù)的限制。
3. MappedByteBuffer VS FileChannel
現(xiàn)在我們已經(jīng)清楚了 FileChannel 以及 MappedByteBuffer 進行文件讀寫的整個過程,下面我們就來把兩種文件讀寫方式放在一起來對比一下,但這里有一個對比的前提:
-
對于 MappedByteBuffer 來說,我們對比的是其在缺頁處理之后,讀寫文件的開銷。
-
對于 FileChannel 來說,我們對比的是文件數(shù)據(jù)已經(jīng)存在于 page cache 中的情況下讀寫文件的開銷。
因為筆者認為只有基于這個前提來對比兩者的性能差異才有意義。
-
對于 FileChannel 來說,無論是通過 read 方法對文件的讀取,還是通過 write 方法對文件的寫入,它們都需要兩次上下文切換,以及一次 CPU 拷貝,其中上下文切換是其主要的性能開銷點。
-
對于 MappedByteBuffer 來說,由于其背后直接映射的就是 page cache,讀寫 MappedByteBuffer 本質(zhì)上就是讀寫 page cache,整個讀寫過程和讀寫普通的內(nèi)存沒有任何區(qū)別,因此沒有上下文切換的開銷,不會切態(tài),更沒有任何拷貝。
從上面的對比我們可以看出使用 MappedByteBuffer 來讀寫文件既沒有上下文切換的開銷,也沒有數(shù)據(jù)拷貝的開銷(可忽略),簡直是完爆 FileChannel。
既然 MappedByteBuffer 這么屌,那我們何不干脆在所有文件的讀寫場景中全部使用 MappedByteBuffer,這樣豈不省事 ?JDK 為何還保留了 FileChannel 的 read , write 方法呢 ?讓我們來帶著這個疑問繼續(xù)下面的內(nèi)容~~
4. 通過 Benchmark 從內(nèi)核層面對比兩者的性能差異
到現(xiàn)在為止,筆者已經(jīng)帶著大家完整的剖析了 mmap,read,write 這些系統(tǒng)調(diào)用在內(nèi)核中的源碼實現(xiàn),并基于源碼對 MappedByteBuffer 和 FileChannel 兩者進行了性能開銷上的對比。
雖然祭出了源碼,但畢竟還是 talk is cheap,本小節(jié)我們就來對兩者進行一次 Benchmark,來看一下 MappedByteBuffer 與 FileChannel 對文件讀寫的實際性能表現(xiàn)如何 ? 是否和我們從源碼中分析的結(jié)果一致。
我們從兩個方面來對比 MappedByteBuffer 和 FileChannel 的文件讀寫性能:
-
文件數(shù)據(jù)完全加載到 page cache 中,并且將 page cache 鎖定在內(nèi)存中,不允許 swap,MappedByteBuffer 不會有缺頁中斷,F(xiàn)ileChannel 不會觸發(fā)磁盤 IO 都是直接對 page cache 進行讀寫。
-
文件數(shù)據(jù)不在 page cache 中,我們加上了 缺頁中斷,磁盤IO,以及 swap 對文件讀寫的影響。
具體的測試思路是,用 MappedByteBuffer 和 FileChannel 分別以
64B ,128B ,512B ,1K ,2K ,4K ,8K ,32K ,64K ,1M ,32M ,64M ,512M 為單位依次對 1G 大小的文件進行讀寫,從以上兩個方面對比兩者在不同讀寫單位下的性能表現(xiàn)。
需要提醒大家的是本小節(jié)中得出的讀寫性能具體數(shù)值是沒有參考價值的,因為不同軟硬件環(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)境中運行一下,然后將跑出的結(jié)果提交到這個倉庫中。這樣方便大家在不同的測試環(huán)境下對比兩者的文件讀寫性能差異 —— 眾人拾柴火焰高。
4.1 文件數(shù)據(jù)在 page cache 中
由于這里我們要測試 MappedByteBuffer 和 FileChannel 直接對 page cache 的讀寫性能,所以筆者讓 MappedByteBuffer ,F(xiàn)ileChannel 只針對同一個文件進行讀寫測試。
在對文件進行讀寫之前,首先通過 mlock 系統(tǒng)調(diào)用將文件數(shù)據(jù)提前加載到 page cache 中并主動觸發(fā)缺頁處理,在進程頁表中建立好 MappedByteBuffer 和 page cache 的映射關(guān)系。最后將 page cache 鎖定在內(nèi)存中不允許 swap。
下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的讀取性能測試:
運行結(jié)果如下:
為了直觀的讓大家一眼看出 MappedByteBuffer 和 FileChannel 在對 page cache 讀取的性能差異,筆者根據(jù)上面跑出的性能數(shù)據(jù)繪制成下面這幅柱狀圖,方便大家觀察兩者的性能趨勢走向。
這里我們可以看出,MappedByteBuffer 在 4K 之前具有明顯的壓倒性優(yōu)勢,在 [8K , 32M] 這個區(qū)間內(nèi),MappedByteBuffer 依然具有優(yōu)勢但已經(jīng)不是十分明顯了,從 64M 開始 FileChannel 實現(xiàn)了一點點反超。
我們可以得到的性能趨勢是,在 [64B, 2K] 這個單次讀取數(shù)據(jù)量級范圍內(nèi),MappedByteBuffer 讀取的性能越來越快,并在 2K 這個數(shù)據(jù)量級下達到了性能最高值,僅消耗了 73 ms。從 4K 開始讀取性能在一點一點的逐漸下降,并在 64M 這個數(shù)據(jù)量級下被 FileChannel 反超。
而 FileChannel 的讀取性能會隨著數(shù)據(jù)量的增大反而越來越好,并在某一個數(shù)據(jù)量級下性能會反超 MappedByteBuffer。FileChannel 的最佳讀取性能點是在 64K 處,消耗了 167ms 。
因此 MappedByteBuffer 適合頻繁讀取小數(shù)據(jù)量的場景,具體多小,需要大家根據(jù)自己的環(huán)境進行測試,本小節(jié)我們得出的數(shù)據(jù)是 4K 以下。
FileChannel 適合大數(shù)據(jù)量的批量讀取場景,具體多大,還是需要大家根據(jù)自己的環(huán)境進行測試,本小節(jié)我們得出的數(shù)據(jù)是 64M 以上。
下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的寫入性能測試:
運行結(jié)果如下:
MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對 page cache 的寫入性能的趨勢走向柱狀圖:
這里我們可以看到 MappedByteBuffer 在 8K 之前具有明顯的寫入優(yōu)勢,它的寫入性能趨勢是在 [64B , 8K] 這個數(shù)據(jù)集方位內(nèi),寫入性能隨著數(shù)據(jù)量的增大而越來越快,直到在 8K 這個數(shù)據(jù)集下達到了最佳寫入性能。
而在 [32K, 32M] 這個數(shù)據(jù)集范圍內(nèi),MappedByteBuffer 仍然具有優(yōu)勢,但已經(jīng)不是十分明顯了,最終在 64M 這個數(shù)據(jù)集下被 FileChannel 反超。
和前面的讀取性能趨勢一樣,F(xiàn)ileChannel 的寫入性能也是隨著數(shù)據(jù)量的增大反而越來越好,最佳的寫入性能是在 64K 處,僅消耗了 160 ms 。
4.2 文件數(shù)據(jù)不在 page cache 中
在這一小節(jié)中,我們將缺頁中斷和磁盤 IO 的影響加入進來,不添加任何的優(yōu)化手段純粹地測一下 MappedByteBuffer 和 FileChannel 對文件讀寫的性能。
為了避免被 page cache 影響,所以我們需要在每一個測試數(shù)據(jù)集下,單獨分別為 MappedByteBuffer 和 FileChannel 創(chuàng)建各自的測試文件。
下面是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對文件的讀取性能測試:
運行結(jié)果:
從這里我們可以看到,在加入了缺頁中斷和磁盤 IO 的影響之后,MappedByteBuffer 在缺頁中斷的影響下平均比之前多出了 500 ms 的開銷。FileChannel 在磁盤 IO 的影響下在 [64B , 512B] 這個數(shù)據(jù)集范圍內(nèi)比之前平均多出了 1000 ms 的開銷,在 [1K, 512M] 這個數(shù)據(jù)集范圍內(nèi)比之前平均多出了 100 ms 的開銷。
在 2K 之前, MappedByteBuffer 具有明顯的讀取性能優(yōu)勢,最佳的讀取性能出現(xiàn)在 512B 這個數(shù)據(jù)集下,從 512B 往后,MappedByteBuffer 的讀取性能趨勢總體成下降趨勢,并在 4K 這個地方被 FileChannel 反超。
FileChannel 則是在 [64B, 1M] 這個數(shù)據(jù)集范圍內(nèi),讀取性能會隨著數(shù)據(jù)集的增大而提高,并在 1M 這個地方達到了 FileChannel 的最佳讀取性能,僅消耗了 258 ms,在 [32M , 512M] 這個范圍內(nèi) FileChannel 的讀取性能在逐漸下降,但是比 MappedByteBuffer 的性能高出了一倍。
讀到這里大家不禁要問了,理論上來講 MappedByteBuffer 應(yīng)該是完爆 FileChannel 才對啊,因為 MappedByteBuffer 沒有系統(tǒng)調(diào)用的開銷,為什么性能在后面反而被 FileChannel 超越了近一倍之多呢 ?
要明白這個問題,我們就需要分別把 MappedByteBuffer 和 FileChannel 在讀寫文件時候所涉及到的性能開銷點一一列舉出來,并對這些性能開銷點進行詳細對比,這樣答案就有了。
首先 MappedByteBuffer 的主要性能開銷是在缺頁中斷,而 FileChannel 的主要開銷是在系統(tǒng)調(diào)用,兩者都會涉及上下文的切換。
FileChannel 在讀寫文件的時候有磁盤IO,有預(yù)讀。同樣 MappedByteBuffer 的缺頁中斷也有磁盤IO 也有預(yù)讀。目前來看他倆一比一打平。
但別忘了 MappedByteBuffer 是需要進程頁表支持的,在實際訪問內(nèi)存的過程中會遇到頁表競爭以及 TLB shootdown 等問題。還有就是 MappedByteBuffer 剛剛被映射出來的時候,其在進程頁表中對應(yīng)的各級頁表以及頁目錄可能都是空的。所以缺頁中斷這里需要做的一件非常重要的事情就是補齊完善 MappedByteBuffer 在進程頁表中對應(yīng)的各級頁目錄表和頁表,并在頁表項中將 page cache 映射起來,最后還要刷新 TLB 等硬件緩存。
想更多了解缺頁中斷細節(jié)的讀者可以看下之前的文章——
《一文聊透 Linux 缺頁異常的處理 —— 圖解 Page Faults》
而 FileChannel 并不會涉及上面的這些開銷,所以 MappedByteBuffer 的缺頁中斷要比 FileChannel 的系統(tǒng)調(diào)用開銷要大,這一點我們可以在上小節(jié)和本小節(jié)的讀寫性能對比中看得出來。
文件數(shù)據(jù)在 page cache 中與不在 page cache 中,MappedByteBuffer 前后的讀取性能平均差了 500 ms,而 FileChannel 前后卻只平均差了 100 ms。
MappedByteBuffer 的缺頁中斷是平均每 4K 觸發(fā)一次,而 FileChannel 的系統(tǒng)調(diào)用開銷則是每次都會觸發(fā)。當兩者單次按照小數(shù)據(jù)量讀取 1G 文件的時候,MappedByteBuffer 的缺頁中斷較少觸發(fā),而 FileChannel 的系統(tǒng)調(diào)用卻在頻繁觸發(fā),所以在這種情況下,F(xiàn)ileChannel 的系統(tǒng)調(diào)用是主要的性能瓶頸。
這也就解釋了當我們在頻繁讀寫小數(shù)據(jù)量的時候,MappedByteBuffer 的性能具有壓倒性優(yōu)勢。當單次讀寫的數(shù)據(jù)量越來越大的時候,F(xiàn)ileChannel 調(diào)用的次數(shù)就會越來越少,這時候缺頁中斷就會成為 MappedByteBuffer 的性能瓶頸,到某一個點之后,F(xiàn)ileChannel 就會反超 MappedByteBuffer。因此當我們需要高吞吐量讀寫文件的時候 FileChannel 反而是最合適的。
除此之外,內(nèi)核的臟頁回寫也會對 MappedByteBuffer 以及 FileChannel 的文件寫入性能有非常大的影響,無論是我們在用戶態(tài)中調(diào)用 fsync 或者 msync 主動觸發(fā)臟頁回寫還是內(nèi)核通過 pdflush 線程異步臟頁回寫,當我們使用 MappedByteBuffer 或者 FileChannel 寫入 page cache 的時候,如果恰巧遇到文件頁的回寫,那么寫入操作都會有非常大的延遲,這個在 MappedByteBuffer 身上體現(xiàn)的更為明顯。
為什么這么說呢 ? 我們還是到內(nèi)核源碼中去探尋原因,先來看臟頁回寫對 FileChannel 的寫入影響。下面是 FileChannel 文件寫入在內(nèi)核中的核心實現(xiàn):
ssize_t generic_perform_write(struct file *file,
struct iov_iter *i, loff_t pos)
{
// 從 page cache 中獲取要寫入的文件頁并準備記錄文件元數(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);
// 將寫入的文件頁標記為臟頁并完成文件元數(shù)據(jù)日志的寫入
status = a_ops->write_end(file, mapping, pos, bytes, copied,
page, fsdata);
// 判斷是否需要同步回寫臟頁
balance_dirty_pages_ratelimited(mapping);
}
首先內(nèi)核會在 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;
}
在這里會調(diào)用一個非常重要的函數(shù) wait_for_stable_page,這個函數(shù)的作用就是判斷當前 page cache 中的這個文件頁是否正在被回寫,如果正在回寫到磁盤,那么當前進程就會阻塞直到臟頁回寫完畢。
/**
* 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);
等到臟頁回寫完畢之后,進程才會調(diào)用 iov_iter_copy_from_user_atomic 將待寫入數(shù)據(jù)拷貝到 page cache 中,最后在 write_end 中調(diào)用 mark_buffer_dirty 將寫入的文件頁標記為臟頁。
除了正在回寫的臟頁會阻塞 FileChannel 的寫入過程之外,如果此時系統(tǒng)中的臟頁太多了,超過了 dirty_ratio
或者 dirty_bytes
等內(nèi)核參數(shù)配置的臟頁比例,那么進程就會同步去回寫臟頁,這也對寫入性能有非常大的影響。
我們接著再來看臟頁回寫對 MappedByteBuffer 的寫入影響,在開始分析之前,筆者先問大家一個問題:通過 MappedByteBuffer 寫入 page cache 之后,page cache 中的相應(yīng)文件頁是怎么變臟的 ?
FileChannel 很好理解,因為 FileChannel 走的是系統(tǒng)調(diào)用,會進入到文件系統(tǒng)由內(nèi)核進行處理,如果寫入文件頁恰好正在回寫時,內(nèi)核會調(diào)用 wait_for_stable_page 阻塞當前進程。在將數(shù)據(jù)寫入文件頁之后,內(nèi)核又會調(diào)用 mark_buffer_dirty 將頁面變臟。
MappedByteBuffer 就很難理解了,因為 MappedByteBuffer 不會走系統(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)文件頁正在回寫并阻塞等待呢 ?
既然我們涉及到了軟件的概念和行為,那么一定就會有內(nèi)核的參與,我們回想一下整個 MappedByteBuffer 的生命周期,唯一一次和內(nèi)核打交道的機會就是缺頁中斷,我們看看能不能在缺頁中斷中發(fā)現(xiàn)點什么~
當 MappedByteBuffer 剛剛被 mmap 映射出來的時候它還只是一段普通的虛擬內(nèi)存,背后什么都沒有,其在進程頁表中的各級頁目錄項以及頁表項都還是空的。
當我們立即對 MappedByteBuffer 進行寫入的時候就會發(fā)生缺頁中斷,在缺頁中斷的處理中,內(nèi)核會在進程頁表中補齊與 MappedByteBuffer 映射相關(guān)的各級頁目錄并在頁表項中與 page cache 進行映射。
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)核還會調(diào)用 do_page_mkwrite 方法將 MappedByteBuffer 對應(yīng)的頁表項變成可寫狀態(tài),并將與其映射的文件頁立即設(shè)置位臟頁,如果此時文件頁正在回寫,那么 MappedByteBuffer 在缺頁中斷中也會阻塞。
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 進行寫入,而 FileChannel 是先寫入 page cache 后在變臟。
從此之后,通過 MappedByteBuffer 對 page cache 的寫入就會變得非常絲滑,那么問題來了,當 page cache 中的臟頁被內(nèi)核異步回寫之后,內(nèi)核會把文件頁中的臟頁標記清除掉,那么這時如果 MappedByteBuffer 對 page cache 寫入,由于不會發(fā)生缺頁中斷,那么 page cache 中的文件頁如何再次變臟呢 ?
內(nèi)核這里的設(shè)計非常巧妙,當內(nèi)核回寫完臟頁之后,會調(diào)用 page_mkclean_one 函數(shù)清除文件頁的臟頁標記,在這里會首先通過 page_vma_mapped_walk 判斷該文件頁是不是被 mmap 映射到進程地址空間的,如果是,那么說明該文件頁是被 MappedByteBuffer 映射的。隨后內(nèi)核就會做一些特殊處理:
-
通過 pte_wrprotect 對 MappedByteBuffer 在進程頁表中對應(yīng)的頁表項 pte 進行寫保護,變?yōu)橹蛔x權(quán)限。
-
通過 pte_mkclean 清除頁表項上的臟頁標記。
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)的我們都是透明的,當再次對 MappedByteBuffer 寫入的時候就不是那么絲滑了,會觸發(fā)寫保護缺頁中斷(我們以為不會有缺頁中斷,其實是有的),在寫保護中斷的處理中,內(nèi)核會重新將頁表項 pte 變?yōu)榭蓪?,文件頁標記為臟頁。如果文件頁正在回寫,缺頁中斷會阻塞。如果臟頁積累的太多,這里也會同步回寫臟頁。
static vm_fault_t wp_page_shared(struct vm_fault *vmf)
__releases(vmf->ptl)
{
if (vma->vm_ops && vma->vm_ops->page_mkwrite) {
// 設(shè)置頁表項為可寫
// 標記文件頁為臟頁
// 如果文件頁正在回寫則阻塞等待
tmp = do_page_mkwrite(vmf);
}
// 判斷是否需要同步回寫臟頁,
fault_dirty_shared_page(vma, vmf->page);
return VM_FAULT_WRITE;
}
所以并不是對 MappedByteBuffer 調(diào)用 mlock 之后就萬事大吉了,在遇到臟頁回寫的時候,MappedByteBuffer 依然會發(fā)生寫保護類型的缺頁中斷。在缺頁中斷處理中會等待臟頁的回寫,并且還可能會發(fā)生臟頁的同步回寫。這對 MappedByteBuffer 的寫入性能會有非常大的影響。
在明白這些問題之后,下面我們繼續(xù)來看 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集下對文件的寫入性能測試:
運行結(jié)果:
在筆者的測試環(huán)境中,我們看到 MappedByteBuffer 在對文件的寫入性能一路碾壓 FileChannel,并沒有出現(xiàn)被 FileChannel 反超的情況。但我們看到 MappedByteBuffer 從 4K 開始寫入性能是在逐漸下降的,而 FileChannel 的寫入性能卻在一路升高。
根據(jù)上面的分析,我們可以推斷出,后面隨著數(shù)據(jù)量的增大,由于 MappedByteBuffer 缺頁中斷瓶頸的影響,在 512M 后面某一個數(shù)據(jù)集下,F(xiàn)ileChannel 的寫入性能最終是會超過 MappedByteBuffer 的。
在本小節(jié)的開頭,筆者就強調(diào)了,本小節(jié)值得參考的是 MappedByteBuffer 和 FileChannel 在不同數(shù)據(jù)集大小下的讀寫性能趨勢走向,而不是具體的性能數(shù)值。文章來源:http://www.zghlxwxcb.cn/news/detail-844032.html
文章來源地址http://www.zghlxwxcb.cn/news/detail-844032.html
到了這里,關(guān)于MappedByteBuffer VS FileChannel:從內(nèi)核層面對比兩者的性能差異的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!