從rocketmq入手,解析各種零拷貝的jvm層原理

tera發表於2022-05-14

在上一篇文章中,主要介紹了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中目前好像還未有支援。

相關文章