博客 / 詳情

返回

從 Linux 內核角度探秘 JDK MappedByteBuffer

本文涉及到的內核源碼版本為: 5.4 ,JVM 源碼為:OpenJDK17,RocketMQ 源碼版本為:5.1.1

在之前的文章《一步一圖帶你深入剖析 JDK NIO ByteBuffer 在不同字節序下的設計與實現》 中,筆者為大家詳細剖析了 JDK Buffer 的整個設計體系,從總體上來講,JDK NIO 為每一種 Java 基本類型定義了對應的 Buffer 類(boolean 類型除外)。

image

而 Buffer 本質上其實是 JDK 對 OS 中某一段內存在 Java 語言層面上的封裝,當然了,這裏的內存指的是虛擬內存,我們需要從之前文章中的內核空間視角切換到用户空間上來,所以本文提到的內存如無特殊説明均是指虛擬內存

JVM 在操作系統的視角來看其實就是一個普通的進程,而進程的虛擬內存空間我們通過前面 Linux 內存管理系列文章 的洗禮,可以説是非常熟悉了。內核會根據進程在運行期間所需數據的功能特性不同,而為每一類數據專門開闢出一段虛擬內存區域出來。比如:

  • 用於存放進程程序二進制文件中的機器指令以及只讀常量的代碼段
  • 用於存放程序二進制文件中定義的全局變量和靜態變量的數據段和 BSS 段。
  • 用於在程序運行過程中動態申請內存的堆,這裏指的是 OS 堆。
  • 用於存放動態鏈接庫以及內存映射區域的文件映射與匿名映射區。
  • 用於存放進程在函數調用過程中的局部變量和函數參數的棧。

而 JDK Buffer 也會根據其背後所依賴的虛擬內存在進程虛擬內存空間中具體所屬的虛擬內存區域而演變出 HeapByteBuffer , MappedByteBuffer , DirectByteBuffer 。這三種不同類型 ByteBuffer 的本質區別就是其背後依賴的虛擬內存在 JVM 進程虛擬內存空間中的佈局位置不同。

image

如下圖所示,HeapByteBuffer 底層依賴的字節數組背後的內存位於 JVM 堆中:

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer> {
    // 所屬內存位於 JVM 堆中
    final byte[] hb;  
}

image

位於 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 映射出來的內存就是在這個區域中劃分的。

image

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。

image

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 表示我們要從文件中的哪個位置偏移處開始映射文件內容。

image

由於 JDK 只對用户開放了文件映射的方式,所以本小節的 OS 視角我們也只是聚焦在文件映射在內核的實現部分。

文件映射有私有文件映射和共享文件映射之分,我們在使用 mmap 系統調用的時候,通過將參數 flags 設置為 MAP_PRIVATE,然後指定參數 fd 為映射文件的 file descriptor 來實現對文件的私有映射。通過將參數 flags 設置為 MAP_SHARED 來實現對文件的共享映射。

無論是私有映射的方式還是共享映射的方式,內核在對文件進行內存映射之前,都需要通過 get_unmapped_area 函數在 JVM 進程虛擬內存空間中的文件映射與匿名映射區裏尋找一段還沒有被映射過的空閒虛擬內存區域。

image

在拿到這段空閒的虛擬內存區域之後,通過 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 內存文件映射的本質所在。

image

當虛擬內存與映射文件發生關聯之後,內核會通過 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 內核中的內存文件映射結構圖,我們先以私有文件映射進行説明:

image

由於現在我們只是剛剛完成了文件映射,僅僅只是在 JVM 層面得到了一個 MappedByteBuffer,這個 MappedByteBuffer 背後所依賴的虛擬內存就是我們通過 mmap 映射出來的。

此時我們還未對文件進行讀寫操作,所以該映射文件對應的 page cache 裏還是空,沒有任何文件頁(用於存儲文件數據的物理內存頁)。而虛擬內存(MappedByteBuffer)與物理內存之間的關聯是通過進程頁表來完成的,由於此時內核還未對 MappedByteBuffer 分配物理內存,所以 MappedByteBuffer 在 JVM 進程頁表中對應的頁表項 PTE 還是空的。

image

當我們開始訪問這段 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 中了。

image

雖然現在 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 與文件頁就發生了關聯,並且映射的文件內容也已經加載到文件頁中了。

image

後續 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 也是隻讀的。

image

現在 進程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);
}

image

從此進程 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 緩存的文件頁之間的關聯。

image

這裏和私有文件映射不同的地方是,私有文件映射由於是私有的,所以在內核創建 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/...

圖片來源: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)分為兩大類:

  1. 第一類是 Volatile Memory,它包括寄存器,CPU Cache,DRAM,它們的特點是容量有限,CPU 訪問的速度快,但是一旦遭遇到斷電或者系統崩潰,這些存儲設備裏的內容就會丟失。
  2. 第二類是 Non-Volatile Storage,它包括 SSD,Hard Disk。它們的特點是容量大,CPU 訪問它們的速度相比於訪問 Volatile Memory 會慢上幾個數量級,但是遇到斷電或者系統崩潰的時候,它們存儲的數據不會丟失。

貪婪的成年世界往往喜歡選擇既要又要,那麼有沒有一種存儲設備既可以繼承 Volatile Memory 訪問速度快的特點又可以繼承 Non-Volatile Storage 的大容量,且數據不會丟失的特點呢 ? 答案就是 persistent memory (Non-Volatile Memory)。

圖片來源:https://docs.pmem.io/persistent-memory/getting-started-guide/introduction

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] 這段文件區域的映射。

image

不過這只是我們站在 JVM 視角中觀察到的現象,但站在 OS 內核的視角中卻不一定是這樣映射的,JDK 使用了一個障眼法將本質給隱藏了。

磁盤文件在文件系統中是按照磁盤塊為單位組織管理的,當磁盤塊加載到內存中就變成了文件頁,它們的大小都是 4K,內核對於內存的管理也是按照內存頁 page 為單位進行了,包括本文中介紹的內存映射,也是按照 page 為粒度進行映射的。

所以我們在應用程序中指定的相關映射參數,比如這裏的 position 以及 size 都應該是按照內存頁 page 尺寸對齊的,如果沒有對齊,JDK 和內核都會默默的幫助我們進行對齊。

image

如上圖所示,假設我們指定的 position 沒有與文件頁的尺寸進行對齊,那麼內核則不會從一個沒有對齊的位置處開始映射,而是會選擇 position 所在文件頁的起始位置處( mapPosition)開始映射。

 // position 距離其所在文件頁起始位置的距離
 // allocationGranularity 表示內存映射的單位粒度,這裏是 4K (內存頁尺寸)
 pagePosition = (int)(position % allocationGranularity);
 // mapPosition 內核真正開始的映射位置,同 mmap 系統調用中的 offset 參數
 // 這裏的 mapPosition 為 position 所屬文件頁的起始位置
 long mapPosition = position - pagePosition;

image

我們原本期望的是從文件的 position 處開始映射,並映射長度為 size 大小的文件區域,由於我們指定的 position 沒有與文件頁尺寸對齊,所以內核選擇從文件的 mapPosition 位置處開始映射。

這樣一來,如果我們繼續按照原本的 size 大小進行映射的話,那麼映射出來的文件區域肯定小了,所以這裏需要調整映射的長度,在原來的映射長度 size 的基礎上,多映射 pagePosition 大小的區域出來。總體映射長度為 mapSize。

    // 映射位置 mapPosition 是通過 position 減去了 pagePosition 得到的
    // 所以這裏的映射長度 mapSize 需要把 pagePosition 加回來
    mapSize = size + pagePosition;

image

上圖中展示這段 [position, position+size] 藍色文件區域是我們原本指定的文件映射區域,FileChannel#map 函數中返回的 MappedByteBuffer 背後映射的就是這段文件區域。

而內核真實映射的文件區域其實是從 mapPosition 開始,映射長度為 mapSize 的這段文件區域。

 addr = map0(prot, mapPosition, mapSize, isSync);

addr 是通過 mmap 在進程虛擬內存空間中映射出來的虛擬內存區域的起始地址,這段虛擬內存區域的內存範圍是 [addr, addr+mapSize],背後映射的文件區域範圍是 [mapPosition, mapPositionn+mapSize]

image

內核映射出來的虛擬內存區域是一個全集,而我們需要的映射區其實是 [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 堆中的內存佔用,但這部分內存佔用很小,就相當於是冰山的一角。

image

而位於冰山下面的大一片 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 也是空的。

image

當我們開始訪問這段 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 系統調用必須要從文件頁的起始位置處開始映射。

image

如果我們指定的 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 內存映射的本質(源碼實現篇)》

image

如果我們指定的起始虛擬內存地址 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 中。

image

當 madvise 系統調用返回的時候,雖然此時映射的文件內容已經在 page cache 中了,但是這些剛剛被加載進 page cache 的文件頁還沒有與 MappedByteBuffer 進行關聯,也就是説 MappedByteBuffer 在 JVM 進程頁表中對應的頁表項 pte 仍然還是空的。

image

後續我們訪問這段 MappedByteBuffer 的時候仍然會觸發缺頁中斷,但是這種情況下的缺頁中斷是輕量的,屬於 VM_FAULT_MINOR 類型的缺頁,因為之前映射的文件內容已經通過 madvise 加載到 page cache 中了,這裏只需要通過進程頁表將 MappedByteBuffer 與 page cache 中的文件頁關聯映射起來就可以了,不需要重新分配內存以及發生磁盤 IO 。

image

所以這也是為什麼在 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 的核心主要分為兩個步驟:

  1. 利用 apply_vma_lock_flags 函數在鎖定範圍內的虛擬內存區域內打上一個 VM_LOCKED 標記,後續內核在 swap 的時候,如果遇到被 VM_LOCKED 標記的虛擬內存區域,那麼它背後映射的物理內存將不會被 swap out 出去,而是會一直駐留在內存中。
  2. 如果指定鎖定範圍內的虛擬內存還未有物理內存與之映射,那麼內核則調用 __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 表示不對鎖定內存進行限制。

image

進程能夠鎖定的物理內存資源配額通過 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 函數判斷一下是否允許本次鎖定操作:

  1. rlimit(RLIMIT_MEMLOCK) != 0 表示進程能夠鎖定的內存資源限額還沒有用完,允許本次鎖定操作。
  2. 如果鎖定內存資源的限額已經用完,但是 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 進程的虛擬內存空間佈局,如下圖所示:

image

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 對文件的讀寫流程:

image

  1. 當 JVM 在 native 層使用 read 系統調用進行文件讀取的時候,JVM 進程會發生第一次上下文切換,從用户態轉為內核態。
  2. 隨後 JVM 進程進入虛擬文件系統層,在這一層內核首先會查看讀取文件對應的 page cache 中是否含有請求的文件數據,如果有,那麼直接將文件數據拷貝到 DirectByteBuffer 中返回,避免一次磁盤 IO。並根據內核預讀算法從磁盤中異步預讀若干文件數據到 page cache 中
  3. 如果請求的文件數據不在 page cache 中,則會進入具體的文件系統層,在這一層內核會啓動磁盤塊設備驅動觸發真正的磁盤 IO。並根據內核預讀算法同步預讀若干文件數據。請求的文件數據和預讀的文件數據將被一起填充到 page cache 中。
  4. 磁盤控制器 DMA 將從磁盤中讀取的數據拷貝到頁高速緩存 page cache 中。發生第一次數據拷貝
  5. 由於 page cache 是屬於內核空間的,不能被 JVM 進程直接尋址,所以還需要 CPU 將 page cache 中的數據拷貝到位於用户空間的 DirectByteBuffer 中,發生第二次數據拷貝
  6. 最後 JVM 進程從系統調用 read 中返回,並從內核態切換回用户態。發生第二次上下文切換

從以上過程我們可以看到,當使用 FileChannel#read 對文件讀取的時候,如果文件數據在 page cache 中,涉及到的性能開銷點主要有兩次上下文切換,以及一次 CPU 拷貝。其中上下文切換是主要的性能開銷點。

下面是通過 FileChannel#write 寫入文件的整個過程:

image

  1. 當 JVM 在 native 層使用 write 系統調用進行文件寫入的時候,JVM 進程會發生第一次上下文切換,從用户態轉為內核態。
  2. 進入內核態之後,JVM 進程在虛擬文件系統層調用 vfs_write 觸發對 page cache 寫入的操作。內核調用 iov_iter_copy_from_user_atomic 函數將 DirectByteBuffer 中的待寫入數據拷貝到 page cache 中。發生第一次拷貝動作( CPU 拷貝)。
  3. 當待寫入數據拷貝到 page cache 中時,內核會將對應的文件頁標記為髒頁,內核會根據一定的閾值判斷是否要對 page cache 中的髒頁進行回寫,如果不需要同步回寫,進程直接返回。這裏發生第二次上下文切換
  4. 髒頁回寫又會根據髒頁數量在內存中的佔比分為:進程同步回寫和內核異步回寫。當髒頁太多了,進程自己都看不下去的時候,會同步回寫內存中的髒頁,直到回寫完畢才會返回。在回寫的過程中會發生第二次拷貝(DMA 拷貝)。

從以上過程我們可以看到,當使用 FileChannel#write 對文件寫入的時候,如果不考慮髒頁回寫的情況,單純對於 JVM 這個進程來説涉及到的性能開銷點主要有兩次上下文切換,以及一次 CPU 拷貝。其中上下文切換仍然是主要的性能開銷點。

下面我們來看下通過 MappedByteBuffer 對文件進行讀寫的過程:

image

首先我們需要通過 FileChannel#map 將文件的某個區域映射到 JVM 進程的虛擬內存空間中,從而獲得一段文件映射的虛擬內存區域 MappedByteBuffer。由於底層使用到了 mmap 系統調用,所以這個過程也涉及到了兩次上下文切換

如上圖所示,當 MappedByteBuffer 在剛剛映射出來的時候,它只是進程地址空間中的一段虛擬內存,其對應在進程頁表中的頁表項還是空的,背後還沒有映射物理內存。此時映射文件對應的 page cache 也是空的,我們要映射的文件內容此時還靜靜地躺在磁盤中。

當 JVM 進程開始對 MappedByteBuffer 進行讀寫的時候,就會觸發缺頁中斷,內核會將映射的文件內容從磁盤中加載到 page cache 中,然後在進程頁表中建立 MappedByteBuffer 與 page cache 的映射關係。由於這裏涉及到了缺頁中斷的處理,因此也會有兩次上下文切換的開銷。

image

後面 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 大小的文件進行讀寫,從以上兩個方面對比兩者在不同讀寫單位下的性能表現。

image

需要提醒大家的是本小節中得出的讀寫性能具體數值是沒有參考價值的,因為不同軟硬件環境下測試得出的具體性能數值都不一樣,值得參考的是 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 的讀取性能測試:

image

運行結果如下:

image

為了直觀的讓大家一眼看出 MappedByteBuffer 和 FileChannel 在對 page cache 讀取的性能差異,筆者根據上面跑出的性能數據繪製成下面這幅柱狀圖,方便大家觀察兩者的性能趨勢走向。

image

這裏我們可以看出,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 以上。

image

下面是 MappedByteBuffer 和 FileChannel 在不同數據集下對 page cache 的寫入性能測試:

image

運行結果如下:

image

MappedByteBuffer 和 FileChannel 在不同數據集下對 page cache 的寫入性能的趨勢走向柱狀圖:

image

這裏我們可以看到 MappedByteBuffer 在 8K 之前具有明顯的寫入優勢,它的寫入性能趨勢是在 [64B , 8K] 這個數據集方位內,寫入性能隨着數據量的增大而越來越快,直到在 8K 這個數據集下達到了最佳寫入性能。

而在 [32K, 32M] 這個數據集範圍內,MappedByteBuffer 仍然具有優勢,但已經不是十分明顯了,最終在 64M 這個數據集下被 FileChannel 反超。

和前面的讀取性能趨勢一樣,FileChannel 的寫入性能也是隨着數據量的增大反而越來越好,最佳的寫入性能是在 64K 處,僅消耗了 160 ms 。

image

5.2 文件數據不在 page cache 中

在這一小節中,我們將缺頁中斷和磁盤 IO 的影響加入進來,不添加任何的優化手段純粹地測一下 MappedByteBuffer 和 FileChannel 對文件讀寫的性能。

為了避免被 page cache 影響,所以我們需要在每一個測試數據集下,單獨分別為 MappedByteBuffer 和 FileChannel 創建各自的測試文件。

下面是 MappedByteBuffer 和 FileChannel 在不同數據集下對文件的讀取性能測試:

image

運行結果:

image

從這裏我們可以看到,在加入了缺頁中斷和磁盤 IO 的影響之後,MappedByteBuffer 在缺頁中斷的影響下平均比之前多出了 500 ms 的開銷。FileChannel 在磁盤 IO 的影響下在 [64B , 512B] 這個數據集範圍內比之前平均多出了 1000 ms 的開銷,在 [1K, 512M] 這個數據集範圍內比之前平均多出了 100 ms 的開銷。

image

在 2K 之前, MappedByteBuffer 具有明顯的讀取性能優勢,最佳的讀取性能出現在 512B 這個數據集下,從 512B 往後,MappedByteBuffer 的讀取性能趨勢總體成下降趨勢,並在 4K 這個地方被 FileChannel 反超。

FileChannel 則是在 [64B, 1M] 這個數據集範圍內,讀取性能會隨着數據集的增大而提高,並在 1M 這個地方達到了 FileChannel 的最佳讀取性能,僅消耗了 258 ms,在 [32M , 512M] 這個範圍內 FileChannel 的讀取性能在逐漸下降,但是比 MappedByteBuffer 的性能高出了一倍。

image

讀到這裏大家不禁要問了,理論上來講 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 映射的。隨後內核就會做一些特殊處理:

  1. 通過 pte_wrprotect 對 MappedByteBuffer 在進程頁表中對應的頁表項 pte 進行寫保護,變為只讀權限。
  2. 通過 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 在不同數據集下對文件的寫入性能測試:

image

運行結果:

image

image

在筆者的測試環境中,我們看到 MappedByteBuffer 在對文件的寫入性能一路碾壓 FileChannel,並沒有出現被 FileChannel 反超的情況。但我們看到 MappedByteBuffer 從 4K 開始寫入性能是在逐漸下降的,而 FileChannel 的寫入性能卻在一路升高。

根據上面的分析,我們可以推斷出,後面隨着數據量的增大,由於 MappedByteBuffer 缺頁中斷瓶頸的影響,在 512M 後面某一個數據集下,FileChannel 的寫入性能最終是會超過 MappedByteBuffer 的。

在本小節的開頭,筆者就強調了,本小節值得參考的是 MappedByteBuffer 和 FileChannel 在不同數據集大小下的讀寫性能趨勢走向,而不是具體的性能數值。

image

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 文件混合存儲。

image

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 中的索引。

image

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 定位哈希槽,如果遇到哈希槽衝突,就會將衝突的消息索引採用頭插法插入到哈希槽指向的鏈表中,這樣可以保證最新生產出來的消息位於鏈表的最前面。

image

消息索引就存放在各個哈希槽指向的這個鏈表中,按照消息的生產時間從近到遠依次排列。一個 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 函數對文件進行預熱:

  1. 對 mappedByteBuffer 這段虛擬內存範圍內的虛擬內存按照內存頁為單位,逐個觸發缺頁中斷,目的是提前講映射文件的內容加載到 page cache 中,並在進程頁表中建立好 mappedByteBuffer 與 page cache 的映射關係。
  2. 使用前面介紹的 mlock 系統調用將 mappedByteBuffer 背後映射的 page cache 鎖定在內存中,不允許內核 swap。
  3. 使用 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 在中間件中的應用。好了,今天的內容就到這裏,我們下篇文章見~~~

user avatar lianhuatongzina 頭像 chengxuyuanxiaohui 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.