本文涉及到的核心原始碼版本為: 5.4 ,JVM 原始碼為:OpenJDK17,RocketMQ 原始碼版本為:5.1.1
在之前的文章《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同位元組序下的設計與實現》 中,筆者為大家詳細剖析了 JDK Buffer 的整個設計體系,從總體上來講,JDK NIO 為每一種 Java 基本型別定義了對應的 Buffer 類(boolean 型別除外)。
而 Buffer 本質上其實是 JDK 對 OS 中某一段記憶體在 Java 語言層面上的封裝,當然了,這裡的記憶體指的是虛擬記憶體,我們需要從之前文章中的核心空間視角切換到使用者空間上來,所以本文提到的記憶體如無特殊說明均是指虛擬記憶體。
JVM 在作業系統的視角來看其實就是一個普通的程序,而程序的虛擬記憶體空間我們透過前面 Linux 記憶體管理系列文章 的洗禮,可以說是非常熟悉了。核心會根據程序在執行期間所需資料的功能特性不同,而為每一類資料專門開闢出一段虛擬記憶體區域出來。比如:
-
用於存放程序程式二進位制檔案中的機器指令以及只讀常量的程式碼段
-
用於存放程式二進位制檔案中定義的全域性變數和靜態變數的資料段和 BSS 段。
-
用於在程式執行過程中動態申請記憶體的堆,這裡指的是 OS 堆。
-
用於存放動態連結庫以及記憶體對映區域的檔案對映與匿名對映區。
-
用於存放程序在函式呼叫過程中的區域性變數和函式引數的棧。
而 JDK Buffer 也會根據其背後所依賴的虛擬記憶體在程序虛擬記憶體空間中具體所屬的虛擬記憶體區域而演變出 HeapByteBuffer , MappedByteBuffer , DirectByteBuffer 。這三種不同型別 ByteBuffer 的本質區別就是其背後依賴的虛擬記憶體在 JVM 程序虛擬記憶體空間中的佈局位置不同。
如下圖所示,HeapByteBuffer 底層依賴的位元組陣列背後的記憶體位於 JVM 堆中:
public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer> {
// 所屬記憶體位於 JVM 堆中
final byte[] hb;
}
位於 JVM 堆之外的記憶體其實都可以歸屬到 DirectByteBuffer 的範疇中。比如,位於 OS 堆之內,JVM 堆之外的 MetaSpace,即時編譯(JIT) 之後的 codecache,JVM 執行緒棧,Native 執行緒棧,JNI 相關的記憶體,等等。
JVM 在 OS 堆中劃分出的 Direct Memory (上圖紅色部分)特指受到引數 -XX:MaxDirectMemorySize
限制的直接記憶體區域,比如透過 ByteBuffer#allocateDirect
申請到的 Direct Memory 容量就會受到該引數的限制。
public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer> {
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
}
而透過 Unsafe#allocateMemory
申請到的 Direct Memory 容量則不會受任何 JVM 引數的限制,只會受作業系統本身對程序所使用記憶體容量的限制。也就是說 Unsafe 類會脫離 JVM 直接向作業系統進行記憶體申請。
public final class Unsafe {
public long allocateMemory(long bytes) {
return theInternalUnsafe.allocateMemory(bytes);
}
}
MappedByteBuffer 背後所佔用的記憶體位於 JVM 程序虛擬記憶體空間中的檔案對映與匿名對映區中,系統呼叫 mmap 對映出來的記憶體就是在這個區域中劃分的。
mmap 有兩種對映方式,一種是匿名對映,常用於程序動態的向 OS 申請記憶體,比如,glibc 庫裡提供的用於動態申請記憶體的 malloc 函式,當申請的記憶體大於 128K 的時候,malloc 就會呼叫 mmap 採用匿名對映的方式來申請。
另一種就是檔案對映,用於將磁碟檔案中的某段區域與程序虛擬記憶體空間中檔案對映與匿名對映區裡的某段虛擬記憶體區域進行關聯對映。後續我們針對這段對映記憶體的讀寫就相當於是對磁碟檔案的讀寫了,整個讀寫過程沒有資料的複製,也沒有切態的發生(這裡特指在完成缺頁處理之後)。
JDK 僅僅只是對 mmap 檔案對映方式進行了封裝,所以 MappedByteBuffer 的本質其實是對檔案對映與匿名對映區中某一段虛擬對映區域在 JVM 層面上的描述。這段虛擬對映區的起始記憶體地址 addr 以及對映長度 length 被封裝在 MappedByteBuffer 中的 address , capacity 屬性中:
public abstract class Buffer {
// 虛擬對映區域的起始地址
long address;
// 對映長度
private int capacity;
}
好了,現在我們已經從總體上清楚了 JDK Buffer 體系在 JVM 程序虛擬記憶體空間中的佈局情況,下面我們正式開始本文的主題,筆者會從 OS 核心,JVM ,中介軟體應用,這三個視角帶大家深入拆解一下 MappedByteBuffer。
1. OS 核心視角下的 MappedByteBuffer
我們先從與 MappedByteBuffer 緊密相關的底層系統呼叫 mmap 開始切入 OS 核心的視角:
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
mmap 的主要任務就是在檔案對映與匿名對映區中為本次對映劃分出一段虛擬記憶體區域出來,然後 JVM 將這段劃分出來的虛擬記憶體區域在 Java 語言層面包裝成 MappedByteBuffer 供程式設計師來使用。
那麼核心該從檔案對映與匿名對映區的哪個位置開始,以及劃分多大的虛擬對映區呢 ?這就用到了 mmap 系統呼叫引數 addr 和 length。length 引數用於指定我們需要對映的虛擬記憶體區域大小。
如果我們指定了 addr,表示我們希望核心從這個地址開始劃分虛擬對映區,但是這個引數只是給核心的一個暗示,核心並非一定得從我們指定的 addr 處劃分虛擬記憶體區域。
核心在檔案對映與匿名對映區中劃分虛擬記憶體區域的時候會優先考慮我們指定的 addr,如果這個虛擬地址已經被使用或者是一個無效的地址,那麼核心則會自動選取一個合適的虛擬記憶體地址開始對映。
如果我們需要強制核心從 addr 指定的虛擬記憶體地址處開始對映的話,就需要在 flags 引數中指定 MAP_FIXED
標誌,這樣一來無論這段虛擬記憶體區域 [addr , addr + length] 是否已經存在對映關係,核心都會強行進行對映,如果這塊區域已經存在對映關係,那麼後續核心會把舊的對映關係覆蓋掉。
unsigned long
arch_get_unmapped_area(struct file *filp, unsigned long addr,
unsigned long len, unsigned long pgoff, unsigned long flags)
{
if (flags & MAP_FIXED)
return addr;
}
我們一般會將 addr 設定為 NULL,意思就是完全交由核心來幫我們決定虛擬對映區的起始地址。
透過 mmap 對映出來的這段虛擬記憶體區域相關訪問許可權由引數 prot 進行指定:
#define PROT_READ 0x1 /* page can be read */
#define PROT_WRITE 0x2 /* page can be written */
#define PROT_EXEC 0x4 /* page can be executed */
#define PROT_NONE 0x0 /* page can not be accessed */
PROT_READ 表示可讀許可權,PROT_WRITE 表示可寫許可權,PROT_EXEC 表示執行許可權。PROT_NONE 表示這段虛擬記憶體區域是不能被訪問的,既不可讀寫,也不可執行。
PROT_NONE 常用於中介軟體預先向作業系統一次性申請一批記憶體作為預留記憶體(reserve_memory),當使用者使用的時候,中介軟體再從這些預留記憶體中一點一點的分配。
比如,JVM 堆以及 MetaSpace 等 JVM 中的記憶體區域,JVM 在一開始的時候就會根據 -Xmx
,-XX:MaxMetaspaceSize
指定的大小預先向作業系統申請一批記憶體作為 reserve_memory。這部分 reserve_memory 的許可權就是 PROT_NONE
,是不可訪問的。用於首先確定 JVM 堆和 MetaSpace 這些記憶體區域的地址範圍(首先劃分勢力範圍)。
// 檔案:/hotspot/os/linux/os_linux.cpp
char* os::pd_reserve_memory(size_t bytes, bool exec) {
return anon_mmap(NULL, bytes);
}
static char* anon_mmap(char* requested_addr, size_t bytes) {
const int flags = MAP_PRIVATE | MAP_NORESERVE | MAP_ANONYMOUS;
char* addr = (char*)::mmap(requested_addr, bytes, PROT_NONE, flags, -1, 0);
return addr == MAP_FAILED ? NULL : addr;
}
這樣一來,後續我們根據一個虛擬記憶體地址就可以定位到該記憶體地址究竟是屬於 JVM 中哪一個記憶體區域,方便後續做近一步的處理。
當 JVM 真正需要記憶體的時候,就會從這部分 reserve_memory 中劃分出一部分(commit_memory)來使用 —— JVM 透過 mmap 重新對映 commit_memory 大小的虛擬記憶體出來。
JVM 在呼叫 mmap 重新對映的時候,flags 引數指定了 MAP_FIXED
標誌,強制核心從之前的 reserve_memory 中重新對映。引數 prot 重新指定了 PROT_READ | PROT_WRITE
許可權。
// 檔案:/hotspot/os/linux/os_linux.cpp
bool os::pd_commit_memory(char* addr, size_t size, bool exec) {
return os::Linux::commit_memory_impl(addr, size, exec) == 0;
}
int os::Linux::commit_memory_impl(char* addr, size_t size, bool exec) {
int prot = exec ? PROT_READ|PROT_WRITE|PROT_EXEC : PROT_READ|PROT_WRITE;
uintptr_t res = (uintptr_t) ::mmap(addr, size, prot,
MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0);
}
mmap 系統呼叫的對映方式由 flags 引數決定:
#define MAP_FIXED 0x10 /* Interpret addr exactly */
#define MAP_ANONYMOUS 0x20 /* don't use a file */
#define MAP_SHARED 0x01 /* Share changes */
#define MAP_PRIVATE 0x02 /* Changes are private */
MAP_ANONYMOUS 表示進行的是匿名對映,常用於向 OS 申請記憶體,比如上面的 JVM 原始碼,透過 mmap 系統呼叫申請記憶體的時候,flags 引數就指定了 MAP_ANONYMOUS 標誌。
MAP_SHARED 表示共享對映,透過 mmap 對映出的這片記憶體區域(MappedByteBuffer)在多程序之間是共享的,一個程序修改了共享對映的記憶體區域,其他程序是可以看到的,用於多程序之間的通訊。
MAP_PRIVATE 表示私有對映,透過 mmap 對映出的這片記憶體區域(MappedByteBuffer)是程序私有的,其他程序是看不到的。如果是私有檔案對映,那麼多程序針對同一對映檔案的修改將不會回寫到磁碟檔案上。
如果我們想要透過 mmap 將檔案對映到記憶體中,就需要指定引數 fd 以及 offset。fd 就是對映檔案在 JVM 程序中的 file descriptor ,offset 表示我們要從檔案中的哪個位置偏移處開始對映檔案內容。
由於 JDK 只對使用者開放了檔案對映的方式,所以本小節的 OS 視角我們也只是聚焦在檔案對映在核心的實現部分。
檔案對映有私有檔案對映和共享檔案對映之分,我們在使用 mmap 系統呼叫的時候,透過將引數 flags 設定為 MAP_PRIVATE,然後指定引數 fd 為對映檔案的 file descriptor 來實現對檔案的私有對映。透過將引數 flags 設定為 MAP_SHARED 來實現對檔案的共享對映。
無論是私有對映的方式還是共享對映的方式,核心在對檔案進行記憶體對映之前,都需要透過 get_unmapped_area
函式在 JVM 程序虛擬記憶體空間中的檔案對映與匿名對映區裡尋找一段還沒有被對映過的空閒虛擬記憶體區域。
在拿到這段空閒的虛擬記憶體區域之後,透過 mmap_region
函式將檔案對映到這塊虛擬記憶體區域中來。
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, vm_flags_t vm_flags,
unsigned long pgoff, unsigned long *populate,
struct list_head *uf)
{
// 首先在程序虛擬記憶體空間中的檔案對映與匿名對映區中尋找一段還沒有被對映過的空閒虛擬記憶體區域
addr = get_unmapped_area(file, addr, len, pgoff, flags);
// 將這段空閒虛擬記憶體區域與檔案進行對映
addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);
}
這段被核心拿來用作檔案對映的虛擬記憶體區域在 Java 層面的表現形式就是 JDK 中的 MappedByteBuffer,在 OS 核心中的表現形式是 vm_area_struct。
struct vm_area_struct {
// MappedByteBuffer 在核心中的起始記憶體地址
unsigned long vm_start; /* Our start address within vm_mm. */
// MappedByteBuffer 在核心中的結束記憶體地址
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/*
* Access permissions of this VMA
* MappedByteBuffer 相關的操作許可權(核心角度)
* 透過 mmap 引數 prot 傳遞
*/
pgprot_t vm_page_prot;
// 相關對映方式,透過 mmap 引數 flags 傳遞
unsigned long vm_flags;
// 對映檔案
struct file * vm_file; /* File we map to (can be NULL). */
// 需要對映的檔案內容在磁碟檔案中的偏移
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units */
/* Function pointers to deal with this struct. */
// 核心層面針對這片虛擬記憶體區域 MappedByteBuffer 的相關操作函式
const struct vm_operations_struct *vm_ops;
}
在 mmap_region 函式的開始,核心需要為這段虛擬記憶體區域分配 vma 結構,類比我們在 Java 語言層面建立一個 MappedByteBuffer 。隨後會並根據具體的檔案對映方式對 vma 結構相關的屬性進行初始化,最後將這個 vma 結構透過 vma_link
插入到程序的虛擬記憶體空間中。這樣一來,我們在 Java 應用層面就拿到了一個完整的 MappedByteBuffer。
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
// 從 slab 記憶體池中申請一個新的 vma 結構
vma = vm_area_alloc(mm);
// 根據我們要對映的虛擬記憶體區域屬性初始化 vma 結構中的相關屬性
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;
// 針對檔案對映的處理
if (file) {
// 將檔案與虛擬記憶體 MappedByteBuffer 對映起來
vma->vm_file = get_file(file);
// 這一步中將虛擬記憶體區域 vma 的操作函式 vm_ops 對映成檔案的操作函式(和具體檔案系統有關)
// ext4 檔案系統中的操作函式為 ext4_file_vm_ops
// 從這一刻開始,讀寫記憶體就和讀寫檔案是一樣的了
error = call_mmap(file, vma);
}
// 將 vma 結構插入到當前 JVM 程序的地址空間中
vma_link(mm, vma, prev, rb_link, rb_parent);
}
記憶體檔案對映最關鍵的部分是下面兩行核心程式碼:
vma->vm_file = get_file(file);
error = call_mmap(file, vma);
核心層面的 vm_area_struct( vma )對應於 Java 層面的 MappedByteBuffer,核心層面的 file 對應於 Java 層面的 FileChannel。
struct file 結構是核心用來描述被程序開啟的磁碟檔案的,它和程序是強相關的( fd 的作用域也是和程序相關的),即使多個程序開啟同一個檔案,那麼核心會為每一個程序建立一個 struct file 結構。struct file 中指向了一個非常重要的結構 —— struct inode。
struct file {
struct inode *f_inode;
}
每一個磁碟上的檔案在核心中都會有一個唯一的 struct inode 結構,inode 結構和程序是沒有關係的,一個檔案在核心中只對應一個 inode,inode 結構用於描述檔案的元資訊,比如,檔案的許可權,檔案中包含多少個磁碟塊,每個磁碟塊位於磁碟中的什麼位置等等。
// ext4 檔案系統中的 inode 結構
struct ext4_inode {
// 檔案許可權
__le16 i_mode; /* File mode */
// 檔案包含磁碟塊的個數
__le32 i_blocks_lo; /* Blocks count */
// 存放檔案包含的磁碟塊
__le32 i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
};
在檔案系統中,Linux 是按照磁碟塊為單位對磁碟中的資料進行管理的,磁碟塊的大小為 4K。找到了檔案中的磁碟塊,我們就可以定址到檔案在磁碟上的儲存內容了。
核心透過將 vma->vm_file
與對映檔案進行關聯之後,就可以透過 vm_file->f_inode
找到對映檔案的 struct inode 結構,近而找到到對映檔案在磁碟中的磁碟塊 i_block。這樣一來,虛擬記憶體就與底層檔案系統中的磁碟塊發生了關聯,這也是 mmap 記憶體檔案對映的本質所在。
當虛擬記憶體與對映檔案發生關聯之後,核心會透過 call_mmap 函式,將虛擬記憶體 vm_area_struct 的相關操作函式 vma->vm_ops
對映成檔案相關的操作函式(和底層檔案系統的實現相關)—— ext4_file_vm_ops
。這樣一來,程序後續對這段虛擬記憶體的讀寫就相當於是讀寫對映檔案了。
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
vma->vm_ops = &ext4_file_vm_ops;
}
到這裡,mmap 系統呼叫的整個對映過程就結束了,從上面的核心處理過程中我們可以看到,當我們呼叫 mmap 之後,OS 核心只是會為我們分配一段虛擬記憶體,然後將虛擬記憶體與磁碟檔案進行對映,整個過程都只是在和虛擬記憶體打交道,並未出現任何實體記憶體的身影。而這段虛擬記憶體在 Java 層面就是 MappedByteBuffer。
1.1 私有檔案對映下的 MappedByteBuffer
下圖展示的是當多個 JVM 程序透過 mmap 對同一個磁碟檔案上的同一段檔案區域進行記憶體對映之後,OS 核心中的記憶體檔案對映結構圖,我們先以私有檔案對映進行說明:
由於現在我們只是剛剛完成了檔案對映,僅僅只是在 JVM 層面得到了一個 MappedByteBuffer,這個 MappedByteBuffer 背後所依賴的虛擬記憶體就是我們透過 mmap 對映出來的。
此時我們還未對檔案進行讀寫操作,所以該對映檔案對應的 page cache 裡還是空,沒有任何檔案頁(用於儲存檔案資料的實體記憶體頁)。而虛擬記憶體(MappedByteBuffer)與實體記憶體之間的關聯是透過程序頁表來完成的,由於此時核心還未對 MappedByteBuffer 分配實體記憶體,所以 MappedByteBuffer 在 JVM 程序頁表中對應的頁表項 PTE 還是空的。
當我們開始訪問這段 MappedByteBuffer 的時候, CPU 會將 MappedByteBuffer 背後的虛擬記憶體地址送到 MMU 地址翻譯單元中進行地址翻譯查詢其背後的實體記憶體地址。
如果 MMU 發現 MappedByteBuffer 在 JVM 程序頁表中對應的頁表項 PTE 還是空的,這說明 MappedByteBuffer 是剛剛被 mmap 系統呼叫對映出來的,還沒有分配實體記憶體。
於是 MMU 就會產生缺頁中斷,隨後 JVM 程序切入到核心態,進行缺頁處理,為 MappedByteBuffer 分配實體記憶體。
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
// pte 是空的,表示 MappedByteBuffer 背後還從來沒有對映過實體記憶體,接下來就要處理實體記憶體的對映
if (!vmf->pte) {
// 判斷缺頁的虛擬記憶體地址 address 所在的虛擬記憶體區域 vma 是否是匿名對映區
if (vma_is_anonymous(vmf->vma))
// 處理匿名對映區發生的缺頁
return do_anonymous_page(vmf);
else
// 處理檔案對映區發生的缺頁,JDK 的 MappedByteBuffer 屬於檔案對映區
return do_fault(vmf);
}
}
核心在 do_fault 函式中處理 MappedByteBuffer 缺頁的時候,首先會呼叫 find_get_page 從對映檔案的 page cache 中嘗試獲取檔案頁,前面已經說了,當 MappedByteBuffer 剛剛被對映出來的時候,對映檔案的 page cache 還是空的,沒有快取任何檔案頁,需要對映到記憶體的檔案內容此時還靜靜地躺在磁碟上。
當檔案頁不在 page cache 中,核心則會呼叫 do_sync_mmap_readahead 來同步預讀,這裡首先會分配一個實體記憶體頁出來,然後將新分配的記憶體頁加入到 page cache 中,並增加頁引用計數。
如果檔案頁已經快取在 page cache 中了,則呼叫 do_async_mmap_readahead 啟動非同步預讀機制,將相鄰的若干檔案頁一起預讀進 page cache 中。
隨後會透過 address_space_operations (page cache 相關的操作函式集合)中定義的 readpage 啟用塊裝置驅動從磁碟中讀取對映的檔案內容並填充到 page cache 裡的檔案頁中。
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
// 獲取對映檔案
struct file *file = vmf->vma->vm_file;
// 獲取 page cache
struct address_space *mapping = file->f_mapping;
// 獲取對映檔案的 inode
struct inode *inode = mapping->host;
// 獲取對映檔案內容在檔案中的偏移
pgoff_t offset = vmf->pgoff;
// 從 page cache 讀取到的檔案頁,存放在 vmf->page 中返回
struct page *page;
// 根據檔案偏移 offset,到 page cache 中查詢對應的檔案頁
page = find_get_page(mapping, offset);
if (likely(page) && !(vmf->flags & FAULT_FLAG_TRIED)) {
// 如果檔案頁在 page cache 中,則啟動非同步預讀,預讀後面的若干檔案頁到 page cache 中
fpin = do_async_mmap_readahead(vmf, page);
} else if (!page) {
// 如果檔案頁不在 page cache,那麼就需要啟動 io 從檔案中讀取內容到 page cache
// 啟動同步預讀,將所需的檔案資料讀取進 page cache 中並同步預讀若干相鄰的檔案資料到 page cache
fpin = do_sync_mmap_readahead(vmf);
retry_find:
// 嘗試到 page cache 中重新讀取檔案頁,這一次就可以讀到了
page = pagecache_get_page(mapping, offset,
FGP_CREAT|FGP_FOR_MMAP,
vmf->gfp_mask);
}
}
..... 省略 ......
}
EXPORT_SYMBOL(filemap_fault);
經過 filemap_fault 函式的處理,此時 MappedByteBuffer 背後所對映的檔案內容已經載入到 page cache 中了。
雖然現在 MappedByteBuffer 背後所需要的檔案頁已經載入到記憶體中了,但是還沒有和 MappedByteBuffer 這段虛擬記憶體發生關聯,缺頁處理的最後一步就是透過 JVM 程序頁表將 MappedByteBuffer(虛擬記憶體)與剛剛載入進來的檔案頁(實體記憶體)關聯對映起來。
既然現在 MappedByteBuffer 在 JVM 程序頁表中對應的 pte 是空的,核心就透過 mk_pte 建立一個 pte 出來,並將剛載入進來的檔案頁的實體記憶體地址,以及 MappedByteBuffer 相關的操作許可權 vm_page_prot,設定到 pte 中。
隨後透過 set_pte_at 函式將新初始化的這個 pte 塞到 JVM 頁表中。但是這裡要注意的是,這裡的 MappedByteBuffer 是 mmap 私有對映出來的,所以這個 pte 是隻讀的。
vm_fault_t alloc_set_pte(struct vm_fault *vmf, struct mem_cgroup *memcg,
struct page *page)
{
// 根據之前分配出來的記憶體頁 pfn 以及相關頁屬性 vma->vm_page_prot 構造一個 pte 出來
// 對於私有檔案對映來說,這裡的 pte 是隻讀的
entry = mk_pte(page, vma->vm_page_prot);
// 將構造出來的 pte (entry)賦值給 MappedByteBuffer 在頁表中真正對應的 vmf->pte
// 現在程序頁表體系就全部被構建出來了,檔案頁缺頁處理到此結束
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
// 重新整理 mmu
update_mmu_cache(vma, vmf->address, vmf->pte);
return 0;
}
經過這一輪的處理,MappedByteBuffer 與檔案頁就發生了關聯,並且對映的檔案內容也已經載入到檔案頁中了。
後續 JVM 程序在訪問這段 MappedByteBuffer 的時候就相當於是直接訪問對映檔案的 page cache。整個過程是在使用者態進行,不需要切態。
假設現在系統中有兩個 JVM 程序同時透過 mmap 對同一個磁碟檔案上的同一段檔案區域進行私有記憶體對映,那麼這兩個 JVM 程序就會在各自的記憶體空間中獲取到一段屬於各自的 MappedByteBuffer(程序的虛擬記憶體空間是相互隔離的)。
現在第一個 JVM 程序已經訪問過它的 MappedByteBuffer 了,並且已經完成了缺頁處理,但是第二個 JVM 程序還沒有訪問過它的 MappedByteBuffer,所以 JVM 程序2 頁表中相應的 pte 還是空的,它訪問這段 MappedByteBuffer 的時候仍然會產生缺頁中斷。
但是 程序2 的缺頁處理就很簡單了,因為前面 程序1 已經透過缺頁中斷將對映的檔案內容載入到 page cache 中了,所以 程序2 進入到核心中一下就在 page cache 中找到它所需要的檔案頁了,與屬於它的 MappedByteBuffer 透過頁表關聯一下就可以了。同樣是因為採用私有檔案對映的原因,程序 2 的這個頁表項 pte 也是隻讀的。
現在 程序1 和 程序2 各自的 MappedByteBuffer 都已經透過各自的頁表直接對映到對映檔案的 page cache 中了,後續 程序1 和 程序2 對各自的 MappedByteBuffer 進行讀取的時候就相當於是直接讀取 page cache, 整個過程都發生在使用者態,不需要切態,更不需要複製。
由於私有檔案對映的特點,程序1 和 程序2 各自透過 MappedByteBuffer 對檔案的修改是不會回寫到磁碟上的,所以現在 程序1 和 程序2 各自頁表中對應的 pte 是隻讀的。
因為現在 MappedByteBuffer 背後直接對映的是 page cache,如果 pte 是可寫的話,程序此時對 MappedByteBuffer 的寫入操作就會直接反映到 page cache 上,而核心則會定期將 page cache 中的髒頁回寫到磁碟上,這樣一來就違背了私有檔案對映的特點了。
所以當這兩個 JVM 程序試圖對各自的 MappedByteBuffer 進行寫入操作時,MMU 會發現 MappedByteBuffer 在程序頁表中對應的 pte 是隻讀的,於是產生防寫型別的缺頁中斷。
當 JVM 程序進入核心開始缺頁處理的時候,核心會發現 MappedByteBuffer 在核心中的許可權 —— vma->vm_page_prot 是可寫的,但 pte 是隻讀的,於是開始進行寫時複製 —— Copy On Write ,COW 的過程會在 do_wp_page 函式中進行。
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
// 判斷本次缺頁是否為寫時複製引起的
if (vmf->flags & FAULT_FLAG_WRITE) {
// 這裡說明 vma 是可寫的,但是 pte 被標記為不可寫,說明是防寫型別的中斷
if (!pte_write(entry))
// 進行寫時複製處理,cow 就發生在這裡
return do_wp_page(vmf);
}
}
核心在寫時複製的時候首先為缺頁程序分配一個新的實體記憶體頁 new_page,然後呼叫 cow_user_page 將 MappedByteBuffer 背後對映的檔案頁中的內容全部複製到新記憶體頁中。
隨後透過 mk_pte 建立一個新的臨時頁表項 entry,利用新的記憶體頁以及之前對映的 MappedByteBuffer 操作許可權 —— vma->vm_page_prot 初始化這個臨時頁表項 entry,讓 entry 指向新的記憶體頁,並將 entry 標記為可寫。
最後透過 set_pte_at_notify 將 entry 值設定到 MappedByteBuffer 在頁表中對應的 pte 中。這樣一來,原來的 pte 就由只讀變成可寫了,而且重新對映到了新分配的記憶體頁上。
static vm_fault_t wp_page_copy(struct vm_fault *vmf)
{
// MappedByteBuffer 在核心中的表現形式
struct vm_area_struct *vma = vmf->vma;
// 當前程序地址空間
struct mm_struct *mm = vma->vm_mm;
// MappedByteBuffer 當前對映在 page cache 中的檔案頁
struct page *old_page = vmf->page;
// 用於寫時複製的新記憶體頁
struct page *new_page = NULL;
// 新申請一個實體記憶體頁,用於寫時複製
new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma,
vmf->address);
if (!new_page)
goto oom;
// 將原來記憶體頁 old page 中的內容複製到新記憶體頁 new page 中
cow_user_page(new_page, old_page, vmf->address, vma);
// 建立一個臨時的 pte 對映到新記憶體頁 new page 上
entry = mk_pte(new_page, vma->vm_page_prot);
// 設定 entry 為可寫的,正是這裡, pte 的許可權由只讀變為了可寫
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
// 將 entry 值重新設定到子程序頁表 pte 中
set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
// 更新 mmu
update_mmu_cache(vma, vmf->address, vmf->pte);
}
從此程序 1 和程序 2 各自的 MappedByteBuffer 就脫離了 page cache,重新對映到了各自專屬的實體記憶體頁上,這個新記憶體頁中的內容和 page cache 中快取的內容一模一樣。
後續這兩個 JVM 程序針對 MappedByteBuffer 的任何修改均只能發生在各自的專屬實體記憶體頁上,不會體現在 page cache 中,自然這些修改也不會同步到磁碟檔案中了,而且各自的修改在程序之間是互不可見的。
1.2 共享檔案對映下的 MappedByteBuffer
共享檔案對映與私有檔案對映的整個 mmap 對映過程其實是一樣的,甚至在缺頁處理的大致流程上也是一樣的,都是首先要到 page cache 中查詢是否有快取相應的檔案頁(對映的磁碟塊對應的檔案頁)。
如果檔案頁不在 page cache 中,核心則會在實體記憶體中分配一個記憶體頁,然後將新分配的記憶體頁加入到 page cache 中,隨後啟動磁碟 IO 將共享對映的檔案內容 DMA 到新分配的這個記憶體頁裡
最後在缺頁程序的頁表中建立共享對映出來的 MappedByteBuffer 與 page cache 快取的檔案頁之間的關聯。
這裡和私有檔案對映不同的地方是,私有檔案對映由於是私有的,所以在核心建立 PTE 的時候會將 PTE 設定為只讀,目的是當程序寫入的時候觸發防寫型別的缺頁中斷進行寫時複製 (copy on write)。
共享檔案對映由於是共享的,PTE 被建立出來的時候就是可寫的,後續程序在對 MappedByteBuffer 寫入的時候不會觸發缺頁中斷進行寫時複製,而是直接寫入 page cache 中,整個過程沒有切態,沒有資料複製。
所以對於共享檔案對映來說,多程序讀寫都是共享的,由於多程序直接讀寫的是 page cache ,所以多程序對各自 MappedByteBuffer 的任何修改,最終都會透過核心回寫執行緒 pdflush 重新整理到磁碟檔案中。
static vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
vm_fault_t ret, tmp;
// 從 page cache 中讀取檔案頁
ret = __do_fault(vmf);
if (vma->vm_ops->page_mkwrite) {
unlock_page(vmf->page);
// 將檔案頁變為可寫狀態,併為後續記錄檔案日誌做一些準備工作
tmp = do_page_mkwrite(vmf);
}
// 將檔案頁對映到 MappedByteBuffer 在頁表中對應的 pte 上
ret |= finish_fault(vmf);
// 將 page 標記為髒頁,記錄相關檔案系統的日誌,防止資料丟失
// 判斷是否將髒頁回寫
fault_dirty_shared_page(vma, vmf->page);
return ret;
}
2. JVM 視角下的 MappedByteBuffer
現在筆者已經從 OS 核心的視角將 MappedByteBuffer 最本質的內容給大家剖析完了,基於這個最底層的技術基座,我們把視角在往上移一移,看看 JVM 內部是如何把玩 MappedByteBuffer 的,無非就是對底層系統呼叫 mmap 的一層封裝罷了。
OS 提供的 mmap 系統呼叫被 JVM 封裝在 FileChannelImpl 實現類中的 native 方法 map0 中,在 map0 的底層 native 實現中會直接對 mmap 發起呼叫。
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
public class FileChannelImpl extends FileChannel
{
// Creates a new mapping
private native long map0(int prot, long position, long length, boolean isSync)
throws IOException;
}
// FileChannelImpl.c 中對 map0 的 native 實現
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len, jboolean map_sync)
{
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);
}
JDK 對使用者提供的 mmap 介面封裝在下面的 FileChannel#map
方法中,我們可以看到在呼叫引數的設定上與系統呼叫 mmap 是非常相似的,畢竟提供底層基座能力的是 mmap,JDK 的 FileChannel#map
只是提供了一層封裝而已。
public abstract class FileChannel {
public abstract MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException;
}
2.1 關於 JDK 記憶體對映引數的解析
FileChannel 中的引數 position 對應於 mmap 系統呼叫的引數 offset,表示我們要從檔案中的哪個位置偏移處開始對映檔案內容。
引數 size 對應於 mmap 中的 length ,用於指定我們需要對映的檔案區域大小,也就是 MappedByteBuffer 的大小。
引數 MapMode 實際上是對 mmap 系統呼叫引數 prot 和 flags 的一層封裝。
//A file-mapping mode.
public static class MapMode {
/**
* Mode for a read-only mapping.
*/
public static final MapMode READ_ONLY
= new MapMode("READ_ONLY");
/**
* Mode for a read/write mapping.
*/
public static final MapMode READ_WRITE
= new MapMode("READ_WRITE");
/**
* Mode for a private (copy-on-write) mapping.
*/
public static final MapMode PRIVATE
= new MapMode("PRIVATE");
}
READ_ONLY
表示我們進行的是共享檔案對映,不過對映出來的 MappedByteBuffer 是隻讀許可權,JVM 在 native 實現中呼叫 mmap 的時候會將 prot 設定為 PROT_READ,將 flag 設定為 MAP_SHARED。
READ_WRITE
也是進行共享檔案對映,對映出來的 MappedByteBuffer 有讀寫許可權,native 實現中會將 prot 設定為 PROT_WRITE | PROT_READ
,flag 仍然為 MAP_SHARED。
PRIVATE
則表示進行的是私有檔案對映,對映出來的 MappedByteBuffer 有讀寫許可權,native 實現中將 flags 設定為 MAP_PRIVATE, prot 設定為 PROT_WRITE | PROT_READ
。
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len, jboolean map_sync)
{
if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) { // READ_ONLY
protections = PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) { // READ_WRITE
protections = PROT_WRITE | PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) { // PRIVATE
protections = PROT_WRITE | PROT_READ;
flags = MAP_PRIVATE;
}
....... 省略 ........
}
除了以上幾種常見的對映方式之外,在 JDK14 中又額外擴充套件了兩種新的對映方式,分別為:READ_ONLY_SYNC 和 READ_WRITE_SYNC。
public class ExtendedMapMode {
public static final MapMode READ_ONLY_SYNC = newMapMode("READ_ONLY_SYNC");
public static final MapMode READ_WRITE_SYNC = newMapMode("READ_WRITE_SYNC");
}
我們注意到這兩種新的對映方式在命名上只是比之前的對映方式多了一個 _SYNC
字尾。當 MapMode 設定了 READ_ONLY_SYNC 或者 READ_WRITE_SYNC 之後,底層的 native 實現中,會在 mmap 系統呼叫的 flags 引數中設定兩個新的標誌 MAP_SYNC | MAP_SHARED_VALIDATE
。
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len, jboolean map_sync)
{
....... 省略 ........
// should never be called with map_sync and prot == PRIVATE
// 當 MapMode 被設定成了 READ_ONLY_SYNC 或者 READ_WRITE_SYNC 的時候,map_sync 為 true
// map_sync 只能用於共享對映,不能用於私有對映。
assert((prot != sun_nio_ch_FileChannelImpl_MAP_PV) || !map_sync);
if (map_sync) {
flags |= MAP_SYNC | MAP_SHARED_VALIDATE;
}
....... 省略 ........
}
// 核心中擴充套件的相關 flag 標誌
#define MAP_SHARED_VALIDATE 0x03 /* share + validate extension flags */
#define MAP_SYNC 0x080000 /* perform synchronous page faults for the mapping */
這兩個新的 flags 標誌是 Linux 核心在 4.15 版本之後新加入的兩個擴充套件,主要用於對 non-volatile memory (persistent memory) 進行對映,mmap 的對映範圍很廣,不僅僅能夠對檔案進行對映,還能夠對匿名的記憶體頁進行對映(正如前面提到的匿名對映),除此之外,mmap 還可以直接對 IO 裝置進行對映,比如這裡透過 mmap 直接對 persistent memory 進行對映。
2.2 針對 persistent memory 的對映
那麼什麼是 persistent memory 呢 ? 我們得先從計算機系統中的儲存層次結構開始聊起~~~
以下相關圖片以及資料來源於:https://docs.pmem.io/persistent-memory/getting-started-guide/introduction
由於摩爾定律的影響,CPU 中的核數越來越多,其處理速度也越來越快,而造價卻越來越低,這就造成了提升 CPU 的執行速度比提升記憶體的執行速度要容易和便宜的多,所以就導致了 CPU 與記憶體之間的速度差距越來越大。
為了填補 CPU 與儲存裝置之間處理速度的巨大差異,提高 CPU 的處理效率和吞吐,計算機系統又根據區域性性原理引入了上圖所示的多級儲存層次結構。這個儲存層次金字塔結構有一個顯著的特點就是,從金字塔的底部到頂部的方向來看的話,CPU 訪問這些儲存裝置的速度會越來越快,但這些儲存裝置的造價也會越來越高,容量越來越小。
比如 CPU 訪問速度最快的 Register 暫存器,訪問延時為 0.1ns ,那些被 CPU 頻繁訪問到的資料最應該放到暫存器中,但是暫存器雖然訪問速度快,但其造價昂貴,容量很小,所以又引入了 CPU Cache,它的訪問延時為 1-10ns 作為暫存器的降級選擇,同時也可以彌補一下 CPU 與 DRAM (記憶體) 速度上的差異。
DRAM 的訪問速度是在 80 - 100 ns 這個量級,下面的 SSD 訪問延時的量級跨越的就有點大了,直接從 ns 這個量級一下跨越到了 us,訪問速度為 10 - 100 us,CPU 訪問 SSD 的延時大概是訪問 DRAM 延時的 1000 倍。
SSD 下面的 Hard Disk 訪問延時量級跨度就更大了,來到了 ms 級,訪問速度是 10 ms。而上圖所展示的計算機系統儲存體系又會根據儲存資料的易失性(Volatile)分為兩大類:
-
第一類是 Volatile Memory,它包括暫存器,CPU Cache,DRAM,它們的特點是容量有限,CPU 訪問的速度快,但是一旦遭遇到斷電或者系統崩潰,這些儲存裝置裡的內容就會丟失。
-
第二類是 Non-Volatile Storage,它包括 SSD,Hard Disk。它們的特點是容量大,CPU 訪問它們的速度相比於訪問 Volatile Memory 會慢上幾個數量級,但是遇到斷電或者系統崩潰的時候,它們儲存的資料不會丟失。
貪婪的成年世界往往喜歡選擇既要又要,那麼有沒有一種儲存裝置既可以繼承 Volatile Memory 訪問速度快的特點又可以繼承 Non-Volatile Storage 的大容量,且資料不會丟失的特點呢 ? 答案就是 persistent memory (Non-Volatile Memory)。
persistent memory 提供了比 SSD , Hard Disk 更快的訪問速度(1us),比 DRAM 更大的儲存容量(TB 級),更關鍵的是 persistent memory 具有和 Hard Disk 一樣的非易失特性(Non-Volatile),在斷電或者系統崩潰之後,儲存在 persistent memory 中的資料不會丟失。
從 IO 效能這個角度來對比的話,我們針對傳統的磁碟 IO 操作都需要經歷核心漫長的 IO 棧,資料首先要經過檔案的 page cache,然後透過核心的回寫策略或者透過手動呼叫 msync or fsync 等系統呼叫,啟動磁碟塊裝置驅動將資料寫入到磁碟裝置(Non-Volatile Storage)中。整個鏈路經過了核心的虛擬檔案系統,page cache,檔案系統,塊裝置驅動,Non-Volatile Storage。
而且我們對 Non-Volatile Storage 相關的讀寫,在核心的處理上是按照磁碟塊為單位進行的,即使我們只讀取幾個位元組,核心也會將整個磁碟塊大小(4K)的資料讀取進來,即使我們只寫入了幾個位元組,核心在回寫資料的時候也是將整個磁碟塊大小的資料回寫到磁碟中。
而針對 persistent memory 相關的 IO 操作就大不相同了,我們可以直接透過 CPU 的 load / store 指令來對 persistent memory 中儲存的內容進行讀寫,直接繞過了 page cache, 塊裝置層等傳統的 IO 路徑。
一個是直接透過 CPU 指令來讀寫(persistent memory),一個是透過塊裝置驅動進行讀寫(Non-Volatile Storage),效能上的差異顯而易見了。
由於我們是透過 CPU 指令來訪問 persistent memory,這就使得我們可以按照位元組為粒度( byte level access)對 persistent memory 中儲存的內容進行定址,當我們讀寫 persistent memory 時,不再需要像傳統的 Non-Volatile Storage 那樣還需要對齊磁碟 block 的大小(4K)。
明明只是讀寫幾個位元組,卻需要先從磁碟中讀取整個 block 的資料,修改幾個位元組之後,又得把整個 block 回寫到磁碟中,而對於具有 byte level access 特性的 persistent memory 來說,我們卻可以自由的進行讀寫,極大的提升了 IO 效能以及減少了不必要的記憶體佔用開銷。
無論是 persistent memory 還是傳統的 Non-Volatile Storage,當我們對其寫完資料之後,也都是需要回寫重新整理的,否則都有可能面臨資料丟失的風險。
比如,我們透過 mmap 系統呼叫對磁碟上的一個檔案進行共享對映之後,針對對映出來的 MappedByteBuffer 進行寫入的時候是直接寫入到磁碟檔案的 page cache 中,並沒有寫入到磁碟中,此時如果發生斷電或者系統崩潰,資料是會丟失的。如果我們需要手動觸發資料回寫,就需要透過 msync 系統呼叫將檔案中的後設資料以及髒頁資料透過磁碟塊裝置回寫到磁碟中。
對於 persistent memory 來說也是一樣,由於 CPU Cache 的存在,當我們透過 store 指令來向 persistent memory 寫入資料的時候,資料會先快取在 CPU Cache 中,此時的寫入資料並沒有持久化在 persistent memory 中,如果不巧發生斷電或者系統崩潰,資料一樣會丟失。
所以對於 persistent memory 來說在寫入之後也是需要重新整理的,不過這個重新整理操作是透過 CLWK 指令(cache line writeback)將 cache line 中的資料 flush 到 persistent memory 中。而不需要像傳統 Non-Volatile Storage 透過塊裝置來回寫磁碟。
這也是 Linux 核心在 4.15 版本之後加入 MAP_SYNC
標誌的原因,當我們使用 MAP_SYNC 標誌透過 mmap 對 persistent memory 進行對映之後,對映出來的這段記憶體區域 —— MappedByteBuffer ,如果需要進行 force 重新整理操作的時候,底層就是透過 CLWK 指令來重新整理的,而不是傳統的 msync 系統呼叫。
#define MAP_SYNC 0x080000 /* perform synchronous page faults for the mapping
被 MAP_SYNC 修飾的記憶體檔案對映區會提供一個保證,就是當我們對這段對映出來的 MappedByteBuffer 進行寫入操作之前,核心會保證對映檔案的相關後設資料 metadata 已經被持久化的到 persistent memory 中了。
這也就使得位於 persistent memory 中的檔案 metadata 始終處於一致性的狀態,在系統崩潰重啟的前後,我們看到的檔案 metadata 都是一樣的。
被 MAP_SYNC 修飾的 MappedByteBuffer 當發生由寫入操作引起的缺頁中斷時會產生一個 synchronous page faults,這也是字尾 _SYNC
要表達的語義,而 synchronous 的資料就是對映檔案的 metadata。
如果我們使用 MAP_SYNC 透過 mmap 對 persistent memory 中的檔案進行對映的時候,當檔案的 metadata 產生髒資料的時候,核心會將這段對映的 persistent memory 在程序頁表中對應的頁表項 PTE 改為只讀的。
隨後程序嘗試對這段對映區域進行寫入的時候,核心中就會產生一個 synchronous page faults,在這個 write page fault 的處理中,核心首先會同步地將檔案的 dirty metadata 重新整理,然後將 PTE 改為可寫。這樣就可以保證程序在寫入被 MAP_SYNC 修飾的 MappedByteBuffer 之前,對映檔案的相關 metadata 已經被重新整理了,使得檔案始終處於一致性的狀態,隨後程序就可以放心的寫入資料了。
MAP_SYNC 必須和 MAP_SHARED_VALIDATE 一起配合使用:
#define MAP_SHARED_VALIDATE 0x03 /* share + validate extension flags */
MAP_SHARED_VALIDATE 提供的語義和 MAP_SHARED 是一樣的,唯一不同的是 MAP_SHARED 會忽略掉所有後面擴充套件的 flags 標誌,比如這裡的 MAP_SYNC,而 MAP_SHARED_VALIDATE 會校驗所有由 mmap 傳入的 flags 標誌,對於那些不被核心支援的 flags 標誌會丟擲 EOPNOTSUPP 異常,而 MAP_SHARED 則會直接選擇忽略,不會有任何異常。
在實際使用的過程中,我們為了相容之前老版本的核心,通常會將 MAP_SHARED | MAP_SHARED_VALIDATE | MAP_SYNC
一起設定到 mmap 的 flags 引數中。對於 4.15 之前的核心版本來說,這樣設定的語義就相當於 MAP_SHARED
, 對於 4.15 之後的核心版本來說,這樣設定的語義就相當於是 MAP_SYNC
。
當我們呼叫 JDK 中的 FileChannel#map
方法來對 persistent memory 進行對映的時候,如果我們對 MapMode 設定了 READ_ONLY_SYNC 或者 READ_WRITE_SYNC ,那麼在其 native 實現中呼叫 mmap 的時候,JVM 就會將 flags 引數設定為 MAP_SHARED | MAP_SHARED_VALIDATE | MAP_SYNC
。
在 JDK 中的體現是 MappedByteBuffer 的 isSync 屬性會被設定為 true :
public abstract class MappedByteBuffer extends ByteBuffer
{
// 當 MapMode 設定了 READ_WRITE_SYNC 或者 READ_ONLY_SYNC(這兩個標誌只適用於共享對映,不能用於私有對映),isSync 會為 true
// isSync = true 表示 MappedByteBuffer 背後直接對映的是 non-volatile memory 而不是普通磁碟上的檔案
// isSync = true 提供的語義當 MappedByteBuffer 在 force 回寫資料的時候是透過 CPU 指令完成的而不是 msync 系統呼叫
// 並且可以保證在檔案對映區 MappedByteBuffer 進行寫入之前,檔案的 metadata 已經被重新整理,檔案始終處於一致性的狀態
// isSync 的開啟需要依賴底層 CPU 硬體體系架構的支援
private final boolean isSync;
}
public class FileChannelImpl extends FileChannel
{
private boolean isSync(MapMode mode) {
// Do not want to initialize ExtendedMapMode until
// after the module system has been initialized
return !VM.isModuleSystemInited() ? false :
(mode == ExtendedMapMode.READ_ONLY_SYNC ||
mode == ExtendedMapMode.READ_WRITE_SYNC);
}
}
persistent memory 之上也是需要構建檔案系統來進行管理的, 支援 persistent memory 的檔案系統有 ext2 ,ext4, xfs, btrfs 等,我們可以透過 mkfs
命令在 persistent memory 裝置檔案 —— /dev/pmem0
之上構建相應的 persistent memory filesystem 。
mkfs -t xfs /dev/pmem0
然後透過 mount
命令將 persistent memory filesystem 掛載到指定的目錄 /mnt/pmem/
中,這樣一來,我們就可以在應用程式中透過 mmap 系統呼叫對映 /mnt/pmem/
上的檔案,對映出來的 MappedByteBuffer 背後就是 persistent memory 了,後續對 MappedByteBuffer 的讀寫就相當於是直接對 persistent memory 進行讀寫了,而且是 byte level access 。
mount -o dax /dev/pmem0 /mnt/pmem/
但這裡需要注意一點的是,在我們掛載 persistent memory filesystem 時需要特別指定 -o dax
,這裡的 dax 表示的是 direct access mode,dax 可以使應用程式繞過 page cache 直接去訪問對映的 persistent memory。
MAP_SYNC 只支援對映 dax 模式下掛載的 filesystem 上的檔案
當我們透過 mmap 系統呼叫對映普通磁碟(Non-Volatile Storage)上的檔案到程序空間中的 MappedByteBuffer 的時候,MappedByteBuffer 背後其實對映的是磁碟檔案的 page cache 。
當我們透過 mmap 系統呼叫對映 persistent memory filesystem 上的檔案到 MappedByteBuffer 的時候,MappedByteBuffer 背後直接對映的就是 persistent memory。
由於我們對映的是 persistent memory ,所以也就不再需要 page cache 來對對映內容重複再做一層複製了,我們直接訪問 persistent memory 就可以。
2.3 JDK 記憶體對映的整體框架
到這裡,關於 FileChannelImpl#map
方法中相關呼叫引數的資訊筆者就為大家交代完了,透過以上內容的介紹,我們最起碼對 JDK 如何封裝 mmap 系統呼叫有了一個總體框架層面上的認識,下面筆者繼續為大家補充一下封裝的細節。
public class FileChannelImpl extends FileChannel
{
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
// 對映長度不能超過 Integer.MAX_VALUE,最大可以對映 2G 大小的記憶體
if (size > Integer.MAX_VALUE)
throw new IllegalArgumentException("Size exceeds Integer.MAX_VALUE");
// 當 MapMode 設定了 READ_WRITE_SYNC 或者 READ_ONLY_SYNC(這兩個標誌只適用於共享對映,不能用於私有對映),isSync 會為 true
// isSync = true 表示 MappedByteBuffer 背後直接對映的是 non-volatile memory 而不是普通磁碟上的檔案
// isSync = true 提供的語義是當 MappedByteBuffer 在 force 回寫資料的時候是透過 CPU 指令完成的而不是 msync 系統呼叫
// 並且可以保證在對檔案對映區 MappedByteBuffer 進行寫入之前,檔案的 metadata 已經被重新整理,檔案始終處於一致性的狀態
// isSync 的開啟需要依賴底層 CPU 硬體體系架構的支援
boolean isSync = isSync(Objects.requireNonNull(mode, "Mode is null"));
// MapMode 轉換成相關 prot 常量
int prot = toProt(mode);
// 進行記憶體對映,對映成功之後,相關對映區的資訊,比如對映起始地址,對映長度,對映檔案等等會封裝在 Unmapper 裡返回
// MappedByteBuffer 的釋放也封裝在 Unmapper中
Unmapper unmapper = mapInternal(mode, position, size, prot, isSync);
// 根據 Unmapper 中的資訊建立 MappedByteBuffer
// 當對映 size 指定為 0 時,unmapper = null,隨後會返回一個空的 MappedByteBuffer
if (unmapper == null) {
// a valid file descriptor is not required
FileDescriptor dummy = new FileDescriptor();
if ((!writable) || (prot == MAP_RO))
return Util.newMappedByteBufferR(0, 0, dummy, null, isSync);
else
return Util.newMappedByteBuffer(0, 0, dummy, null, isSync);
} else if ((!writable) || (prot == MAP_RO)) {
// 如果我們指定的是 read-only 的對映方式,這裡就會建立一個只讀的 MappedByteBufferR 出來
return Util.newMappedByteBufferR((int)unmapper.cap,
unmapper.address + unmapper.pagePosition,
unmapper.fd,
unmapper, isSync);
} else {
return Util.newMappedByteBuffer((int)unmapper.cap,
unmapper.address + unmapper.pagePosition,
unmapper.fd,
unmapper, isSync);
}
}
}
在開始對映之前,JDK 首先會透過 toProt 方法將引數 MapMode 指定的相關列舉值轉換成 MAP_
字首的常量值,後續進入 native 實現的時候,JVM 會根據這個常量值來設定 mmap 系統呼叫引數 prot 以及 flags。
private static final int MAP_INVALID = -1;
private static final int MAP_RO = 0;
private static final int MAP_RW = 1;
private static final int MAP_PV = 2;
private int toProt(MapMode mode) {
int prot;
if (mode == MapMode.READ_ONLY) {
// 共享只讀
prot = MAP_RO;
} else if (mode == MapMode.READ_WRITE) {
// 共享讀寫
prot = MAP_RW;
} else if (mode == MapMode.PRIVATE) {
// 私有讀寫
prot = MAP_PV;
} else if (mode == ExtendedMapMode.READ_ONLY_SYNC) {
// 共享 non-volatile memory 只讀
prot = MAP_RO;
} else if (mode == ExtendedMapMode.READ_WRITE_SYNC) {
// 共享 non-volatile memory 讀寫
prot = MAP_RW;
} else {
prot = MAP_INVALID;
}
return prot;
}
隨後 JDK 呼叫 mapInternal 方法對檔案進行記憶體對映,關於記憶體對映的細節全部都封裝在這個方法中,之前介紹的 native 方法 map0 就是在這裡被 JDK 呼叫的。
public class FileChannelImpl extends FileChannel
{
// Creates a new mapping
private native long map0(int prot, long position, long length, boolean isSync)
throws IOException;
}
map0 會將 mmap 在程序地址空間中對映出來的虛擬記憶體區域的起始地址 addr 返回給 JDK 。
private Unmapper mapInternal(MapMode mode, long position, long size, int prot, boolean isSync) throws IOException
{
addr = map0(prot, mapPosition, mapSize, isSync);
Unmapper um = (isSync
? new SyncUnmapper(addr, mapSize, size, mfd, pagePosition)
: new DefaultUnmapper(addr, mapSize, size, mfd, pagePosition));
}
最後 JDK 會將這塊虛擬記憶體區域的相關資訊,比如起始對映地址,對映長度等資訊全部封裝在 Unmapper 類中,隨後根據這些封裝在 Unmapper 類中的資訊建立初始化 MappedByteBuffer 並返回給上層應用程式。
Util.newMappedByteBuffer((int)unmapper.cap,
unmapper.address + unmapper.pagePosition,
unmapper.fd,
unmapper, isSync);
具體這個 Unmapper 類是幹什麼的,裡面封裝的這些屬性具體的含義我們先不用管,後面筆者在介紹到具體對映細節的時候會詳細介紹。這裡我們只需要知道 unmapper.fd 封裝的是對映檔案的檔案描述符,unmapper.address + unmapper.pagePosition 表示的是 MappedByteBuffer 的起始對映地址,unmapper.cap 表示的是 MappedByteBuffer 的總體容量 capacity。先記住這個結構,後面我們在討論為什麼。
public abstract class MappedByteBuffer extends ByteBuffer
{
// unmapper.fd
private final FileDescriptor fd;
private final boolean isSync;
// unmapper.address + unmapper.pagePosition
long address;
// unmapper.cap
private int limit;
// unmapper.cap
private int capacity;
private int mark = -1;
private int position = 0;
}
上面出現的這些 MappedByteBuffer 相關屬性的具體含義以及作用,筆者已經在《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同位元組序下的設計與實現》 一文中講述 ByteBuffer 總體設計與實現的時候詳細介紹過了,忘記的同學可以在回看下。
2.4 一些對映細節
下面的內容我們主要來聚焦一些對映的細節,順便給大家解答一下 Unmapper 類中究竟封裝了哪些資訊。
2.4.1 Unmapper 到底包裝了哪些對映資訊
我們都知道,FileChannel#map
函式中的 position 引數指定的是我們期望從磁碟檔案中的哪個位置偏移處開始對映,引數 size 用於指定我們期望的對映長度。
public abstract class FileChannel {
public abstract MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException;
}
我們使用 FileChannel#map
函式得到的這個 MappedByteBuffer 背後其實是對 [position, position+size]
這段檔案區域的對映。
不過這只是我們站在 JVM 視角中觀察到的現象,但站在 OS 核心的視角中卻不一定是這樣對映的,JDK 使用了一個障眼法將本質給隱藏了。
磁碟檔案在檔案系統中是按照磁碟塊為單位組織管理的,當磁碟塊載入到記憶體中就變成了檔案頁,它們的大小都是 4K,核心對於記憶體的管理也是按照記憶體頁 page 為單位進行了,包括本文中介紹的記憶體對映,也是按照 page 為粒度進行對映的。
所以我們在應用程式中指定的相關對映引數,比如這裡的 position 以及 size 都應該是按照記憶體頁 page 尺寸對齊的,如果沒有對齊,JDK 和核心都會默默的幫助我們進行對齊。
如上圖所示,假設我們指定的 position 沒有與檔案頁的尺寸進行對齊,那麼核心則不會從一個沒有對齊的位置處開始對映,而是會選擇 position 所在檔案頁的起始位置處( mapPosition)開始對映。
// position 距離其所在檔案頁起始位置的距離
// allocationGranularity 表示記憶體對映的單位粒度,這裡是 4K (記憶體頁尺寸)
pagePosition = (int)(position % allocationGranularity);
// mapPosition 核心真正開始的對映位置,同 mmap 系統呼叫中的 offset 引數
// 這裡的 mapPosition 為 position 所屬檔案頁的起始位置
long mapPosition = position - pagePosition;
我們原本期望的是從檔案的 position 處開始對映,並對映長度為 size 大小的檔案區域,由於我們指定的 position 沒有與檔案頁尺寸對齊,所以核心選擇從檔案的 mapPosition 位置處開始對映。
這樣一來,如果我們繼續按照原本的 size 大小進行對映的話,那麼對映出來的檔案區域肯定小了,所以這裡需要調整對映的長度,在原來的對映長度 size 的基礎上,多對映 pagePosition 大小的區域出來。總體對映長度為 mapSize。
// 對映位置 mapPosition 是透過 position 減去了 pagePosition 得到的
// 所以這裡的對映長度 mapSize 需要把 pagePosition 加回來
mapSize = size + pagePosition;
上圖中展示這段 [position, position+size]
藍色檔案區域是我們原本指定的檔案對映區域,FileChannel#map
函式中返回的 MappedByteBuffer 背後對映的就是這段檔案區域。
而核心真實對映的檔案區域其實是從 mapPosition 開始,對映長度為 mapSize 的這段檔案區域。
addr = map0(prot, mapPosition, mapSize, isSync);
addr 是透過 mmap 在程序虛擬記憶體空間中對映出來的虛擬記憶體區域的起始地址,這段虛擬記憶體區域的記憶體範圍是 [addr, addr+mapSize]
,背後對映的檔案區域範圍是 [mapPosition, mapPositionn+mapSize]
。
核心對映出來的虛擬記憶體區域是一個全集,而我們需要的對映區其實是 [addr + pagePosition, addr + mapSize] 這一段對映長度為 size 大小的子集。所以 MappedByteBuffer 的起始地址其實是 addr + pagePosition
,整個容量為 size 。
JDK 會將上述介紹的這些對映區域相關資訊都封裝在 Unmapper 類中。
Unmapper um = (isSync
? new SyncUnmapper(addr, mapSize, size, mfd, pagePosition)
: new DefaultUnmapper(addr, mapSize, size, mfd, pagePosition));
-
mmap 系統呼叫在程序地址空間真實對映出來的虛擬記憶體區域起始地址 addr 封裝在 Unmapper 類的 address 屬性中。
-
虛擬記憶體區域真實的對映長度 mapSize 封裝在 Unmapper 類的 size 屬性中。
-
FileChannel#map 函式中指定的 size 引數其實就是 MappedByteBuffer 的真實容量,封裝在 Unmapper 類的 cap 屬性中。
-
mfd 表示對映檔案的 file descriptor,pagePosition 表示我們指定的 position 距離其所在檔案頁起始位置的距離。
-
Unmapper 中封裝的 address 與 pagePosition 一相加就得到了 MappedByteBuffer 的起始記憶體地址。
private static class DefaultUnmapper extends Unmapper {
public DefaultUnmapper(long address, long size, long cap,
FileDescriptor fd, int pagePosition) {
// 封裝對映出來的虛擬記憶體區域 MappedByteBuffer 相關資訊,比如,起始對映地址,對映長度 等等
super(address, size, cap, fd, pagePosition);
incrementStats();
}
}
private static abstract class Unmapper implements Runnable, UnmapperProxy {
// 透過 mmap 系統呼叫在程序地址空間中對映出來的虛擬記憶體區域的起始地址
private volatile long address;
// mmap 對映出來的真實虛擬記憶體區域大小
protected final long size;
// MappedByteBuffer 的容量 cap (由 FileChannel#map 引數 size 指定)
protected final long cap;
private final FileDescriptor fd;
private final int pagePosition;
private Unmapper(long address, long size, long cap,
FileDescriptor fd, int pagePosition)
{
assert (address != 0);
this.address = address;
this.size = size;
this.cap = cap;
this.fd = fd;
this.pagePosition = pagePosition;
}
}
除此之外,Unmapper 中還封裝了 JVM 程序對於記憶體對映的相關統計資訊:
-
count 用於記錄 JVM 程序呼叫 mmap 進行記憶體檔案對映的總次數
-
totalSize 是站在核心的視角中,統計 mmap 對映出來的虛擬記憶體總大小,這個是虛擬記憶體佔用的真實用量。
-
totalCapacity 是站在 JVM 的視角中,統計所有 MappedByteBuffer 佔用虛擬記憶體的總大小。
private static class DefaultUnmapper extends Unmapper {
// keep track of non-sync mapped buffer usage
// jvm 呼叫 mmap 進行記憶體檔案對映的總次數
static volatile int count;
// jvm 在程序地址空間中對映出來的真實虛擬記憶體總大小(核心角度的虛擬記憶體佔用)
// 所有 mapSize 的總和
static volatile long totalSize;
// jvm 中所有 MappedByteBuffer 佔用虛擬記憶體的總大小(jvm角度的虛擬記憶體佔用)
// 所有 size 的總和
static volatile long totalCapacity;
// 每一次對映都會呼叫該方法
protected void incrementStats() {
synchronized (DefaultUnmapper.class) {
count++;
totalSize += size;
totalCapacity += cap;
}
}
}
Unmapper 中的 unmap 方法用於釋放本次透過 mmap 在程序地址空間中對映出來的真實虛擬記憶體區域,這裡筆者還是要強調一下,mmap 對映出來的虛擬記憶體區域範圍為 [addr, addr + mapSize],這個是真實的虛擬記憶體用量。
我們在 Java 程式中看到的 MappedByteBuffer 只是這段虛擬記憶體的一個子集,範圍為 [addr + pagePosition, addr + mapSize]。所以這裡的 unmap 方法釋放的是在核心中真實佔用的虛擬記憶體 —— [addr, addr + mapSize]
。
private static abstract class Unmapper implements Runnable, UnmapperProxy {
public void unmap() {
if (address == 0)
return;
// 底層呼叫 unmmap 系統呼叫,用於釋放 [addr, addr+mapSize] 這段 mmap 對映出來的虛擬記憶體以及實體記憶體
unmap0(address, size);
address = 0;
// if this mapping has a valid file descriptor then we close it
if (fd.valid()) {
try {
nd.close(fd);
} catch (IOException ignore) {
// nothing we can do
}
}
// incrementStats 的反向操作
decrementStats();
}
}
2.4.2 System.gc 之後到底發生了什麼
如果一切順利的話,記憶體對映的流程本該到這裡就結束了,但是現實中往往有很多異常情況的發生,比如在對映的過程中如果發現記憶體不足,mmap 系統呼叫就會返回 ENOMEM
錯誤,這個錯誤會被 JVM 在 native 層轉換成 OutOfMemoryError
丟擲。
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len, jboolean map_sync)
{
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 */
if (mapAddress == MAP_FAILED) {
// 虛擬記憶體不足
if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "Map failed");
return IOS_THROWN;
}
return handle(env, -1, "Map failed");
}
return ((jlong) (unsigned long) mapAddress);
}
注意這裡的 OutOfMemoryError
指的是虛擬記憶體不足和實體記憶體沒有關係,因為 mmap 系統呼叫只是在程序的虛擬記憶體空間中為本次對映分配出一段虛擬記憶體區域,並將這段虛擬記憶體區域與磁碟檔案對映起來就結束了,整個過程並不涉及實體記憶體的分配。
如果 mmap 發現程序的虛擬記憶體空間不足以劃分出我們指定對映長度的虛擬記憶體區域的話,核心就會返回 ENOMEM
錯誤給 JVM 程序。
當 JDK 捕獲到 OutOfMemoryError
異常的時候,就會意識到此時程序虛擬記憶體空間中的虛擬記憶體已經不足了,無法支援本次記憶體對映,於是就會呼叫 System.gc
強制觸發一次 GC ,試圖釋放一些虛擬記憶體出來,然後再次嘗試來 mmap 一把,如果程序地址空間中的虛擬記憶體還是不足,則丟擲 IOException
。
private Unmapper mapInternal(MapMode mode, long position, long size, int prot, boolean isSync)
throws IOException
{
try {
// If map0 did not throw an exception, the address is valid
addr = map0(prot, mapPosition, mapSize, isSync);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted
// memory so force gc and re-attempt map
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(prot, mapPosition, mapSize, isSync);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}
}
通常情況下我們應當避免在應用程式中主動呼叫 System.gc
,因為這會導致 JVM 立即觸發一次 Full GC,使得整個 JVM 程序陷入到 Stop The World 階段,對效能會有很大的影響。
但是在本小節的場景中,呼叫 System.gc
卻是有必要的,因為 NIO 中的 DirectByteBuffer 非常特殊,當然了 MappedByteBuffer 其實也屬於 DirectByteBuffer 的一種。它們背後依賴的記憶體均屬於 JVM 之外(Native Memory),因此不會受垃圾回收的控制。
前面我們多次提過,DirectByteBuffer 只是 OS 中的這些 Native Memory 在 JVM 中的封裝形式,DirectByteBuffer 這個 Java 類的例項是分配在 JVM 堆中的,但是這個例項的背後可能會引用著一大片的 Native Memory ,這些 Native Memory 是不會被 JVM 察覺的。
當這些 DirectByteBuffer 例項(位於 JVM 堆中)沒有任何引用的時候,如果又恰巧碰到 GC 的話,那麼 GC 在回收這些 DirectByteBuffer 例項的同時,也會將與其關聯的 Cleaner 放到一個 pending 佇列中。
protected DirectByteBuffer(int cap, long addr,
FileDescriptor fd,
Runnable unmapper,
boolean isSync, MemorySegmentProxy segment)
{
super(-1, 0, cap, cap, fd, isSync, segment);
address = addr;
// 對於 MappedByteBuffer 來說,在它被 GC 的時候,JVM 會呼叫這裡的 cleaner
// cleaner 近而會呼叫 Unmapper#unmap 釋放背後的 native memory
cleaner = Cleaner.create(this, unmapper);
att = null;
}
當 GC 結束之後,JVM 會喚醒 ReferenceHandler 執行緒去執行 pending 佇列中的這些 Cleaner,在 Cleaner 中會釋放其背後引用的 Native Memory。
但在現實的 NIO 使用場景中,DirectByteBuffer 卻很難觸發 GC,因為 DirectByteBuffer 的例項實在太小了(在 JVM 堆中的記憶體佔用),而且通常情況下這些例項是被應用程式長期持有的,很容易就會晉升到老年代。
即使 DirectByteBuffer 例項已經沒有任何引用關係了,由於它的例項足夠的小,一時很難把老年代撐爆,所以需要等很久才能觸發一次 Full GC,在這之前,這些沒有任何引用關係的 DirectByteBuffer 例項將會持續在老年代中堆積,其背後所引用的大片 Native Memory 將一直不會得到釋放。
DirectByteBuffer 的例項可以形象的比喻為冰山物件,JVM 可以看到的只是 DirectByteBuffer 在 JVM 堆中的記憶體佔用,但這部分記憶體佔用很小,就相當於是冰山的一角。
而位於冰山下面的大一片 Native Memory ,JVM 是察覺不到的, 這也是 Full GC 遲遲不會觸發的原因,因此導致了大量的 DirectByteBuffer 例項的堆積,背後引用的一大片 Native Memory 一直得不到釋放,嚴重的情況下可能會導致核心的 OOM,當前程序會被 kill 。
所以在 NIO 的場景下,這裡呼叫 System.gc 去主動觸發一次 Full GC 是有必要的。關於 System.gc ,網上的說法眾多,其中大部分認為 —— “System.gc 只是給 JVM 的一個暗示或者是提示,但是具體 GC 會不會發生,以及什麼時候發生都是不可預期的”。
這個說法以及 Java 標準庫中關於 System.gc 的註釋都是非常模糊的,那麼在 System.gc 被呼叫之後具體會發生什麼行為,我想還是應該到具體的 JVM 實現中去一探究竟,畢竟原始碼面前了無秘密,下面我們以 hotspot 實現進行說明。
public final class System {
public static void gc() {
Runtime.getRuntime().gc();
}
}
public class Runtime {
public native void gc();
}
System.gc 最終依賴的是 Runtime 類中定義的 gc 方法,該方法是一個 native 實現,定義在 Runtime.c 檔案中。
// Runtime.c 檔案
JNIEXPORT void JNICALL
Java_java_lang_Runtime_gc(JNIEnv *env, jobject this)
{
JVM_GC();
}
// jvm.cpp 檔案
JVM_ENTRY_NO_ENV(void, JVM_GC(void))
// DisableExplicitGC 預設為 false,如果設定了 -XX:+DisableExplicitGC 則為 true
if (!DisableExplicitGC) {
EventSystemGC event;
event.set_invokedConcurrent(ExplicitGCInvokesConcurrent);
// 立即觸發一次 full gc
Universe::heap()->collect(GCCause::_java_lang_system_gc);
event.commit();
}
JVM_END
從 hotspot 的實現中我們可以看出,如果我們設定了 -XX:+DisableExplicitGC
,那麼呼叫 System.gc 則不會起任何作用,在預設情況下,System.gc 會立即觸發一次 Full GC,這一點我們可以從 Universe::heap()->collect
方法的呼叫看得出來。而且會特殊註明引起本次 GC 的原因 GCCause 為 _java_lang_system_gc
。
JVM 堆的例項封裝在 Universe 類中,我們可以透過 heap() 方法來獲取 JVM 堆的例項,隨後呼叫堆的 collect 方法在 JVM 堆中執行垃圾回收的動作。
// universe.hpp 檔案
// jvm 堆例項
static CollectedHeap* _collectedHeap;
static CollectedHeap* heap() { return _collectedHeap; }
Java 堆在 JVM 原始碼中使用 CollectedHeap 型別來描述,該型別為整個 JVM 堆結構型別的基類,具體的實現型別取決於我們選擇的垃圾回收器。比如,當我們選擇 ZGC 作為垃圾回收器時,JVM 堆的型別是 ZCollectedHeap,選擇 G1 作為垃圾回收器時,JVM 堆的型別則是 G1CollectedHeap。
JVM 在初始化堆的時候,會透過 GCConfig::arguments()->create_heap()
根據我們選擇的具體垃圾回收器來建立相應的堆型別,具體的 JVM 堆例項會儲存在 _collectedHeap
中,後續透過 Universe::heap()
即可獲取。
// universe.cpp 檔案
// jvm 堆例項
CollectedHeap* Universe::_collectedHeap = NULL;
jint Universe::initialize_heap() {
assert(_collectedHeap == NULL, "Heap already created");
// 根據 JVM 引數 -XX: 指定的相關 gc 配置建立對應的 heap
// 比如,設定了 -XX:+UseZGC,這裡建立的就是 ZCollectedHeap
_collectedHeap = GCConfig::arguments()->create_heap();
log_info(gc)("Using %s", _collectedHeap->name());
// 初始化 jvm 堆
return _collectedHeap->initialize();
}
GCConfig 是 JVM 專門用於封裝 GC 相關資訊的類,具體建立堆的行為 —— create_heap(),則封裝在 GCConfig 類中的 _arguments 屬性中(GCArguments 型別)。這裡也是一樣,不同的垃圾回收器對應不同的 GCArguments,比如,ZGC 對應的是 ZArguments,G1 對應的是 G1Arguments。典型工廠,策略模式的應用,不同的 GCArguments 負責建立不用型別的 JVM 堆。
// gcConfig.cpp 檔案
GCArguments* GCConfig::arguments() {
assert(_arguments != NULL, "Not initialized");
// 真正負責建立 jvm 堆的類
return _arguments;
}
JVM 在啟動的時候會對 GCConfig 進行初始化,透過 select_gc()
根據我們指定的 -XX:
相關 GC 配置選項來選擇具體的 _arguments,比如,我們設定了 -XX:+UseZGC
, 這裡的 select_gc 就會返回 ZArguments 例項,並儲存在 _arguments 屬性中,隨後我們就可以透過 GCConfig::arguments()
獲取。
void GCConfig::initialize() {
assert(_arguments == NULL, "Already initialized");
_arguments = select_gc();
}
select_gc()
的邏輯其實非常簡單,核心就是遍歷一個叫做 IncludedGCs
的陣列,該陣列裡包含的是當前 JVM 版本中所支援的所有垃圾回收器集合。比如,當我們透過 command line 指定了 -XX:+UseZGC
的時候,相關的 GC 引數 UseZGC 就會為 true,其他的 GC 引數都為 false,如果 JVM 在遍歷 IncludedGCs
陣列的時候發現,當前遍歷元素的 GC 引數為 true,那麼就會將對應的 _arguments (zArguments)返回。
// gcConfig.cpp 檔案
// Table of included GCs, for translating between command
// line flag, CollectedHeap::Name and GCArguments instance.
static const IncludedGC IncludedGCs[] = {
EPSILONGC_ONLY_ARG(IncludedGC(UseEpsilonGC, CollectedHeap::Epsilon, epsilonArguments, "epsilon gc"))
G1GC_ONLY_ARG(IncludedGC(UseG1GC, CollectedHeap::G1, g1Arguments, "g1 gc"))
PARALLELGC_ONLY_ARG(IncludedGC(UseParallelGC, CollectedHeap::Parallel, parallelArguments, "parallel gc"))
SERIALGC_ONLY_ARG(IncludedGC(UseSerialGC, CollectedHeap::Serial, serialArguments, "serial gc"))
SHENANDOAHGC_ONLY_ARG(IncludedGC(UseShenandoahGC, CollectedHeap::Shenandoah, shenandoahArguments, "shenandoah gc"))
ZGC_ONLY_ARG(IncludedGC(UseZGC, CollectedHeap::Z, zArguments, "z gc"))
};
IncludedGCs
陣列的元素型別為 IncludedGC,用於封裝具體垃圾回收器的相關配置資訊:
// gcConfig.cpp 檔案
struct IncludedGC {
// GCArgument,如果我們透過 command line 配置了具體的垃圾回收器
// 那麼對應的 IncludedGC 型別中的 _flag 就為 true。
// -XX:+UseG1GC 對應 UseG1GC,-XX:+UseZGC 對應 UseZGC
bool& _flag;
// 具體垃圾回收器的名稱
CollectedHeap::Name _name;
// 對應的 GCArguments,後續用於 create_heap
GCArguments& _arguments;
const char* _hs_err_name;
};
select_gc()
就是遍歷這個 IncludedGCs 陣列,查詢 _flag 為 true 的陣列項,然後返回其 _arguments。
GCArguments* GCConfig::select_gc() {
// 遍歷 IncludedGCs 陣列
FOR_EACH_INCLUDED_GC(gc) {
// GCArgument 為 true 則返回對應的 _arguments
if (gc->_flag) {
return &gc->_arguments;
}
}
return NULL;
}
#define FOR_EACH_INCLUDED_GC(var) \
for (const IncludedGC* var = &IncludedGCs[0]; var < &IncludedGCs[ARRAY_SIZE(IncludedGCs)]; var++)
當我們透過設定 -XX:+UseG1GC
選擇 G1 垃圾回收器的時候,對應在 GCConfig 中的 _arguments 為 G1Arguments ,透過 GCConfig::arguments()->create_heap()
建立出來的 JVM 堆的型別為 G1CollectedHeap。
CollectedHeap* G1Arguments::create_heap() {
return new G1CollectedHeap();
}
同理,當我們透過設定 -XX:+UseZGC
選擇 ZGC 垃圾回收器的時候,JVM 堆的型別為 ZCollectedHeap。
CollectedHeap* ZArguments::create_heap() {
return new ZCollectedHeap();
}
當我們透過設定 -XX:+UseSerialGC
選擇 SerialGC 垃圾回收器的時候,JVM 堆的型別為 SerialHeap。
CollectedHeap* SerialArguments::create_heap() {
return new SerialHeap();
}
當我們透過設定 -XX:+UseParallelGC
選擇 ParallelGC 垃圾回收器的時候,JVM 堆的型別為 ParallelScavengeHeap。
CollectedHeap* ParallelArguments::create_heap() {
return new ParallelScavengeHeap();
}
當我們透過設定 -XX:+UseShenandoahGC
選擇 Shenandoah 垃圾回收器的時候,JVM 堆的型別為 ShenandoahHeap。
CollectedHeap* ShenandoahArguments::create_heap() {
return new ShenandoahHeap(new ShenandoahCollectorPolicy());
}
現在我們已經明確了各個垃圾回收器對應的 JVM 堆型別,而 System.gc 本質上呼叫的其實就是具體 JVM 堆中的 collect 方法來立即觸發一次 Full GC。
// jvm.cpp 檔案
JVM_ENTRY_NO_ENV(void, JVM_GC(void))
if (!DisableExplicitGC) {
Universe::heap()->collect(GCCause::_java_lang_system_gc);
}
JVM_END
下面我們就來結合具體的垃圾回收器看一下 System.gc 的行為,長話短說,先把結論丟擲來:
-
如果我們在 command line 中設定了
-XX:+DisableExplicitGC
,那麼呼叫 System.gc 則不會起任何作用。 -
如果我們選擇的垃圾回收器是 SerialGC,ParallelGC,ZGC 的話,那麼呼叫 System.gc 就會立即觸發一次 Full GC,整個 JVM 程序會陷入 Stop The World 階段,呼叫 System.gc 的執行緒會一直阻塞,直到整個 Full GC 結束才會返回。
-
如果我們選擇的垃圾回收器是 CMS(已在 Java 9 中廢棄),G1,Shenandoah,並且在 command line 中設定了
-XX:+ExplicitGCInvokesConcurrent
的話,那麼在呼叫 System.gc 則會立即觸發一次 Concurrent Full GC,JVM 程序不會陷入 Stop The World 階段,業務執行緒和 GC 執行緒可以併發執行,而且呼叫 System.gc 的執行緒在觸發 Concurrent Full GC 之後就立即返回了,不需要等到 GC 結束。
2.4.2.1 SerialGC
對於 SerialGC 來說,在呼叫 System.gc 之後,JVM 背後其實直接呼叫的是 SerialHeap 的 collect 方法。
// serialHeap.hpp 檔案
class SerialHeap : public GenCollectedHeap {
}
由於 SerialHeap 繼承的是 GenCollectedHeap,collect 方法是在 GenCollectedHeap 中實現的。
// genCollectedHeap.cpp 檔案
void GenCollectedHeap::collect(GCCause::Cause cause) {
// GCCause 為 _java_lang_system_gc 的時候會呼叫到這裡
// Stop-the-world full collection.
collect(cause, OldGen);
}
void GenCollectedHeap::collect(GCCause::Cause cause, GenerationType max_generation) {
collect_locked(cause, max_generation);
}
void GenCollectedHeap::collect_locked(GCCause::Cause cause, GenerationType max_generation) {
// 在這裡會觸發 Full Gc 的執行
VM_GenCollectFull op(gc_count_before, full_gc_count_before,
cause, max_generation);
// 提交給 VMThread 來執行 Full Gc
VMThread::execute(&op);
}
這裡需要注意的是執行這段程式碼的執行緒依然是呼叫 System.gc 的 Java 業務執行緒,而 JVM 內部的相關操作,比如這裡的 GC 操作,均是由 JVM 中的 VMThread 來執行的。
所以這裡 Java 業務執行緒需要將 Full Gc 的任務 —— VM_GenCollectFull 透過 VMThread::execute(&op)
提交給 VMThread 來執行。而 Java 業務執行緒一直會在這裡阻塞等待,直到 VMThread 執行完 Full Gc 之後,Java 業務執行緒才會從 System.gc 呼叫中返回。
這樣設計也是合理的,因為畢竟 Full Gc 會讓整個 JVM 程序陷入 Stop The World 階段,所有 Java 執行緒必須到達 SafePoint 之後 Full Gc 才會執行,而我們透過 JNI 進入到 Native 方法的實現之後,由於 Native 程式碼不會訪問 Java 物件、不會呼叫 Java 方法,不再執行任何位元組碼指令,所以 Java 虛擬機器的堆疊不會發生改變,因此 Native 方法本身就是一個 SafePoint。在 Full Gc 沒有結束之前,Java 執行緒會一直停留在這個 SafePoint 中。
void VMThread::execute(VM_Operation* op) {
// 獲取當前執行執行緒
Thread* t = Thread::current();
if (t->is_VM_thread()) {
// 如果當前執行緒是 VMThread 的話,直接執行 VM_Operation(Full Gc)
((VMThread*)t)->inner_execute(op);
return;
}
// doit_prologue 為執行 VM_Operation 的前置回撥函式,Full Gc 之前執行一些準備校驗工作。
// 返回 true 表示可以執行本次 GC 操作, 返回 false 表示忽略本次 GC
// JVM 可能會觸發多次 GC 請求,比如多個 java 執行緒遇到分配失敗的時候
// 但我們只需要執行一次 GC 就可以了,其他 GC 請求在這裡就會被忽略
// 另外執行 GC 之前需要給 JVM 堆加鎖,heap lock 也是在這裡完成的。
if (!op->doit_prologue()) {
return; // op was cancelled
}
// java 執行緒將 Full Gc 的任務提交給 VMThread 執行
// 並且會在這裡一直阻塞等待,直到 Full Gc 執行完畢。
wait_until_executed(op);
// 釋放 heap lock,喚醒 ReferenceHandler 執行緒去執行 pending 佇列中的 Cleaner
op->doit_epilogue();
}
注意這裡的 op->doit_epilogue()
方法,在 GC 結束之後就會呼叫到這裡,而與 DirectByteBuffer 相關聯的 Cleaner 正是在這裡被觸發執行的。
void VM_GC_Operation::doit_epilogue() {
if (Universe::has_reference_pending_list()) {
// 通知 cleaner thread 執行 cleaner,release native memory
Heap_lock->notify_all();
}
// Heap_lock->unlock()
VM_GC_Sync_Operation::doit_epilogue();
}
2.4.2.2 ParallelGC
對於 ParallelGC 來說,在呼叫 System.gc 之後,JVM 背後其實直接呼叫的是 ParallelScavengeHeap 的 collect 方法。
// This method is used by System.gc() and JVMTI.
void ParallelScavengeHeap::collect(GCCause::Cause cause) {
VM_ParallelGCSystemGC op(gc_count, full_gc_count, cause);
VMThread::execute(&op);
}
我們透過下面的 is_cause_full
方法可以知道 VM_ParallelGCSystemGC 執行的也是 Full Gc,同樣也是需要將 Full Gc 任務提交給 VMThread 執行,Java 業務執行緒在這裡阻塞等待直到 Full Gc 完成。
// Only used for System.gc() calls
VM_ParallelGCSystemGC::VM_ParallelGCSystemGC(uint gc_count,
uint full_gc_count,
GCCause::Cause gc_cause) :
VM_GC_Operation(gc_count, gc_cause, full_gc_count, is_cause_full(gc_cause))
{
}
// 對於 System.gc 來說這裡執行的是 full_gc
static bool is_cause_full(GCCause::Cause cause) {
return (cause != GCCause::_gc_locker) && (cause != GCCause::_wb_young_gc)
DEBUG_ONLY(&& (cause != GCCause::_scavenge_alot));
}
2.4.2.3 ZGC
對於 ZGC 來說,在呼叫 System.gc 之後,JVM 背後其實直接呼叫的是 ZCollectedHeap 的 collect 方法。JVM 會執行一個同步的 GC 操作,Java 業務執行緒仍然會在這裡阻塞,直到 GC 完成才會返回。
// zCollectedHeap.cpp 檔案
void ZCollectedHeap::collect(GCCause::Cause cause) {
_driver->collect(cause);
}
// zDriver.cpp 檔案
void ZDriver::collect(const ZDriverRequest& request) {
switch (request.cause()) {
// System.gc
case GCCause::_java_lang_system_gc:
// Start synchronous GC
_gc_cycle_port.send_sync(request);
break;
..... 省略 ,,,,,,
}
}
template <typename T>
inline void ZMessagePort<T>::send_sync(const T& message) {
Request request;
{
// Enqueue message
// 隨後 ZDriver 執行緒會非同步從佇列中取出 message,執行 gc
MonitorLocker ml(&_monitor, Monitor::_no_safepoint_check_flag);
request.initialize(message, _seqnum);
_queue.insert_last(&request);
// 喚醒 ZDriver 執行緒執行 gc
ml.notify();
}
// java 業務執行緒在這裡阻塞等待,直到 gc 完成
request.wait();
}
2.4.2.4 G1
對於 G1 來說,在呼叫 System.gc 之後,JVM 背後其實直接呼叫的是 G1CollectedHeap 的 collect 方法。
// g1CollectedHeap.cpp 檔案
void G1CollectedHeap::collect(GCCause::Cause cause) {
try_collect(cause);
}
G1 這裡首先會透過 should_do_concurrent_full_gc
方法判斷是否發起一次 Concurrent Full GC,從下面的原始碼中可以看出,對於 System.gc 來說,該方法其實是對 ExplicitGCInvokesConcurrent 這個 GC 引數的判斷。
當我們在 command line 中設定了 -XX:+ExplicitGCInvokesConcurrent
的話,ExplicitGCInvokesConcurrent 為 true,預設為 false。
bool G1CollectedHeap::should_do_concurrent_full_gc(GCCause::Cause cause) {
switch (cause) {
case GCCause::_g1_humongous_allocation: return true;
case GCCause::_g1_periodic_collection: return G1PeriodicGCInvokesConcurrent;
case GCCause::_wb_breakpoint: return true;
// System.gc 會走這裡的 default 分支
default: return is_user_requested_concurrent_full_gc(cause);
}
}
bool G1CollectedHeap::is_user_requested_concurrent_full_gc(GCCause::Cause cause) {
switch (cause) {
// System.gc
case GCCause::_java_lang_system_gc: return ExplicitGCInvokesConcurrent;
...... 省略 .....
}
}
當我們設定了 -XX:+ExplicitGCInvokesConcurrent
的時候,System.gc 就會觸發一次 Concurrent Full GC,GC 過程不需要經歷 Stop The World 階段,由 G1 相關的 Concurrent GC 執行緒來執行 Concurrent Full GC 而不是之前的 VMThread。
而且呼叫 System.gc 的 Java 業務執行緒在觸發 Concurrent Full GC 之後就返回了,不需要等到 GC 執行完畢。
但在預設情況下,也就是沒有設定 -XX:+ExplicitGCInvokesConcurrent
的時候,仍然會執行一次完整的 Full GC。
bool G1CollectedHeap::try_collect(GCCause::Cause cause) {
assert_heap_not_locked();
// -XX:+ExplicitGCInvokesConcurrent
if (should_do_concurrent_full_gc(cause)) {
// 由 Concurrent GC 執行緒來執行
return try_collect_concurrently(cause,
gc_count_before,
old_marking_started_before);
} else {
// Schedule a Full GC.
VM_G1CollectFull op(gc_count_before, full_gc_count_before, cause);
VMThread::execute(&op);
return op.gc_succeeded();
}
}
對於 CMS 來說,雖然它已經在 Java 9 中被廢棄了,但從 Java 8 的原始碼中可以看出,CMS 這裡的邏輯(System.gc )和 G1 是一樣的,首先都會透過 should_do_concurrent_full_gc 方法來判斷是否執行一次 Concurrent Full GC,都是取決於是否設定了 -XX:+ExplicitGCInvokesConcurrent
,否則執行完整的 Full GC。
2.4.2.5 Shenandoah
對於 Shenandoah 來說,在呼叫 System.gc 之後,JVM 背後其實直接呼叫的是 ShenandoahHeap 的 collect 方法。
void ShenandoahHeap::collect(GCCause::Cause cause) {
control_thread()->request_gc(cause);
}
首先會透過 is_user_requested_gc
方法判斷本次 GC 是否是由 System.gc 所觸發的,如果是,則進入 handle_requested_gc 中處理,GCCause 為 java_lang_system_gc 。
// gcCause.hpp 檔案
inline static bool is_user_requested_gc(GCCause::Cause cause) {
return (cause == GCCause::_java_lang_system_gc ||
cause == GCCause::_dcmd_gc_run);
}
如果我們在 command line 中設定了 -XX:+DisableExplicitGC
,那麼這裡的 System.gc 將不會起任何作用。
// shenandoahControlThread.cpp
void ShenandoahControlThread::request_gc(GCCause::Cause cause) {
assert(GCCause::is_user_requested_gc(cause) || ....... ,"only requested GCs here");
// System.gc
if (is_explicit_gc(cause)) {
if (!DisableExplicitGC) {
// 沒有設定 -XX:+DisableExplicitGC 的情況下會走這裡
handle_requested_gc(cause);
}
} else {
handle_requested_gc(cause);
}
}
bool ShenandoahControlThread::is_explicit_gc(GCCause::Cause cause) const {
return GCCause::is_user_requested_gc(cause) ||
GCCause::is_serviceability_requested_gc(cause);
}
呼叫 System.gc 的 Java 業務執行緒首先在 handle_requested_gc 方法中會設定 gc 請求標誌 _gc_requested.set
,ShenandoahControlThread 會定時檢測這個 _gc_requested 標誌,如果被設定了,則進行後續的 GC 處理。
Java 業務執行緒最後會一直阻塞在 handle_requested_gc 方法中,如果進行的是 Concurrent Full GC 的話,那麼 GC 任務在被提交給對應的 Concurrent GC 執行緒之後就會喚醒 Java 業務執行緒。如果執行的是 Full GC 的話,那麼當 VMthread 執行完 Full GC 的時候才會喚醒阻塞在這裡的 Java 業務執行緒,隨後 Java 執行緒從 System.gc 呼叫中返回。
void ShenandoahControlThread::handle_requested_gc(GCCause::Cause cause) {
MonitorLocker ml(&_gc_waiters_lock);
while (current_gc_id < required_gc_id) {
// 設定 gc 請求標誌,後續會由 ShenandoahControlThread 來執行
_gc_requested.set();
// java_lang_system_gc
_requested_gc_cause = cause;
if (cause != GCCause::_wb_breakpoint) {
// java 業務執行緒會在這裡阻塞等待
// 對於 Concurrent Full GC 來說,GC 在被觸發的時候,java 執行緒就會被喚醒直接返回
// 對於 Full GC 來說,java 執行緒需要等到 gc 被執行完才會被喚醒
ml.wait();
}
}
}
ShenandoahControlThread 會根據一定的間隔時間來檢測 _gc_requested 標誌是否被設定,如果被設定則繼續後續的 GC 處理:
-
如果我們設定了
-XX:+ExplicitGCInvokesConcurrent
,Shenandoah 會觸發一次 Concurrent Full GC ,否則進行的是 Full GC ,這一點和 G1 的處理方式是一樣的。 -
最後透過
notify_gc_waiters()
喚醒在 handle_requested_gc 中阻塞等待的 java 執行緒。
void ShenandoahControlThread::run_service() {
ShenandoahHeap* heap = ShenandoahHeap::heap();
// 預設的一些設定,後面會根據配置修改
GCMode default_mode = concurrent_normal;// 併發模式
GCCause::Cause default_cause = GCCause::_shenandoah_concurrent_gc;
while (!in_graceful_shutdown() && !should_terminate()) {
// _gc_requested 如果被設定,後續則會處理 System.gc 的邏輯
bool explicit_gc_requested = _gc_requested.is_set() && is_explicit_gc(_requested_gc_cause);
// Choose which GC mode to run in. The block below should select a single mode.
GCMode mode = none;
if (explicit_gc_requested) {
// java_lang_system_gc
cause = _requested_gc_cause;
log_info(gc)("Trigger: Explicit GC request (%s)", GCCause::to_string(cause));
// -XX:+ExplicitGCInvokesConcurrent
if (ExplicitGCInvokesConcurrent) {
policy->record_explicit_to_concurrent();
// concurrent_normal 併發模式
mode = default_mode;
} else {
policy->record_explicit_to_full();
mode = stw_full; // Full GC 模式
}
}
switch (mode) {
case concurrent_normal:
// 由 concurrent gc 執行緒非同步執行
service_concurrent_normal_cycle(cause);
break;
case stw_full:
// 觸發 VM_ShenandoahFullGC ,由 VMthread 同步執行
service_stw_full_cycle(cause);
break;
default:
ShouldNotReachHere();
}
// If this was the requested GC cycle, notify waiters about it
if (explicit_gc_requested || implicit_gc_requested) {
// 喚醒在 handle_requested_gc 中阻塞等待的 java 執行緒
notify_gc_waiters();
}
}
}
2.5 JDK 完整的記憶體對映過程
private Unmapper mapInternal(MapMode mode, long position, long size, int prot, boolean isSync)
throws IOException
{
// 確保檔案處於 open 狀態
ensureOpen();
// 對相關對映引數進行校驗
if (mode == null)
throw new NullPointerException("Mode is null");
if (position < 0L)
throw new IllegalArgumentException("Negative position");
if (size < 0L)
throw new IllegalArgumentException("Negative size");
if (position + size < 0)
throw new IllegalArgumentException("Position + size overflow");
// 如果 mode 設定了 READ_ONLY,但檔案並沒有以讀的模式開啟,則會丟擲 NonReadableChannelExceptio
// 如果 mode 設定了 READ_WRITE 或者 PRIVATE ,那麼檔案必須要以讀寫的模式開啟,否則會丟擲 NonWritableChannelException
// 如果 isSync 為 true,但是對應 CPU 體系架構不支援 cache line write back 指令,那麼就會丟擲 UnsupportedOperationException
checkMode(mode, prot, isSync);
long addr = -1;
int ti = -1;
try {
// 這裡不要被命名誤導,beginBlocking 並不會阻塞當前執行緒,只是標記一下表示當前執行緒下面會執行一個 IO 操作可能會無限期阻塞
// 而這個 IO 操作是可以被中斷的,這裡會設定中斷的回撥函式 interruptor,線上程被中斷的時候回撥
beginBlocking();
// threads 是一個 NativeThread 的集合,用於暫存阻塞在該 channel 上的 NativeThread,用於後續統一喚醒
ti = threads.add();
// 如果當前 channel 已經關閉,則不能進行 mmap 操作
if (!isOpen())
return null;
// 對映檔案大小,同 mmap 系統呼叫中的 length 引數
long mapSize;
// position 距離其所在檔案頁起始位置的距離,OS 核心以 page 為單位進行記憶體管理
// 記憶體對映的單位也應該按照 page 進行,pagePosition 用於後續將 position,size 與 page 大小對齊
int pagePosition;
// 確保執行緒序列操作檔案的 position
synchronized (positionLock) {
long filesize;
do {
// 底層透過 fstat 系統呼叫獲取檔案大小
filesize = nd.size(fd);
// 如果系統呼叫被中斷則一直重試
} while ((filesize == IOStatus.INTERRUPTED) && isOpen());
if (!isOpen())
return null;
// 如果要對映的檔案區域已經超過了 filesize 則需要擴充套件檔案
if (filesize < position + size) { // Extend file size
if (!writable) {
throw new IOException("Channel not open for writing " +
"- cannot extend file to required size");
}
int rv;
do {
// 底層透過 ftruncate 系統呼叫將檔案大小擴充套件至 (position + size)
rv = nd.truncate(fd, position + size);
} while ((rv == IOStatus.INTERRUPTED) && isOpen());
if (!isOpen())
return null;
}
// 對映大小為 0 則直接返回 null,隨後會建立一個空的 MappedByteBuffer
if (size == 0) {
return null;
}
// OS 核心是按照記憶體頁 page 為單位來對記憶體進行管理的,因此我們記憶體對映的粒度也應該按照 page 的單位進行
// allocationGranularity 表示記憶體分配的粒度,這裡是記憶體頁的大小 4K
// 我們指定的對映 offset 也就是這裡的 position 應該是與 4K 對齊的,同理對映長度 size 也應該與 4K 對齊
// position 距離其所在檔案頁起始位置的距離
pagePosition = (int)(position % allocationGranularity);
// mapPosition 為對映的檔案內容在磁碟檔案中的偏移,同 mmap 系統呼叫中的 offset 引數
// 這裡的 mapPosition 為 position 所屬檔案頁的起始位置
long mapPosition = position - pagePosition;
// 對映位置 mapPosition 減去了 pagePosition,所以這裡的對映長度 mapSize 需要把 pagePosition 加回來
mapSize = size + pagePosition;
try {
// If map0 did not throw an exception, the address is valid
// native 方法,底層呼叫 mmap 進行記憶體檔案對映
// 返回值 addr 為 mmap 系統呼叫在程序地址空間真實對映出來的虛擬記憶體區域起始地址
addr = map0(prot, mapPosition, mapSize, isSync);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted
// memory so force gc and re-attempt map
// 如果記憶體不足導致 mmap 失敗,這裡觸發 Full GC 進行記憶體回收,前提是沒有設定 -XX:+DisableExplicitGC
// 預設情況下在呼叫 System.gc() 之後,JVM 馬上會執行 Full GC,並且等到 Full GC 完成之後才返回的。
// 只有使用 CMS ,G1,Shenandoah 時,並且配置 -XX:+ExplicitGCInvokesConcurrent 的情況下
// 呼叫 System.gc() 會觸發 Concurrent Full GC,java 執行緒在觸發了 Concurrent Full GC 之後立馬返回
System.gc();
try {
// 這裡不是等待 gc 結束,而是等待 cleaner thread 執行 directBuffer 的 cleaner,在 cleaner 中釋放 native memory
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
// 重新進行記憶體對映
addr = map0(prot, mapPosition, mapSize, isSync);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}
} // synchronized
// 檢查 mmap 呼叫是否成功,失敗的話錯誤資訊會放在 addr 中
assert (IOStatus.checkAll(addr));
// addr 需要與檔案頁尺寸對齊
assert (addr % allocationGranularity == 0);
// Unmapper 用於呼叫 unmmap 釋放對映出來的虛擬記憶體以及實體記憶體
// 並統計整個 JVM 程序呼叫 mmap 的總次數以及對映的記憶體總大小
// 本次 mmap 對映出來的記憶體區域資訊都會封裝在 Unmapper 中
Unmapper um = (isSync
? new SyncUnmapper(addr, mapSize, size, mfd, pagePosition)
: new DefaultUnmapper(addr, mapSize, size, mfd, pagePosition));
return um;
} finally {
// IO 操作完畢,從 threads 集合中刪除當前執行緒
threads.remove(ti);
// IO 操作完畢,清空執行緒的中斷回撥函式,如果此時執行緒已被中斷則丟擲 closedByInterruptException 異常
endBlocking(IOStatus.checkAll(addr));
}
}
3. 與 MappedByteBuffer 相關的幾個系統呼叫
從第一小節介紹的 mmap 在核心中的整個記憶體對映的過程我們可以看出,當呼叫 mmap 之後,OS 核心只是會為我們分配了一段虛擬記憶體(MappedByteBuffer),然後將虛擬記憶體與磁碟檔案進行對映,僅此而已。
我們對映的檔案內容此時還靜靜地躺在磁碟中還未載入進記憶體,對映檔案的 page cache 還是空的,由於還未發生實體記憶體的分配,所以 MappedByteBuffer 在 JVM 程序頁表中相關的頁表項 pte 也是空的。
當我們開始訪問這段 MappedByteBuffer 的時候,由於此時還沒有實體記憶體與之對映,於是會產生一個缺頁中斷,隨後 JVM 程序進入核心態,在核心缺頁處理程式中分配實體記憶體頁,然後將剛剛分配的實體記憶體頁加入到對映檔案的 page cache。
最後將對映的檔案內容從磁碟中讀取到這個實體記憶體頁中並在頁表中建立 MappedByteBuffer 與實體記憶體頁的對映關係,後面我們在訪問這段 MappedByteBuffer 的時候就是直接訪問 page cache 了。
我們利用 MappedByteBuffer 去對映磁碟檔案的目的其實就是為了透過 MappedByteBuffer 去直接訪問磁碟檔案的 page cache,不想切到核心態,也不想發生資料複製。
所以為了避免訪問 MappedByteBuffer 可能帶來的缺頁中斷產生的開銷,我們通常會在呼叫 FileChannel#map
對映完磁碟檔案之後,馬上主動去觸發一次缺頁中斷,目的就是先把 MappedByteBuffer 背後對映的檔案內容預先載入到 page cache 中,並在 JVM 程序頁表中建立好 page cache 中的實體記憶體與 MappedByteBuffer 的對映關係。
後續我們對 MappedByteBuffer 的訪問速度就變得非常快了,上述針對 MappedByteBuffer 的預熱過程,JDK 封裝在 MappedByteBuffer#load
方法中:
public abstract class MappedByteBuffer extends ByteBuffer
{
public final MappedByteBuffer load() {
if (fd == null) {
return this;
}
try {
// 最終會呼叫到 MappedMemoryUtils#load 方法
SCOPED_MEMORY_ACCESS.load(scope(), address, isSync, capacity());
} finally {
Reference.reachabilityFence(this);
}
return this;
}
}
MappedByteBuffer 預熱的核心邏輯主要分為兩個步驟:首先 JDK 會呼叫一個 native 方法 load0
將 MappedByteBuffer 背後對映的檔案內容先預讀進 page cache 中。
private static native void load0(long address, long length);
// MappedMemoryUtils.c 檔案
JNIEXPORT void JNICALL
Java_java_nio_MappedMemoryUtils_load0(JNIEnv *env, jobject obj, jlong address,
jlong len)
{
char *a = (char *)jlong_to_ptr(address);
int result = madvise((caddr_t)a, (size_t)len, MADV_WILLNEED);
if (result == -1) {
JNU_ThrowIOExceptionWithLastError(env, "madvise failed");
}
}
這裡我們看到 load0
方法在 native 層面呼叫了一個叫做 madvise
的系統呼叫:
#include <sys/mman.h>
int madvise(caddr_t addr, size_t len, int advice);
madvise 在各大中介軟體中應用還是非常廣泛的,應用程式可以透過該系統呼叫告知核心,接下來我們將會如何使用 [addr, addr + len] 這段範圍的虛擬記憶體,核心後續會根據我們提供的 advice
做針對性的處理,用以提高應用程式的效能。
比如,我們可以透過 madvise 系統呼叫告訴核心接下來我們將順序訪問這段指定範圍的虛擬記憶體,那麼核心將會增大對對映檔案的預讀頁數。如果我們是隨機訪問這段虛擬記憶體,核心將會禁止對對映檔案的預讀。
這裡我們用到的 advice 選項為 MADV_WILLNEED
,該選項用來告訴核心我們將會馬上訪問這段虛擬記憶體,核心在收到這個建議之後,將會馬上觸發一次預讀操作,儘可能將 MappedByteBuffer 背後對映的檔案內容全部載入到 page cache 中。
但是 madvise 這裡只是負責將 MappedByteBuffer 對映的檔案內容載入到記憶體中(page cache),並不負責將 MappedByteBuffer(虛擬記憶體) 與 page cache 中的這些檔案頁(實體記憶體)進行關聯對映,也就是說此時 MappedByteBuffer 在 JVM 程序頁表中相關的頁表項 PTE 還是空的。
所以 JDK 在呼叫完 load0
方法之後,還需要再次按照記憶體頁的粒度對 MappedByteBuffer 進行訪問,目的是觸發缺頁中斷,在缺頁中斷處理中核心會將 MappedByteBuffer 與 page cache 透過程序頁表關聯對映起來。後續我們在對 MappedByteBuffer 進行訪問就是直接訪問 page cache 了,沒有缺頁中斷也沒有磁碟 IO 的開銷。
關於 MappedByteBuffer 的 load 邏輯 , JDK 封裝在 MappedMemoryUtils
類中:
class MappedMemoryUtils {
static void load(long address, boolean isSync, long size) {
// no need to load a sync mapped buffer
// isSync = true 表示 MappedByteBuffer 背後直接對映的是 non-volatile memory 而不是普通磁碟上的檔案
// MappedBuffer 背後對映的內容已經在 non-volatile memory 中了不需要 load
if (isSync) {
return;
}
if ((address == 0) || (size == 0))
return;
// 返回 pagePosition
long offset = mappingOffset(address);
// MappedBuffer 實際對映的記憶體區域大小 也就是呼叫 mmap 時指定的 mapSize
long length = mappingLength(offset, size);
// mappingAddress 用於獲取實際的對映起始位置 mapPosition
// madvise 也是按照記憶體頁為粒度進行操作的,所以這裡和 mmap 一樣
// 需要對指定的 address 和 length 按照記憶體頁的尺寸對齊
load0(mappingAddress(address, offset), length);
// 對 MappedByteBuffer 進行訪問,觸發缺頁中斷
// 目的是將 MappedByteBuffer 與 page cache 在程序頁表中進行關聯對映
Unsafe unsafe = Unsafe.getUnsafe();
// 獲取記憶體頁的尺寸,大小為 4K
int ps = Bits.pageSize();
// 計算 MappedByteBuffer 這片虛擬記憶體區域所包含的虛擬記憶體頁個數
long count = Bits.pageCount(length);
// mmap 起始的對映地址,後面將基於這個地址挨個觸發缺頁中斷
long a = mappingAddress(address, offset);
byte x = 0;
for (long i=0; i<count; i++) {
// 以記憶體頁為粒度,挨個對 MappedByteBuffer 中包含的虛擬記憶體頁觸發缺頁中斷
x ^= unsafe.getByte(a);
a += ps;
}
if (unused != 0)
unused = x;
}
}
這裡我們呼叫 load 方法的目的就是希望將 MappedByteBuffer 背後所對映的檔案內容載入到實體記憶體中,在本文 《2.2 針對 persistent memory 的對映》 小節中,筆者介紹過,當我們呼叫 FileChannel#map
對檔案進行記憶體對映的時候,如果引數 MapMode
設定了 READ_ONLY_SYNC 或者 READ_WRITE_SYNC 的話,那麼這裡的 isSync = true
。
表示 MappedByteBuffer 背後直接對映的是 non-volatile memory 而不是普通磁碟上的檔案,對映內容已經在 non-volatile memory 中了,因此就不需要載入了,直接 return 掉。
non-volatile memory 也是需要 filesystem 來進行管理的,這些 filesystem 會透過 dax(direct access mode)進行掛載,從後面相關的 madvise 系統呼叫原始碼中我們也會看出,如果對映檔案是 DAX 模式的,那麼核心也會直接 return,不需要載入。
if (IS_DAX(file_inode(file))) {
return 0;
}
本文 《2.4.1 Unmapper 到底包裝了哪些對映資訊》小節中我們介紹過,透過 mmap 系統呼叫真實對映出來的虛擬記憶體範圍與 MappedByteBuffer 所表示的虛擬記憶體範圍是不一樣的,MappedByteBuffer 只是其中的一個子集而已。
因為我們在 FileChannel#map
函式中指定的對映起始位置 position 是需要與檔案頁尺寸進行對齊的,這也就是說底層 mmap 系統呼叫必須要從檔案頁的起始位置處開始對映。
如果我們指定的 position 沒有和檔案頁進行對齊,那麼在 JDK 層面就需要找到 position 所在檔案頁的起始位置,也就是上圖中的 mapPosition
,mmap 將會從這裡開始對映,對映出來的虛擬記憶體範圍為 [mapPosition,mapPosition+mapSize]
。最後 JDK 在從這段虛擬記憶體範圍內劃分出 MappedByteBuffer 所需要的範圍,也就是我們在 FileChannel#map
引數中指定的 [position,position+size]
這段區域。
而 madvise 和 mmap 都是核心層面的系統呼叫,不管你 JDK 內部如何劃分,它們只關注核心層面實際對映出來的虛擬記憶體,所以我們在呼叫 madvise 指定虛擬記憶體範圍的時候需要與 mmap 真實對映出來的範圍保持一致。
native 方法 load0 中的引數 address
,其實就是 mmap 的起始對映地址 mapPosition,引數 length
其實就是 mmap 真實的對映長度 mapSize。
private static native void load0(long address, long length);
而 MappedMemoryUtils#load
方法中的引數 address
指的是 MappedByteBuffer 的起始地址也就是上面的 position
,引數 size
指的是 MappedByteBuffer 的容量也就是我們指定的對映長度(並不是實際的對映長度)。
static void load(long address, boolean isSync, long size) {
所以在進入 load0
native 實現之前,需要做一些轉換工作。首先透過 mappingOffset 根據 MappedByteBuffer 的起始地址 address
計算出 address 距離其所在檔案頁的起始地址的長度,也就是上圖中的 pagePosition。該函式的計算邏輯比較簡單且之前也已經介紹過了,這裡不再贅述。
private static long mappingOffset(long address)
透過 mappingLength 計算出 mmap 底層實際對映出的虛擬記憶體大小 mapSize。
private static long mappingLength(long mappingOffset, long length) {
// mappingOffset 即為 pagePosition
// length 是之前指定的對映長度 size,也就是 MappedByteBuffer 的容量
return length + mappingOffset;
}
mappingAddress 用於獲取 mmap 起始對映地址 mapPosition。
private static long mappingAddress(long address, long mappingOffset, long index) {
// address 為 MappedByteBuffer 的起始地址
// index 這裡指定為 0
long indexAddress = address + index;
// mmap 對映的起始地址
return indexAddress - mappingOffset;
}
這樣一來,我們透過 load0
方法進入 native 實現中呼叫 madvise 的時候,這裡指定的引數 addr
就是上面 mappingAddress 方法返回的 mapPosition
,引數 len
就是 mappingLength 方法返回的 mapSize
,引數 advice
指定為 MADV_WILLNEED,立即觸發一次預讀。
#include <sys/mman.h>
int madvise(caddr_t addr, size_t len, int advice);
3.1 madvise
// 檔案:/mm/madvise.c
SYSCALL_DEFINE3(madvise, unsigned long, start, size_t, len_in, int, behavior)
{
end = start + len;
vma = find_vma_prev(current->mm, start, &prev);
for (;;) {
/* Here vma->vm_start <= start < tmp <= (end|vma->vm_end). */
error = madvise_vma(vma, &prev, start, tmp, behavior);
}
out:
return error;
}
madvise 的作用其實就是在我們指定的虛擬記憶體範圍 [start, end] 內包含的所有虛擬記憶體區域 vma 中依次根據我們指定的 behavior 觸發 madvise_vma 執行相關的 behavior 處理邏輯。
find_vma_prev 的作用就是根據我們指定的對映起始地址 addr(start),在程序地址空間中查詢出符合 addr < vma->vm_end
條件的第一個 vma 出來(下圖中的藍色部分)。
關於該函式的詳細實現,感興趣的讀者可以回看下筆者之前的文章《從核心世界透視 mmap 記憶體對映的本質(原始碼實現篇)》
如果我們指定的起始虛擬記憶體地址 start 是一個無效的地址(未被對映),那麼核心這裡就會返回 ENOMEM
錯誤。
透過 find_vma_prev 查詢出來的 vma 就是我們指定虛擬記憶體範圍 [start, end] 內的第一個虛擬記憶體區域,後續核心會在一個 for 迴圈內從這個 vma 開始依次呼叫 madvise_vma,在指定虛擬記憶體範圍內的所有 vma 中執行 behavior 相關的處理邏輯。
static long
madvise_vma(struct vm_area_struct *vma, struct vm_area_struct **prev,
unsigned long start, unsigned long end, int behavior)
{
switch (behavior) {
case MADV_WILLNEED:
return madvise_willneed(vma, prev, start, end);
}
}
其中 MADV_WILLNEED
的處理邏輯被核心封裝在 madvise_willneed
方法中:
static long madvise_willneed(struct vm_area_struct *vma,
struct vm_area_struct **prev,
unsigned long start, unsigned long end)
{
// 獲取對映檔案
struct file *file = vma->vm_file;
// 對映內容在檔案中的偏移
loff_t offset;
// 判斷對映檔案是否是 persistent memory filesystem 上的檔案
if (IS_DAX(file_inode(file))) {
// 這裡說明 mmap 對映的是 persistent memory 直接返回
return 0;
}
// madvise 底層其實呼叫的是 fadvise
vfs_fadvise(file, offset, end - start, POSIX_FADV_WILLNEED);
return 0;
}
從這裡我們可以看出,如果對映檔案是 persistent memory filesystem (透過 DAX 模式掛載)中的檔案,那麼表示這段虛擬記憶體背後直接對映的是 persistent memory ,madvise 系統呼叫直接就返回了。
這也解釋了為什麼 JDK 會在 MappedMemoryUtils#load
方法的一開始,就會判斷如果 isSync = true
就直接返回,因為對映的檔案內容已經存在於 persistent memory 中了,不需要再次載入了。
最終核心關於 advice 的處理邏輯封裝在 vfs_fadvise
函式中,這裡我們也可以看出 madvise 系統呼叫與 fadvise 系統呼叫本質上是一樣的,最終都是透過這裡的 vfs_fadvise 函式來處理。
// 檔案:/mm/fadvise.c
int vfs_fadvise(struct file *file, loff_t offset, loff_t len, int advice)
{
return generic_fadvise(file, offset, len, advice);
}
int generic_fadvise(struct file *file, loff_t offset, loff_t len, int advice)
{
// 獲取對映檔案的 page cache
mapping = file->f_mapping;
switch (advice) {
case POSIX_FADV_WILLNEED:
// 將檔案中範圍為 [start_index, end_index] 的內容預讀進 page cache 中
start_index = offset >> PAGE_SHIFT;
end_index = endbyte >> PAGE_SHIFT;
// 計算需要預讀的記憶體頁數
// 但核心不一定會按照 nrpages 指定的頁數進行預讀,還需要結合預讀視窗來綜合判斷具體的預讀頁數
nrpages = end_index - start_index + 1;
// 強制進行預讀,之後對映的檔案內容就會載入進 page cache 中了
// 如果預讀失敗的話,這裡會忽略掉錯誤,所以在應用層面是感知不到預讀成功或者失敗了的
force_page_cache_readahead(mapping, file, start_index, nrpages);
break;
}
return 0;
}
EXPORT_SYMBOL(generic_fadvise);
核心對於 MADV_WILLNEED
的處理其實就是透過 force_page_cache_readahead
立即觸發一次預讀,將之前透過 mmap 對映的檔案內容全部預讀進 page cache 中。
關於 force_page_cache_readahead 的詳細內容,感興趣的讀者可以回看之前的文章 《從 Linux 核心角度探秘 JDK NIO 檔案讀寫本質》
但這裡需要注意的是預讀可能會失敗,核心這裡會忽略掉預讀失敗的錯誤,我們在應用層面呼叫 madvise 的時候是感知不到預讀失敗的。
還有一點就是 madvise 中的 MADV_WILLNEED 只是將虛擬記憶體(MappedByteBuffer)背後對映的檔案內容載入到 page cache 中。
當 madvise 系統呼叫返回的時候,雖然此時對映的檔案內容已經在 page cache 中了,但是這些剛剛被載入進 page cache 的檔案頁還沒有與 MappedByteBuffer 進行關聯,也就是說 MappedByteBuffer 在 JVM 程序頁表中對應的頁表項 pte 仍然還是空的。
後續我們訪問這段 MappedByteBuffer 的時候仍然會觸發缺頁中斷,但是這種情況下的缺頁中斷是輕量的,屬於 VM_FAULT_MINOR 型別的缺頁,因為之前對映的檔案內容已經透過 madvise 載入到 page cache 中了,這裡只需要透過程序頁表將 MappedByteBuffer 與 page cache 中的檔案頁關聯對映起來就可以了,不需要重新分配記憶體以及發生磁碟 IO 。
所以這也是為什麼在 MappedMemoryUtils#load
方法中,JDK 在呼叫完 native 方法 load0
之後,仍然需要以記憶體頁為粒度再次訪問一下 MappedByteBuffer 的原因,目的是透過缺頁中斷(VM_FAULT_MINOR)將 page cache 與 MappedByteBuffer 透過頁表關聯對映起來。
3.2 mlock
MappedByteBuffer 經過上面 MappedByteBuffer#load
函式的處理之後,現在 MappedByteBuffer 背後所對映的檔案內容已經載入到 page cache 中了,並且在 JVM 程序頁表中也已經建立好了 MappedByteBuffer 與 page cache 的對映關係。
從目前來看我們透過 MappedByteBuffer 就可以直接訪問到 page cache 了,不需要經歷缺頁中斷的開銷。但 page cache 所佔用的是實體記憶體,當系統中實體記憶體壓力大的時候,核心仍然會將 page cache 中的檔案頁 swap out 出去。
這時如果我們再次訪問 MappedByteBuffer 的時候,依然會發生缺頁中斷,當 MappedByteBuffer 被我們用來實現系統中的核心功能時,這就迫使我們要想辦法讓 MappedByteBuffer 背後對映的實體記憶體一直駐留在記憶體中,不允許核心 swap 。那麼本小節要介紹的 mlock 系統呼叫就派上用場了。
#include <sys/mman.h>
int mlock(const void *addr, size_t len);
mlock 的主要作用是將 [addr, addr+len] 這段範圍內的虛擬記憶體背後對映的實體記憶體鎖定在記憶體中,當記憶體資源緊張的時候,這段實體記憶體將不會被 swap out 出去。
如果 [addr, addr+len] 這段虛擬記憶體背後還未對映實體記憶體,那麼 mlock 也會立即在這段虛擬記憶體上主動觸發缺頁中斷,為其分配實體記憶體,並在程序頁表中建立對映關係。
// 檔案:/mm/mlock.c
SYSCALL_DEFINE2(mlock, unsigned long, start, size_t, len)
{
return do_mlock(start, len, VM_LOCKED);
}
do_mlock 的核心主要分為兩個步驟:
-
利用 apply_vma_lock_flags 函式在鎖定範圍內的虛擬記憶體區域內打上一個
VM_LOCKED
標記,後續核心在 swap 的時候,如果遇到被VM_LOCKED
標記的虛擬記憶體區域,那麼它背後對映的實體記憶體將不會被 swap out 出去,而是會一直駐留在記憶體中。 -
如果指定鎖定範圍內的虛擬記憶體還未有實體記憶體與之對映,那麼核心則呼叫 __mm_populate 主動為其填充實體記憶體,並在程序頁表中建立虛擬記憶體與實體記憶體的對映關係,從本文的視角上來說,就是建立 MappedByteBuffer 與 page cache 的對映關係。
static __must_check int do_mlock(unsigned long start, size_t len, vm_flags_t flags)
{
// 本次需要鎖定的記憶體頁個數
unsigned long locked;
// 核心允許單個程序能夠鎖定的實體記憶體頁個數
unsigned long lock_limit;
// 檢查核心是否允許進行記憶體鎖定
if (!can_do_mlock())
return -EPERM;
// 程序的相關資源限制配額定義在 task_struct->signal_struct->rlim 陣列中
// rlimit(RLIMIT_MEMLOCK) 表示核心允許單個程序對實體記憶體鎖定的限額,單位為位元組
lock_limit = rlimit(RLIMIT_MEMLOCK);
// 轉換為記憶體頁個數
lock_limit >>= PAGE_SHIFT;
locked = len >> PAGE_SHIFT;
// mm->locked_vm 表示當前程序已經鎖定的實體記憶體頁個數
locked += current->mm->locked_vm;
// 如果需要鎖定的記憶體資源沒有超過核心的限制
// 並且核心允許進行記憶體鎖定
if ((locked <= lock_limit) || capable(CAP_IPC_LOCK))
// 將 VM_LOCKED 標誌設定到 [start, start + len] 這段虛擬記憶體範圍內所有 vma 的屬性 vm_flags 中
error = apply_vma_lock_flags(start, len, flags);
// 遍歷 [start, start + len] 這段虛擬記憶體範圍內所包含的所有虛擬記憶體頁
// 依次在每個虛擬記憶體頁上進行缺頁處理,將其背後對映的檔案內容讀取到 page cache 中
// 並在程序頁表中建立好虛擬記憶體到 page cache 的對映關係
error = __mm_populate(start, len, 0);
return 0;
}
一個程序能夠允許鎖定的記憶體資源在核心中是有限制的,核心對程序相關資源的限制配額儲存在 task_struct->signal_struct->rlim
陣列中:
struct task_struct {
struct signal_struct *signal;
}
struct signal_struct {
// 程序相關的資源限制,相關的資源限制以陣列的形式組織在 rlim 中
// RLIMIT_MEMLOCK 下標對應的是程序能夠鎖定的記憶體資源,單位為bytes
struct rlimit rlim[RLIM_NLIMITS];
}
struct rlimit {
__kernel_ulong_t rlim_cur;
__kernel_ulong_t rlim_max;
};
我們可以透過修改 /etc/security/limits.conf
檔案中的 memlock 相關配置項來調整能夠被鎖定的記憶體資源配額,設定為 unlimited 表示不對鎖定記憶體進行限制。
程序能夠鎖定的實體記憶體資源配額透過 rlimit(RLIMIT_MEMLOCK)
來獲取,單位為位元組。
// 定義在檔案:/include/linux/sched/signal.h
static inline unsigned long rlimit(unsigned int limit)
{
// 引數 limit 為相關資源的下標
return task_rlimit(current, limit);
}
static inline unsigned long task_rlimit(const struct task_struct *task,
unsigned int limit)
{
return READ_ONCE(task->signal->rlim[limit].rlim_cur);
}
核心在對記憶體進行鎖定之前,需要透過 can_do_mlock 函式判斷一下是否允許本次鎖定操作:
-
rlimit(RLIMIT_MEMLOCK) != 0
表示程序能夠鎖定的記憶體資源限額還沒有用完,允許本次鎖定操作。 -
如果鎖定記憶體資源的限額已經用完,但是
capable(CAP_IPC_LOCK) = true
表示當前程序擁有CAP_IPC_LOCK
許可權,那麼即使在鎖定資源配額用完的情況下,核心也是允許程序對記憶體資源進行鎖定的。
bool can_do_mlock(void)
{
// 核心會限制能夠被鎖定的記憶體資源大小,單位為bytes
// 這裡獲取 RLIMIT_MEMLOCK 能夠鎖定的記憶體資源,如果為 0 ,則不能夠鎖定記憶體了。
if (rlimit(RLIMIT_MEMLOCK) != 0)
return true;
// 檢查核心是否允許 mlock ,mlockall 等記憶體鎖定操作
if (capable(CAP_IPC_LOCK))
return true;
return false;
}
如果當前程序已經鎖定的記憶體資源沒有超過核心的限制或者是當前程序擁有 CAP_IPC_LOCK
許可權,那麼核心就呼叫 apply_vma_lock_flags 將 [start, start + len] 這段虛擬記憶體範圍內對映的實體記憶體鎖定在記憶體中。
if ((locked <= lock_limit) || capable(CAP_IPC_LOCK))
error = apply_vma_lock_flags(start, len, flags);
記憶體鎖定的邏輯其實非常簡單,首先將 [start, start + len] 這段虛擬記憶體範圍內的所有的虛擬記憶體區域 vma 查詢出來,然後依次遍歷這些 vma , 並將 VM_LOCKED
標誌設定到 vma 的 vm_flags 標誌位中。
struct vm_area_struct {
unsigned long vm_flags;
}
後續在實體記憶體資源緊張,核心開始 swap 的時候,當遇到 vm_flags 設定了 VM_LOCKED
的虛擬記憶體區域 vma 的時候,那麼它背後對映的實體記憶體將不會被核心 swap out 出去。
從這裡我們可以看出,所謂的記憶體鎖定只不過是在指定鎖定範圍內的所有虛擬記憶體區域 vma 上打一個 VM_LOCKED
標記而已,但我們鎖定的物件卻是虛擬記憶體背後對映的實體記憶體。
所以接下來核心就會呼叫 __mm_populate 為 [start, start + len] 這段虛擬記憶體分配實體記憶體。核心這裡首先還是將 [start, start + len] 這段虛擬記憶體範圍內的所有 vma 查詢出來,並立即依次為每個 vma 填充實體記憶體。
int __mm_populate(unsigned long start, unsigned long len, int ignore_errors)
{
end = start + len;
// 依次遍歷程序地址空間中 [start , end] 這段虛擬記憶體範圍的所有 vma
for (nstart = start; nstart < end; nstart = nend) {
........ 省略查詢指定地址範圍內 vma 的過程 ....
// 為 vma 分配實體記憶體
ret = populate_vma_page_range(vma, nstart, nend, &locked);
// 繼續為下一個 vma (如果有的話)分配實體記憶體
nend = nstart + ret * PAGE_SIZE;
ret = 0;
}
return ret; /* 0 or negative error code */
}
populate_vma_page_range 負責計算單個 vma 中包含的虛擬記憶體頁個數,然後呼叫 __get_user_pages
函式在每一個虛擬記憶體頁上依次主動觸發缺頁中斷處理。
long populate_vma_page_range(struct vm_area_struct *vma,
unsigned long start, unsigned long end, int *nonblocking)
{
// 獲取程序地址空間
struct mm_struct *mm = vma->vm_mm;
// 計算 vma 中包含的虛擬記憶體頁個數,後續會按照 nr_pages 分配實體記憶體
unsigned long nr_pages = (end - start) / PAGE_SIZE;
int gup_flags;
// 迴圈遍歷 vma 中的每一個虛擬記憶體頁,依次為其分配實體記憶體頁
return __get_user_pages(current, mm, start, nr_pages, gup_flags,
NULL, NULL, nonblocking);
}
__get_user_pages 函式首先會透過 follow_page_mask
在程序頁表中檢查一下每一個虛擬記憶體頁是否已經對映了實體記憶體,如果已經有實體記憶體了,那麼這裡就不用分配了,直接跳過。
如果虛擬記憶體頁還沒有對映實體記憶體,那麼核心就會呼叫 faultin_page
立即觸發一次缺頁中斷,在缺頁中斷的處理中,核心就會將該虛擬記憶體頁(MappedByteBuffer)背後所對映的檔案內容讀取到 page cache 中,並在程序頁表中建立 MappedByteBuffer 與 page cache 的對映關係。
關於缺頁中斷的處理細節,感興趣的讀者可以回看下《一文聊透 Linux 缺頁異常的處理 —— 圖解 Page Faults》
static long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,
unsigned long start, unsigned long nr_pages,
unsigned int gup_flags, struct page **pages,
struct vm_area_struct **vmas, int *nonblocking)
{
// 迴圈遍歷 vma 中的每一個虛擬記憶體頁
do {
struct page *page;
// 在程序頁表中檢查該虛擬記憶體頁背後是否有實體記憶體頁對映
page = follow_page_mask(vma, start, foll_flags, &ctx);
if (!page) {
// 如果虛擬記憶體頁在頁表中並沒有實體記憶體頁對映,那麼這裡呼叫 faultin_page
// 底層會呼叫到 handle_mm_fault 進入缺頁處理流程 (write fault),分配實體記憶體,在頁表中建立好對映關係
ret = faultin_page(tsk, vma, start, &foll_flags,
nonblocking);
} while (nr_pages);
return i ? i : ret;
}
到這裡 mlock 系統呼叫就為大家介紹完了,接下來我們把上小節介紹的 madvise 系統呼叫與本小節的 mlock 放在一起對比一下,加深一下理解。
首先 madvise 系統呼叫中的 MADV_WILLNEED
作用很簡單,當我們在 MappedByteBuffer 身上運用 madvise 之後,核心只是會將 MappedByteBuffer 背後所對映的檔案內容載入到 page cache 中而已。
但 madvise 不會將 page cache 與 MappedByteBuffer 在程序頁表中對映,後面程序在訪問 MappedByteBuffer 的時候仍然會產生缺頁中斷,在缺頁中斷處理中才會與 page cache 在程序頁表中進行對映關聯。
當記憶體資源緊張的時候,page cache 中的檔案頁可能會被核心 swap out 出去,這時訪問 MappedByteBuffer 還是會觸發缺頁中斷。
當我們在 MappedByteBuffer 身上運用 mlock 之後,情況就不一樣了,首先 mlock 系統呼叫也會將 MappedByteBuffer 背後所對映的檔案內容載入到 page cache 中,除此之外,mlock 還會將 MappedByteBuffer 與 page cache 在程序頁表中對映起來,更重要的一點是,mlock 會將 page cache 中快取的相關檔案頁鎖定在記憶體中。
3.3 msync
我們都知道 MappedByteBuffer 在剛被 FileChannel#map
對映出來的時候,它只是一片虛擬記憶體而已,對映檔案的 page cache 是空的,程序頁表中對應的頁表項也都是空的。
後續我們透過訪問 MappedByteBuffer 直接觸發缺頁中斷也好,亦或者是透過前面介紹的兩個系統呼叫 madvise , mlock 也罷,它們解決的問題是負責將 MappedByteBuffer 背後對映的檔案內容載入到實體記憶體中(page cache),然後在程序頁表中設定 MappedByteBuffer 與 page cache 的關聯關係,以保證後續程序可以透過 MappedByteBuffer 直接訪問 page cache。
但無論是透過 MappedByteBuffer 還是傳統的 FileChannel#read or write
,它們在對檔案進行讀寫的時候都是直接操作的 page cache。page cache 中被寫入的檔案頁就會變成髒頁,後續核心會根據自己的回寫策略將髒頁重新整理到磁碟檔案中。
但核心的回寫策略是核心自己的行為,站在使用者程序的角度來看屬於被動回寫,如果使用者程序想要自己主動觸發髒頁的回寫就需要用到一些相關的系統呼叫。
而負責髒頁回寫的系統呼叫有很多,比如:sync,fsync, fdatasync 以及本小節要介紹的 msync。其中 sync 主要負責回寫整個系統內所有的髒頁以及相關檔案的 metadata。
而 fsync 和 fdatasync 主要是針對特定檔案的髒頁回寫,其中 fsync 不僅會回寫特定檔案的髒頁資料而且會回寫檔案的 metadata,fdatasync 就只會回寫特定檔案的髒頁資料不會回寫檔案的 metadata。
FileChannel 中的 force 方法就是針對特定檔案髒頁的回寫操作,引數 metaData 指定為 true 表示我們不僅需要對檔案髒頁內容進行回寫還需要對檔案的 metadata 進行回寫,所以在 native 層呼叫的是 fsync。
引數 metaData 指定為 false 表示我們僅僅是需要回寫檔案的髒頁內容,所以在 native 層呼叫的是 fdatasync 。
public class FileChannelImpl extends FileChannel
{
public void force(boolean metaData) throws IOException {
do {
// metaData = true 呼叫 fsync
// metaData = false 呼叫 fdatasync
rv = nd.force(fd, metaData);
} while ((rv == IOStatus.INTERRUPTED) && isOpen());
}
}
但 MappedByteBuffer 的回寫卻不是針對整個檔案的,而是針對其所對映的檔案區域進行回寫,這就用到了 msync 系統呼叫。
#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);
msync 主要針對 [addr, addr+ken] 這段虛擬記憶體範圍內所對映的檔案區域進行回寫,但 msync 只會回寫髒頁資料並不會回寫檔案的 metadata。引數 flags 用於指定回寫的方式,最常用的是 MS_SYNC
,它表示程序需要等到回寫操作完成之後才會從該系統呼叫中返回。
除了 MS_SYNC 之外核心還提供了 MS_ASYNC,MS_INVALIDATE 這兩個 flags 選項,但翻閱 msync 系統呼叫的原始碼你會發現,當我們設定了 MS_ASYNC 或者 MS_INVALIDATE 時,msync 不會做任何事情,相當於白白呼叫了一次。核心之所以會繼續保留這兩個選項,筆者這裡猜測可能是為了相容老版本核心關於髒頁相關的處理邏輯,這裡我們就不詳細展開了。
MappedByteBuffer#force
方法用於對指定對映範圍 [index,index+len] 內的檔案內容進行回寫:
public abstract class MappedByteBuffer extends ByteBuffer
{
public final MappedByteBuffer force(int index, int length) {
int capacity = capacity();
if ((address != 0) && (capacity != 0)) {
SCOPED_MEMORY_ACCESS.force(scope(), fd, address, isSync, index, length);
}
return this;
}
}
關於 MappedByteBuffer 的核心回寫邏輯 JDK 封裝在 MappedMemoryUtils 類中:
class MappedMemoryUtils {
static void force(FileDescriptor fd, long address, boolean isSync, long index, long length) {
if (isSync) {
// 如果 MappedByteBuffer 背後對映的是 persistent memory
// 那麼在 force 回寫資料的時候是透過 CPU 指令完成的而不是 msync 系統呼叫
Unsafe.getUnsafe().writebackMemory(address + index, length);
} else {
// force writeback via file descriptor
long offset = mappingOffset(address, index);
try {
force0(fd, mappingAddress(address, offset, index), mappingLength(offset, length));
} catch (IOException cause) {
throw new UncheckedIOException(cause);
}
}
}
private static native void force0(FileDescriptor fd, long address, long length) throws IOException;
}
如果 MappedByteBuffer 背後對映的是 persistent memory(isSync = true),那麼這裡的回寫指的是將資料從 CPU 快取記憶體 cache line 中重新整理到 persistent memory 中。
不過這個重新整理操作是透過 CLWK 指令(cache line writeback)將 cache line 中的資料 flush 到 persistent memory 中。不需要像傳統磁碟檔案那樣需要啟動塊裝置 IO 來回寫磁碟。
如果 MappedByteBuffer 背後對映的是普通磁碟檔案的話,JDK 這裡就會呼叫一個 native 方法 force0 將對映檔案區域的髒頁回寫到磁碟中,我們在 force0 的 native 實現中可以看到,JVM 這裡呼叫了 msync。
msync 和 mmap 也是需要配對使用的,mmap 負責對映,msync 負責將對映出來的檔案區域相關的髒頁回寫到磁碟中,所以我們在呼叫 msync 的時候,指定的虛擬記憶體範圍需要和 mmap 真實對映出來的虛擬記憶體範圍保持一致。
透過 mappingAddress 函式獲取 mmap 真實的起始對映地址 mapPosition,透過 mappingLength 獲取真實對映出來的區域大小 mapSize,將這兩個值作為要進行回寫的檔案對映範圍傳入 msync 系統呼叫中。
// 檔案:MappedMemoryUtils.c
JNIEXPORT void JNICALL
Java_java_nio_MappedMemoryUtils_force0(JNIEnv *env, jobject obj, jobject fdo,
jlong address, jlong len)
{
void* a = (void *)jlong_to_ptr(address);
int result = msync(a, (size_t)len, MS_SYNC);
if (result == -1) {
JNU_ThrowIOExceptionWithLastError(env, "msync failed");
}
}
下面我們來看一下當 JVM 呼叫 msync 之後,在核心中到底發生了什麼:
首先如果我們指定的這段 [start , end] 虛擬記憶體地址是無效的,也就是還未被對映過,那麼核心就會返回 ENOMEM
錯誤。
後面還是老套路,透過 find_vma
函式在程序地址空間中查詢出 [start , end] 這段虛擬記憶體範圍內第一個 vma 出來,然後在一個 for 迴圈中依次遍歷指定範圍內的所有 vma,並透過 vfs_fsync_range
將 vma 背後對映的檔案區域內的髒頁回寫到磁碟中。
// 檔案:/mm/msync.c
SYSCALL_DEFINE3(msync, unsigned long, start, size_t, len, int, flags)
{
unsigned long end;
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma;
// [start,end] 這段虛擬記憶體範圍內所對映的檔案內容將會被回寫到磁碟中
end = start + len;
// 在程序地址空間中查詢第一個符合 start < vma->vm_end 的 vma 區域
vma = find_vma(mm, start);
// 遍歷 [start,end] 區域內的所有 vma,依次回寫髒頁
for (;;) {
// 對映檔案
struct file *file;
// MappedByteBuffer 對映的檔案區域 [fstart,fend]
loff_t fstart, fend;
// 如果我們指定了一段無效的虛擬記憶體區域 [start,end],那麼核心會返回 ENOMEM 錯誤
error = -ENOMEM;
if (!vma)
goto out_unlock;
/* Here start < vma->vm_end. */
if (start < vma->vm_start) {
start = vma->vm_start;
if (start >= end)
goto out_unlock;
unmapped_error = -ENOMEM;
}
file = vma->vm_file;
// 對映的檔案內容在磁碟檔案中的起始偏移
fstart = (start - vma->vm_start) +
((loff_t)vma->vm_pgoff << PAGE_SHIFT);
// 對映的檔案內容在檔案中的結束偏移
fend = fstart + (min(end, vma->vm_end) - start) - 1;
if ((flags & MS_SYNC) && file &&
(vma->vm_flags & VM_SHARED)) {
// 回寫 [fstart,fend] 這段檔案區域內的髒頁到磁碟中
error = vfs_fsync_range(file, fstart, fend, 1);
}
}
out_unlock:
// 釋放程序地址空間鎖
up_read(&mm->mmap_sem);
out:
return error ? : unmapped_error;
}
vfs_fsync_range 函式最後一個引數 datasync
表示是否回寫對映檔案的 metadata,datasync = 0
表示檔案的 metadata 以及髒頁內容都需要回寫。datasync = 1
表示只需要回寫髒頁內容。
這裡我們看到 msync 系統呼叫將 datasync 設定為 1,只需要回寫髒頁內容即可。
int vfs_fsync_range(struct file *file, loff_t start, loff_t end, int datasync)
{
struct inode *inode = file->f_mapping->host;
// 對映檔案所在的檔案系統必須定義髒頁回寫函式 fsync
if (!file->f_op->fsync)
return -EINVAL;
if (!datasync && (inode->i_state & I_DIRTY_TIME))
// datasync = 0 表示不僅需要回寫髒頁資料而且還需要回寫檔案 metadata
mark_inode_dirty_sync(inode);
// 呼叫具體檔案系統中實現的 fsync 函式,實現對指定檔案區域內的髒頁進行回寫
return file->f_op->fsync(file, start, end, datasync);
}
EXPORT_SYMBOL(vfs_fsync_range);
msync 系統呼叫最終會呼叫到檔案相關的操作函式 fsync,它和具體的檔案系統相關,不同的檔案系統有不同的實現,但最終回寫髒頁的時候都需要啟動磁碟塊裝置 IO 對髒頁進行回寫。
4. 零複製
關於零複製這個話題,筆者原本不想再聊了,因為網上有太多討論零複製的文章了,而且有些寫的真挺不錯的,可是大部分文章都在寫 MappedByteBuffer 相較於傳統 FileChannel 的優勢,但好像很少有人來寫一寫 MappedByteBuffer 的劣勢,所以筆者這裡想寫一點不一樣的,來和大家討論討論 MappedByteBuffer 的劣勢有哪些。
但在開始討論這個話題之前,筆者想了想還是不能免俗,仍然需要把 MappedByteBuffer 和 FileChannel 放在一起從頭到尾對比一下,基於這個思路,我們先來重新簡要梳理一下 FileChannel 和 MappedByteBuffer 讀寫檔案的流程。
在之前的文章《從 Linux 核心角度探秘 JDK NIO 檔案讀寫本質》中,由於當時我們還未介紹 DirectByteBuffer 以及 MappedByteBuffer,所以筆者以 HeapByteBuffer 為例來介紹 FileChannel 讀寫檔案的整個原始碼實現邏輯。
當我們使用 HeapByteBuffer 傳入 FileChannel 的 read or write 方法對檔案進行讀寫時,JDK 會首先建立一個臨時的 DirectByteBuffer,對於 FileChannel#read
來說,JDK 在 native 層會將 read 系統呼叫從檔案中讀取的內容首先存放到這個臨時的 DirectByteBuffer 中,然後在複製到 HeapByteBuffer 中返回。
對於 FileChannel#write
來說,JDK 會首先將 HeapByteBuffer 中的待寫入資料複製到臨時的 DirectByteBuffer 中,然後在 native 層透過 write 系統呼叫將 DirectByteBuffer 中的資料寫入到檔案的 page cache 中。
public class IOUtil {
static int read(FileDescriptor fd, ByteBuffer dst, long position,
NativeDispatcher nd)
throws IOException
{
// 如果我們傳入的 dst 是 DirectBuffer,那麼直接進行檔案的讀取
// 將檔案內容讀取到 dst 中
if (dst instanceof DirectBuffer)
return readIntoNativeBuffer(fd, dst, position, nd);
// 如果我們傳入的 dst 是一個 HeapBuffer,那麼這裡就需要建立一個臨時的 DirectBuffer
// 在呼叫 native 方法底層利用 read or write 系統呼叫進行檔案讀寫的時候
// 傳入的只能是 DirectBuffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
try {
// 底層透過 read 系統呼叫將檔案內容複製到臨時 DirectBuffer 中
int n = readIntoNativeBuffer(fd, bb, position, nd);
if (n > 0)
// 將臨時 DirectBuffer 中的檔案內容在複製到 HeapBuffer 中返回
dst.put(bb);
return n;
}
}
static int write(FileDescriptor fd, ByteBuffer src, long position,
NativeDispatcher nd) throws IOException
{
// 如果傳入的 src 是 DirectBuffer,那麼直接將 DirectBuffer 中的內容複製到檔案 page cache 中
if (src instanceof DirectBuffer)
return writeFromNativeBuffer(fd, src, position, nd);
// 如果傳入的 src 是 HeapBuffer,那麼這裡需要首先建立一個臨時的 DirectBuffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
try {
// 首先將 HeapBuffer 中的待寫入內容複製到臨時的 DirectBuffer 中
// 隨後透過 write 系統呼叫將臨時 DirectBuffer 中的內容寫入到檔案 page cache 中
int n = writeFromNativeBuffer(fd, bb, position, nd);
return n;
}
}
}
當時有很多讀者朋友給我留言提問說,為什麼必須要在 DirectByteBuffer 中做一次中轉,直接將 HeapByteBuffer 傳給 native 層不行嗎 ?
答案是肯定不行的,在本文開頭筆者為大家介紹過 JVM 程序的虛擬記憶體空間佈局,如下圖所示:
HeapByteBuffer 和 DirectByteBuffer 從本質上來說均是 JVM 程序地址空間內的一段虛擬記憶體,對於 Java 程式來說 HeapByteBuffer 被用來特定表示 JVM 堆中的記憶體,而 DirectByteBuffer 就是一個普通的 C++ 程式透過 malloc 系統呼叫向作業系統申請的一段 Native Memory 位於 JVM 堆之外。
既然 HeapByteBuffer 是位於 JVM 堆中的記憶體,那麼它必然會受到 GC 的管理,當發生 GC 的時候,如果我們選擇的垃圾回收器採用的是 Mark-Copy 或者 Mark-Compact 演算法的時候(Mark-Swap 除外),GC 會來回移動存活的物件,這就導致了存活的 Java 物件比如這裡的 HeapByteBuffer 在 GC 之後它背後的記憶體地址可能已經發生了變化。
而 JVM 中的這些 native 方法是處於 safepoint 之下的,執行 native 方法的執行緒由於是處於 safepoint 中,所以在執行 native 方法的過程中可能會有 GC 的發生。
如果我們把一個 HeapByteBuffer 傳遞給 native 層進行檔案讀寫的時候不巧發生了 GC,那麼 HeapByteBuffer 背後的記憶體地址就會變化,這樣一來,如果我們在讀取檔案的話,核心將會把檔案內容複製到另一個記憶體地址中。如果我們在寫入檔案的話,核心將會把另一個記憶體地址中的記憶體寫入到檔案的 page cache 中。
所以我們在透過 native 方法執行相關係統呼叫的時候必須要保證傳入的記憶體地址是不會變化的,由於 DirectByteBuffer 背後所依賴的 Native Memory 位於 JVM 堆之外,是不會受到 GC 管理的,因此不管發不發生 GC,DirectByteBuffer 所引用的這些 Native Memory 地址是不會發生變化的。
所以我們在呼叫 native 方法進行檔案讀寫的時候需要傳入 DirectByteBuffer,如果我們用得是 HeapByteBuffer ,那麼就需要一個臨時的 DirectByteBuffer 作為中轉。
這時可能有讀者朋友又會問了,我們在使用 HeapByteBuffer 透過 FileChannel#write
對檔案進行寫入的時候,首先會將 HeapByteBuffer 中的內容複製到臨時的 DirectByteBuffer 中,那如果在這個複製的過程中發生了 GC,HeapByteBuffer 背後引用記憶體的地址發生了變化,那麼複製到 DirectByteBuffer 中的內容仍然是錯的啊。
事實上在這個複製的過程中是不會發生 GC 的,因為 JVM 這裡會使用 Unsafe#copyMemory
方法來實現 HeapByteBuffer 到 DirectByteBuffer 的複製操作,copyMemory 被 JVM 實現為一個 intrinsic 方法,中間是沒有 safepoint 的,執行 copyMemory 的執行緒由於不在 safepoint 中,所以在複製的過程中是不會發生 GC 的。
public final class Unsafe {
// intrinsic 方法
public native void copyMemory(Object srcBase, long srcOffset,
Object destBase, long destOffset,
long bytes);
}
在交代完這個遺留的問題之後,下面我們就以 DirectByteBuffer 為例來重新簡要回顧下傳統 FileChannel 對檔案的讀寫流程:
-
當 JVM 在 native 層使用 read 系統呼叫進行檔案讀取的時候,JVM 程序會發生第一次上下文切換,從使用者態轉為核心態。
-
隨後 JVM 程序進入虛擬檔案系統層,在這一層核心首先會檢視讀取檔案對應的 page cache 中是否含有請求的檔案資料,如果有,那麼直接將檔案資料複製到 DirectByteBuffer 中返回,避免一次磁碟 IO。並根據核心預讀演算法從磁碟中非同步預讀若干檔案資料到 page cache 中
-
如果請求的檔案資料不在 page cache 中,則會進入具體的檔案系統層,在這一層核心會啟動磁碟塊裝置驅動觸發真正的磁碟 IO。並根據核心預讀演算法同步預讀若干檔案資料。請求的檔案資料和預讀的檔案資料將被一起填充到 page cache 中。
-
磁碟控制器 DMA 將從磁碟中讀取的資料複製到頁快取記憶體 page cache 中。發生第一次資料複製。
-
由於 page cache 是屬於核心空間的,不能被 JVM 程序直接定址,所以還需要 CPU 將 page cache 中的資料複製到位於使用者空間的 DirectByteBuffer 中,發生第二次資料複製。
-
最後 JVM 程序從系統呼叫 read 中返回,並從核心態切換回使用者態。發生第二次上下文切換。
從以上過程我們可以看到,當使用 FileChannel#read
對檔案讀取的時候,如果檔案資料在 page cache 中,涉及到的效能開銷點主要有兩次上下文切換,以及一次 CPU 複製。其中上下文切換是主要的效能開銷點。
下面是透過 FileChannel#write
寫入檔案的整個過程:
-
當 JVM 在 native 層使用 write 系統呼叫進行檔案寫入的時候,JVM 程序會發生第一次上下文切換,從使用者態轉為核心態。
-
進入核心態之後,JVM 程序在虛擬檔案系統層呼叫 vfs_write 觸發對 page cache 寫入的操作。核心呼叫 iov_iter_copy_from_user_atomic 函式將 DirectByteBuffer 中的待寫入資料複製到 page cache 中。發生第一次複製動作( CPU 複製)。
-
當待寫入資料複製到 page cache 中時,核心會將對應的檔案頁標記為髒頁,核心會根據一定的閾值判斷是否要對 page cache 中的髒頁進行回寫,如果不需要同步回寫,程序直接返回。這裡發生第二次上下文切換。
-
髒頁回寫又會根據髒頁數量在記憶體中的佔比分為:程序同步回寫和核心非同步回寫。當髒頁太多了,程序自己都看不下去的時候,會同步回寫記憶體中的髒頁,直到回寫完畢才會返回。在回寫的過程中會發生第二次複製(DMA 複製)。
從以上過程我們可以看到,當使用 FileChannel#write
對檔案寫入的時候,如果不考慮髒頁回寫的情況,單純對於 JVM 這個程序來說涉及到的效能開銷點主要有兩次上下文切換,以及一次 CPU 複製。其中上下文切換仍然是主要的效能開銷點。
下面我們來看下透過 MappedByteBuffer 對檔案進行讀寫的過程:
首先我們需要透過 FileChannel#map
將檔案的某個區域對映到 JVM 程序的虛擬記憶體空間中,從而獲得一段檔案對映的虛擬記憶體區域 MappedByteBuffer。由於底層使用到了 mmap 系統呼叫,所以這個過程也涉及到了兩次上下文切換。
如上圖所示,當 MappedByteBuffer 在剛剛對映出來的時候,它只是程序地址空間中的一段虛擬記憶體,其對應在程序頁表中的頁表項還是空的,背後還沒有對映實體記憶體。此時對映檔案對應的 page cache 也是空的,我們要對映的檔案內容此時還靜靜地躺在磁碟中。
當 JVM 程序開始對 MappedByteBuffer 進行讀寫的時候,就會觸發缺頁中斷,核心會將對映的檔案內容從磁碟中載入到 page cache 中,然後在程序頁表中建立 MappedByteBuffer 與 page cache 的對映關係。由於這裡涉及到了缺頁中斷的處理,因此也會有兩次上下文切換的開銷。
後面 JVM 程序對 MappedByteBuffer 的讀寫就相當於是直接讀寫 page cache 了,關於這一點,很多讀者朋友會有這樣的疑問:page cache 是核心態的部分,為什麼我們透過使用者態的 MappedByteBuffer 就可以直接訪問核心態的東西了?
這裡大家不要被核心態這三個字給唬住了,雖然 page cache 是屬於核心部分的,但其本質上還是一塊普通的實體記憶體,想想我們是怎麼訪問記憶體的 ? 不就是先有一段虛擬記憶體,然後在申請一段實體記憶體,最後透過程序頁表將虛擬記憶體和實體記憶體對映起來麼,程序在訪問虛擬記憶體的時候,透過頁表找到其對映的實體記憶體地址,然後直接透過實體記憶體地址訪問實體記憶體。
回到我們討論的內容中,這段虛擬記憶體不就是 MappedByteBuffer 嗎,實體記憶體就是 page cache 啊,在透過頁表對映起來之後,程序在透過 MappedByteBuffer 訪問 page cache 的過程就和訪問普通記憶體的過程是一模一樣的。
也正因為 MappedByteBuffer 背後對映的實體記憶體是核心空間的 page cache,所以它不會消耗任何使用者空間的實體記憶體(JVM 的堆外記憶體),因此也不會受到 -XX:MaxDirectMemorySize
引數的限制。
現在我們已經清楚了 FileChannel 以及 MappedByteBuffer 進行檔案讀寫的整個過程,下面我們就來把兩種檔案讀寫方式放在一起來對比一下,但這裡有一個對比的前提:
-
對於 MappedByteBuffer 來說,我們對比的是其在缺頁處理之後,讀寫檔案的開銷。
-
對於 FileChannel 來說,我們對比的是檔案資料已經存在於 page cache 中的情況下讀寫檔案的開銷。
因為筆者認為只有基於這個前提來對比兩者的效能差異才有意義。
-
對於 FileChannel 來說,無論是透過 read 方法對檔案的讀取,還是透過 write 方法對檔案的寫入,它們都需要兩次上下文切換,以及一次 CPU 複製,其中上下文切換是其主要的效能開銷點。
-
對於 MappedByteBuffer 來說,由於其背後直接對映的就是 page cache,讀寫 MappedByteBuffer 本質上就是讀寫 page cache,整個讀寫過程和讀寫普通的記憶體沒有任何區別,因此沒有上下文切換的開銷,不會切態,更沒有任何複製。
從上面的對比我們可以看出使用 MappedByteBuffer 來讀寫檔案既沒有上下文切換的開銷,也沒有資料複製的開銷(可忽略),簡直是完爆 FileChannel。
既然 MappedByteBuffer 這麼屌,那我們何不乾脆在所有檔案的讀寫場景中全部使用 MappedByteBuffer,這樣豈不省事 ?JDK 為何還保留了 FileChannel 的 read , write 方法呢 ?讓我們來帶著這個疑問繼續下面的內容~~
5. MappedByteBuffer VS FileChannel
到現在為止,筆者已經帶著大家完整的剖析了 mmap,read,write 這些系統呼叫在核心中的原始碼實現,並基於原始碼對 MappedByteBuffer 和 FileChannel 兩者進行了效能開銷上的對比。
雖然祭出了原始碼,但畢竟還是 talk is cheap,本小節我們就來對兩者進行一次 Benchmark,來看一下 MappedByteBuffer 與 FileChannel 對檔案讀寫的實際效能表現如何 ? 是否和我們從原始碼中分析的結果一致。
我們從兩個方面來對比 MappedByteBuffer 和 FileChannel 的檔案讀寫效能:
-
檔案資料完全載入到 page cache 中,並且將 page cache 鎖定在記憶體中,不允許 swap,MappedByteBuffer 不會有缺頁中斷,FileChannel 不會觸發磁碟 IO 都是直接對 page cache 進行讀寫。
-
檔案資料不在 page cache 中,我們加上了 缺頁中斷,磁碟IO,以及 swap 對檔案讀寫的影響。
具體的測試思路是,用 MappedByteBuffer 和 FileChannel 分別以
64B ,128B ,512B ,1K ,2K ,4K ,8K ,32K ,64K ,1M ,32M ,64M ,512M 為單位依次對 1G 大小的檔案進行讀寫,從以上兩個方面對比兩者在不同讀寫單位下的效能表現。
需要提醒大家的是本小節中得出的讀寫效能具體數值是沒有參考價值的,因為不同軟硬體環境下測試得出的具體效能數值都不一樣,值得參考的是 MappedByteBuffer 和 FileChannel 在不同資料集大小下的讀寫效能趨勢走向。筆者的軟硬體測試環境如下:
- 處理器:2.5 GHz 四核Intel Core i7
- 記憶體:16 GB 1600 MHz DDR3
- SSD:APPLE SSD SM0512F
- 作業系統:macOS
- JVM:OpenJDK 17
測試程式碼:https://github.com/huibinliupush/benchmark , 大家也可以在自己的測試環境中執行一下,然後將跑出的結果提交到這個倉庫中。這樣方便大家在不同的測試環境下對比兩者的檔案讀寫效能差異 —— 眾人拾柴火焰高。
5.1 檔案資料在 page cache 中
由於這裡我們要測試 MappedByteBuffer 和 FileChannel 直接對 page cache 的讀寫效能,所以筆者讓 MappedByteBuffer ,FileChannel 只針對同一個檔案進行讀寫測試。
在對檔案進行讀寫之前,首先透過 mlock 系統呼叫將檔案資料提前載入到 page cache 中並主動觸發缺頁處理,在程序頁表中建立好 MappedByteBuffer 和 page cache 的對映關係。最後將 page cache 鎖定在記憶體中不允許 swap。
下面是 MappedByteBuffer 和 FileChannel 在不同資料集下對 page cache 的讀取效能測試:
執行結果如下:
為了直觀的讓大家一眼看出 MappedByteBuffer 和 FileChannel 在對 page cache 讀取的效能差異,筆者根據上面跑出的效能資料繪製成下面這幅柱狀圖,方便大家觀察兩者的效能趨勢走向。
這裡我們可以看出,MappedByteBuffer 在 4K 之前具有明顯的壓倒性優勢,在 [8K , 32M] 這個區間內,MappedByteBuffer 依然具有優勢但已經不是十分明顯了,從 64M 開始 FileChannel 實現了一點點反超。
我們可以得到的效能趨勢是,在 [64B, 2K] 這個單次讀取資料量級範圍內,MappedByteBuffer 讀取的效能越來越快,並在 2K 這個資料量級下達到了效能最高值,僅消耗了 73 ms。從 4K 開始讀取效能在一點一點的逐漸下降,並在 64M 這個資料量級下被 FileChannel 反超。
而 FileChannel 的讀取效能會隨著資料量的增大反而越來越好,並在某一個資料量級下效能會反超 MappedByteBuffer。FileChannel 的最佳讀取效能點是在 64K 處,消耗了 167ms 。
因此 MappedByteBuffer 適合頻繁讀取小資料量的場景,具體多小,需要大家根據自己的環境進行測試,本小節我們得出的資料是 4K 以下。
FileChannel 適合大資料量的批次讀取場景,具體多大,還是需要大家根據自己的環境進行測試,本小節我們得出的資料是 64M 以上。
下面是 MappedByteBuffer 和 FileChannel 在不同資料集下對 page cache 的寫入效能測試:
執行結果如下:
MappedByteBuffer 和 FileChannel 在不同資料集下對 page cache 的寫入效能的趨勢走向柱狀圖:
這裡我們可以看到 MappedByteBuffer 在 8K 之前具有明顯的寫入優勢,它的寫入效能趨勢是在 [64B , 8K] 這個資料集方位內,寫入效能隨著資料量的增大而越來越快,直到在 8K 這個資料集下達到了最佳寫入效能。
而在 [32K, 32M] 這個資料集範圍內,MappedByteBuffer 仍然具有優勢,但已經不是十分明顯了,最終在 64M 這個資料集下被 FileChannel 反超。
和前面的讀取效能趨勢一樣,FileChannel 的寫入效能也是隨著資料量的增大反而越來越好,最佳的寫入效能是在 64K 處,僅消耗了 160 ms 。
5.2 檔案資料不在 page cache 中
在這一小節中,我們將缺頁中斷和磁碟 IO 的影響加入進來,不新增任何的最佳化手段純粹地測一下 MappedByteBuffer 和 FileChannel 對檔案讀寫的效能。
為了避免被 page cache 影響,所以我們需要在每一個測試資料集下,單獨分別為 MappedByteBuffer 和 FileChannel 建立各自的測試檔案。
下面是 MappedByteBuffer 和 FileChannel 在不同資料集下對檔案的讀取效能測試:
執行結果:
從這裡我們可以看到,在加入了缺頁中斷和磁碟 IO 的影響之後,MappedByteBuffer 在缺頁中斷的影響下平均比之前多出了 500 ms 的開銷。FileChannel 在磁碟 IO 的影響下在 [64B , 512B] 這個資料集範圍內比之前平均多出了 1000 ms 的開銷,在 [1K, 512M] 這個資料集範圍內比之前平均多出了 100 ms 的開銷。
在 2K 之前, MappedByteBuffer 具有明顯的讀取效能優勢,最佳的讀取效能出現在 512B 這個資料集下,從 512B 往後,MappedByteBuffer 的讀取效能趨勢總體成下降趨勢,並在 4K 這個地方被 FileChannel 反超。
FileChannel 則是在 [64B, 1M] 這個資料集範圍內,讀取效能會隨著資料集的增大而提高,並在 1M 這個地方達到了 FileChannel 的最佳讀取效能,僅消耗了 258 ms,在 [32M , 512M] 這個範圍內 FileChannel 的讀取效能在逐漸下降,但是比 MappedByteBuffer 的效能高出了一倍。
讀到這裡大家不禁要問了,理論上來講 MappedByteBuffer 應該是完爆 FileChannel 才對啊,因為 MappedByteBuffer 沒有系統呼叫的開銷,為什麼效能在後面反而被 FileChannel 超越了近一倍之多呢 ?
要明白這個問題,我們就需要分別把 MappedByteBuffer 和 FileChannel 在讀寫檔案時候所涉及到的效能開銷點一一列舉出來,並對這些效能開銷點進行詳細對比,這樣答案就有了。
首先 MappedByteBuffer 的主要效能開銷是在缺頁中斷,而 FileChannel 的主要開銷是在系統呼叫,兩者都會涉及上下文的切換。
FileChannel 在讀寫檔案的時候有磁碟IO,有預讀。同樣 MappedByteBuffer 的缺頁中斷也有磁碟IO 也有預讀。目前來看他倆一比一打平。
但別忘了 MappedByteBuffer 是需要程序頁表支援的,在實際訪問記憶體的過程中會遇到頁表競爭以及 TLB shootdown 等問題。還有就是 MappedByteBuffer 剛剛被對映出來的時候,其在程序頁表中對應的各級頁表以及頁目錄可能都是空的。所以缺頁中斷這裡需要做的一件非常重要的事情就是補齊完善 MappedByteBuffer 在程序頁表中對應的各級頁目錄表和頁表,並在頁表項中將 page cache 對映起來,最後還要重新整理 TLB 等硬體快取。
想更多瞭解缺頁中斷細節的讀者可以看下之前的文章——
《一文聊透 Linux 缺頁異常的處理 —— 圖解 Page Faults》
而 FileChannel 並不會涉及上面的這些開銷,所以 MappedByteBuffer 的缺頁中斷要比 FileChannel 的系統呼叫開銷要大,這一點我們可以在上小節和本小節的讀寫效能對比中看得出來。
檔案資料在 page cache 中與不在 page cache 中,MappedByteBuffer 前後的讀取效能平均差了 500 ms,而 FileChannel 前後卻只平均差了 100 ms。
MappedByteBuffer 的缺頁中斷是平均每 4K 觸發一次,而 FileChannel 的系統呼叫開銷則是每次都會觸發。當兩者單次按照小資料量讀取 1G 檔案的時候,MappedByteBuffer 的缺頁中斷較少觸發,而 FileChannel 的系統呼叫卻在頻繁觸發,所以在這種情況下,FileChannel 的系統呼叫是主要的效能瓶頸。
這也就解釋了當我們在頻繁讀寫小資料量的時候,MappedByteBuffer 的效能具有壓倒性優勢。當單次讀寫的資料量越來越大的時候,FileChannel 呼叫的次數就會越來越少,這時候缺頁中斷就會成為 MappedByteBuffer 的效能瓶頸,到某一個點之後,FileChannel 就會反超 MappedByteBuffer。因此當我們需要高吞吐量讀寫檔案的時候 FileChannel 反而是最合適的。
除此之外,核心的髒頁回寫也會對 MappedByteBuffer 以及 FileChannel 的檔案寫入效能有非常大的影響,無論是我們在使用者態中呼叫 fsync 或者 msync 主動觸發髒頁回寫還是核心透過 pdflush 執行緒非同步髒頁回寫,當我們使用 MappedByteBuffer 或者 FileChannel 寫入 page cache 的時候,如果恰巧遇到檔案頁的回寫,那麼寫入操作都會有非常大的延遲,這個在 MappedByteBuffer 身上體現的更為明顯。
為什麼這麼說呢 ? 我們還是到核心原始碼中去探尋原因,先來看髒頁回寫對 FileChannel 的寫入影響。下面是 FileChannel 檔案寫入在核心中的核心實現:
ssize_t generic_perform_write(struct file *file,
struct iov_iter *i, loff_t pos)
{
// 從 page cache 中獲取要寫入的檔案頁並準備記錄檔案後設資料日誌工作
status = a_ops->write_begin(file, mapping, pos, bytes, flags,
&page, &fsdata);
// 將使用者空間緩衝區 DirectByteBuffer 中的資料複製到 page cache 中的檔案頁中
copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
// 將寫入的檔案頁標記為髒頁並完成檔案後設資料日誌的寫入
status = a_ops->write_end(file, mapping, pos, bytes, copied,
page, fsdata);
// 判斷是否需要同步回寫髒頁
balance_dirty_pages_ratelimited(mapping);
}
首先核心會在 write_begin 函式中透過 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 中查詢寫入資料的快取頁
page = pagecache_get_page(mapping, index, fgp_flags,
mapping_gfp_mask(mapping));
if (page)
wait_for_stable_page(page);
return page;
}
在這裡會呼叫一個非常重要的函式 wait_for_stable_page,這個函式的作用就是判斷當前 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);
等到髒頁回寫完畢之後,程序才會呼叫 iov_iter_copy_from_user_atomic 將待寫入資料複製到 page cache 中,最後在 write_end 中呼叫 mark_buffer_dirty 將寫入的檔案頁標記為髒頁。
除了正在回寫的髒頁會阻塞 FileChannel 的寫入過程之外,如果此時系統中的髒頁太多了,超過了 dirty_ratio
或者 dirty_bytes
等核心引數配置的髒頁比例,那麼程序就會同步去回寫髒頁,這也對寫入效能有非常大的影響。
我們接著再來看髒頁回寫對 MappedByteBuffer 的寫入影響,在開始分析之前,筆者先問大家一個問題:透過 MappedByteBuffer 寫入 page cache 之後,page cache 中的相應檔案頁是怎麼變髒的 ?
FileChannel 很好理解,因為 FileChannel 走的是系統呼叫,會進入到檔案系統由核心進行處理,如果寫入檔案頁恰好正在回寫時,核心會呼叫 wait_for_stable_page 阻塞當前程序。在將資料寫入檔案頁之後,核心又會呼叫 mark_buffer_dirty 將頁面變髒。
MappedByteBuffer 就很難理解了,因為 MappedByteBuffer 不會走系統呼叫,直接讀寫的就是 page cache,而 page cache 也只是核心在軟體層面上的定義,它的本質還是實體記憶體。另外髒頁以及髒頁的回寫都是核心在軟體層面上定義的概念和行為。
MappedByteBuffer 直接寫入的是硬體層面的實體記憶體(page cache),硬體哪管你軟體上定義的髒頁以及髒頁回寫啊,沒有核心的參與,那麼在透過 MappedByteBuffer 寫入檔案頁之後,檔案頁是如何變髒的呢 ?還有就是 MappedByteBuffer 如何探測到對應檔案頁正在回寫並阻塞等待呢 ?
既然我們涉及到了軟體的概念和行為,那麼一定就會有核心的參與,我們回想一下整個 MappedByteBuffer 的生命週期,唯一一次和核心打交道的機會就是缺頁中斷,我們看看能不能在缺頁中斷中發現點什麼~
當 MappedByteBuffer 剛剛被 mmap 對映出來的時候它還只是一段普通的虛擬記憶體,背後什麼都沒有,其在程序頁表中的各級頁目錄項以及頁表項都還是空的。
當我們立即對 MappedByteBuffer 進行寫入的時候就會發生缺頁中斷,在缺頁中斷的處理中,核心會在程序頁表中補齊與 MappedByteBuffer 對映相關的各級頁目錄並在頁表項中與 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);
// 將檔案頁變為可寫狀態,並設定檔案頁為髒頁
// 如果檔案頁正在回寫,那麼阻塞等待
tmp = do_page_mkwrite(vmf);
}
}
除此之外,核心還會呼叫 do_page_mkwrite 方法將 MappedByteBuffer 對應的頁表項變成可寫狀態,並將與其對映的檔案頁立即設定位髒頁,如果此時檔案頁正在回寫,那麼 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 在核心中是先變髒然後在對 page cache 進行寫入,而 FileChannel 是先寫入 page cache 後在變髒。
從此之後,透過 MappedByteBuffer 對 page cache 的寫入就會變得非常絲滑,那麼問題來了,當 page cache 中的髒頁被核心非同步回寫之後,核心會把檔案頁中的髒頁標記清除掉,那麼這時如果 MappedByteBuffer 對 page cache 寫入,由於不會發生缺頁中斷,那麼 page cache 中的檔案頁如何再次變髒呢 ?
核心這裡的設計非常巧妙,當核心回寫完髒頁之後,會呼叫 page_mkclean_one 函式清除檔案頁的髒頁標記,在這裡會首先透過 page_vma_mapped_walk 判斷該檔案頁是不是被 mmap 對映到程序地址空間的,如果是,那麼說明該檔案頁是被 MappedByteBuffer 對映的。隨後核心就會做一些特殊處理:
-
透過 pte_wrprotect 對 MappedByteBuffer 在程序頁表中對應的頁表項 pte 進行防寫,變為只讀許可權。
-
透過 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 在頁表中就變成只讀的了,這一切對使用者態的我們都是透明的,當再次對 MappedByteBuffer 寫入的時候就不是那麼絲滑了,會觸發防寫缺頁中斷(我們以為不會有缺頁中斷,其實是有的),在防寫中斷的處理中,核心會重新將頁表項 pte 變為可寫,檔案頁標記為髒頁。如果檔案頁正在回寫,缺頁中斷會阻塞。如果髒頁積累的太多,這裡也會同步回寫髒頁。
static vm_fault_t wp_page_shared(struct vm_fault *vmf)
__releases(vmf->ptl)
{
if (vma->vm_ops && vma->vm_ops->page_mkwrite) {
// 設定頁表項為可寫
// 標記檔案頁為髒頁
// 如果檔案頁正在回寫則阻塞等待
tmp = do_page_mkwrite(vmf);
}
// 判斷是否需要同步回寫髒頁,
fault_dirty_shared_page(vma, vmf->page);
return VM_FAULT_WRITE;
}
所以並不是對 MappedByteBuffer 呼叫 mlock 之後就萬事大吉了,在遇到髒頁回寫的時候,MappedByteBuffer 依然會發生防寫型別的缺頁中斷。在缺頁中斷處理中會等待髒頁的回寫,並且還可能會發生髒頁的同步回寫。這對 MappedByteBuffer 的寫入效能會有非常大的影響。
在明白這些問題之後,下面我們繼續來看 MappedByteBuffer 和 FileChannel 在不同資料集下對檔案的寫入效能測試:
執行結果:
在筆者的測試環境中,我們看到 MappedByteBuffer 在對檔案的寫入效能一路碾壓 FileChannel,並沒有出現被 FileChannel 反超的情況。但我們看到 MappedByteBuffer 從 4K 開始寫入效能是在逐漸下降的,而 FileChannel 的寫入效能卻在一路升高。
根據上面的分析,我們可以推斷出,後面隨著資料量的增大,由於 MappedByteBuffer 缺頁中斷瓶頸的影響,在 512M 後面某一個資料集下,FileChannel 的寫入效能最終是會超過 MappedByteBuffer 的。
在本小節的開頭,筆者就強調了,本小節值得參考的是 MappedByteBuffer 和 FileChannel 在不同資料集大小下的讀寫效能趨勢走向,而不是具體的效能數值。
6. MappedByteBuffer 在 RocketMQ 中的應用
在 RocketMQ 的訊息儲存架構模型中有三個非常核心的檔案,它們分別是:CommitLog,ConsumeQueue,IndexFile。其中 CommitLog 是訊息真正儲存的地方,而 ConsumeQueue 和 IndexFile 都是根據 CommitLog 生成的訊息索引檔案,它們包含了訊息在 CommitLog 檔案中的真實物理偏移。
6.1 CommitLog
當 Producer 將訊息傳送到 Broker 之後,RocketMQ 會根據訊息的序列化協議將訊息持久化到 CommitLog 檔案中,一旦訊息被刷到磁碟中,Producer 傳送給 Broker 的訊息就不會丟失了。CommitLog 檔案儲存的主體是訊息的 body 以及相關的後設資料,CommitLog 並不會區分訊息的 Topic。也就是說在同一 Broker 例項中,所有 Topic 下的訊息都會被順序的寫入 CommitLog 檔案混合儲存。
CommitLog 檔案的預設大小為 1G,儲存路徑:/{storePathRootDir}/store/commitlog/{fileName}
。檔案的命名規則為 CommitLog 檔案中儲存訊息的最小物理偏移,當一個 CommitLog 檔案被寫滿之後,RocketMQ 就會建立一個新的 CommitLog 檔案。
比如,第一個 CommitLog 檔案會命名為 00000000000000000000
,檔名一共 20 位,左邊補零,剩餘為訊息在檔案中的最小物理偏移,檔案大小為 1G,表示第一個 CommitLog 檔案中訊息的最小物理偏移為 0 。
當第一個 CommitLog 檔案被寫滿之後,第二個 CommitLog 檔案就會被命名為 00000000001073741824
(1G = 1073741824),表示第二個 CommitLog 檔案中訊息的最小物理偏移為 1073741824。後面第三個,第四個 CommitLog 檔案的命名規則都是一樣的,以此類推。
單個 Broker 例項下的每條訊息的物理偏移是全域性唯一的,而 CommitLog 檔案的命名規則是根據訊息的物理偏移依次遞增的,所以給定一個訊息的物理偏移,透過二分查詢就能很快的定位到儲存該訊息的具體 CommitLog 檔案。
6.2 ConsumeQueue
現在訊息的儲存解決了,但是訊息的消費卻成了難題,因為單個 Broker 例項下的所有 Topic 訊息都是混合儲存在 CommitLog 中,而 Consumer 是基於訂閱的 Topic 進行消費的,這樣一來,Consumer 想要消費具體 Topic 下的訊息,就需要根據 Topic 來遍歷 CommitLog 檢索訊息,這樣效率是非常低下的。
因此就有必要為 Consumer 消費訊息專門建立一個索引檔案,這個索引檔案就是 ConsumeQueue ,ConsumeQueue 可以看做是基於 Topic 的 CommitLog 索引檔案 。
每個 Topic 下邊包含多個 MessageQueue,該 Topic 下的所有訊息會均勻的分佈在各個 MessageQueue 中,有點像 Kafka 裡的 Partition 概念。Producer 在向 Broker 傳送訊息的時候會指定該訊息所屬的 MessageQueue。每個 MessageQueue 下邊會有多個 ConsumeQueue 檔案,用於儲存該佇列中的訊息在 CommitLog 中的索引。
ConsumeQueue 檔案的儲存路徑結構為:Topic/MessageQueue/ConsumeQueue
,具體的儲存路徑是:/{storePathRootDir}/store/consumequeue/{topic}/{queueId}/{fileName}
,單個 ConsumeQueue 檔案可以儲存 30 萬條訊息索引,每條訊息索引佔用 20 個位元組,分別是:訊息在 CommitLog 中的物理偏移(8位元組),訊息的長度(4位元組),訊息 tag 的 hashcode(8位元組)。每個 ConsumeQueue 檔案大小約為 5.72M(30萬 * 20 = 600 萬位元組)。
ConsumeQueue 檔案的命名規則是訊息索引在檔案中的最小物理偏移,比如,每個 MessageQueue 下第一個 ConsumeQueue 檔案會被命名為 00000000000000000000
,檔案大小為 5.72M。當第一個檔案寫滿之後,就會建立第二個 ConsumeQueue 檔案,命名為 00000000000006000000
。這樣依次類推。
RocketMQ 會啟動一個叫做 ReputMessageService 的後臺執行緒,每隔 1ms 執行一次,負責不停地從 CommitLog 中構建訊息索引並寫入到 ConsumeQueue 檔案。而訊息的索引一旦被構建到 ConsumeQueue 檔案中之後,Consumer 就可以看到了。
訊息索引在 ConsumeQueue 檔案中的物理偏移我們稱之為訊息的邏輯偏移,ConsumerGroup 中儲存的消費進度就是這個邏輯偏移,當 ConsumerGroup 根據當前儲存的消費進度從 Broker 中拉取訊息的時候,RocketMQ 就是先根據訊息的這個邏輯偏移透過二分查詢定位到訊息索引所在的具體 ConsumeQueue 檔案,然後從 ConsumeQueue 檔案中讀取訊息索引,而訊息索引中儲存了該訊息在 CommitLog 中的物理偏移,最後根據這個物理偏移從 CommitLog 中讀取出具體的訊息內容。
6.3 IndexFile
IndexFile 也是一種訊息索引檔案,同樣也是由後臺執行緒 ReputMessageService 來構建的,不同的是 IndexFile 是根據 CommitLog 中儲存的訊息 key 以及訊息的儲存時間來構建的訊息索引檔案,這樣我們就可以透過訊息 key 或者訊息生產的時間來查詢訊息了。
IndexFile 索引檔案可以看做是一個雜湊表的結構,其中包含了 500 萬個雜湊槽(hashSlot),每個雜湊槽佔用 4 個位元組,用來指向一個連結串列。在構建 IndexFile 的時候,會計算每一個訊息 key 的 hashcode,然後透過 hashcode % hashSlotNum
定位雜湊槽,如果遇到雜湊槽衝突,就會將衝突的訊息索引採用頭插法插入到雜湊槽指向的連結串列中,這樣可以保證最新生產出來的訊息位於連結串列的最前面。
訊息索引就存放在各個雜湊槽指向的這個連結串列中,按照訊息的生產時間從近到遠依次排列。一個 IndexFile 可以容納 2000W 條訊息索引,每條訊息索引佔用 20 個位元組,分別是:訊息 key 的 hashcode (4位元組),訊息在 CommitLog 中的物理偏移 Physical Offset (8位元組),Time Diff(4位元組),Next Index Pos(4位元組)用於指向該訊息索引在雜湊連結串列中的下一個訊息索引。這裡的 Time Diff 指的是訊息的儲存時間與 beginTimestamp 的差值,而 beginTimestamp 表示的是 IndexFile 中所有訊息的最小儲存時間。
除此之外,在 IndexFile 的開頭會有一個 40 位元組大小的 indexHeader 頭部,用於儲存檔案中關於訊息索引的一些統計資訊:
-
8 位元組的 beginTimestamp 表示 IndexFile 中訊息的最小儲存時間
-
8 位元組的 endTimestamp 表示 IndexFile 中訊息的最大儲存時間
-
8 位元組的 beginPhyoffset 表示 IndexFile 中訊息在 CommitLog 中的最小物理偏移
-
8 位元組的 endPhyoffset 表示 IndexFile 中訊息在 CommitLog 中的最大物理偏移
-
4 位元組的 hashSlotcount 表示 IndexFile 中當前用到的雜湊槽個數。
-
4 位元組的 indexCount 表示 IndexFile 中目前儲存的訊息索引條數。
單個 IndexFile 的總大小為 :40 位元組的 Header + 500 萬 * 4 位元組的雜湊槽 + 2000 萬 * 20 位元組的訊息索引 = 400 M
。IndexFile 的命名規則是用建立檔案時候的當前時間戳,儲存路徑為:/{storePathRootDir}/store/index/{fileName}
。
我們首先會根據訊息的生產時間透過二分查詢的方式定位具體的 IndexFile,在透過訊息 key 的 hashcode 定位到具體的訊息索引,從訊息索引中拿到 Physical Offset,最後在 CommitLog 中定位到具體的訊息內容。
6.4 檔案預熱
RocketMQ 對於 CommitLog,ConsumeQueue,IndexFile 等檔案的讀寫都是透過 MappedByteBuffer 來進行的,因此 RocketMQ 專門定義了一個用於描述記憶體檔案對映的模型 —— MappedFile,其中封裝了針對記憶體對映檔案的所有操作。比如,檔案的預熱,檔案的讀寫,檔案的回寫等操作。
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 只是一段虛擬記憶體,背後並未與任何實體記憶體發生關聯(檔案的 page cache), 後續在讀寫這段 mappedByteBuffer 的時候就會產生缺頁中斷的開銷,對檔案的讀寫效能產生比較大的影響。
所以 RocketMQ 為了最大化檔案讀寫的效能而提供了檔案預熱的功能,檔案預熱在預設情況下是關閉的,如果需要可以在 Broker 的配置檔案中開啟 warmMapedFileEnable。
warmMapedFileEnable=true
當 warmMapedFileEnable 開啟之後,RocketMQ 在初始化完 MappedFile 之後,就會呼叫 warmMappedFile 函式對檔案進行預熱:
-
對 mappedByteBuffer 這段虛擬記憶體範圍內的虛擬記憶體按照記憶體頁為單位,逐個觸發缺頁中斷,目的是提前講對映檔案的內容載入到 page cache 中,並在程序頁表中建立好 mappedByteBuffer 與 page cache 的對映關係。
-
使用前面介紹的 mlock 系統呼叫將 mappedByteBuffer 背後對映的 page cache 鎖定在記憶體中,不允許核心 swap。
-
使用 madvise 系統呼叫再次觸發一次預讀,感覺這裡完全沒必要呼叫 madvise,甚至也沒必要進行步驟 1。只呼叫 mlock 就可以了,因為核心在執行 mlock 的過程中步驟 1 和步驟 3 的事情就都順便做了。不清楚 RocketMQ 這裡為什麼要有這麼多重複的不必要動作,可能是為了相容不同的作業系統以及不同版本的核心吧,這裡我們就不深入去探究了。
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 讀寫分離
再對檔案進行預熱之後,後續對 mappedByteBuffer 的讀寫就是直接讀寫 page cache 了,整個過程沒有系統呼叫也沒有資料複製的開銷,經過本文第五小節的分析我們知道 mappedByteBuffer 非常適合頻繁小資料量的檔案讀寫場景,而 RocketMQ 主要處理的是業務訊息,通常這些業務訊息不會很大,所以 RocketMQ 選擇 mappedByteBuffer 來讀寫檔案實在是太合適了。
但是如果我們透過 mappedByteBuffer 來高頻地不斷向 CommitLog 寫入訊息的話, page cache 中的髒頁比例就會越來越大,而 page cache 回寫髒頁的時機是由核心來控制的,當髒頁積累到一定程度,核心就會啟動 pdflush 執行緒來將 page cache 中的髒頁回寫到磁碟中。
雖然現在 page cache 已經被我們 mlock 住了,但是我們在使用者態無法控制髒頁的回寫,當髒頁回寫完畢之後,我們透過 mappedByteBuffer 寫入檔案時仍然會觸發防寫缺頁中斷。這樣也會加大 mappedByteBuffer 的寫入延遲,產生效能毛刺。
為了避免這種寫入毛刺的產生,RocketMQ 引入了讀寫分離的機制,預設是關閉的,可以透過 transientStorePoolEnable
開啟。
transientStorePoolEnable=true
在開啟讀寫分離之後,RocketMQ 會初始化一個堆外記憶體池 transientStorePool,隨後從這個堆外記憶體池中獲取一個 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);
// 用於暫存資料的 directBuffer
this.writeBuffer = transientStorePool.borrowBuffer();
// 堆外記憶體池
this.transientStorePool = transientStorePool;
}
}
後續 Broker 再對 CommitLog 寫入訊息的時候,首先會寫到 writeBuffer 中,因為 writeBuffer 只是一段普通的堆外記憶體,不會涉及到髒頁回寫,因此 CommitLog 的寫入過程就會非常平滑,不會有效能毛刺。而從 CommitLog 讀取訊息的時候仍然是透過 mappedByteBuffer 進行。
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;
}
訊息資料現在只是暫存在 writeBuffer 中,當積攢的資料超過了 16K(可透過 commitCommitLogLeastPages 配置),或者訊息在 writeBuffer 中停留時間超過了 200 ms(可透過 commitCommitLogThoroughInterval 配置)。
private int commitCommitLogThoroughInterval = 200;
private int commitCommitLogLeastPages = 4
protected boolean isAbleToCommit(final int commitLeastPages) {
if (commitLeastPages > 0) {
// writeBuffer 中積攢的資料超過了 16 k,開始 commit
return ((write / OS_PAGE_SIZE) - (commit / OS_PAGE_SIZE)) >= commitLeastPages;
}
return write > commit;
}
那麼 RocketMQ 就會將 writeBuffer 中的訊息資料透過 FileChannel 一次性批次非同步寫入到 page cache 中。
public int commit(final int commitLeastPages) {
if (this.isAbleToCommit(commitLeastPages)) {
this.fileChannel.write(byteBuffer);
}
}
既然 RocketMQ 在讀寫分離模式下設計的是透過 FileChannel 來批次寫入訊息,那麼就需要考慮 FileChannel 的最佳寫入效能點,這裡 RocketMQ 選擇了 16K,而我們在本文第五小節中測試的 FileChannel 最佳寫入效能點也差不多是在 32K 附近,而且寫入效能是要比 MappedByteBuffer 高很多的。
6.6 檔案刷盤
無論是透過 MappedByteBuffer 還是 FileChannel 對檔案進行寫入,當系統中的髒頁積累到一定量的時候,都會對其寫入檔案的效能造成非常大的影響。另外髒頁不及時回寫還會造成資料丟失的風險。
因此為了避免資料丟失的風險以及對寫入效能的影響,當髒頁在 page cache 中積累到 16K 或者髒頁在 page cache 中停留時間超過 10s 的時候,RocketMQ 就會透過 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();
}
}
}
總結
本文從 OS 核心,JVM ,中介軟體應用三個視角帶著大家全面深入地拆解了一下關於 MappedByteBuffer 的方方面面,在文章的開始,我們先是在 OS 核心的視角下,分別從私有檔案對映,共享檔案對映兩個方面,介紹了 MappedByteBuffer 的對映過程以及缺頁處理。還原了 MappedByteBuffer 最為本質的面貌。
在此基礎之上,我們來到了 JVM 的視角,介紹了 JDK 如何對系統呼叫 mmap 進行一步一步的封裝,並介紹了很多對映的細節,比如經常被誤解的 System,gc 之後到底發生了什麼,真的是無法預測嗎 ?
隨後筆者接著為大家介紹了和 MappedByteBuffer 相關的幾個系統呼叫:madvise , mlock , msync,並詳細的分析了他們在核心中的原始碼實現。
最後筆者從對映檔案資料在與不在 page cache 中這兩個角度,詳細對比了 MappedByteBuffer 與 FileChannel 在檔案讀寫上的效能差異,並從核心的角度分析了具體導致兩者效能差異的原因。
在文章的結尾,筆者以 RocketMQ 為例,介紹了 MappedByteBuffer 在中介軟體中的應用。好了,今天的內容就到這裡,我們下篇文章見~~~