從rocketmq入手,解析各種零拷貝的jvm層原理
在上一篇文章中,主要介紹了rocketmq訊息的儲存流程。其主要使用了mmap的零拷貝技術實現了硬碟和記憶體的對映,從而提高了讀寫效能。在流程中有一個非常有意思的預熱方法並沒有詳細分析,因為其中涉及到了一些系統方法的呼叫。而本文就從該方法入手,進而分享除了mmap之外,還有哪些零拷貝方法,以及他們的系統底層呼叫是怎樣的。
本文的主要內容
1.page cache與mmap的關係
2.rocketmq對零拷貝的使用和優化
3.transferTo/From的零拷貝
4.splice的零拷貝
1.page cache與mmap的關係
page cache允許系統將一部分硬碟上的資料存放在記憶體中,使得對這部分資料的訪問不需要再讀取硬碟了,從而提高了讀寫效能。我理解這就是所謂的核心快取。page cache以頁為單位,一般一頁為4kb。當程式需要將資料寫入檔案時,並不會,也不能直接將資料寫到磁碟上,而是先將資料複製到page cache中,並標記為dirty,等待系統的flusher執行緒定時將這部分資料落到硬碟上。
對於使用者程式來說,因為不能直接訪問核心快取,所以讀取檔案資料都必須等待系統將資料從磁碟上覆制到page cache中,再從page cache複製一份到使用者態的記憶體中。於是讀取檔案就產生了2次資料的複製:硬碟=>page cache,page cache=>使用者態記憶體。同樣的資料在記憶體中會存在2份,這既佔用了不必要的記憶體空間,也產生了冗餘的拷貝。針對此問題,作業系統提供了記憶體對映機制,對於linux來說,就提供了mmap操作。
mmap是一種記憶體對映檔案的方法,即將一個檔案或者其它物件對映到程序的記憶體中,實現檔案磁碟地址和程序記憶體地址的對映關係。對映完成後,程序就可以直接讀寫操作這一段記憶體,而系統會自動回寫dirty頁面到對應的檔案磁碟上,即完成了對檔案的操作而不必再呼叫read,write等系統呼叫函式。
2.rocketmq對零拷貝的使用和優化
map的底層呼叫
rocketmq建立mappedFile物件後,會呼叫其init方法,完成了最終的對映操作。呼叫的方法是fileChannel.map。
檢視FileChannelImpl.map:
public MappedByteBuffer map(MapMode var1, long var2, long var4) throws IOException { ... //呼叫map0方法完成對映,並返回記憶體地址 var7 = this.map0(var6, var36, var10); ... //根據記憶體地址建立MappedByteBuffer物件,供java層面的操作 var37 = Util.newMappedByteBuffer(var35, var7 + (long)var12, var13, var15); return var37; ... }
繼續檢視map0方法:
private native long map0(int var1, long var2, long var4) throws IOException;
發現其是一個native方法,於是就需要去jdk原始碼中看看了。
檢視jdk原始碼:/src/java.base/unix/native/libnio/ch/FileChannelImpl.c
#define mmap64 mmap JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this, jint prot, jlong off, jlong len) { ... //這裡呼叫的是mmap64,但是在檔案開頭define了mmap64就是mmap方法 mapAddress = mmap64( 0, /* Let OS decide location */ len, /* Number of bytes to map */ protections, /* File permissions */ flags, /* Changes are shared */ fd, /* File descriptor of mapped file */ off); /* Offset into file */ ... //返回對映完成的記憶體地址 return ((jlong) (unsigned long) mapAddress); }
因此fileChannel.map最底層呼叫就是linux的系統方法mmap。
mmap系統方法:為程序建立虛擬地址空間對映
參考說明: https://man7.org/linux/man-pages/man2/mmap.2.html
warmMappedFile的底層呼叫
rocketmq在建立完mmap對映後,還會作一個預熱
檢視mappedFile.warmMappedFile方法:
public void warmMappedFile(FlushDiskType type, int pages) { ByteBuffer byteBuffer = this.mappedByteBuffer.slice(); int flush = 0; //用0來填充檔案,特別注意這裡i每次遞增都是OS_PAGE_SIZE,檢視可以看到是1024*4,即4kb //因此初始化是以頁為單位填充的 for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) { byteBuffer.put(i, (byte) 0); //如果需要同步刷盤,那麼如果寫入mappedByteBuffer的資料超過了指定頁數,就做一次強制刷盤 if (type == FlushDiskType.SYNC_FLUSH) { //i是當前寫入的資料位置,flush是已經刷盤的資料位置,如果差值大於指定的頁數pages,就做一次強制刷盤 if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) { flush = i; mappedByteBuffer.force(); } } ... } //全部填充完畢後,如果配置了同步刷盤,就再做一次強制刷盤操作 if (type == FlushDiskType.SYNC_FLUSH) { mappedByteBuffer.force(); } //這裡是對記憶體再做一些預處理 this.mlock(); }
接著檢視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); }
mlock方法主要做了2個系統方法的呼叫,mlock和madvise
mlock系統方法:鎖定記憶體中的虛擬地址空間,防止其被交換系統的swap空間中。
swap空間就是磁碟上的一塊空間,當記憶體不夠用時,系統會將部分記憶體中不常用的資料放到磁碟上。mmap本身就是為了提高讀寫效能,如果被對映的記憶體資料被放到了磁碟上,那就失去了mmap的意義了,所以要做一個mlock進行記憶體的鎖定。
參考說明: https://man7.org/linux/man-pages/man2/mlock.2.html
madvise系統方法:該方法功能很多,主要是給系統核心提供記憶體處理建議,可以根據需要傳入引數。
在rocketmq中,傳入的引數是 MADV_WILLNEE ,該引數的意思是告訴系統核心,這塊記憶體一會兒就會用到,於是系統就會提前載入被對映的檔案資料到記憶體中,這樣就不會在需要使用的時候才去讀取磁碟,影響效能。其他建議型別可以參考下面的連結。
參考說明: https://man7.org/linux/man-pages/man2/madvise.2.html
落盤的底層呼叫
上面的分析僅僅是建立mappedFile的過程,而在實際儲存訊息的時候,無論是使用堆外記憶體還是直接使用mappedByteBuffer,都需要額外的刷盤任務負責保證資料寫入磁碟。因此接下去看下刷盤的底層呼叫是什麼。
檢視MappedFile.flush方法:
public int flush(final int flushLeastPages) { ... if (writeBuffer != null || this.fileChannel.position() != 0) { //如果使用了堆外記憶體,則呼叫fileChannel的force方法 this.fileChannel.force(false); } else { //如果使用的是mappedByteBuffer,則呼叫相應的force方法 this.mappedByteBuffer.force(); } ... }
該方法比較簡單,根據是否啟用堆外記憶體,呼叫不同的force方法。
檢視FileChannelImpl.force方法:
public void force(boolean var1) throws IOException { ... do { //呼叫FileDispatcher的force方法 var2 = this.nd.force(this.fd, var1); } while(var2 == -3 && this.isOpen()); ... }
檢視FileDispatcherImpl.force方法,會發現其呼叫的force0的natvie方法,因此直接看jdk原始碼
JNIEXPORT jint JNICALL Java_sun_nio_ch_FileDispatcherImpl_force0(JNIEnv *env, jobject this, jobject fdo, jboolean md) { ... result = fsync(fd); ... }
因此fileChannel.force的底層就是呼叫了fsync方法
fsync系統方法:將核心記憶體中有修改的資料同步到相應檔案的磁碟空間
參考說明: https://man7.org/linux/man-pages/man2/fsync.2.html
檢視MappedByteBuffer的force方法,可以看到直接呼叫了force0的native方法:
JNIEXPORT void JNICALL Java_java_nio_MappedByteBuffer_force0(JNIEnv *env, jobject obj, jobject fdo, jlong address, jlong len) { int result = msync(a, (size_t)len, MS_SYNC); ... }
因此mappedByteBuffer.force的底層呼叫了msync方法
msync系統方法:將mmap對映的記憶體空間中的修改同步到檔案系統中
參考說明: https://man7.org/linux/man-pages/man2/msync.2.html
因此做一個總結,rocketmq對零拷貝的使用和優化分為5步:
1.呼叫系統mmap方法進行虛擬記憶體地址對映
2.用0來填充page cache,初始化檔案
3.呼叫系統mlock方法,防止對映的記憶體被放入swap空間
4.呼叫系統madvise方法,使得檔案會被系統預載入
5.根據是否啟用堆外記憶體,呼叫fsync或者msync刷盤
transferTo/From的零拷貝
在使用fileChannel時,如果不需要對資料作修改,僅僅是傳輸,那麼可以使用transferTo或者transferFrom進行2個channel間的傳遞,這種傳遞是完全處於核心態的,因此效能較好。
簡單的例子如下:
SocketChannel sc = SocketChannel.open(new InetSocketAddress("localhost", 8090)); FileChannel fc = new RandomAccessFile("filename", "r").getChannel(); fc.transferTo(0, 100, sc);
檢視FileChannelImpl.transferTo方法,最終會呼叫到transfer0方法,呼叫鏈如下:
transferTo->transferToDirectly->transferToDirectlyInternal->transferTo0
檢視jdk原始碼:/src/java.base/unix/native/libnio/ch/FileChannelImpl.c
... JNIEXPORT jlong JNICALL Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this, jobject srcFDO, jlong position, jlong count, jobject dstFDO) { #if defined(__linux__) off64_t offset = (off64_t)position; jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count); ... #elif defined (__solaris__) result = sendfilev64(dstFD, &sfv, 1, &numBytes); ... #elif defined(__APPLE__) result = sendfile(srcFD, dstFD, position, &numBytes, NULL, 0); ... #endif } ...
根據不同的系統呼叫sendfile方法。
sendfile系統方法:在核心態中進行兩個檔案描述符之間資料的闡述
參考說明: https://man7.org/linux/man-pages/man2/sendfile.2.html
splice的零拷貝
在查詢資料的過程中,瞭解到Linux 2.6.17支援了splice。該方法和sendFile類似,也是直接在核心中完成了資料的傳輸。區別在於sendfile將磁碟資料載入到核心快取後,需要一次CPU拷貝將資料拷貝到socket快取,而splice是更進一步,連這個CPU拷貝也不需要了,直接將兩個核心空間的buffer進行pipe。
好像java對此並沒有支援,所以就不深究了。
參考說明: https://man7.org/linux/man-pages/man2/splice.2.html
到此從rocketmq的mmap到其他零拷貝的底層呼叫分析就結束了,總結如下:
1.rocketmq底層採用了mmap的零拷貝技術提高讀寫效能。
2.使用了mlock和madvise進一步優化效能
3.根據是否使用堆外記憶體選擇呼叫fsync或者msync進行刷盤
4.sendfile實現了核心態的資料拷貝,java中有fileChannel.transferTo/From支援該操作
5.Linux2.6.17新支援了splice的零拷貝,可能比sendfile更優秀,但java中目前好像還未有支援。
- Spring框架系列(3) - 深入淺出Spring核心之控制反轉(IOC)
- 面試突擊59:一個表中可以有多個自增列嗎?
- 前端學習 linux —— 第一篇
- 選擇排序的簡單理解
- 使用nodejs的wxmnode模組,開發一個微信自動監控提醒功能,做個天氣預報。
- 程式分析與優化 - 7 靜態單賦值(SSA)
- 聊聊C#中的composite模式
- k8s client-go原始碼分析 informer原始碼分析(6)-Indexer原始碼分析
- 牛亞男:基於多Domain多工學習框架和Transformer,搭建快精排模型
- 表示式的動態解析和計算,Flee用起來真香
- 【Java面試】為什麼引入偏向鎖、輕量級鎖,介紹下升級流程
- 電腦可以模擬人腦嗎
- 軟體專案管理 7.4.5.進度計劃編排-敏捷計劃
- LVGL庫入門教程04-樣式
- 氣泡排序的簡單理解
- Python中的邏輯表示式
- vue大型電商專案尚品彙(後臺篇)day03
- 程式設計技巧│瀏覽器 Notification 桌面推送通知
- JVM 輸出 GC 日誌導致 JVM 卡住,我 TM 人傻了
- 5種在TypeScript中使用的型別保護