從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系統方法:為進程創建虛擬地址空間映射
參考說明://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進行記憶體的鎖定。
參考說明://man7.org/linux/man-pages/man2/mlock.2.html
madvise系統方法:該方法功能很多,主要是給系統內核提供記憶體處理建議,可以根據需要傳入參數。
在rocketmq中,傳入的參數是MADV_WILLNEE,該參數的意思是告訴系統內核,這塊記憶體一會兒就會用到,於是系統就會提前載入被映射的文件數據到記憶體中,這樣就不會在需要使用的時候才去讀取磁碟,影響性能。其他建議類型可以參考下面的鏈接。
參考說明://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系統方法:將內核記憶體中有修改的數據同步到相應文件的磁碟空間
參考說明://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映射的記憶體空間中的修改同步到文件系統中
參考說明://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系統方法:在內核態中進行兩個文件描述符之間數據的闡述
參考說明://man7.org/linux/man-pages/man2/sendfile.2.html
splice的零拷貝
在查詢資料的過程中,了解到Linux 2.6.17支援了splice。該方法和sendFile類似,也是直接在內核中完成了數據的傳輸。區別在於sendfile將磁碟數據載入到內核快取後,需要一次CPU拷貝將數據拷貝到socket快取,而splice是更進一步,連這個CPU拷貝也不需要了,直接將兩個內核空間的buffer進行pipe。
好像java對此並沒有支援,所以就不深究了。
參考說明://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中目前好像還未有支援。