BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

知秋z發表於2019-03-03

前言

此係列文章會詳細解讀NIO的功能逐步豐滿的路程,為Reactor-Netty 庫的講解鋪平道路。

關於Java程式設計方法論-Reactor與Webflux的視訊分享,已經完成了Rxjava 與 Reactor,b站地址如下:

Rxjava原始碼解讀與分享:www.bilibili.com/video/av345…

Reactor原始碼解讀與分享:www.bilibili.com/video/av353…

本系列相關視訊分享: www.bilibili.com/video/av432…

本系列原始碼解讀基於JDK11 api細節可能與其他版本有所差別,請自行解決jdk版本問題。

本系列前幾篇:

BIO到NIO原始碼的一些事兒之BIO

BIO到NIO原始碼的一些事兒之NIO 上

BIO到NIO原始碼的一些事兒之NIO 中

BIO到NIO原始碼的一些事兒之NIO 下 之 Selector
BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 上

一些作業系統知識

在瞭解NIODirectByteBuffer操作Buffer之前,我們有必要了解作業系統的相關知識,這樣也就能理解程式為什麼要這麼設計實現了。

使用者態與核心態

這裡,我們以Linux作業系統為例,首先來看一張圖:

BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

​ 圖1

如上圖所示,從巨集觀上看,Linux作業系統的架構體系分為核心態和使用者態。作業系統本質上是執行在硬體資源上的軟體。而硬體資源,拿Intel x86架構CPU來說,在CPU的所有指令中,有一些指令是非常危險的,如果用錯,將導致整個系統崩潰。如:清理記憶體、設定時鐘等。如果所有的程式都能使用這些指令,那麼你的系統一天到晚也就在崩潰中度過了。所以,CPU將指令分為特權指令非特權指令,對於那些危險的指令,只允許作業系統及其相關模組使用,普通的應用程式只能使用那些不會造成災難的指令。由此,IntelCPU將特權級別分為4個級別:RING0RING1RING2RING3

對應作業系統,RING0實際就是核心態,擁有最高許可權。而一般應用程式處於RING3狀態–使用者態。在許可權約束上,使用的是高特權等級狀態可以讀低等級狀態的資料,例如程式上下文、程式碼、資料等等,但是反之則不可。即RING0最高可以讀取RING0-3所有的內容,RING1可以讀RING1-3的,RING2以此類推,RING3只能讀自己的資料。也就是Ring3狀態不能訪問Ring0的地址空間,包括程式碼和資料。

我們知道,在32位機器上Linux作業系統中的程式的地址空間大小是4G,其中0-3G對應使用者空間3G-4G對應核心空間。假如我們物理機的記憶體只有2G大小呢?所以,這個4G地址空間其實就是我們所說的虛擬地址記憶體空間(所以,當在32位作業系統下,如windows,我們會遇到在實體記憶體大於8G的情況下,只識別4G記憶體)。

那虛擬地址記憶體空間是什麼呢,它與實際實體記憶體空間又是怎樣對應的呢?

程式使用虛擬地址記憶體中的地址,由作業系統協助相關硬體,把它“轉換”成真正的實體地址。虛擬地址通過頁表(Page Table)對映到實體記憶體,頁表由作業系統維護並被處理器引用。核心空間在頁表中擁有最高特權級,因此使用者態程式試圖訪問這些頁時會導致一個頁錯誤(page fault)。在Linux中,核心空間是持續存在的,並且在所有程式中都對映到同樣的實體記憶體。核心程式碼和資料總是可定址,隨時準備處理中斷和系統呼叫。與此相反,使用者模式地址空間的對映隨程式切換的發生而不斷變化。

Linux程式在虛擬記憶體中的標準記憶體段佈局如下圖所示:

BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

​ 圖2

注意這裡是32位核心地址空間劃分,64位核心地址空間劃分是不同的。

由上圖,我們從左側Kernel Space可以看到在x86結構中,核心空間分三種型別的區域:

ZONE_DMA 記憶體開始的16MB

ZONE_NORMAL 16MB~896MB

ZONE_HIGHMEM 896MB ~ 結束

高位記憶體的由來

當核心模組程式碼或程式訪問記憶體時,程式碼中所指向的記憶體地址都為邏輯地址,而對應到真正的實體記憶體地址,需要地址一對一的對映,如邏輯地址0xc0000003對應的實體地址為0×30xc0000004對應的實體地址為0×4,… …,邏輯地址與實體地址對應的關係為:

實體地址 = 邏輯地址 – 0xC0000000

邏輯地址 實體記憶體地址
0xc0000000 0×0
0xc0000001 0×1
0xc0000002 0×2
0xc0000003 0×3
0xe0000000 0×20000000
0xffffffff **0×40000000 **

假 設按照上述簡單的地址對映關係,那麼由圖2可知,核心邏輯地址空間訪問為0xc0000000 ~ 0xffffffff,那麼對應的實體記憶體範圍就為0×0 ~ 0×40000000,即只能訪問1G實體記憶體。若機器中安裝8G實體記憶體,那麼核心就只能訪問前1G實體記憶體,後面7G實體記憶體將會無法訪問,因為核心 的地址空間已經全部對映到實體記憶體地址範圍0×0 ~ 0×40000000。即使安裝了8G實體記憶體,那麼實體地址為0×40000001的記憶體,核心該怎麼去訪問呢?程式碼中必須要有記憶體邏輯地址 的,0xc0000000 ~ 0xffffffff的地址空間已經被用完了,所以無法訪問實體地址0×40000000以後的記憶體。

顯 然不能將核心地址空間0xc0000000 ~ 0xfffffff全部用來簡單的地址對映。因此x86架構中將核心地址空間劃分三部分:ZONE_DMAZONE_NORMALZONE_HIGHMEMZONE_HIGHMEM即為高位記憶體,這就是高位記憶體概念的由來。

那麼如核心是如何通過藉助128MB高位記憶體地址空間達到可以訪問所有實體記憶體的目的

當核心想訪問高於896MB實體地址記憶體時,從0xF8000000 ~ 0xFFFFFFFF地址空間範圍內找一段相應大小空閒的邏輯地址空間,借用一會。借用這段邏輯地址空間,建立對映到想訪問的那段實體記憶體(即填充核心PTE頁面表),臨時用一會,用完後歸還。這樣別人也可以借用這段地址空間訪問其他實體記憶體,實現了使用有限的地址空間,訪問所有所有實體記憶體。

例如核心想訪問2G開始的一段大小為1MB的實體記憶體,即實體地址範圍為0×80000000 ~ 0x800FFFFF。訪問之前先找到一段1MB大小的空閒地址空間,假設找到的空閒地址空間為0xF8700000 ~ 0xF87FFFFF,用這1MB的邏輯地址空間對映到實體地址空間0×80000000 ~ 0x800FFFFF的記憶體。對映關係如下:

邏輯地址 實體記憶體地址
0xF8700000 0×80000000
0xF8700001 0×80000001
0xF8700002 0×80000002
0xF87FFFFF 0x800FFFFF

當核心訪問完0×80000000 ~ 0x800FFFFF實體記憶體後,就將0xF8700000 ~ 0xF87FFFFF核心線性空間釋放。這樣其他程式或程式碼也可以使用0xF8700000 ~ 0xF87FFFFF這段地址訪問其他實體記憶體。

從上面的描述,我們可以知道高位記憶體的最基本思想:借一段地址空間,建立臨時地址對映,用完後釋放,達到這段地址空間可以迴圈使用,訪問所有實體記憶體。

看到這裡,不禁有人會問:萬一有核心程式或模組一直佔用某段邏輯地址空間不釋放,怎麼辦?若真的出現的這種情況,則核心的高位記憶體地址空間越來越緊張,若都被佔用不釋放,則沒有可建立對映到實體記憶體的高位地址空間,也就無法訪問對應的實體記憶體了。

程式的虛擬空間

簡單的說,程式在使用記憶體的時候,都不是直接訪問記憶體實體地址的,程式訪問的都是虛擬記憶體地址,然後虛擬記憶體地址再轉化為記憶體實體地址。
程式看到的所有地址組成的空間,就是虛擬空間。虛擬空間是某個程式對分配給它的所有實體地址(已經分配的和將會分配的)的重新對映。

這裡可以認為虛擬空間都被對映到了硬碟空間中,並且由頁表記錄對映位置,當訪問到某個地址的時候,通過頁表中的有效位,可以得知此資料是否在記憶體中,如果不是,則通過缺頁異常,將硬碟對應的資料拷貝到記憶體中,如果沒有空閒記憶體,則選擇犧牲頁面,替換其他頁面(即覆蓋老頁面)。

此處想進一步深入可參考linux 程式的虛擬記憶體

我們回到核心態與使用者態這兩個概念。作業系統的核心態是用來控制計算機的硬體資源,並提供上層應用程式執行的環境。使用者態即上層應用程式的活動空間,應用程式的執行必須依託於核心提供的資源,包括CPU資源、儲存資源、I/O資源等。為了使上層應用能夠訪問到這些資源,核心必須為上層應用提供訪問的介面:即系統呼叫。

  系統呼叫是作業系統的最小功能單位,這些系統呼叫根據不同的應用場景可以進行擴充套件和裁剪,現在各種版本的Unix實現都提供了不同數量的系統呼叫,如Linux的不同版本提供了240-260個系統呼叫,FreeBSD大約提供了320個。我們可以把系統呼叫看成是一種不能再化簡的操作(類似於原子操作,但是不同概念),有人把它比作一個漢字的一個“筆畫”,而一個“漢字”就代表一個上層應用。

使用者空間的應用程式,通過系統呼叫,進入核心空間。這個時候使用者空間的程式要傳遞很多變數、引數的值給核心,核心態執行的時候也要儲存使用者程式的一些暫存器值、變數等。所謂的“程式上下文”,可以看作是使用者程式傳遞給核心的這些引數以及核心要儲存的那一整套的變數和暫存器值和當時的環境等。

#### 系統IO呼叫

那我們來看一下一般的IO呼叫。在傳統的檔案IO操作中,都是呼叫作業系統提供的底層標準IO系統呼叫函式 read()、write() ,此時呼叫此函式的程式(在JAVA中即java程式)由當前的使用者態切換到核心態,然後OS的核心程式碼負責將相應的檔案資料讀取到核心的IO緩衝區,然後再把資料從核心IO緩衝區拷貝到程式的私有地址空間中去,這樣便完成了一次IO操作。如下圖所示。

BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

​ 圖3

此處,我們通過一個Demo來捋一下這個過程:

byte[] b = new byte[1024];

while((read = inputStream.read(b))>=0) {
        total = total + read;
            // other code....
        }
複製程式碼

我們通過new byte[1024]來建立一個緩衝區,由於JVM處於使用者態程式中,所以,此處建立的這個緩衝區為使用者緩衝區。然後在一個while迴圈裡面呼叫read()方法讀資料來觸發syscall read系統呼叫。
我們著重來分析下inputStream.read呼叫時所發生的細節:

  1. 核心給硬碟控制器發命令:我要讀硬碟上的某硬碟塊上的資料。
  2. 在DMA的控制下,把硬碟上的資料讀入到核心緩衝區。
  3. 核心把資料從核心緩衝區複製到使用者緩衝區。

這裡的使用者緩衝區就是我們程式碼中所new的位元組陣列。整個過程請對照圖3所示內容進行理解。

對於作業系統而言,JVM處於使用者態空間中。而處於使用者態空間的程式是不能直接操作底層的硬體的。而IO操作就需要操作底層的硬體,比如硬碟。因此,IO操作必須得藉助核心的幫助才能完成(中斷,trap),即:會有使用者態到核心態的切換。

我們寫程式碼 new byte[] 陣列時,一般是都是“隨意” 建立一個“任意大小”的陣列。比如,new byte[128]、new byte[1024]、new byte[4096]….

但是,對於硬碟塊的讀取而言,每次訪問硬碟讀資料時,並不是讀任意大小的資料的,而是:每次讀一個硬碟塊或者若干個硬碟塊(這是因為訪問硬碟操作代價是很大的) 因此,就需要有一個“中間緩衝區”–即核心緩衝區。先把資料從硬碟讀到核心緩衝區中,然後再把資料從核心緩衝區搬到使用者緩衝區。

這也是為什麼我們總感覺到第一次read操作很慢,而後續的read操作卻很快的原因。對於後續的read操作而言,它所需要讀的資料很可能已經在核心緩衝區了,此時只需將核心緩衝區中的資料拷貝到使用者緩衝區即可,並未涉及到底層的讀取硬碟操作,當然就快了。

而當資料不可用,這個處理程式將會被掛起,並等待核心從硬碟上把資料取到核心緩衝區中。

DMA—用來在裝置記憶體與主存RAM之間直接進行資料交換,這個過程無需CPU干預,對於系統中有大量資料交換的裝置而言,如果能夠充分利用DMA特性,可以大大提高系統效能。可參考Linux核心中DMA分析

直接記憶體對映IO

DMA讀取資料這種操作涉及到底層的硬體,硬體一般是不能直接訪問使用者態空間的,也就是DMA不能直接訪問使用者緩衝區,普通IO操作需要將資料來回地在 使用者緩衝區 和 核心緩衝區移動,這在一定程式上影響了IO的速度。那有沒有相應的解決方案呢?

這裡就涉及到了我們想要提及的核心內容:直接記憶體對映IO。

虛擬地址空間有一塊區域,在記憶體對映檔案的時候將某一段的虛擬地址和檔案物件的某一部分建立起對映關係,此時並沒有拷貝資料到記憶體中去,而是當程式程式碼第一次引用這段程式碼內的虛擬地址時,觸發了缺頁異常,這時候OS根據對映關係直接將檔案的相關部分資料拷貝到程式的使用者私有空間中去,如下圖所示。

BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

​ 圖4

從圖4可以看出:核心空間的 buffer 與 使用者空間的 buffer 都對映到同一塊 實體記憶體區域。

它的主要特點如下:

  1. 對檔案的操作不需要再發出read 或者 write 系統IO呼叫
  2. 當使用者程式訪問“記憶體對映檔案”地址時,自動產生缺頁異常,然後由底層的OS負責將硬碟上的資料寫到記憶體。
  3. 記憶體對映檔案的效率比標準IO高的重要原因就是因為少了把資料拷貝到OS核心緩衝區這一步。

探究DirectByteBuffer

在經過了上面的層層鋪墊之後,我們再來回顧下ByteBufferByteBuffer作為一個抽象類,其實現分為兩類:HeapByteBufferDirectByteBufferHeapByteBuffer是堆內ByteBuffer,基於使用者態的實現,使用byte[]儲存資料,我們前面已經接觸過。DirectByteBuffer是堆外ByteBuffer,直接使用堆外記憶體空間儲存資料,使用直接記憶體對映IO,這也是NIO高效能的核心所在之一。那麼我們一起來分析一下DirectByteBuffer的相關實現。

DirectByteBuffer的建立

我們可以使用java.nio.ByteBuffer#allocateDirect方法來例項化一個DirectByteBuffer

//java.nio.ByteBuffer#allocateDirect
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

 
DirectByteBuffer(int cap) {   // package-private
	// 初始化Buffer四個核心屬性
    super(-1, 0, cap, cap);
    // 判斷是否需要頁面對齊,通過引數-XX:+PageAlignDirectMemory控制,預設為false
    boolean pa = VM.isDirectMemoryPageAligned();
    // 獲取每頁記憶體大小
    int ps = Bits.pageSize();
    // 分配記憶體的大小,如果是按頁對齊方式,需要再加一頁記憶體的容量
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    // 用Bits類儲存總分配記憶體(按頁分配)的大小和實際記憶體的大小
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 呼叫unsafe方法分配記憶體
        base = UNSAFE.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        // 分配失敗,釋放記憶體
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    
    // 初始化分配記憶體空間,指定記憶體大小,該空間中每個位置值為0
    UNSAFE.setMemory(base, size, (byte) 0);
     // 設定記憶體起始地址,如果需要頁面對齊,
     // 則判斷base是否有對齊,有且不是一個頁的起始位置則通過計算進行地址對齊操作
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
     // 建立一個cleaner,最後會呼叫Deallocator.run來釋放記憶體
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}
複製程式碼
頁面對齊

首先,通過VM.isDirectMemoryPageAligned()判斷是否需要頁面對齊,關於對齊,我們這裡來接觸下內在理論。

在現代計算架構中,從記憶體中讀取資料,基本上都是按2^N個位元組來從主存載入CPU中。這個值,基本是cache line的大小。也就是說,如果所讀資料在同一塊cache line之內是最快的。目前來說,多數PC的cache line值是128個位元組。 對於首地址也是一樣的。在32位機器上,如果有4個位元組的記憶體塊,跨2個cache line,那麼被載入到CPU的時候,需要2次記憶體缺失中斷。

好了,言歸正傳。對於任何一種小記憶體請求,都不會按實際大小分配,首先會按照一定規則進行對齊。這種對齊的規則比較複雜,一般會依照系統頁大小,機器字大小,和系統特性來定製。通常來說,會在不同區間採用不同的步長。舉個例子:

序號 大小區間 位元組對齊
0 [0–16] 8
1 (16 , 128] 16
2 (128 , 256] 32
3 (256 , 512] 64

由於每個區間的步長不一樣,又被劃分成更多的區間。比如(256 , 320]之間長度請求,實際被分配應該是320個位元組,而不是512。而1個位元組的請求,總是被分配8個位元組。

簡單點說,其實就是效率問題,現代計算機讀取記憶體的時候,一般只能在偶數邊界上開始讀,什麼意思呢,打個比方,在32位的機器上,一個int變數變數佔用4位元組,假如這個變數的真實實體記憶體地址是0x400005,那計算機在取數的時候會先從0x4000044個位元組,再從0x4000084個位元組,然後這個變數的值就是前4個位元組的後三位和後4個位元組的第一位,也就是說如果一個變數的地址從奇數開始,就可能要多讀一次記憶體,而如果從偶數開始,特別是計算機位數/8的倍數開始,效率就高了!

當需要按頁對齊的時候,核心總是會把vmalloc函式的引數size調整到頁對齊,並在調整後的數值上再加一個頁面的大小.核心之所以加一個頁面大小,是為了防止可能出現的越界訪問。頁是可傳輸到IO裝置的最小記憶體塊。因此,將資料與頁面大小對齊,並使用頁面大小作為分配單元,以此在寫入硬碟/網路裝置時對互動產生影響。這樣,通過多分配一頁空間,可以在資料超出一頁大小時,類似於上一段所描述的場景,多讀一次記憶體,以及要多佔用一頁空間。

 // -- Processor and memory-system properties --

    private static int PAGE_SIZE = -1;
// java.nio.Bits#pageSize
    static int pageSize() {
        if (PAGE_SIZE == -1)
            PAGE_SIZE = UNSAFE.pageSize();
        return PAGE_SIZE;
    }
/**
 * Reports the size in bytes of a native memory page (whatever that is).
 * This value will always be a power of two.
 */
public native int pageSize();

複製程式碼
判斷可分配空間是否滿足需求

由上面DirectByteBuffer(int cap)這個構造器程式碼中給的中文註釋可知,申請分配記憶體前會呼叫java.nio.Bits#reserveMemory判斷是否有足夠的空間可供申請:

//java.nio.Bits#tryReserveMemory
private static boolean tryReserveMemory(long size, int cap) {

    // -XX:MaxDirectMemorySize limits the total capacity rather than the
    // actual memory usage, which will differ when buffers are page
    // aligned.
    //通過-XX:MaxDirectMemorySize來判斷使用者申請的大小是否合理,
    long totalCap;
    //可使用最大空間減去已使用空間,剩餘可用空間滿足需求分配的空間的話設定相關引數,並返回true
    while (cap <= MAX_MEMORY - (totalCap = TOTAL_CAPACITY.get())) {
        if (TOTAL_CAPACITY.compareAndSet(totalCap, totalCap + cap)) {
            RESERVED_MEMORY.addAndGet(size);
            COUNT.incrementAndGet();
            return true;
        }
    }

    return false;
}
// java.nio.Bits#reserveMemory
// size:根據是否按頁對齊,得到的真實需要申請的記憶體大小
// cap:使用者指定需要的記憶體大小(<=size)
static void reserveMemory(long size, int cap) {
    // 獲取最大可以申請的對外記憶體大小
    // 可通過引數-XX:MaxDirectMemorySize=<size>設定這個大小
    if (!MEMORY_LIMIT_SET && VM.initLevel() >= 1) {
        MAX_MEMORY = VM.maxDirectMemory();
        MEMORY_LIMIT_SET = true;
    }

    // optimist!
    // 有足夠空間可供分配,則直接return,否則,繼續執行下面邏輯,嘗試重新分配
    if (tryReserveMemory(size, cap)) {
        return;
    }

    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    boolean interrupted = false;
    try {

        // Retry allocation until success or there are no more
        // references (including Cleaners that might free direct
        // buffer memory) to process and allocation still fails.
        boolean refprocActive;
        do {
            //這個do while迴圈中,若沒有更多引用(包括可能釋放直接緩衝區記憶體的Cleaners)進行處理,接著就重新嘗			  //試判斷所申請記憶體空間是否滿足條件,如果這個過程發生異常,則interrupted設定為true,同時在最後的				//finally程式碼塊中打斷當前所線上程。
            try {
                refprocActive = jlra.waitForReferenceProcessing();
            } catch (InterruptedException e) {
                // Defer interrupts and keep trying.
                interrupted = true;
                refprocActive = true;
            }
            if (tryReserveMemory(size, cap)) {
                return;
            }
        } while (refprocActive);

        // trigger VM`s Reference processing
        System.gc();

        long sleepTime = 1;
        int sleeps = 0;
        while (true) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
            if (sleeps >= MAX_SLEEPS) {
                break;
            }
            try {
                if (!jlra.waitForReferenceProcessing()) {
                    Thread.sleep(sleepTime);
                    sleepTime <<= 1;
                    sleeps++;
                }
            } catch (InterruptedException e) {
                interrupted = true;
            }
        }

        // no luck
        throw new OutOfMemoryError("Direct buffer memory");

    } finally {
        if (interrupted) {
            // don`t swallow interrupts
            Thread.currentThread().interrupt();
        }
    }
}
複製程式碼

該方法主要用於判斷申請的堆外記憶體是否超過了用例指定的最大值,如果還有足夠空間可以申請,則更新對應的變數,如果已經沒有空間可以申請,則丟擲OutOfMemoryError

預設可以申請的最大堆外記憶體

上文提到了DirectByteBuffer申請記憶體前會判斷是否有足夠的空間可供申請。使用者可以通過設定-XX:MaxDirectMemorySize=<size>來控制可以申請最大的DirectByteBuffer記憶體。但是預設情況下這個大小是多少呢?

由上面程式碼可知,DirectByteBuffer通過sun.misc.VM#maxDirectMemory來獲取這個值,我們來看一下對應的程式碼:

 // A user-settable upper limit on the maximum amount of allocatable direct
    // buffer memory.  This value may be changed during VM initialization if
    // "java" is launched with "-XX:MaxDirectMemorySize=<size>".
    //
    // The initial value of this field is arbitrary; during JRE initialization
    // it will be reset to the value specified on the command line, if any,
    // otherwise to Runtime.getRuntime().maxMemory().
    //
    private static long directMemory = 64 * 1024 * 1024;

    // Returns the maximum amount of allocatable direct buffer memory.
    // The directMemory variable is initialized during system initialization
    // in the saveAndRemoveProperties method.
    //
    public static long maxDirectMemory() {
        return directMemory;
    }
複製程式碼

這裡directMemory賦值為64MB,那堆外記憶體預設最大是64MB嗎?答案是否定的,我們來看註釋,可以知道,這個值會在JRE初始化啟動的過程中被重新設定為使用者指定的值,如果使用者沒有指定,則會設定為Runtime.getRuntime().maxMemory()

/**
 * Returns the maximum amount of memory that the Java virtual machine
 * will attempt to use.  If there is no inherent limit then the value
 * {@link java.lang.Long#MAX_VALUE} will be returned.
 *
 * @return  the maximum amount of memory that the virtual machine will
 *          attempt to use, measured in bytes
 * @since 1.4
 */
public native long maxMemory();

//srcjava.baseshare
ativelibjavaRuntime.c
JNIEXPORT jlong JNICALL
Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this)
{
    return JVM_MaxMemory();
}
//srchotspotshareincludejvm.h
JNIEXPORT jlong JNICALL
JVM_MaxMemory(void);

//srchotspotshareprimsjvm.cpp
JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))
  JVMWrapper("JVM_MaxMemory");
  size_t n = Universe::heap()->max_capacity();
  return convert_size_t_to_jlong(n);
JVM_END
複製程式碼

我們來看JRE相關的初始化啟動原始碼:

	/**
	 * java.lang.System#initPhase1
     * Initialize the system class.  Called after thread initialization.
     */
    private static void initPhase1() {

        // VM might invoke JNU_NewStringPlatform() to set those encoding
        // sensitive properties (user.home, user.name, boot.class.path, etc.)
        // during "props" initialization, in which it may need access, via
        // System.getProperty(), to the related system encoding property that
        // have been initialized (put into "props") at early stage of the
        // initialization. So make sure the "props" is available at the
        // very beginning of the initialization and all system properties to
        // be put into it directly.
        props = new Properties(84);
        initProperties(props);  // initialized by the VM

        // There are certain system configurations that may be controlled by
        // VM options such as the maximum amount of direct memory and
        // Integer cache size used to support the object identity semantics
        // of autoboxing.  Typically, the library will obtain these values
        // from the properties set by the VM.  If the properties are for
        // internal implementation use only, these properties should be
        // removed from the system properties.
        //
        // See java.lang.Integer.IntegerCache and the
        // VM.saveAndRemoveProperties method for example.
        //
        // Save a private copy of the system properties object that
        // can only be accessed by the internal implementation.  Remove
        // certain system properties that are not intended for public access.
        // 我們關注此處即可
        VM.saveAndRemoveProperties(props);

        lineSeparator = props.getProperty("line.separator");
        StaticProperty.javaHome();          // Load StaticProperty to cache the property values
        VersionProps.init();

        FileInputStream fdIn = new FileInputStream(FileDescriptor.in);
        FileOutputStream fdOut = new FileOutputStream(FileDescriptor.out);
        FileOutputStream fdErr = new FileOutputStream(FileDescriptor.err);
        setIn0(new BufferedInputStream(fdIn));
        setOut0(newPrintStream(fdOut, props.getProperty("sun.stdout.encoding")));
        setErr0(newPrintStream(fdErr, props.getProperty("sun.stderr.encoding")));

        // Setup Java signal handlers for HUP, TERM, and INT (where available).
        Terminator.setup();

        // Initialize any miscellaneous operating system settings that need to be
        // set for the class libraries. Currently this is no-op everywhere except
        // for Windows where the process-wide error mode is set before the java.io
        // classes are used.
        VM.initializeOSEnvironment();

        // The main thread is not added to its thread group in the same
        // way as other threads; we must do it ourselves here.
        Thread current = Thread.currentThread();
        current.getThreadGroup().add(current);

        // register shared secrets
        setJavaLangAccess();

        // Subsystems that are invoked during initialization can invoke
        // VM.isBooted() in order to avoid doing things that should
        // wait until the VM is fully initialized. The initialization level
        // is incremented from 0 to 1 here to indicate the first phase of
        // initialization has completed.
        // IMPORTANT: Ensure that this remains the last initialization action!
        VM.initLevel(1);
    }
複製程式碼

上述原始碼中的中文註釋部分表示即為我們關心的相關過程,即對directMemory賦值發生在sun.misc.VM#saveAndRemoveProperties函式中:

 // Save a private copy of the system properties and remove
    // the system properties that are not intended for public access.
    //
    // This method can only be invoked during system initialization.
    public static void saveAndRemoveProperties(Properties props) {
        if (initLevel() != 0)
            throw new IllegalStateException("Wrong init level");

        @SuppressWarnings({"rawtypes", "unchecked"})
        Map<String, String> sp =
            Map.ofEntries(props.entrySet().toArray(new Map.Entry[0]));
        // only main thread is running at this time, so savedProps and
        // its content will be correctly published to threads started later
        savedProps = sp;

        // Set the maximum amount of direct memory.  This value is controlled
        // by the vm option -XX:MaxDirectMemorySize=<size>.
        // The maximum amount of allocatable direct buffer memory (in bytes)
        // from the system property sun.nio.MaxDirectMemorySize set by the VM.
        // The system property will be removed.
        String s = (String)props.remove("sun.nio.MaxDirectMemorySize");
        if (s != null) {
            if (s.equals("-1")) {
                // -XX:MaxDirectMemorySize not given, take default
                directMemory = Runtime.getRuntime().maxMemory();
            } else {
                long l = Long.parseLong(s);
                if (l > -1)
                    directMemory = l;
            }
        }

        // Check if direct buffers should be page aligned
        s = (String)props.remove("sun.nio.PageAlignDirectMemory");
        if ("true".equals(s))
            pageAlignDirectMemory = true;

        // Remove other private system properties
        // used by java.lang.Integer.IntegerCache
        props.remove("java.lang.Integer.IntegerCache.high");

        // used by sun.launcher.LauncherHelper
        props.remove("sun.java.launcher.diag");

        // used by jdk.internal.loader.ClassLoaders
        props.remove("jdk.boot.class.path.append");
    }
複製程式碼

所以預設情況下,DirectByteBuffer堆外記憶體預設最大為Runtime.getRuntime().maxMemory(),而這個值等於可用的最大Java堆大小,也就是我們-Xmx引數指定的值。

System.gc探究

同時,我們在此處也看到了程式碼內有主動呼叫System.gc(),以清理已分配DirectMemory中的不用的物件引用,騰出空間。這裡主動呼叫System.gc()的目的也是為了想觸發一次full gc,此時,我們要看它所處的位置,如果堆外記憶體申請不到足夠的空間,則堆外記憶體會超過其閾值,此時,jdk會通過System.gc()的內在機制觸發一次full gc,來進行回收。呼叫System.gc()本身就是執行一段相應的邏輯,那我們來探索下其中的細節。

//java.lang.System#gc
    public static void gc() {
        Runtime.getRuntime().gc();
    }
//java.lang.Runtime#gc
    public native void gc();
複製程式碼
JNIEXPORT void JNICALL
Java_java_lang_Runtime_gc(JNIEnv *env, jobject this)
{
    JVM_GC();
}
複製程式碼

可以看到直接呼叫了JVM_GC()方法,這個方法的實現在jvm.cpp中

//srchotspotshareprimsjvm.cpp
JVM_ENTRY_NO_ENV(void, JVM_GC(void))
  JVMWrapper("JVM_GC");
  if (!DisableExplicitGC) {
    Universe::heap()->collect(GCCause::_java_lang_system_gc);
  }
JVM_END

//srchotspotshare
untimeinterfaceSupport.inline.hpp
#define JVM_ENTRY_NO_ENV(result_type, header)                        
extern "C" {                                                         
  result_type JNICALL header {                                       
    JavaThread* thread = JavaThread::current();                      
    ThreadInVMfromNative __tiv(thread);                              
    debug_only(VMNativeEntryWrapper __vew;)                          
    VM_ENTRY_BASE(result_type, header, thread)
    ...
    #define JVM_END } }

#define VM_ENTRY_BASE(result_type, header, thread)                   
  TRACE_CALL(result_type, header)                                    
  HandleMarkCleaner __hm(thread);                                    
  Thread* THREAD = thread;                                           
  os::verify_stack_alignment();                                      
  /* begin of body */

複製程式碼
巨集定義淺析

此處#define JVM_ENTRY_NO_ENV屬於巨集定義,這裡可能大家不是很瞭解,就簡單說下。

巨集定義分類

  1. 不帶引數的巨集定義

    • 形式: #define 巨集名 [巨集體]
    • 功能:可以實現用巨集體代替巨集名
    • 使用例項:#define TRUE 1
    • 作用:程式中多次使用TRUE,如果需要對TRUE的值進行修改,只需改動一處就可以了
  2. 帶引數的巨集: #define 巨集名 ( 參數列) [巨集體]

巨集定義作用

  1. 方便程式的修改

    • 上面的#define TRUE 1就是一個例項
  2. 提高程式的執行效率

    • 巨集定義的展開是在程式的預處理階段完成的,無需執行時分配記憶體,能夠部分實現函式的功能,卻沒有函式呼叫的壓棧、彈棧開銷,效率較高
  3. 增強可讀性

    • 這點不言而喻,當我們看到類似PI這樣的巨集定義時,自然可以想到它對應的是圓周率常量
  4. 字串拼接

例如:

#define CAT(a,b,c) a##b##c

main()
{
    printf("%d
" CAT(1,2,3));
    printf("%s
", CAT(`a`, `b`, `c`);
}
複製程式碼

程式的輸出會是:

123
abc
複製程式碼
  1. 引數轉化成字串

示例:

#defind CAT(n) "abc"#n

main()
{
    printf("%s
", CAT(15));
}
複製程式碼

輸出的結果會是

abc15
複製程式碼
  1. 用於程式除錯跟蹤
    • 常見的用於除錯的巨集有,_ L I N E F I L E D A T E T I M E S T D C _
  2. 實現可變巨集
    舉例來說:
#define PR(...) printf(_ _VA_ARGS_ _)  
複製程式碼

其實有點像直譯器模式,簡單點說,我們彼此約定,我喊 1,你就說:天生我材必有用。接下來我們進行如下定義:

#define a abcdefg(也可以是很長一段程式碼一個函式)
複製程式碼

同理巨集就相當於你和編譯器之間的約定,你告訴它 ,當我寫 a ,其實就是指後面那段內容。那麼,預編譯的時候, 編譯器一看 a是這個,這時候它就會把所有的a都替換成了後面那個字串。

想要繼續深入,可以參考[C++巨集定義詳解](www.cnblogs.com/fnlingnzb-l…)。

參考我們在前面列出的jvm.cppJVM_GC()相關的部分程式碼,可以知道,interfaceSupport.inline.hpp內定義了JVM_ENTRY_NO_ENV的巨集邏輯,而下面這段程式碼則定義了JVM_GC的相關邏輯,然後JVM_GC作為子邏輯在JVM_ENTRY_NO_ENV的巨集邏輯中執行。

JVM_ENTRY_NO_ENV(void, JVM_GC(void))
  JVMWrapper("JVM_GC");
  if (!DisableExplicitGC) {
    Universe::heap()->collect(GCCause::_java_lang_system_gc);
  }
JVM_END
複製程式碼

我們這裡再接觸個JDK中我們常見的AccessController.doPrivileged方法,它是在jvm.cpp中對應的實現為:

JVM_ENTRY(jobject, JVM_DoPrivileged(JNIEnv *env, jclass cls, jobject action, jobject context, jboolean wrapException))
  JVMWrapper("JVM_DoPrivileged");

  # 省略的方法體
JVM_END
複製程式碼

JVM_ENTRY也是是一個巨集定義,位於interfaceSupport.hpp中:

#define JVM_ENTRY(result_type, header)                               
extern "C" {                                                         
  result_type JNICALL header {                                       
    JavaThread* thread=JavaThread::thread_from_jni_environment(env); 
    ThreadInVMfromNative __tiv(thread);                              
    debug_only(VMNativeEntryWrapper __vew;)                          
    VM_ENTRY_BASE(result_type, header, thread)
複製程式碼

然後轉換後,得到結果如下:

extern "C" {                                                          
  jobject JNICALL JVM_DoPrivileged(JNIEnv *env, jclass cls, jobject action, jobject context, jboolean wrapException) {                                       
    JavaThread* thread=JavaThread::thread_from_jni_environment(env); 
    ThreadInVMfromNative __tiv(thread);                              
    debug_only(VMNativeEntryWrapper __vew;)                          
  	....
                }
           }
複製程式碼

關於interfaceSupport.inline.hpp內定義的JVM_ENTRY_NO_ENV巨集邏輯中的extern "C" 就是下面程式碼以 C 語言方式進行編譯,C++可以巢狀 C 程式碼。

原始碼中特別常見的 JNICALL 就是一個空的巨集定義,只是為了告訴人這是一個 JNI 呼叫,巨集定義如下:

#define JNICALL
複製程式碼

關於JNI,我們可以參考https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/jniTOC.html文件來深入。

JVM_GC方法解讀

參考前面給出的相關原始碼,我們可以知道,最終呼叫的是heap的collect方法,GCCause_java_lang_system_gc,即因為什麼原因而產生的gc。我們可以通過其相關原始碼來看到造成GC的各種狀況定義。

//
// This class exposes implementation details of the various
// collector(s), and we need to be very careful with it. If
// use of this class grows, we should split it into public
// and implementation-private "causes".
//
// The definitions in the SA code should be kept in sync
// with the definitions here.
//
// srchotspotsharegcsharedgcCause.hpp
class GCCause : public AllStatic {
 public:
  enum Cause {
    /* public */
    _java_lang_system_gc,
    _full_gc_alot,
    _scavenge_alot,
    _allocation_profiler,
    _jvmti_force_gc,
    _gc_locker,
    _heap_inspection,
    _heap_dump,
    _wb_young_gc,
    _wb_conc_mark,
    _wb_full_gc,

    /* implementation independent, but reserved for GC use */
    _no_gc,
    _no_cause_specified,
    _allocation_failure,

    /* implementation specific */

    _tenured_generation_full,
    _metadata_GC_threshold,
    _metadata_GC_clear_soft_refs,

    _cms_generation_full,
    _cms_initial_mark,
    _cms_final_remark,
    _cms_concurrent_mark,

    _old_generation_expanded_on_last_scavenge,
    _old_generation_too_full_to_scavenge,
    _adaptive_size_policy,

    _g1_inc_collection_pause,
    _g1_humongous_allocation,

    _dcmd_gc_run,

    _z_timer,
    _z_warmup,
    _z_allocation_rate,
    _z_allocation_stall,
    _z_proactive,

    _last_gc_cause
  };
複製程式碼

我們接著回到JVM_GC定義中,這裡需要注意的是DisableExplicitGC,如果為true就不會執行collect方法,也就使得System.gc()無效,DisableExplicitGC這個引數對應配置為-XX:+DisableExplicitGC,預設是false,可自行配置為true

DisableExplicitGC為預設值的時候,會進入Universe::heap()->collect(GCCause::_java_lang_system_gc);程式碼邏輯,此時,我們可以看到,這是一個函式表示式,傳入的引數為Universe::heap()

 // The particular choice of collected heap.
static CollectedHeap* heap() { return _collectedHeap; }
CollectedHeap*  Universe::_collectedHeap = NULL;
CollectedHeap* Universe::create_heap() {
  assert(_collectedHeap == NULL, "Heap already created");
  return GCConfig::arguments()->create_heap();
}
複製程式碼
BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

如上圖所示,heap有好幾種,具體是哪種heap,需要看我們所選擇使用的GC演算法,這裡以常用的CMS GC為例,其對應的heapCMSHeap,所以我們再看看cmsHeap.hpp對應的collect方法:

//srchotspotsharegccmscmsHeap.hpp
class CMSHeap : public GenCollectedHeap {
public:
  CMSHeap(GenCollectorPolicy *policy);
...
  void CMSHeap::collect(GCCause::Cause cause) {
  if (should_do_concurrent_full_gc(cause)) {
    // Mostly concurrent full collection.
    collect_mostly_concurrent(cause);
  } else {
    GenCollectedHeap::collect(cause);
  }
}
    ...
}
//srchotspotsharegcsharedgenCollectedHeap.cpp

void GenCollectedHeap::collect(GCCause::Cause cause) {
  if (cause == GCCause::_wb_young_gc) {
    // Young collection for the WhiteBox API.
    collect(cause, YoungGen);
  } else {
#ifdef ASSERT
  if (cause == GCCause::_scavenge_alot) {
    // Young collection only.
    collect(cause, YoungGen);
  } else {
    // Stop-the-world full collection.
    collect(cause, OldGen);
  }
#else
    // Stop-the-world full collection.
    collect(cause, OldGen);
#endif
  }
}

複製程式碼

首先通過should_do_concurrent_full_gc方法判斷是否需要進行一次並行Full GC,如果是則呼叫collect_mostly_concurrent方法,進行並行Full GC;如果不是則一般會走到 collect(cause, OldGen)這段邏輯,進行Stop-the-world full collection,我們一般稱之為全域性暫停(STW)Full GC

我們先看看should_do_concurrent_full_gc到底有哪些條件:

bool CMSHeap::should_do_concurrent_full_gc(GCCause::Cause cause) {
  switch (cause) {
    case GCCause::_gc_locker:           return GCLockerInvokesConcurrent;
    case GCCause::_java_lang_system_gc:
    case GCCause::_dcmd_gc_run:         return ExplicitGCInvokesConcurrent;
    default:                            return false;
  }
}

複製程式碼

如果是_java_lang_system_gc並且ExplicitGCInvokesConcurrenttrue則進行並行Full GC,這裡又引出了另一個引數ExplicitGCInvokesConcurrent,如果配置-XX:+ExplicitGCInvokesConcurrenttrue,進行並行Full GC,預設為false

並行Full GC

我們先來看collect_mostly_concurrent,是如何進行並行Full GC

//srchotspotsharegccmscmsHeap.cpp
void CMSHeap::collect_mostly_concurrent(GCCause::Cause cause) {
  assert(!Heap_lock->owned_by_self(), "Should not own Heap_lock");

  MutexLocker ml(Heap_lock);
  // Read the GC counts while holding the Heap_lock
  unsigned int full_gc_count_before = total_full_collections();
  unsigned int gc_count_before      = total_collections();
  {
    MutexUnlocker mu(Heap_lock);
    VM_GenCollectFullConcurrent op(gc_count_before, full_gc_count_before, cause);
    VMThread::execute(&op);
  }
}
複製程式碼

最終通過VMThread來進行VM_GenCollectFullConcurrent中的void VM_GenCollectFullConcurrent::doit()方法來進行回收(相關英文註釋很明確,就不再解釋了):

// VM operation to invoke a concurrent collection of a
// GenCollectedHeap heap.
void VM_GenCollectFullConcurrent::doit() {
  assert(Thread::current()->is_VM_thread(), "Should be VM thread");
  assert(GCLockerInvokesConcurrent || ExplicitGCInvokesConcurrent, "Unexpected");

  CMSHeap* heap = CMSHeap::heap();
  if (_gc_count_before == heap->total_collections()) {
    // The "full" of do_full_collection call below "forces"
    // a collection; the second arg, 0, below ensures that
    // only the young gen is collected. XXX In the future,
    // we`ll probably need to have something in this interface
    // to say do this only if we are sure we will not bail
    // out to a full collection in this attempt, but that`s
    // for the future.
    assert(SafepointSynchronize::is_at_safepoint(),
      "We can only be executing this arm of if at a safepoint");
    GCCauseSetter gccs(heap, _gc_cause);
    heap->do_full_collection(heap->must_clear_all_soft_refs(), GenCollectedHeap::YoungGen);
  } // Else no need for a foreground young gc
  assert((_gc_count_before < heap->total_collections()) ||
         (GCLocker::is_active() /* gc may have been skipped */
          && (_gc_count_before == heap->total_collections())),
         "total_collections() should be monotonically increasing");

  MutexLockerEx x(FullGCCount_lock, Mutex::_no_safepoint_check_flag);
  assert(_full_gc_count_before <= heap->total_full_collections(), "Error");
  if (heap->total_full_collections() == _full_gc_count_before) {
    // Nudge the CMS thread to start a concurrent collection.
    CMSCollector::request_full_gc(_full_gc_count_before, _gc_cause);
  } else {
    assert(_full_gc_count_before < heap->total_full_collections(), "Error");
    FullGCCount_lock->notify_all();  // Inform the Java thread its work is done
  }
}
複製程式碼

簡單的說,這裡執行了一次Young GC來回收Young區,接著我們來關注CMSCollector::request_full_gc這個方法:

//srchotspotsharegccmsconcurrentMarkSweepGeneration.cpp
void CMSCollector::request_full_gc(unsigned int full_gc_count, GCCause::Cause cause) {
  CMSHeap* heap = CMSHeap::heap();
  unsigned int gc_count = heap->total_full_collections();
  if (gc_count == full_gc_count) {
    MutexLockerEx y(CGC_lock, Mutex::_no_safepoint_check_flag);
    _full_gc_requested = true;
    _full_gc_cause = cause;
    CGC_lock->notify();   // nudge CMS thread
  } else {
    assert(gc_count > full_gc_count, "Error: causal loop");
  }
}
複製程式碼

這裡主要關注在gc_count == full_gc_count的情況下,_full_gc_requested被設定成true 以及喚醒CMS 回收執行緒。
這裡需要提及一下,CMS GC有個後臺執行緒一直在掃描,以確定是否進行一次CMS GC,這個執行緒預設2s進行一次掃描,其中有個_full_gc_requested是否為true的判斷條件,如果為true,進行一次CMS GC,對OldPerm區進行一次回收。

正常Full GC

正常Full GC會執行下面的邏輯:

void GenCollectedHeap::collect(GCCause::Cause cause, GenerationType max_generation) {
  // The caller doesn`t have the Heap_lock
  assert(!Heap_lock->owned_by_self(), "this thread should not own the Heap_lock");
  MutexLocker ml(Heap_lock);
  collect_locked(cause, max_generation);
}

// this is the private collection interface
// The Heap_lock is expected to be held on entry.
//srchotspotsharegcsharedgenCollectedHeap.cpp
void GenCollectedHeap::collect_locked(GCCause::Cause cause, GenerationType max_generation) {
  // Read the GC count while holding the Heap_lock
  unsigned int gc_count_before      = total_collections();
  unsigned int full_gc_count_before = total_full_collections();
  {
    MutexUnlocker mu(Heap_lock);  // give up heap lock, execute gets it back
    VM_GenCollectFull op(gc_count_before, full_gc_count_before,
                         cause, max_generation);
    VMThread::execute(&op);
  }
}


複製程式碼

通過VMThread呼叫VM_GenCollectFull中的void VM_GenCollectFull::doit()方法來進行回收。

//srchotspotsharegcsharedvmGCOperations.cpp
void VM_GenCollectFull::doit() {
  SvcGCMarker sgcm(SvcGCMarker::FULL);

  GenCollectedHeap* gch = GenCollectedHeap::heap();
  GCCauseSetter gccs(gch, _gc_cause);
  gch->do_full_collection(gch->must_clear_all_soft_refs(), _max_generation);
}

//srchotspotsharegcsharedgenCollectedHeap.cpp
void GenCollectedHeap::do_full_collection(bool clear_all_soft_refs,
                                          GenerationType last_generation) {
  GenerationType local_last_generation;
  if (!incremental_collection_will_fail(false /* don`t consult_young */) &&
      gc_cause() == GCCause::_gc_locker) {
    local_last_generation = YoungGen;
  } else {
    local_last_generation = last_generation;
  }

  do_collection(true,                   // full
                clear_all_soft_refs,    // clear_all_soft_refs
                0,                      // size
                false,                  // is_tlab
                local_last_generation); // last_generation
  // Hack XXX FIX ME !!!
  // A scavenge may not have been attempted, or may have
  // been attempted and failed, because the old gen was too full
  if (local_last_generation == YoungGen && gc_cause() == GCCause::_gc_locker &&
      incremental_collection_will_fail(false /* don`t consult_young */)) {
    log_debug(gc, jni)("GC locker: Trying a full collection because scavenge failed");
    // This time allow the old gen to be collected as well
    do_collection(true,                // full
                  clear_all_soft_refs, // clear_all_soft_refs
                  0,                   // size
                  false,               // is_tlab
                  OldGen);             // last_generation
  }
}
複製程式碼

這裡最終會通過GenCollectedHeapdo_full_collection方法(此方法程式碼量比較多,就不展開分析了)進行一次Full GC,將回收YoungOldPerm區,並且即使Old區使用的是CMS GC,也會對Old區進行compact,也就是MSC,標記-清除-壓縮。

並行和正常Full GC的比較

stop the world

我們前面有提到VMThread,在JVM中通過這個執行緒不斷輪詢它的佇列,該佇列裡主要是存一些VM_operation的動作,比如最常見的就是記憶體分配失敗,並要求做GC操作的請求等,在對GC這些操作執行的時候會先將其他業務執行緒都進入到安全點,也就是這些執行緒從此不再執行任何位元組碼指令,只有當出了安全點的時候才讓他們繼續執行原來的指令,因此這其實就是我們說的stop the world(STW),整個程式相當於靜止了。

CMS GC

CMS GC我們可分為backgroundforeground兩種模式,顧名思義,其中background是在後臺做的,也就是可以不影響正常的業務執行緒跑,觸發條件比如在old的記憶體佔比超過多少的時候就可能觸發一次backgroundCMS GC,這個過程會經歷CMS GC的所有階段,該暫停的暫停,該並行的並行,效率相對來說還比較高,畢竟有和業務執行緒並行的GC階段;而foreground則不然,它發生的場景比如業務執行緒請求分配記憶體,但是記憶體不夠了,於是可能觸發一次CMS GC,這個過程就必須是要等記憶體分配到了執行緒才能繼續往下面走的,因此整個過程必須是STW的,此時的CMS GC整個過程都是暫停應用的,但是為了提高效率,它並不是每個階段都會走的,只走其中一些階段,跳過的階段主要是並行階段,即PrecleaningAbortablePrecleanResizing這幾個階段都不會經歷,其中sweep階段是同步的,但不管怎麼說如果走了類似foregroundcms gc,那麼整個過程業務執行緒都是不可用的,效率會影響挺大。

正常Full GC其實是整個GC過程是真正意義上的Full GC,還有些場景雖然呼叫Full GC的介面,但是並不會都做,有些時候只做Young GC,有些時候只做cms gc。而且由前面的程式碼可知,最終都是由VMThread來執行的,因此整個時間是Young GC+CMS GC的時間之和,其中CMS GC是上面提到的foreground式的,因此整個過程會比較長,也是我們要避免的。

並行Full GC也通樣會做YGCCMS GC,但是效率高就高在CMS GC是走的background的,整個暫停的過程主要是YGC+CMS_initMark+CMS_remark幾個階段。

GenCollectedHeap::collect這個方法中有一句註釋The caller doesn`t have the Heap_lock,即呼叫者並不持有Heap_lock,也就能理解foreground了。

總結

System.gc()會觸發Full GC,可以通過-XX:+DisableExplicitGC引數遮蔽System.gc(),在使用CMS GC的前提下,也可以使用-XX:+ExplicitGCInvokesConcurrent引數來進行並行Full GC,提升效能。
不過,一般不推薦使用System.gc(),因為Full GC 耗時比較長,對應用影響較大。同樣也不建議設定-XX:+DisableExplicitGC,特別是在有使用堆外記憶體的情況下,如果堆外記憶體申請不到足夠的空間,jdk會觸發一次System.gc(),來進行回收,如果遮蔽了,申請不到記憶體,自然就OOME了。

參考部落格 :

lovestblog.cn/blog/2015/0…

www.jianshu.com/p/40412b008…

使用Unsafe.allocateMemory分配記憶體

sun.misc.Unsafe提供了一組方法來進行記憶體的分配,重新分配,以及釋放。它們和Cmalloc/free方法很像:

  1. long Unsafe.allocateMemory(long size)——分配一塊記憶體空間。這塊記憶體可能會包含垃圾資料(沒有自動清零)。如果分配失敗的話會拋一個java.lang.OutOfMemoryError的異常。它會返回一個非零的記憶體地址。
  2. Unsafe.reallocateMemory(long address, long size)——重新分配一塊記憶體,把資料從舊的記憶體緩衝區(address指向的地方)中拷貝到的新分配的記憶體塊中。如果地址等於0,這個方法和allocateMemory的效果是一樣的。它返回的是新的記憶體緩衝區的地址。
  3. Unsafe.freeMemory(long address)——釋放一個由前面那兩方法生成的記憶體緩衝區。如果address0則什麼也不做。
//jdk.internal.misc.Unsafe#allocateMemory
public long allocateMemory(long bytes) {
        allocateMemoryChecks(bytes);

        if (bytes == 0) {
            return 0;
        }

        long p = allocateMemory0(bytes);
        if (p == 0) {
            throw new OutOfMemoryError();
        }

        return p;
    }
//jdk.internal.misc.Unsafe#allocateMemory0
private native long allocateMemory0(long bytes);
複製程式碼

關於allocateMemory0這個本地方法定義如下:

//srchotspotshareprimsunsafe.cpp
UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) {
  size_t sz = (size_t)size;

  sz = align_up(sz, HeapWordSize);
  void* x = os::malloc(sz, mtOther);

  return addr_to_java(x);
} UNSAFE_END
複製程式碼

可以看出sun.misc.Unsafe#allocateMemory使用malloc這個C標準庫的函式來申請記憶體。如果使用的是Linux,多半就是用的Linux自帶的glibc裡的ptmalloc

DirectByteBuffer記憶體釋放原理

DirectByteBuffer的建構函式的最後,我們看到這行程式碼:

 // 建立一個cleaner,最後會呼叫Deallocator.run來釋放記憶體 
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
複製程式碼

DirectByteBuffer本身是一個Java物件,其是位於堆記憶體中的,通過JDKGC機制可以自動幫我們回收,但其申請的直接記憶體,不在GC範圍之內,無法自動回收。我們是不是可以為DirectByteBuffer這個堆記憶體物件註冊一個鉤子函式(這裡可以通過Runnable介面的run方法來實現這個動作),當DirectByteBuffer物件被GC回收的時候,會回撥這個run方法,即在這個方法中執行釋放DirectByteBuffer引用的直接記憶體,也就是在run方法中呼叫UnsafefreeMemory 方法。由上面所示程式碼可知,註冊是通過sun.misc.Cleaner類的Create方法來實現的。

//jdk.internal.ref.Cleaner#create	
/**
 * Creates a new cleaner.
 *
 * @param  ob the referent object to be cleaned
 * @param  thunk
 *         The cleanup code to be run when the cleaner is invoked.  The
 *         cleanup code is run directly from the reference-handler thread,
 *         so it should be as simple and straightforward as possible.
 *
 * @return  The new cleaner
 */
public static Cleaner create(Object ob, Runnable thunk) {
    if (thunk == null)
        return null;
    return add(new Cleaner(ob, thunk));
}

//jdk.internal.ref.Cleaner#clean
/**
 * Runs this cleaner, if it has not been run before.
 */
public void clean() {
    if (!remove(this))
        return;
    try {
        thunk.run();
    } catch (final Throwable x) {
        AccessController.doPrivileged(new PrivilegedAction<>() {
            public Void run() {
                if (System.err != null)
                    new Error("Cleaner terminated abnormally", x)
                    .printStackTrace();
                System.exit(1);
                return null;
            }});
    }
}
複製程式碼

由之前程式碼和上面程式碼註釋可知,其中第一個引數是一個堆記憶體物件,這裡是指DirectByteBuffer物件,第二個引數是一個Runnable任務,其內定義了一個動作,表示這個堆記憶體物件被回收的時候,需要執行的回撥方法。我們可以看到在DirectByteBuffer的最後一行中,傳入的這兩個引數分別是this,和一個Deallocator(實現了Runnable介面),其中this表示就是當前DirectByteBuffer例項,也就是當前DirectByteBuffer被回收的時候,回撥Deallocatorrun方法,清除DirectByteBuffer引用的直接記憶體,程式碼如下所示:

private static class Deallocator
    implements Runnable
{

    private long address;
    private long size;
    private int capacity;

    private Deallocator(long address, long size, int capacity) {
        assert (address != 0);
        this.address = address;
        this.size = size;
        this.capacity = capacity;
    }

    public void run() {
        if (address == 0) {
            // Paranoia
            return;
        }
        UNSAFE.freeMemory(address);
        address = 0;
        Bits.unreserveMemory(size, capacity);
    }

}
複製程式碼

可以看到run方法中呼叫了UNSAFE.freeMemory方法釋放了直接記憶體的引用。

DirectByteBuffer記憶體釋放流程

因為DirectByteBuffer申請的記憶體是在堆外,而DirectByteBuffer本身也只儲存了記憶體的起始地址,所以DirectByteBuffer的記憶體佔用是由堆內的DirectByteBuffer物件與堆外的對應記憶體空間共同構成。

按照我們之前的玩法,Java中可以利用的特性有finalize函式,但是finalize機制是Java官方不推薦的,因為有諸多需要注意的地方,推薦的做法是使用虛引用來處理物件被回收時的後續處理工作。這裡JDK提供了Cleaner類來簡化這個操作,CleanerPhantomReference的子類,那麼就可以在PhantomReference被加入ReferenceQueue時觸發對應的Runnable回撥。

DirectByteBuffer讀寫操作

DirectByteBuffer最終會使用sun.misc.Unsafe#getByte(long)sun.misc.Unsafe#putByte(long, byte)這兩個方法來讀寫堆外記憶體空間的指定位置的位元組資料。無非就是通過地址來讀寫相應記憶體位置的資料,具體程式碼如下所示。

//java.nio.Buffer#nextGetIndex()
final int nextGetIndex() {                          // package-private
    if (position >= limit)
        throw new BufferUnderflowException();
    return position++;
}
//java.nio.DirectByteBuffer
public long address() {
    return address;
}

private long ix(int i) {
    return address + ((long)i << 0);
}

public byte get() {
    try {
        return ((UNSAFE.getByte(ix(nextGetIndex()))));
    } finally {
        Reference.reachabilityFence(this);
    }
}

public byte get(int i) {
    try {
        return ((UNSAFE.getByte(ix(checkIndex(i)))));
    } finally {
        Reference.reachabilityFence(this);
    }
}

public ByteBuffer put(byte x) {
    try {
        UNSAFE.putByte(ix(nextPutIndex()), ((x)));
    } finally {
        Reference.reachabilityFence(this);
    }
    return this;
}

public ByteBuffer put(int i, byte x) {
    try {
        UNSAFE.putByte(ix(checkIndex(i)), ((x)));
    } finally {
        Reference.reachabilityFence(this);
    }
    return this;
}
複製程式碼

MappedByteBuffer的二三事

MappedByteBuffer本應該是DirectByteBuffer的子類,但為了保持結構規範清晰簡單,並且出於優化目的,反過來更恰當,也是因為DirectByteBuffer屬於包級別的私有類(即class關鍵字前並沒有類許可權定義),在定義抽象類的時候本就是為了可擴充套件,這樣,大家也就可以明白JDK為何這麼設計了。雖然MappedByteBuffer在邏輯上應該是DirectByteBuffer的子類,而且MappedByteBuffer的記憶體的GC和DirectByteBuffer的GC類似(和堆GC不同),但是分配的MappedByteBuffer的大小不受-XX:MaxDirectMemorySize引數影響。
因為要基於系統級別的IO操作,所以需要給其設定一個FileDescriptor來對映buffer的操作,如果並未對映到buffer,那這個FileDescriptornull

MappedByteBuffer封裝的是記憶體對映檔案操作,也就是隻能進行檔案IO操作。MappedByteBuffer是根據mmap產生的對映緩衝區,這部分緩衝區被對映到對應的檔案頁上,通過MappedByteBuffer可以直接操作對映緩衝區,而這部分緩衝區又被對映到檔案頁上,作業系統通過對應記憶體頁的調入和調出完成檔案的寫入和寫出。

FileChannel中map方法解讀

我們可以通過java.nio.channels.FileChannel#map(MapMode mode,long position, long size)得到MappedByteBuffer,我們來看sun.nio.ch.FileChannelImpl對它的實現:

	
private static final int MAP_RO = 0;
private static final int MAP_RW = 1;
private static final int MAP_PV = 2;
//sun.nio.ch.FileChannelImpl#map
public MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException
{
    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");
    //最大2G
    if (size > Integer.MAX_VALUE)
        throw new IllegalArgumentException("Size exceeds Integer.MAX_VALUE");

    int imode = -1;
    if (mode == MapMode.READ_ONLY)
        imode = MAP_RO;
    else if (mode == MapMode.READ_WRITE)
        imode = MAP_RW;
    else if (mode == MapMode.PRIVATE)
        imode = MAP_PV;
    assert (imode >= 0);
    if ((mode != MapMode.READ_ONLY) && !writable)
        throw new NonWritableChannelException();
    if (!readable)
        throw new NonReadableChannelException();

    long addr = -1;
    int ti = -1;
    try {
        beginBlocking();
        ti = threads.add();
        if (!isOpen())
            return null;

        long mapSize;
        int pagePosition;
        synchronized (positionLock) {
            long filesize;
            do {
                //nd.size()返回實際的檔案大小
                filesize = nd.size(fd);
            } while ((filesize == IOStatus.INTERRUPTED) && isOpen());
            if (!isOpen())
                return null;
    
            //如果實際檔案大小 小於所需求檔案大小,則增大檔案的大小,
            //檔案的大小被改變,檔案增大的部分預設設定為0。
            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 {
                    //增大檔案的大小
                    rv = nd.truncate(fd, position + size);
                } while ((rv == IOStatus.INTERRUPTED) && isOpen());
                if (!isOpen())
                    return null;
            }
            //如果要求對映的檔案大小為0,則不呼叫作業系統的mmap呼叫,
            //只是生成一個空間容量為0的DirectByteBuffer並返回
            if (size == 0) {
                addr = 0;
                // a valid file descriptor is not required
                FileDescriptor dummy = new FileDescriptor();
                if ((!writable) || (imode == MAP_RO))
                    return Util.newMappedByteBufferR(0, 0, dummy, null);
                else
                    return Util.newMappedByteBuffer(0, 0, dummy, null);
            }
            //allocationGranularity為所對映的緩衝區分配記憶體大小,pagePosition為第多少頁
            pagePosition = (int)(position % allocationGranularity);
            //得到對映的位置,即從mapPosition開始對映
            long mapPosition = position - pagePosition;
            //從頁的最開始對映加pagePosition,以此增大對映空間
            mapSize = size + pagePosition;
            try {
                //後面會進行解讀
                // If map0 did not throw an exception, the address is valid
                addr = map0(imode, mapPosition, mapSize);
            } 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(imode, mapPosition, mapSize);
                } catch (OutOfMemoryError y) {
                    // After a second OOME, fail
                    throw new IOException("Map failed", y);
                }
            }
        } // synchronized

        // On Windows, and potentially other platforms, we need an open
        // file descriptor for some mapping operations.
        FileDescriptor mfd;
        try {
            mfd = nd.duplicateForMapping(fd);
        } catch (IOException ioe) {
            unmap0(addr, mapSize);
            throw ioe;
        }

        assert (IOStatus.checkAll(addr));
        assert (addr % allocationGranularity == 0);
        int isize = (int)size;
        Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
        if ((!writable) || (imode == MAP_RO)) {
            return Util.newMappedByteBufferR(isize,
                                                addr + pagePosition,
                                                mfd,
                                                um);
        } else {
            return Util.newMappedByteBuffer(isize,
                                            addr + pagePosition,
                                            mfd,
                                            um);
        }
    } finally {
        threads.remove(ti);
        endBlocking(IOStatus.checkAll(addr));
    }
}
複製程式碼

我們來看sun.nio.ch.FileChannelImpl#map0的實現:

//srcjava.baseunix
ativelibniochFileChannelImpl.c
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
                                     jint prot, jlong off, jlong len)
{
    void *mapAddress = 0;
    jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
     //這裡得到所操作檔案的讀取狀態,即對應的檔案描述符的值
    jint fd = fdval(env, fdo);
    int protections = 0;
    int flags = 0;

    if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
        protections = PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
        protections = PROT_WRITE | PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
        protections =  PROT_WRITE | PROT_READ;
        flags = MAP_PRIVATE;
    }
//這裡就是作業系統呼叫了,mmap64是巨集定義,實際最後呼叫的是mmap
    mapAddress = mmap64(
        0,                    /* Let OS decide location */
        len,                  /* Number of bytes to map */
        protections,          /* File permissions */
        flags,                /* Changes are shared */
        fd,                   /* File descriptor of mapped file */
        off);                 /* Offset into file */

    if (mapAddress == MAP_FAILED) {
        if (errno == ENOMEM) {
            //如果沒有對映成功,直接丟擲OutOfMemoryError
            JNU_ThrowOutOfMemoryError(env, "Map failed");
            return IOS_THROWN;
        }
        return handle(env, -1, "Map failed");
    }

    return ((jlong) (unsigned long) mapAddress);
}
複製程式碼

這裡要注意的是,雖然FileChannel.map()size引數是long,但是size的大小最大為Integer.MAX_VALUE,也就是最大隻能對映最大2G大小的空間。實際上作業系統提供的mmap可以分配更大的空間,但是JAVA在此處限制在2G
這裡我們來涉及一個生產事故,使用spark處理較大的資料檔案,遇到了分割槽2G限制的問題,spark會報如下的日誌:

WARN scheduler.TaskSetManager: Lost task 19.0 in stage 6.0 (TID 120, 10.111.32.47): java.lang.IllegalArgumentException: Size exceeds Integer.MAX_VALUE
at sun.nio.ch.FileChannelImpl.map(FileChannelImpl.java:828)
at org.apache.spark.storage.DiskStore.getBytes(DiskStore.scala:123)
at org.apache.spark.storage.DiskStore.getBytes(DiskStore.scala:132)
at org.apache.spark.storage.BlockManager.doGetLocal(BlockManager.scala:517)
at org.apache.spark.storage.BlockManager.getLocal(BlockManager.scala:432)
at org.apache.spark.storage.BlockManager.get(BlockManager.scala:618)
at org.apache.spark.CacheManager.putInBlockManager(CacheManager.scala:146)
at org.apache.spark.CacheManager.getOrCompute(CacheManager.scala:70)

複製程式碼

結合之前的原始碼:

//最大2G
if (size > Integer.MAX_VALUE)
    throw new IllegalArgumentException("Size exceeds Integer.MAX_VALUE");
複製程式碼

我們是不是可以很輕易的定位到錯誤的所在,以及為何會產生這樣的錯誤,雖然日誌裡寫的也很清楚,但我們從本質上有了更深入的理解。於是我們就可以想辦法了,既然改變不了2G這個限制,那麼我們就把容器數量提高上來就可以了,也就是手動設定RDD的分割槽數量。當前使用的Spark預設RDD分割槽是18個,手動設定為500個(具體還需要根據自己生產環境中的實際記憶體容量考慮),上面這個問題就迎刃而解了。具體操作為,可以在RDD載入後,使用RDD.repartition(numPart:Int)函式重新設定分割槽數量。

val data_new = data.repartition(500)
複製程式碼

MappedByteBuffer是通過mmap產生得到的緩衝區,這部分緩衝區是由作業系統直接建立和管理的,最後JVM通過unmmap讓作業系統直接釋放這部分記憶體。

private static void unmap(MappedByteBuffer bb) {
    Cleaner cl = ((DirectBuffer)bb).cleaner();
    if (cl != null)
        cl.clean();
}
複製程式碼

可以看到,這裡傳入的一個MappedByteBuffer型別的引數,我們回到sun.nio.ch.FileChannelImpl#map方法實現中,為了方便回收,這裡對所操作的檔案描述符進行再次包裝,即mfd = nd.duplicateForMapping(fd),然後同樣通過一個Runnable介面的實現來定義一個釋放記憶體的行為(這裡是Unmapper實現),於是Unmapper um = new Unmapper(addr, mapSize, isize, mfd);也就不難理解了,最後,因為我們要返回一個MappedByteBuffer物件,所以,就有如下程式碼實現:

int isize = (int)size;
    Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
    if ((!writable) || (imode == MAP_RO)) {
        return Util.newMappedByteBufferR(isize,
                                            addr + pagePosition,
                                            mfd,
                                            um);
    } else {
        return Util.newMappedByteBuffer(isize,
                                        addr + pagePosition,
                                        mfd,
                                        um);
    }
複製程式碼

其實就是建立了一個DirectByteBuffer物件,這裡的回收策略和我們之前接觸的java.nio.ByteBuffer#allocateDirect(也就是java.nio.DirectByteBuffer#DirectByteBuffer(int))是不同的。這裡是需要最後呼叫munmap來進行系統回收的。

protected DirectByteBuffer(int cap, long addr,
                                    FileDescriptor fd,
                                    Runnable unmapper)
{

    super(-1, 0, cap, cap, fd);
    address = addr;
    cleaner = Cleaner.create(this, unmapper);
    att = null;

}

// -- Memory-mapped buffers --
//sun.nio.ch.FileChannelImpl.Unmapper
    private static class Unmapper
        implements Runnable
    {
        // may be required to close file
        private static final NativeDispatcher nd = new FileDispatcherImpl();

        // keep track of mapped buffer usage
        static volatile int count;
        static volatile long totalSize;
        static volatile long totalCapacity;

        private volatile long address;
        private final long size;
        private final int cap;
        private final FileDescriptor fd;

        private Unmapper(long address, long size, int cap,
                         FileDescriptor fd)
        {
            assert (address != 0);
            this.address = address;
            this.size = size;
            this.cap = cap;
            this.fd = fd;

            synchronized (Unmapper.class) {
                count++;
                totalSize += size;
                totalCapacity += cap;
            }
        }

        public void run() {
            if (address == 0)
                return;
            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
                }
            }

            synchronized (Unmapper.class) {
                count--;
                totalSize -= size;
                totalCapacity -= cap;
            }
        }
    }
複製程式碼

此處涉及的unmap0(address, size)本地實現如下,可以看到,它呼叫了munmap

JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileChannelImpl_unmap0(JNIEnv *env, jobject this,
                                       jlong address, jlong len)
{
    void *a = (void *)jlong_to_ptr(address);
    return handle(env,
                  munmap(a, (size_t)len),
                  "Unmap failed");
}
複製程式碼
FileChannel的map方法小結

關於FileChannelmap方法,簡單的說就是將檔案對映為記憶體映像檔案。也就是通過MappedByteBuffer map(int mode,long position,long size)可以把檔案的從position開始的size大小的區域對映為記憶體映像檔案,mode是指可訪問該記憶體映像檔案的方式:READ_ONLYREAD_WRITEPRIVATE

  • READ_ONLYMapMode.READ_ONLY 只讀):試圖修改得到的緩衝區將導致丟擲 ReadOnlyBufferException
  • READ_WRITEMapMode.READ_WRITE 讀/寫):對得到的緩衝區的更改最終將傳播到檔案;該更改對對映到同一檔案的其他程式不一定是可見的。
  • PRIVATEMapMode.PRIVATE 專用): 對得到的緩衝區的更改不會傳播到檔案,並且該更改對對映到同一檔案的其他程式也不是可見的;相反,會建立緩衝區已修改部分的專用副本。

呼叫FileChannelmap()方法後,即可將檔案的某一部分或全部對映到記憶體中,而由前文可知,對映記憶體緩衝區是個直接緩衝區,雖繼承自ByteBuffer,但相對於ByteBuffer,它有更多的優點:

  • 讀取快
  • 寫入快
  • 隨時隨地寫入
mmap快速瞭解

簡而言之,就是通過mmap將檔案直接對映到使用者態的記憶體地址,這樣對檔案的操作就不再是write/read,而是直接對記憶體地址的操作。 在c中提供了三個函式來實現 :

  • mmap: 進行對映。
  • munmap: 取消對映。
  • msync: 程式在對映空間的對共享內容的改變並不直接寫回到硬碟檔案中,往往在呼叫munmap()後才執行該操作。

首先建立好虛擬記憶體和硬碟檔案之間的對映(mmap系統呼叫),當程式訪問頁面時產生一個缺頁中斷,核心將頁面讀入記憶體(也就是說把硬碟上的檔案拷貝到記憶體中),並且更新頁表指向該頁面。
所有程式共享同一實體記憶體,實體記憶體中可以只儲存一份資料,不同的程式只需要把自己的虛擬記憶體對映過去就可以了,這種方式非常方便於同一副本的共享,節省記憶體。
經過記憶體對映之後,檔案內的資料就可以用記憶體讀/寫指令來訪問,而不是用ReadWrite這樣的I/O系統函式,從而提高了檔案存取速度。

更多的可以參考MappedByteBuffer以及mmap的底層原理

FileChannel中的force探究

為了配合FileChannelmap方法,這裡有必要介紹下它的三個配套方法:

  • force():緩衝區是READ_WRITE模式下,此方法會對緩衝區內容的修改強行寫入檔案,即將緩衝區記憶體更新的內容刷到硬碟中。
  • load():將緩衝區的內容載入記憶體,並返回該緩衝區的引用。
  • isLoaded():如果緩衝區的內容在實體記憶體中,則返回真,否則返回假。
    這裡,我們對sun.nio.ch.FileChannelImpl#force實現進行下分析,首來看其相關原始碼。
//sun.nio.ch.FileChannelImpl#force
public void force(boolean metaData) throws IOException {
    ensureOpen();
    int rv = -1;
    int ti = -1;
    try {
        beginBlocking();
        ti = threads.add();
        if (!isOpen())
            return;
        do {
            rv = nd.force(fd, metaData);
        } while ((rv == IOStatus.INTERRUPTED) && isOpen());
    } finally {
        threads.remove(ti);
        endBlocking(rv > -1);
        assert IOStatus.check(rv);
    }
}
//sun.nio.ch.FileDispatcherImpl#force
int force(FileDescriptor fd, boolean metaData) throws IOException {
return force0(fd, metaData);
}
static native int force0(FileDescriptor fd, boolean metaData)
throws IOException;

//srcjava.baseunix
ativelibniochFileDispatcherImpl.c
JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_force0(JNIEnv *env, jobject this,
                                          jobject fdo, jboolean md)
{
    jint fd = fdval(env, fdo);
    int result = 0;

#ifdef MACOSX
    result = fcntl(fd, F_FULLFSYNC);
    if (result == -1 && errno == ENOTSUP) {
        /* Try fsync() in case F_FULLSYUNC is not implemented on the file system. */
        result = fsync(fd);
    }
#else /* end MACOSX, begin not-MACOSX */
    if (md == JNI_FALSE) {
        result = fdatasync(fd);
    } else {
#ifdef _AIX
        /* On AIX, calling fsync on a file descriptor that is opened only for
         * reading results in an error ("EBADF: The FileDescriptor parameter is
         * not a valid file descriptor open for writing.").
         * However, at this point it is not possibly anymore to read the
         * `writable` attribute of the corresponding file channel so we have to
         * use `fcntl`.
         */
        int getfl = fcntl(fd, F_GETFL);
        if (getfl >= 0 && (getfl & O_ACCMODE) == O_RDONLY) {
            return 0;
        }
#endif /* _AIX */
        result = fsync(fd);
    }
#endif /* not-MACOSX */
    return handle(env, result, "Force failed");
}
複製程式碼

我們跳過針對MACOSX的實現,只關注針對linux平臺的。發現force在傳入引數為false的情況下,呼叫的是fdatasync(fsync)
通過查詢Linux函式手冊(可參考fdatasync),我們可以看到:

fsync() transfers ("flushes") all modified in-core data of (i.e., modified buffer cache pages for) the file referred to by the file descriptor fd to the disk device (or other permanent storage device) so that all changed information can be retrieved even after the system crashed or was rebooted. This includes writing through or flushing a disk cache if present. The call blocks until the device reports that the transfer has completed. It also flushes metadata information associated with the file (see stat(2)).

Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk. For that an explicit fsync() on a file descriptor for the directory is also needed.

fdatasync() is similar to fsync(), but does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be correctly handled. For example, changes to st_atime or st_mtime (respectively, time of last access and time of last modification; see stat(2)) do not require flushing because they are not necessary for a subsequent data read to be handled correctly. On the other hand, a change to the file size (st_size, as made by say ftruncate(2)), would require a metadata flush.

The aim of fdatasync() is to reduce disk activity for applications that do not require all metadata to be synchronized with the disk.
複製程式碼

簡單描述下,fdatasync只重新整理資料到硬碟。fsync同時重新整理資料和inode資訊到硬碟,例如st_atime
因為inode和資料不是連續存放在硬碟中,所以fsync需要更多的寫硬碟,但是可以讓inode得到更新。如果不關注inode資訊的情況(例如最近一次訪問檔案),可以通過使用fdatasync提高效能。對於關注inode資訊的情況,則應該使用fsync

需要注意,如果物理硬碟的write cache是開啟的,那麼fsyncfdatasync將不能保證回寫的資料被完整的寫入到硬碟儲存介質中(資料可能依然儲存在硬碟的cache中,並沒有寫入介質),因此可能會出現明明呼叫了fsync系統呼叫但是資料在掉電後依然丟失了或者出現檔案系統不一致的情況。

這裡,為了保證硬碟上實際檔案系統與緩衝區快取記憶體中內容的一致性,UNIX系統提供了syncfsyncfdatasync三個函式。
sync函式只是將所有修改過的塊緩衝區排入寫佇列,然後就返回,它並不等待實際寫硬碟操作結束。
通常稱為update的系統守護程式會週期性地(一般每隔30秒)呼叫sync函式。這就保證了定期沖洗核心的塊緩衝區。命令sync(1)也呼叫sync函式。
fsync函式只對由檔案描述符filedes指定的單一檔案起作用,並且等待寫硬碟操作結束,然後返回。fsync可用於資料庫這樣的應用程式,這種應用程式需要確保將修改過的塊立即寫到硬碟上。
fdatasync函式類似於fsync,但它隻影響檔案的資料部分。而除資料外,fsync還會同步更新檔案的屬性。

也就是說,對於fdatasync而言,會首先寫到page cache,然後由pdflush定時刷到硬碟中,那這麼說mmap只是在程式空間分配一個記憶體地址,真實的記憶體還是使用的pagecache。所以force是呼叫fsyncdirty page刷到硬碟中,但mmap還有共享之類的實現起來應該很複雜。

也就是說,在Linux中,當FileChannel中的force傳入引數為true時,呼叫fsyncfalse呼叫fdatasyncfdatasync只刷資料不刷meta資料 。即使不呼叫force,核心也會定期將dirty page刷到硬碟,預設是30s

最後,我們給出一個使用的Demo:

FileOutputStream outputStream = new FileOutputStream("/Users/simviso/b.txt");

// 強制檔案資料與後設資料落盤
outputStream.getChannel().force(true);

// 強制檔案資料落盤,不關心後設資料是否落盤
outputStream.getChannel().force(false);

複製程式碼

零拷貝

使用記憶體對映緩衝區(Memory-Mapped-Buffer)來操作檔案,它比普通的IO操作讀檔案要快得多。因為,使用記憶體對映緩衝區操作檔案時,並沒有顯式的進行相關係統呼叫(readwrite),而且在一定條件下,OS還會自動快取一些檔案頁(memory page)。
通過zerocopy可以提高IO密集型的JAVA應用程式的效能。IO操作需要資料頻繁地在核心緩衝區和使用者緩衝區之間拷貝,而通過zerocopy可以減少這種拷貝的次數,同時也降低了上下文切換(使用者態與核心態之間的切換)的次數。
我們大多數WEB應用程式執行的一個操作流程就是:接受使用者請求–>從本地硬碟讀資料–>資料進入核心緩衝區–>使用者緩衝區–>核心緩衝區–>使用者緩衝區–>通過socket傳送。
資料每次在核心緩衝區與使用者緩衝區之間的拷貝都會消耗CPU以及記憶體的頻寬。而通過zerocopy可以有效減少這種拷貝次數。
這裡,我們來以檔案伺服器的資料傳輸為例來分析下整個流程:從伺服器硬碟中讀檔案,並把檔案通過網路(socket)傳送給客戶端,寫成程式碼的話,其實核心就兩句話:

File.read(fileDesc, buffer, len);
Socket.send(socket, buffer, len);
複製程式碼

也就兩步操作。第一步:將檔案讀入buffer;第二步:將buffer中的資料通過socket傳送出去。但是,這兩步操作需要四次上下文切換(也就是使用者態與核心態之間的切換)和四次copy操作才能完成。整個過程如下圖所示:

BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下
  1. 第一次上下文切換髮生在 read()方法執行,表示伺服器要去硬碟上讀檔案了,這會觸發一個sys_read()的系統呼叫。此時由使用者態切換到核心態,完成的動作是:DMA把硬碟上的資料讀入到核心緩衝區中(第一次拷貝)。

  2. 第二次上下文切換髮生在read()方法的返回(read()是一個阻塞呼叫),表示資料已經成功從硬碟上讀到核心緩衝區了。此時,由核心態返回到使用者態,完成的動作是:將核心緩衝區中的資料拷貝到使用者緩衝區(第二次拷貝)。

  3. 第三次上下文切換髮生在send()方法執行,表示伺服器準備把資料傳送出去。此時,由使用者態切換到核心態,完成的動作是:將使用者緩衝區中的資料拷貝到核心緩衝區(第三次拷貝)

  4. 第四次上下文切換髮生在send()方法的返回,這裡的send()方法可以非同步返回:執行緒執行了send()之後立即從send()返回,剩下的資料拷貝及傳送就交給作業系統底層實現了。此時,由核心態返回到使用者態,完成的動作是:將核心緩衝區中的資料送到NIC Buffer。(第四次拷貝)

核心緩衝區

為什麼需要核心緩衝區?因為核心緩衝區提高了效能。通過前面的學習可知,正是因為引入了核心緩衝區(中間緩衝區),使得資料來回地拷貝,降低了效率。那為什麼又說核心緩衝區提高了效能?

對於讀操作而言,核心緩衝區就相當於一個預讀快取,當使用者程式一次只需要讀一小部分資料時,首先作業系統會從硬碟上讀一大塊資料到核心緩衝區,使用者程式只取走了一小部分( 比如我只new byte[128]這樣一個小的位元組陣列來讀)。當使用者程式下一次再讀資料,就可以直接從核心緩衝區中取了,作業系統就不需要再次訪問硬碟了!因為使用者要讀的資料已經在核心緩衝區中!這也是前面提到的:為什麼後續的讀操作(read()方法呼叫)要明顯地比第一次快的原因。從這個角度而言,核心緩衝區確實提高了讀操作的效能

再來看寫操作:可以做到 “非同步寫”。所謂的非同步,就是在wirte(dest[])時,使用者程式告訴作業系統,把dest[]陣列中的內容寫到XXX檔案中去,然後write方法就返回了。作業系統則在後臺默默地將使用者緩衝區中的內容(dest[])拷貝到核心緩衝區,再把核心緩衝區中的資料寫入硬碟。那麼,只要核心緩衝區未滿,使用者的write操作就可以很快地返回。這就是所謂的非同步刷盤策略

通過zerocopy處理檔案傳輸

講到copy,在jdk7引入了java.nio.file.Files這個類,方便了很多檔案操作,但是它更多應用於小檔案的傳輸,不適合大檔案,針對後者,應該使用java.nio.channels.FileChannel類下的transferTotransferFrom方法。
這裡,我們來分析下transferTo方法細節,原始碼如下:

 public long transferTo(long position, long count,
                           WritableByteChannel target)
        throws IOException
    {
        ensureOpen();
        if (!target.isOpen())
            throw new ClosedChannelException();
        if (!readable)
            throw new NonReadableChannelException();
        if (target instanceof FileChannelImpl &&
            !((FileChannelImpl)target).writable)
            throw new NonWritableChannelException();
        if ((position < 0) || (count < 0))
            throw new IllegalArgumentException();
        long sz = size();
        if (position > sz)
            return 0;
        int icount = (int)Math.min(count, Integer.MAX_VALUE);
        if ((sz - position) < icount)
            icount = (int)(sz - position);

        long n;

        // Attempt a direct transfer, if the kernel supports it
        if ((n = transferToDirectly(position, icount, target)) >= 0)
            return n;

        // Attempt a mapped transfer, but only to trusted channel types
        if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
            return n;

        // Slow path for untrusted targets
        return transferToArbitraryChannel(position, icount, target);
    }

複製程式碼

這裡使用了三種不同的方式來嘗試去拷貝檔案,我們先來看transferToDirectly

//sun.nio.ch.FileChannelImpl#transferToDirectly
private long transferToDirectly(long position, int icount,
                                    WritableByteChannel target)
        throws IOException
    {
        if (!transferSupported)
            return IOStatus.UNSUPPORTED;

        FileDescriptor targetFD = null;
        if (target instanceof FileChannelImpl) {
            if (!fileSupported)
                return IOStatus.UNSUPPORTED_CASE;
            targetFD = ((FileChannelImpl)target).fd;
        } else if (target instanceof SelChImpl) {
            // Direct transfer to pipe causes EINVAL on some configurations
            if ((target instanceof SinkChannelImpl) && !pipeSupported)
                return IOStatus.UNSUPPORTED_CASE;

            // Platform-specific restrictions. Now there is only one:
            // Direct transfer to non-blocking channel could be forbidden
            SelectableChannel sc = (SelectableChannel)target;
            if (!nd.canTransferToDirectly(sc))
                return IOStatus.UNSUPPORTED_CASE;

            targetFD = ((SelChImpl)target).getFD();
        }

        if (targetFD == null)
            return IOStatus.UNSUPPORTED;
        int thisFDVal = IOUtil.fdVal(fd);
        int targetFDVal = IOUtil.fdVal(targetFD);
        if (thisFDVal == targetFDVal) // Not supported on some configurations
            return IOStatus.UNSUPPORTED;

        if (nd.transferToDirectlyNeedsPositionLock()) {
            synchronized (positionLock) {
                long pos = position();
                try {
                    return transferToDirectlyInternal(position, icount,
                                                      target, targetFD);
                } finally {
                    position(pos);
                }
            }
        } else {
            return transferToDirectlyInternal(position, icount, target, targetFD);
        }
    }
複製程式碼

這個方法中的很多細節我們都已經接觸過了,大家可以借這個方法的細節回顧下前面的知識,這裡,直奔主題,來檢視transferToDirectlyInternal的細節:

//sun.nio.ch.FileChannelImpl#transferToDirectlyInternal
private long transferToDirectlyInternal(long position, int icount,
                                        WritableByteChannel target,
                                        FileDescriptor targetFD)
    throws IOException
{
    assert !nd.transferToDirectlyNeedsPositionLock() ||
            Thread.holdsLock(positionLock);

    long n = -1;
    int ti = -1;
    try {
        beginBlocking();
        ti = threads.add();
        if (!isOpen())
            return -1;
        do {
            n = transferTo0(fd, position, icount, targetFD);
        } while ((n == IOStatus.INTERRUPTED) && isOpen());
        if (n == IOStatus.UNSUPPORTED_CASE) {
            if (target instanceof SinkChannelImpl)
                pipeSupported = false;
            if (target instanceof FileChannelImpl)
                fileSupported = false;
            return IOStatus.UNSUPPORTED_CASE;
        }
        if (n == IOStatus.UNSUPPORTED) {
            // Don`t bother trying again
            transferSupported = false;
            return IOStatus.UNSUPPORTED;
        }
        return IOStatus.normalize(n);
    } finally {
        threads.remove(ti);
        end (n > -1);
    }
}
複製程式碼

可以看到,transferToDirectlyInternal最後呼叫的是transferTo0,我們只看其在Linux下的實現:

Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
                                            jobject srcFDO,
                                            jlong position, jlong count,
                                            jobject dstFDO)
{
    jint srcFD = fdval(env, srcFDO);
    jint dstFD = fdval(env, dstFDO);

#if defined(__linux__)
    off64_t offset = (off64_t)position;
    jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
    if (n < 0) {
        if (errno == EAGAIN)
            return IOS_UNAVAILABLE;
        if ((errno == EINVAL) && ((ssize_t)count >= 0))
            return IOS_UNSUPPORTED_CASE;
        if (errno == EINTR) {
            return IOS_INTERRUPTED;
        }
        JNU_ThrowIOExceptionWithLastError(env, "Transfer failed");
        return IOS_THROWN;
    }
    return n;
    ....
}
複製程式碼

這裡我們可以看到使用是sendfile的呼叫,這裡我們通過一張圖來解讀這個動作:

BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

在發生sendfile呼叫後,資料首先通過DMA從硬體裝置(此處是硬碟)讀取到核心空間,然後將核心空間資料拷貝到socket buffer,之後socket buffer資料拷貝到協議引擎(比如我們常用的網路卡,也就是之前涉及到的NIC)寫到伺服器端。這裡減去了傳統IO在核心和使用者之間的拷貝,但是核心裡邊的拷貝還是存在。
我們將之前以檔案伺服器的資料傳輸為例所畫的四次拷貝操做圖做相應的改進,如下:

BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

我們對transferTo()進行總結下,當此方法被呼叫時,會由使用者態切換到核心態。所進行的動作:DMA將資料從磁碟讀入 Read buffer中(第一次資料拷貝)。接著,依然在核心空間中,將資料從Read buffer 拷貝到 Socket buffer(第二次資料拷貝),最終再將資料從Socket buffer拷貝到NIC buffer(第三次資料拷貝)。最後,再從核心態返回到使用者態。
上面整個過程涉及到三次資料拷貝和二次上下文切換。直觀上感覺也就減少了一次資料拷貝。但這裡已經不涉及使用者空間的緩衝區了。
而且,在這三次資料拷貝中,只有在第2次拷貝時需要到CPU的干預。但是前面的傳統資料拷貝需要四次且有三次拷貝需要CPU的干預。

而在Linux2.4以後的版本又有了改善:

BIO到NIO原始碼的一些事兒之NIO 下 Buffer解讀 下

socket buffer 在這裡不是一個緩衝區了,而是一個檔案描述符,描述的是資料在核心緩衝區的資料從哪裡開始,長度是多少,裡面基本上不儲存資料,大部分是指標,然後協議引擎protocol engine(這裡是NIC)也是通過DMA拷貝的方式從檔案描述符讀取。
也就是說使用者程式執行transferTo()方法後,導致一次系統呼叫,從使用者態切換到核心態。內在會通過DMA將資料從磁碟中拷貝到Read buffer。用一個檔案描述符標記此次待傳輸資料的地址以及長度,DMA直接把資料從Read buffer傳輸到NIC buffer。資料拷貝過程都不用CPU干預了。這裡一共只有兩次拷貝和兩次上下文切換。

參考文章:Efficient data transfer through zero copy

最後,我們再來看下sun.nio.ch.FileChannelImpl#transferTo涉及的其他兩種拷貝方式transferToTrustedChanneltransferToArbitraryChannel,先來看前者的相關原始碼:

// Maximum size to map when using a mapped buffer
private static final long MAPPED_TRANSFER_SIZE = 8L*1024L*1024L;
//sun.nio.ch.FileChannelImpl#transferToTrustedChannel
private long transferToTrustedChannel(long position, long count,
                                        WritableByteChannel target)
    throws IOException
{
    boolean isSelChImpl = (target instanceof SelChImpl);
    if (!((target instanceof FileChannelImpl) || isSelChImpl))
        return IOStatus.UNSUPPORTED;

    // Trusted target: Use a mapped buffer
    long remaining = count;
    while (remaining > 0L) {
        long size = Math.min(remaining, MAPPED_TRANSFER_SIZE);
        try {
            MappedByteBuffer dbb = map(MapMode.READ_ONLY, position, size);
            try {
                // ## Bug: Closing this channel will not terminate the write
                int n = target.write(dbb);
                assert n >= 0;
                remaining -= n;
                if (isSelChImpl) {
                    // one attempt to write to selectable channel
                    break;
                }
                assert n > 0;
                position += n;
            } finally {
                unmap(dbb);
            }
        } catch (ClosedByInterruptException e) {
           ...
        } catch (IOException ioe) {
           ...
        }
    }
    return count - remaining;
}
複製程式碼

可以看到transferToTrustedChannel是通過mmap來拷貝資料,每次最大傳輸8m(MappedByteBuffer緩衝區大小)。而transferToArbitraryChannel一次分配的DirectBuffer最大值為8192:

private static final int TRANSFER_SIZE = 8192;
//sun.nio.ch.FileChannelImpl#transferToArbitraryChannel
private long transferToArbitraryChannel(long position, int icount,
                                        WritableByteChannel target)
    throws IOException
{
    // Untrusted target: Use a newly-erased buffer
    int c = Math.min(icount, TRANSFER_SIZE);
    // Util.getTemporaryDirectBuffer得到的是DirectBuffer
    ByteBuffer bb = Util.getTemporaryDirectBuffer(c);
    long tw = 0;                    // Total bytes written
    long pos = position;
    try {
        Util.erase(bb);
        while (tw < icount) {
            bb.limit(Math.min((int)(icount - tw), TRANSFER_SIZE));
            int nr = read(bb, pos);
            if (nr <= 0)
                break;
            bb.flip();
            // ## Bug: Will block writing target if this channel
            // ##      is asynchronously closed
            int nw = target.write(bb);
            tw += nw;
            if (nw != nr)
                break;
            pos += nw;
            bb.clear();
        }
        return tw;
    } catch (IOException x) {
        if (tw > 0)
            return tw;
        throw x;
    } finally {
        Util.releaseTemporaryDirectBuffer(bb);
    }
}
複製程式碼

上面所示程式碼最重要的邏輯無非就是read(bb, pos)target.write(bb)。這裡,我們只看前者:

//sun.nio.ch.FileChannelImpl#read(java.nio.ByteBuffer, long)
    public int read(ByteBuffer dst, long position) throws IOException {
    if (dst == null)
        throw new NullPointerException();
    if (position < 0)
        throw new IllegalArgumentException("Negative position");
    if (!readable)
        throw new NonReadableChannelException();
    if (direct)
        Util.checkChannelPositionAligned(position, alignment);
    ensureOpen();
    if (nd.needsPositionLock()) {
        synchronized (positionLock) {
            return readInternal(dst, position);
        }
    } else {
        return readInternal(dst, position);
    }
}
//sun.nio.ch.FileChannelImpl#readInternal
private int readInternal(ByteBuffer dst, long position) throws IOException {
    assert !nd.needsPositionLock() || Thread.holdsLock(positionLock);
    int n = 0;
    int ti = -1;

    try {
        beginBlocking();
        ti = threads.add();
        if (!isOpen())
            return -1;
        do {
            n = IOUtil.read(fd, dst, position, direct, alignment, nd);
        } while ((n == IOStatus.INTERRUPTED) && isOpen());
        return IOStatus.normalize(n);
    } finally {
        threads.remove(ti);
        endBlocking(n > 0);
        assert IOStatus.check(n);
    }
}
複製程式碼

由上可知,最後呼叫了IOUtil.read,再往下追原始碼,也就是呼叫了sun.nio.ch.IOUtil#readIntoNativeBuffer,最後呼叫的就是底層的readpread。同樣,target.write(bb)最後也是pwritewrite的系統呼叫,會佔用cpu資源的。

最後,我們來思考下,當需要傳輸的資料遠遠大於核心緩衝區的大小時,核心緩衝區就會成為瓶頸。此時核心緩衝區已經起不到“緩衝”的功能了,畢竟傳輸的資料量太大了,這也是為什麼在進行大檔案傳輸時更適合使用零拷貝來進行。

總結

本文從作業系統級別開始講解IO底層實現原理,分析了IO底層實現細節的一些優缺點,同時對Java NIO中的DirectBufferd以及MappedByteBuffer進行了詳細解讀。最後在之前的基礎上結合原始碼闡述了zerocopy技術的實現原理。

本文部分插圖來源網際網路,版權屬於原作者。若有不妥之處,請留言告知,感謝!

相關文章