萬字概覽 Java 虛擬機

為什麼要學習 JVM

在很多 Java 程式設計師的開發生涯里,JVM 一直是黑盒子一般的存在,大家只知道運行 Java 程式需要依靠 JVM,千篇一律的配置幾個類似 -Xms-Xmx 的參數,可能到最後都不知道自己配置的參數有什麼具體的意義。在我周圍的 Java 程式設計師裡面,甚至還有一部分有數年 Java 開發經驗的人至今都不知道該怎麼開啟 JVM 的 GC 日誌。但是,這一切並不妨礙我們開發出令人驚艷的產品。所以,我們為什麼要學習 JVM?

從功利性的角度來講,越來越多的公司在面試時都會針對 JVM 提問,學習 JVM 可以提高自己的面試通過率,當然這屬於面向面試學習了。從實踐的角度來講,學習 JVM 可以幫助我們寫出更優質的程式碼,比如你不會寫出超過 8000 位元組的巨型方法,因為你知道 JIT 不會編譯它,每次只能解釋執行,這是由 -XX:HugeMethodLimit 參數控制的;你也不會在 Metaspace OOM 時一頭霧水,會首先定位是否是反射太頻繁導致產生的類載入器過多而引發的。總的來說,學習 JVM 是提升我們 Java 內功的一種方式。

JVM 的作用是什麼

如圖所示,我們編寫的 Java 程式碼通過 Java 編譯器編譯後成為位元組碼,即我們常說的 .class 文件。這些位元組碼文件和 JDK 類庫的位元組碼文件分別通過 JVM 提供的基本的三個類載入器被載入到 JVM 之中,JVM 就是負責解析、執行這些位元組碼並管理和協調整個執行生命周期各項事件的平台。位元組碼是一種中間程式碼,通過 JVM 的解釋、編譯,最終通過作業系統與硬體達成交互。

JVM 的運行時區域

JVM Runtime Area 也可以稱作 JVM 運行時記憶體模型。JVM 本質上是作業系統上的一個進程,它的運行需要記憶體空間,而 JVM 運行時記憶體模型描述的就是 JVM 怎麼劃分和管理這些記憶體。

以 Hotspot VM 為例,我們將整個記憶體模型簡化為下圖所示:

圖中上方區域是受 JVM 直接管理的記憶體區域,其中 Stack Area 可以分為 JVM Stack 和 Native Method Stack,但在 Hotspot VM 中兩者併合併到了一個區域。

下方的 Direct Memory 是不受 JVM 管理的記憶體,這些記憶體是屬於作業系統的,JVM 通過在 Heap 中保存一個指向這些記憶體區域的引用來進行記憶體操作。當 Heap 中的引用被 GC 回收時,Direct Memory 中被使用的區域也自動被作業系統回收。這塊記憶體通常用於 JDK1.4 開始提供的 NIO API 上,通過 ByteBuffer#allocateDirect() 方法實現直接記憶體的分配,而這個 ByteBuffer 就是這塊分配出來的 Direct Memory 在 Heap 里存在的引用。基於 Direct Memory 進行 IO 操作的好處是避免了數據在 Direct Memory 和 Java Heap 之間的來回拷貝,也可以進一步實現「零拷貝」,避免數據在內核態與用戶態之間來回拷貝。

當 Direct Memory 不足以分配新的空間時也會拋出 OOM。

PC Register

在多核處理器上,一個時刻只能有一個核進行工作,而多執行緒情況下,執行緒可能分布在不同的核心上。當 A 執行緒任務還沒有處理完時,所在核心失去了 CPU 執行權,此時就需要使用程式計數器記錄當前程式執行的位置,等下次獲得執行權後繼續執行。這個區域是 JVM 規範中唯一一個不會出現 OOM 的區域。

Stack Area

Stack Area 可以分為 JVM Stack 和 Native Method Stack。顧名思義,前者是 JVM 在調用 Java 方法時存儲棧幀的空間,而後者用於在 JVM 調用 Native 方法時存儲產生的棧幀。在 Hotspot VM 的實現中將兩者合併成了一塊記憶體區域。在 JVM 中,一個方法從調用開始到調用結束就是一個棧幀入棧和出棧的過程。每個執行緒棧的大小使用參數 -Xss 進行指定,默認是 1M。絕大部分情況下用不了這麼大,建議配置為 256kb 即可。棧空間越大,則 JVM 能容納的執行緒數越少;棧空間越小,可遞歸深度越低。

棧幀包含有局部變數表,存儲編譯期可知的基本數據類型以及對象的符號引用等資訊,還包含有操作數棧、動態鏈接、方法出口等資訊。

Stack 為什麼是執行緒私有的,這涉及到「棧上分配」和「TLAB」兩個概念。

棧上分配

所謂棧上分配就是允許將對象直接分配在棧上,而不用分配到 Heap 中。這樣對象會隨著棧幀的出棧自動銷毀,不用等待 GC 進行回收,從而提高性能。但是要實現棧上分配是非常複雜的,涉及到「逃逸分析」和「標量替換」兩項技術。

逃逸分析簡單來說就是判斷一個對象的存活範圍是否有可能越出當前方法的執行範圍,也就是說當前棧幀出棧的時候這個對象是否還會存活。如果分析結果是不會存活,那麼這個對象就可以安全的分配在棧上,隨著棧幀的出棧而被銷毀。

標量替換是基於逃逸分析技術的。如果一個對象經過逃逸分析允許被分配在棧上,那麼標量替換機制就可以將這個對象的欄位直接以局部變數的方式進行分配,而不再採用對象的方法進行分配。

棧上分配自 JDK8 開始是默認開啟的,如果關閉了棧上分配或者不符合棧上分配的條件,則 JVM 轉而使用 TLAB 機制進行分配。

TLAB 分配

TLAB 是 Thread Local Allocation Buffer 的首字母縮寫,代表的是一塊執行緒專屬的記憶體區域。由於我們在 Heap 上無論是使用「指針碰撞」還是「空閑列表」方法進行記憶體分配,都會遇到多個執行緒請求記憶體分配時的同步問題。

TLAB 機製為每一條執行緒都劃分了一塊私有的記憶體區域供其分配對象,當一塊 TLAB 被分配滿之後就重新分配一塊,而原來的那塊區域從邏輯上變成了 Heap 的一部分。這樣就避免了多執行緒直接向 Heap 請求記憶體分配的同步問題,提高了對象分配的效率。一定注意:每一塊 TLAB 都是 Heap 的一部分。

從 JVM 源碼來看,創建執行緒的第一步就是分配 TLAB 空間

void JavaThread::run() {
  // initialize thread-local alloc buffer related fields
  this->initialize_tlab();
  // used to test validitity of stack trace backs
  this->record_base_of_stack_pointer();
  // Record real stack base and size.
  this->record_stack_base_and_size();
  // Initialize thread local storage; set before calling MutexLocker
  this->initialize_thread_local_storage();
  this->create_stack_guard_pages();
  this->cache_global_variables();
  // ...
}

TLAB 的數據結構

class ThreadLocalAllocBuffer: public CHeapObj<mtThread> {
  friend class VMStructs;
private:
  HeapWord* _start;
  HeapWord* _top;
  HeapWord* _end;
  size_t    _desired_size;
  size_t    _refill_waste_limit;
  // ...
}

_start_end 指針分別指向 TLAB 空間的頭尾;_top 指針指向當前使用量的邊界;_desired_size 是 TLAB 的大小;_refill_waste_limit 是 TLAB 的最大浪費空間。

假設最大浪費空間為 5K,如果 TLAB 目前還剩餘 4K 空間,這時需要分配一個 8K 對象,這時就可以選擇重新分配一個 TLAB 空間,因為當前這一塊 TLAB 放棄後只會浪費 4K 空間,少於閾值。相反,對象則會直接去 Eden 分配,不會捨棄當前 TLAB。

Heap Area

Heap 是 Java 記憶體模型中最大的一塊區域,它被分為 Young Generation 和 Old (Tenured) Generation 兩個部分,兩個區域的比例默認大約是 1:2。Young 區又按照 8:1:1 的比例分為一個 Eden 區和 2 個 Survivor 區。

根據數據統計,程式運行過程中創建的大部分對象都會很快死亡,如果將對象都分配到一整塊區域中,那麼 GC 在回收這些死亡對象時負擔就會變得非常重。為了提高記憶體的使用效率和 GC 的工作效率,根據對象存活周期的不同,將整個 Heap 劃分為了上圖中的幾個部分。但無論怎麼劃分,每個區域裡面保存的都是分配的對象。

對象的分配過程

當我們需要分配一個對象時,會直接在 Young 區中的 Eden 中嘗試分配(大對象會直接在 Old 區上分配,使用參數 PretenureSizeThreshold 控制這個閾值,默認是無限大),如果空間不足則觸發一次 YGC;YGC 完成後再次嘗試分配,如果還是空間不足,則觸發一次 FGC;FGC 結束後依然空間不足以分配,則再次觸發 FGC,同時將軟引用(Soft Reference)也一併回收;如果還是無法分配,則只能拋出 OOM。

對象在各個區域的流轉

對象最初在 Eden 區中進行分配,當 YGC 發生時會將 Young 區所有存活的對象都放到當前沒有使用的那個 Survivor 區中,兩個 Survivor 區在兩次 YGC 之間輪流使用,每次只使用一個(當 YGC 完成後存活對象超過 Survivor 區的大小時,會將部分對象晉陞到 Old 區中)。Young 區中的對象每活過一次 YGC,存活周期就加 1,當活過 15 次 YGC 後就會晉陞到 Old 區中,直到這個對象不再使用,被 GC 回收掉。

至於為什麼需要兩個 Survivor 區,我們在後面講解 GC 演算法時再分析。

為什麼對象最多活過 15 次 YGC 後就會晉陞老年代

因為對象頭中記錄 YGC 存活周期的欄位只有 4 bit 長度,最大表示數字就是 15。但這並不是說對象一定要活過 15 次 YGC 才能晉陞到老年代,因為每次 YGC 後都會對這個閾值進行重新計算。比如使用 Serial GC 和 ParNew GC 的情況下,JVM 對於這個閾值的計算邏輯:

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
  // TargetSurvivorRatio 默認值是 50
  size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
  size_t total = 0;
  uint age = 1;
  assert(sizes[0] == 0, "no objects with age zero should be recorded");
  while (age < table_size) {
    total += sizes[age];
    // check if including objects of age 'age' made us pass the desired
    // size, if so 'age' is the new threshold
    if (total > desired_survivor_size) break;
    age++;
  }
  uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
  
  if (PrintTenuringDistribution || UsePerfData) {

    if (PrintTenuringDistribution) {
      gclog_or_tty->cr();
      gclog_or_tty->print_cr("Desired survivor size " SIZE_FORMAT " bytes, new threshold %u (max %u)",
        desired_survivor_size*oopSize, result, (int) MaxTenuringThreshold);
    }
    // ....
  }
  return result;
}

這裡的邏輯是將每個年齡段的對象佔用空間進行累加,如果超過了 desired_survivor_size 則重新計算這個閾值,取這個閾值和配置的 MaxTenuringThreshold 中的最小值作為最新的 MaxTenuringThreshold。

desired_survivor_size 就是 survivor 區空間的一半。如果開啟了 PrintTenuringDistribution 則會在 GC 日誌中列印最新的 MaxTenuringThreshold。

PSGC 比較特殊,它通過 InitialTenuringThreshold 參數控制這個閾值,默認是 7,但每次 YGC 結束後也會重新計算閾值。

對象從 Young 區晉陞到 Old 區失敗

當對象從 Young 區晉陞到 Old 區時,如果 Old 區空間不足以容納本次晉陞對象所需要的空間就會晉陞失敗,並會導致觸發一次 Full GC。JVM 並不總是等到 YGC 完成後要執行晉陞時才做這個判斷,而是在 YGC 發生之前就進行判定。根據多次晉陞的數據統計出歷次平均晉陞對象的大小,如果當前 Old 區還有足夠空間容納,則直接進行 YGC,否則直接進行 Full GC 讓 Old 區騰出足夠的空間容納將要晉陞對象。

Method Area

方法區是 JVM 規範中的一塊區域,在 JDK8 之前使用永久代(Permanent Generation)實現方法區,從 JDK8 開始改用元數據區(Metaspace)來實現。所以「方法區」只是一種規範,而永久代和元數據區是對這個規範的一種實現。

Metaspace 被實現在 Native Memory 中,如果不限制最大使用記憶體,則除非系統記憶體耗盡,Metaspace 將無限增長。

為什麼要將永久代替換為元數據區

根據官方給出的[說明](JEP 122: Remove the Permanent Generation),這是為了促進 Oracle JRockit VM 和 Hotspot VM 融合而做出的選擇,JRockit 本身沒有永久代。

Motivation

This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.

從實際使用中來看,永久代和元數據區主要存在以下一些區別:

  1. 記憶體空間設置
    在 JDK8 之前,我們通過 -XX:PermSize-XX:MaxPermSize 來配置永久代的初始和最大容量。但是這個容量應該定多大其實是很難的,永久代裡面有類元資訊、常量池等各種數據,配置太小容易溢出、太大則浪費(儘管不會一次性 Commit 這麼多記憶體)。永久代是緊鄰 Heap 的記憶體區域,它使用的是 JVM 進程所擁有的記憶體。而元數據區是在 Native Memory 中分配,理論上可用最大記憶體就是作業系統的可用記憶體,也可以通過參數 -XX:MaxMetaspaceSize 來設置最大可用記憶體。相比於永久代,這是一種變通方法,以理論上無限大的空間來替代固定大小空間可能導致的空間不足或者空間浪費問題。
  2. GC 觸發
    永久代只有在空間耗盡導致無法分配新的對象時才會被動觸發一次 Full GC;而元數據空間會動態調整參數 -XX:MetaspaceSize 的值,每當元數據空間使用記憶體達到這個閾值時就會主動觸發 Full GC。主動觸發可以避免被動觸發時對象過多掃描困難和垃圾過多清理太慢的問題。
  3. 不適應現代應用的特點
    永久代出現時,動態技術還不成熟和普及,永久代的空間足以容納那時候的應用系統產生的方法區數據;而隨著 JavaEE 和動態化技術的出現,永久代使用具有大小上限的空間來存儲數據就顯得捉襟見肘了。這一點可以參考第一點的說明。

當然,Metaspace 是否真的比永久代更優秀目前還沒有一個統一的定論,也有很大一部分人認為 Metaspace 不僅沒有解決永久代的問題,還增加了 JVM 方法區的實現難度。

Metaspace 的記憶體布局

從上圖可以看到,Metaspace 主要由 Klass Metaspace 和 NoKlass Metaspace 兩部分組成。顧名思義,Klass Metaspace 就是用來存儲 Klass 的空間。

什麼是 Klass ?

Klass 是位元組碼在 JVM 中的運行時數據結構,只會存放在 Metaspace 中。而我們通常使用 .class 屬性或者 getClass() 方法獲取到的是 java.lang.Class 的對象,這個對象是存在於 Heap 中的。

Klass Metaspace 通過 -XX:+UseCompressedClassPointers 參數和 -XX:+UseCompressedOops 參數來啟用,默認是啟用的。其大小通過 -XX:CompressedClassSpaceSize 參數來控制,默認是 1G,但這塊空間的初始大小是無法設定的。從這一點也可以看出,JVM 研發團隊考慮以堆外更大空間來緩解方法區的 OOM。這塊空間也可以不啟用,Klass 將直接存放到 NoKlass Metaspace 中。特別的是,如果 -Xmx 大於 32G,JVM 也必然不會啟用這塊空間。Klass Metaspace 分配在緊鄰 Heap 的 Native Memory 中。

-XX:+UseCompressedClassPointers 這個參數實際要表達的是啟用壓縮指針。在 32bit VM 中,存在於對象頭 MarkWord 中的類型指針占 4 位元組,而在 64bit VM 中佔用 8 位元組。當開啟壓縮指針後,64bit VM 中的類型指針會被壓縮到 4 位元組,從而達到節省記憶體空間的目的。

NoKlassMetaspace 除了在 Klass Metaspace 空間不存在時用於存儲非壓縮 Klass 指針外,還包括運行時常量池、CodeCache 等區域。

運行時常量池

在每一份位元組碼文件中都存在一個 Class 常量池,主要保存的是編譯期生成的符號引用和字面量。字面量包括文本字元串、基本類型的值和使用 final 聲明的常量;符號引用包括類和方法的全限定名、欄位(方法)的名稱和描述符。

當一個類被 JVM 載入後,JVM 就會將它 Class 常量池中的內容放到運行時常量池中,同時將符號引用替換為直接應用,對於有文本字元串字面量的,會在 StringPool 中查找是否存在同樣字元串以確保在運行時常量池中引用的字元串和在 StringPool 中的是一致的。

Class 常量池和 Runtime 常量池的關係

The runtime constant pool is an implementation-specific data structure that maps to the constant pool in the class file。

CodeCache

隨著方法的不斷被調用,JVM 也會統計各個方法被調用的次數。當某個方法調用頻率比較高時,JVM 會使用 C1 JIT 編譯器將其進行編譯,編譯後的程式碼就保存在 CodeCache 中。這樣,之後再調用這個方法就不會在解釋執行,提高了執行效率。

如果被 C1 編譯後的方法中還存在更熱點的方法,JVM 就會使用 C2 編譯器對其進行更高層級的編譯。C2 比 C1 更能編譯出更高的執行效率,但編譯時對時間和資源的消耗也更大,所以 JVM 採用解釋執行、C1 編譯、C2 編譯這種「分層編譯」方式來平衡效率和資源耗用。

分層編譯使用參數 -XX:+TieredCompilation 開啟,默認是開啟的。

使用 -Xint 參數強制 JVM 對所有程式碼都解釋執行,無論是否出現熱點方法,都不進行編譯;-Xcomp 參數強制 JVM 對所有程式碼都編譯執行。

ReservedCodeCacheSize 參數用於設置 CodeCache 的大小,默認開啟分層編譯的情況下是 240M。如果 CodeCache 空間不足,會將一半最早編譯的程式碼放到一個 Old 列表中,如果在一定時間內 Old 列表中的方法沒有被調用就會從 CodeCache 中清除,表示這已經不是熱點方法了。

MetaspaceSize 和 MaxMetaspaceSize

如果配置過 Java8 之前版本的 JVM,你一定使用過 -XX:PermSize-XX:MaxPermSize 兩個參數來控制永久代的大小。當你看到 -XX:MetaspaceSize-XX:MaxMetaspaceSize 兩個參數的時候,也很大可能會認為他們與永久代配置的兩個參數的作用是對應的,但實際上只能參數名稱造成的誤解。

MetaspaceSize 主要用於控制觸發 MetaspaceGC 的閾值,即當 Metaspace 使用記憶體達到指定大小時觸發一次 MetaspaceGC。但是 MetaspaceGC 並不是一個真正的 GC,它只是 Full GC 下的一種 GC Cause,在 GC 日誌中表示為 Full GC (Metadata GC Threshold)。通過參數指定的 MetaspaceSize 的值是第一次觸發 Full GC 的閾值,這個值會在每次 GC 完成後動態增大或者縮小。

MaxMetaspaceSize 用於控制 Metaspace 的最大可使用記憶體,默認數值非常大,基本上可以當做是無限大。由於 Metaspace 使用的是 Native Memory,為了避免因類存泄露而導致 Native Memory 被無限制消耗,通常還是建議設定一個合適的值。另外,MaxMetaspaceSize 只用於限制 Metaspace 可使用記憶體的上限(Max Memory),並不會在 JVM 已啟動就分配這麼大的記憶體空間。

關於 java.lang.management.MemoryUsage 四個值的說明

init:表示 JVM 啟動時向 OS 報告的需要的記憶體(並不會實際分配),約等於 Xms

max:最大可向 OS 申請的記憶體空間,約等於 `Xmx

committed:已向 OS 實際申請到的、可以直接使用的記憶體空間,空間不足時 JVM 繼續向 OS 申請,直到 max

used:表示已實際使用的記憶體空間,小於等於 committed

垃圾回收演算法和垃圾回收器

對象存活判斷演算法

目前主流的存活判斷演算法有兩種,引用計數器法和可達性分析演算法,幾乎所有主流的 JVM 都採用後者來判斷對象是否存活。

可達性分析演算法首先通過確定一些 GC Roots 根節點,然後跟著引用關係不斷搜索對象,最終所有存活的對象形成一個圖,凡是不屬於這個圖的節點的對象就是不可達的,會被 GC 回收掉。最常見的 GC Roots 有三種:

  1. 棧幀中的本地變數表上引用的對象;
  2. 類的 static 屬性引用的對象;
  3. 方法區運行時常量池中的常量引用的對象。

垃圾回收演算法

在 JVM 中最常見的 GC 演算法有三種,分別是標記-清除演算法、複製演算法、標記-整理-清除演算法。

標記-清除演算法

這是最原始最基礎的 GC 演算法,之後的其他演算法都是基於這個演算法改進的。整個演算法分兩步,先標記出所有要回收的對象,第二步將標記好的對象直接回收。這種演算法簡單直接,但是會產生大量的記憶體碎片,最終可能因為總的剩餘空間足夠分配對象,但是卻找不到連續的記憶體空間而提前進行一次垃圾回收。

複製演算法

這種演算法要求把記憶體空間等分為兩塊,每次只使用一塊。對象標記完成後將未被標記的對象全部複製到未使用的另一塊空間,然後將當期使用的這部分空間直接清空。由於複製時目標空間是乾淨的,所以複製也是在連續空間在進行,最終不會產生記憶體碎片。

這種演算法的劣勢是直接讓可用記憶體減少了一半,如果對象存活率過高,複製效率會明顯降低。目前幾乎所有的 JVM 都採用這種演算法來回收 Young 區,但基於 IBM 的一項研究,98% 的對象都會很快死亡,所以將記憶體區域按照 8:1:1 的比例分為了一個 Eden 區和 2 個 Survivor 區,從而克服了演算法最初設計導致的記憶體使用效率低的問題。

為什麼需要兩個 Survivor 區

首先要明確 Survivor 區存在的意義是

  1. 避免 Old 區中存在太多生命周期很短的對象;
  2. 避免記憶體碎片化。

如果只有一個 Survivor 區,當 Eden 滿了之後觸發一次 YGC,存活對象被複制到 Survivor 中,下次 Eden 滿了又觸發 YGC 時問題就出現了。由於第二次 YGC 可能也回收了上次複製到 Survivor 區中的一部分對象,這時如果將本次 Eden 中存活的記憶體複製到 Survivor 區中就會出現記憶體碎片,而且碎片是存在已死亡對象的,這些對象無法被回收。

兩個 Survivor 區的情況下,每次只使用一個區域,另一個區域就會是乾淨且連續的記憶體空間,這樣就避免了記憶體碎片的出現。關於記憶體碎片帶來的問題,我們在後面的 CMS GC 部分還會更細的聊到。

標記-整理-清除演算法

這種演算法也被叫做標記-整理演算法,相比於標記-清除演算法多了一個整理的過程。在標記完對象後,讓存活對象都往一端移動,然後將端邊界之外的記憶體都清空,這樣就解決了標記-清除演算法會產生大量記憶體碎片的問題。

雖然複製演算法也解決了記憶體碎片問題,但是對於對象存活率非常高的記憶體區域而言,複製演算法的效率比較低。

分代回收演算法

分代回收演算法並不是一種實際的演算法,它是指對於 JVM Heap 中的不同記憶體區域採用不同的垃圾回收演算法,從而整體提高 JVM 的記憶體使用效率和 GC 工作效率。

垃圾回收器

正如我們前文提到的「分代回收演算法」,針對不同的記憶體區域,JVM 也提供了不同的垃圾回收器。Young 區常見的垃圾回收器有:

  1. Serial GC
    這是 JVM 以 Client 模式啟動時默認的 GC,實現邏輯簡單,單執行緒全程 STW 進行垃圾回收。這個 GC 不能發揮多核的優勢,通常很少使用。但隨著 Serverless 架構的興起,Serial GC 又有了用武之地。

  2. ParNew GC
    這是 Serial GC 的多執行緒版本,可以通過 XX:ParallelGCThreads 參數設置參與 GC 的執行緒數。當 Old 區啟動 CMS GC 時,將默認在 Young 區使用這個 GC。

  3. PSGC
    這是一款注重吞吐量的垃圾回收器,多用於後台計算類的系統。

Old 區常見的垃圾回收器有:

  1. Serial Old
    Serial GC 的 Old 區版本,主要作用是在 CMS 出現 CMF 時替代 CMS 進行 Full GC。

  2. Parallel Old
    PSGC 的 Old 區版本,同樣注重吞吐量。

  3. CMS
    在 G1GC 出現之前使用最多的 Old 區 GC,它是一款幾乎完全並發的垃圾回收器,注意不是完全並發。它採用標記-清除演算法,隨著應用程式的長時間運行會產生很嚴重的記憶體碎片問題。

除了上面的這些常見分代 GC 外,隨著垃圾回收演算法的不斷改進,又出現了如 G1GC 這類混合式 GC。這類 GC 不再從物理上將 Heap 劃分為 Young 區和 Old 區,而是僅從邏輯上進行劃分,甚至 JDK11 提供的 ZGC 連邏輯上都不存在 Young 區和 Old 區了。

垃圾回收器的默認配對

# 配置 Young 區 Old 區 備註
1 +UseSerialGC Serial Serial Old 默認設置 Old 區 GC
2 +UseParallelGC PS Parallel Old 默認同時設置 -XX:+UseParallelOldGC
3 +UseConcMarkSweepGC ParNew CMS 默認同時設置 +UseParNewGC,且 Old 區使用 CMS 時,Young 區只能使用 ParNew
4 +UseG1GC G1 G1 G1下 Young 區和 Old 區變成了邏輯分區

GC 術語

Young GC = Minor GC

Major GC = 對老年代和方法區的收回

Full GC = 對整個堆進行回收

除了 YGC 和 Minor GC 的意義是在業界達成了一致,都表示對 Young 區的回收,Major GC 和 Full GC 的定義都不確定,包括 JVM 規範也沒有明確定義。Major GC 有時候也可以看做是 Full GC,因為 Major GC 很多時候會觸發一次 Minor GC。

CMS GC

CMS GC 從 JDK1.4 引入,經過多個版本的不斷發展和增強,最終在 Java9 中被標記為 Deprecated。這個 GC 適用於對停頓時間非常敏感的前端系統,目的是以較短的停頓儘快回收垃圾。CMS GC 之所以稱作「幾乎完全並發的垃圾回收器」是因為在整個 GC 過程中仍然存在 2 處 STW(Stop The World),也就是全局暫停,用戶執行緒也不能執行。

CMS 的工作流程

CMS 主要由四個步驟構成:

  1. 初始標記
    STW 階段,尋找 GC Roots

  2. 並發標記
    和用戶執行緒並發執行,這個階段會根據 GC Roots 標記對象

  3. 最終標記
    並發標記過程中客戶執行緒的運行導致引用發生變動,這一步對並發標記階段中不可達對象在最終標記階段變成可達對象的情況進行修正。如果並發標記階段新產生的不可達對象,且沒有在並發標記階段被標記的,成為「浮動垃圾」無法回收,等待下一次 CMS GC。

  4. 並發清除
    和用戶執行緒並發執行,將標記的死亡對象進行清理

一個 CMS GC 會增長兩次 jstatFGC 的值,也就是說,FGC 記錄的是 GC STW 的次數,而不是一次對整個堆的完整收集。

-XX:+UseConcMarkSweepGC

這個參數表示啟用 CMS GC,默認會在 Young 區啟用 ParNew GC。從官方對參數的描述來看,CMS GC 確實是一個應用於 Old 區的 GC。

product(bool, UseConcMarkSweepGC, false,                                  \
          "Use Concurrent Mark-Sweep GC in the old generation")

JVM 默認啟動 ParNew GC 的邏輯:

// Set per-collector flags
if (UseParallelGC || UseParallelOldGC) {
  set_parallel_gc_flags();
} else if (UseConcMarkSweepGC) { // Should be done before ParNew check below
  set_cms_and_parnew_gc_flags();
} else if (UseParNewGC) {  // Skipped if CMS is set above
  set_parnew_gc_flags();
} else if (UseG1GC) {
  set_g1_gc_flags();
}

-XX:CMSInitiatingOccupancyFraction

這個參數用於設置當 Old 區使用率達到多少時應該發生一次 CMS GC,但是要注意是「應該發生」而不是「一定發生」,也就是說即使 Old 區使用率達到了指定閾值也可能不會觸發 CMS GC。因為 CMS GC 的觸發是由其掃描 Old 區使用量的執行緒控制的,只有當執行緒掃描到使用率達到閾值時才發觸發 CMS GC。這個執行緒每 2 秒對 Old 區使用率進行一次計算,如果超過指定值則啟動 CMS GC。這個掃描頻率可以使用參數 -XX:CMSWaitDuration 指定。

這個參與必須要和 -XX:+UseCMSInitiatingOccupancyOnly 參數配合使用,只有開啟了這個參數,自定義配置才起作用,否則會使用 JVM 的默認值,通常是 92%。

-XX:+CMSScavengeBeforeRemark

這個參數表示 CMS GC 在執行最終標記(重新標記)時先「嘗試」執行一次 Young GC。

最終標記時涉及整個堆,包括 Young 區和 Old 區。掃描 Young 區是因為如果 Young 區對象引用了 Old 區對象,那麼 Old 區的對象就是存活對象,Young 區對象就是 Old GC 的 GC Roots。

在最終標記前進行一次 YGC 可以大量減少 Young 區對象,那麼需要掃描的 Young 區對象數量就變少了,從而最終標記的速度也就提高了。

但是有利就有弊,如果運氣不好遇到 Young 區對象存活率極高或者 Young 區對象很少的情況,這次 YGC 的意義就不大了。

JVM 對跨代引用的處理

YGC 的目的是回收掉 Young 區中不再存活的對象,如果 Young 區中的對象直接就在 Tracing Chain 上自然是不可回收,但是 Old 區也有可能引用了 Young 區對象,這時候為了確保 Young 區中的對象不被錯誤回收,最直接的辦法就是掃描整個 Old 區的對象,看他們引用了哪些 Young 區對象。

如果 Old 區很小,上面的做法簡單直接沒有問題,但是如果 Old 區很大的情況,效率就非常低了。JVM 採用了一種叫 CardTable(卡表)的數據結構來解決這個問題。

卡表就是一個 bit 數組,元素默認值為 0。從上圖可以看出,Old 區被等分成了多個區域,每個區域對應卡表上的一個位置,如果某個區域中有對象引用了 Young 區的對象,則這個區域在卡表中對應的位置的值被設為 1。

YGC 時通過卡表的標誌位就能讓 GC 只掃描存在跨代引用的記憶體區域,從而避免了全 Old 區掃描。基於卡表的掃描流程可以從源碼中看到:

void ClearNoncleanCardWrapper::do_MemRegion(MemRegion mr) {
  // ...
  // Old 區最後一個 Card 起始地址
  jbyte* cur_entry = _ct->byte_for(mr.last());
  // Old 區第一個 Card 起始地址
  const jbyte* limit = _ct->byte_for(mr.start());
  // Dirty Card 截止地址
  HeapWord* end_of_non_clean = mr.end();
  // Dirty Card 起始地址
  HeapWord* start_of_non_clean = end_of_non_clean;
  while (cur_entry >= limit) { // 從後往前遍歷 Card
    HeapWord* cur_hw = _ct->addr_for(cur_entry);
    // 如果當前 Card Dirty,先用 clear_card() 方法將其設置為 Clean
    if ((*cur_entry != CardTableRS::clean_card_val()) && clear_card(cur_entry)) {
      // 記錄等會要清除的起始地址
      start_of_non_clean = cur_hw;
    } else { // 如果遇到一個 Clean Card
      // 如果之前遇到過 DirtyCard,先清理掉再繼續掃描
      if (start_of_non_clean < end_of_non_clean) {
        const MemRegion mrd(start_of_non_clean, end_of_non_clean);
        _dirty_card_closure->do_MemRegion(mrd);
      }
      // ...
      end_of_non_clean = cur_hw;
      start_of_non_clean = cur_hw;
    }
    cur_entry--;
  }
  // 最終清理記錄的 Dirty Card
  if (start_of_non_clean < end_of_non_clean) {
    const MemRegion mrd(start_of_non_clean, end_of_non_clean);
    _dirty_card_closure->do_MemRegion(mrd);
  }
}

-XX:+CMSClassUnloadingEnabled

開啟這個參數則每次觸發 CMS GC 時都會順帶收集一次 Metaspace。當 Metaspace 達到空間使用閾值時會觸發一次 FullGC,通過 CMS GC 經常清理 Metaspace,可以減小 Metaspace 觸發 Full GC 的頻率。

-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses

開啟這個參數,則每次由 System.gc() 觸發的 FullGC 都轉變為 CMS GC(前提是使用 CMS GC),並且要對 Metaspace 進行收集。

-XX:+UseCMSCompactAtFullCollection

開啟這個參數表示在 Full GC 後需要進行空間壓縮,清除記憶體碎片,配合參數 -XX:CMSFullGCsBeforeCompaction 使用。後者指定多少次 Full GC 實際發生之後才進行一次壓縮,默認是 0,表示每次 Full GC 後都要壓縮。

清除記憶體碎片需要移動記憶體中的對象,所以只能單執行緒允許,這會讓應用系統停頓時間更長。如果 Old 區足夠大且記憶體足夠零碎,那等待整理碎片的時間是不可接受的。如果不清理記憶體碎片,隨著應用程式的長時間允許,最終會因為大量的記憶體碎片而沒有足夠空間分配對象,導致頻繁 Full GC,最終 OOM。

一個生產可用的 CMS 配置

參考伺服器配置:Linux 64bit、8C16G、JDK8

-Xmx10880M 
-Xms10880M 
-Xmn4032M 
-XX:MaxMetaspaceSize=512M 
-XX:MetaspaceSize=512M 
-XX:+UseConcMarkSweepGC 
-XX:+UseCMSInitiatingOccupancyOnly 
-XX:CMSInitiatingOccupancyFraction=70 
-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 
-XX:+CMSClassUnloadingEnabled 
-XX:+CMSScavengeBeforeRemark   
-XX:+PrintGCDetails 
-XX:+PrintGCDateStamps 
-XX:ErrorFile=/home/admin/gclogs/hs_err_pid%p.log   
-Xloggc:/home/admin/gclogs/gc.log
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/home/admin/gclogs/heapdump 
-XX:+PrintGCApplicationStoppedTime

ErrorFile 是 JVM Crash 日誌;PrintGCApplicationStoppedTime 是列印 GC 過程中用戶執行緒停頓時間,即 STW 的時長。將 Heap、Young、Metaspace 的初始和限制大小設置為一樣可以避免擴容帶來的 Full GC。

CMS 劣勢

  1. CPU 敏感
    默認會使用 (CPU數 + 3)/ 4 條執行緒,如果伺服器只有 2C,那麼服務應用系統的能力直接減少一半。

  2. 記憶體碎片
    默認不會清理碎片,長時間運行會導致嚴重碎片,引發頻繁 Full GC,甚至 OOM。

  3. CMF 導致 GC 退化
    出現 CMS 時 GC 退化為 Serial Old,等待垃圾回收和記憶體清理,停頓時間變長。

CMS 保護性 OOM

如果程式運行過程中,98%的時間都在做垃圾回收,同時這些回收動作清理的堆記憶體空間不足總大小的 2%,則 CMS GC 會主動拋出 OOM。因為 CMS GC 會導致記憶體碎片,這個機制可以防止應用在小堆上長時間運行。因為大部分時間都在 GC,應用基本就等於失去了服務能力。

GC 日誌

GC 日誌反映了 GC 的工作情況,讀懂 GC 日誌可以輔助我們定位記憶體問題,在 GC 日誌中,我們主要關注 GC Cause、GC Flow 以及 GC 成果。以下面示例的 GC 日誌為例:

示常式序

Programmer.java

public class Programmer {
  	private long id;
    private String name;
    private int age;
    private boolean male;
}

CreateTask.java

public class CreateTask implements Runnable {
    private List<Programmer> programmers = new ArrayList<>();
    @Override
    public void run() {
        for (int i = 0; i < 100000000; i++) {
            programmers.add(create());
        }
    }
    private Programmer create() {
        Random random = new Random();
        long id = (long) random.nextInt(99999999);
        String name = "brandon.p.gan";
        int age = random.nextInt(150);
        boolean male = age % 2 == 0;
        return new Programmer(id, name, age, male);
    }
}

Main.java

public class Main {
    public static void main(String[] args) throws Exception {
        Thread[] threads = new Thread[8];
        for (int i = 0; i < 8; i++) {
            threads[i] = new Thread(new CreateTask());
        }
        for (int i = 0; i < 8; i++) {
            threads[i].start();
        }
        TimeUnit.DAYS.sleep(1);
    }
}

JVM 參數

-Xms20M 
-Xmx20M 
-XX:+UseConcMarkSweepGC 
-XX:+UseCMSInitiatingOccupancyOnly 
-XX:CMSInitiatingOccupancyFraction=70 
-XX:+CMSScavengeBeforeRemark
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/Users/brandon.p.gan/Desktop/jvmdemo/oom/logs 
-XX:+PrintGCDetails 
-XX:+PrintGCDateStamps 
-Xloggc:/Users/brandon.p.gan/Desktop/jvmdemo/oom/logs/gc.log 

Young GC 日誌

2019-04-09T15:01:38.575-0800: 0.932: [GC (Allocation Failure) 2019-04-09T15:01:38.575-0800: 0.933: [ParNew: 5504K->639K(6144K), 0.0057317 secs] 5504K->1449K(19840K), 0.0058849 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 

2019-04-09T15:01:38.575-0800 是日誌列印時間;

0.932 是一個相對時間,即 JVM 從啟動到現在所經過的時間;

[GC (Allocation Failure) 2019-04-09T15:01:38.575-0800: 0.933 表示本次 GC 是由於記憶體分配失敗導致的,後面的時間是 GC 觸發的時間和相對時間。

[ParNew: 5504K->639K(6144K), 0.0057317 secs] 表示這是 Young GC,記憶體統計資訊模式為:GC前->GC後(Young區大小),接著是回收 Young 區的耗時。

[Times: user=0.01 sys=0.00, real=0.01 secs] user 是用戶態 CPU 時間,sys 是內核態 CPU 事件和操作的牆鍾時間,real 是最終的實際耗時。

​ 牆鍾時間包括非運算等待耗時,比如執行緒阻塞等待時間、磁碟I/O 等。

​ 多執行緒情況下 user 和 sys 時間可能會疊加,所以輸出中可能會超過 real 時間。

CMS GC 日誌

[GC (CMS Initial Mark) [1 CMS-initial-mark: 11317K(13696K)] 12040K(19840K), 0.0016389 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  

GC (CMS Initial Mark) CMS GC 分為四個階段,這是一次 CMS GC 的一個階段,初始標記。

CMS-initial-mark: 11317K(13696K) 出師標記時 Old 區使用量和總量。

12040K(19840K), 0.0016389 secs 整個堆當前用量和總量,以及本次初始標記耗時。

2019-04-09T15:01:38.783-0800: 1.141: [CMS-concurrent-mark-start]
2019-04-09T15:01:38.792-0800: 1.150: [GC (Allocation Failure) 2019-04-09T15:01:38.792-0800: 1.150: [ParNew: 6144K->6144K(6144K), 0.0000375 secs]2019-04-09T15:01:38.792-0800: 1.150: [CMS2019-04-09T15:01:38.803-0800: 1.161: [CMS-concurrent-mark: 0.020/0.020 secs] [Times: user=0.04 sys=0.00, real=0.02 secs] 

GC (Allocation Failure) 結合上一條並發標記日誌,表示這是在並發標記過程中 YGC 發生。

 (concurrent mode failure): 11317K->13695K(13696K), 0.0506163 secs] 17461K->14595K(19840K), [Metaspace: 3407K->3407K(1056768K)], 0.0507949 secs] [Times: user=0.05 sys=0.00, real=0.05 secs] 

並發標記中 YGC 發生導致對象晉陞,Old 區空間不足,出現 CMF。

[Full GC (Allocation Failure) 2019-04-09T15:01:38.851-0800: 1.209: [CMS: 13695K->13695K(13696K), 0.0442915 secs] 19839K->17237K(19840K), [Metaspace: 3407K->3407K(1056768K)], 0.0444186 secs] [Times: user=0.05 sys=0.00, real=0.04 secs] 

Full GC (Allocation Failure) 由於 CMF 出現,轉入 Full GC。

CMS: 13695K->13695K(13696K), 0.0442915 secs] 19839K->17237K(19840K) 這次 Full GC 的結果是 Old 區沒有對象被回收,Young 區回收了 2M 左右。

怎麼選擇垃圾回收器

  1. 單核只能用串列;
  2. 看吞吐量用並行;
  3. 看停頓時間用並發;
  4. 不會選可以把堆的大小設置好,交給 JVM 幫你選。

什麼時候會觸發 FullGC

  1. System.gc() 調用
    雖然該方法調用並不一定觸發 Full GC,但很大概率下都會觸發,可以使用 -XX:+ DisableExplicitGC 來禁止通過這個方法觸發 FullGC。

  2. Old 區空間不足
    在 Old 區分配大數組大對象失敗時觸發 Full GC 嘗試騰出空間

  3. Metaspace 空間不足
    類載入過多、自定義類載入器過多,以及反射操作都會導致這裡空間不足

  4. Promotion Failed 晉陞失敗
    根據歷次晉陞數據大小的平均值計算,如果 Old 區空間不足以容納本次晉陞對象,則觸發 Full GC 騰空間

  5. CMS 出現 CMF
    CMF 的出現是因為 GC 執行緒和應用執行緒在「並發清理」階段是並行的,如果 GC 執行緒不能及時回收完對象騰出足夠的記憶體空間,當回收過程中出現對象晉陞失敗或者大對象分配失敗,就會出現 CMF,這時候會轉為 Serial Old GC 進行 FullGC。

記憶體溢出問題排查

JVM 內部定義的 OOM 有 6 種:

Handle msg = java_lang_String::create_from_str("Java heap space", CHECK_false);
java_lang_Throwable::set_message(Universe::_out_of_memory_error_java_heap, msg());

msg = java_lang_String::create_from_str("Metaspace", CHECK_false);
java_lang_Throwable::set_message(Universe::_out_of_memory_error_metaspace, msg());

msg = java_lang_String::create_from_str("Compressed class space", CHECK_false);
java_lang_Throwable::set_message(Universe::_out_of_memory_error_class_metaspace, msg());

msg = java_lang_String::create_from_str("Requested array size exceeds VM limit", CHECK_false);
java_lang_Throwable::set_message(Universe::_out_of_memory_error_array_size, msg());

msg = java_lang_String::create_from_str("GC overhead limit exceeded", CHECK_false);
java_lang_Throwable::set_message(Universe::_out_of_memory_error_gc_overhead_limit, msg());

msg = java_lang_String::create_from_str("Java heap space: failed reallocation of scalar replaced objects", CHECK_false);
java_lang_Throwable::set_message(Universe::_out_of_memory_error_realloc_objects, msg());

當應用系統出現 OOM 時,我們可以根據異常資訊快速定位到 OOM 的記憶體區域。下一步是保留現場,把當前記憶體 Dump 成文件。

jmap -dump:live,format=b,file=heap.bin <pid>

需要注意如果使用了 :live 則會先觸發一次 Full GC 再 Dump,這個參數表示只 Dump 活著的對象。

如果配置了參數

-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/home/admin/gclogs/heapdump

當發生 OOM 時會自動 Dump 記憶體到指定目錄。

拿到了 Dump 文件可以選擇 MAT 工具或者 jhat 進行分析。以 MAT 為例,從 Histogram 和 Dominator_tree 視圖中可分別以「類」和「對象實例」視角進行分析。前者可以快速定位存在大量實例的類;後者可以快速定位大量相同類的實例的引用關係。

Histogram 視角

從這個視角中可以很方便的看到 Programmer 的實例有 51.7 萬個,佔用了 16 M的堆記憶體。

Shallow Heap & Retained Heap

Shallow Heap 表示對象自身所佔用的空間,不包括直接或間接引用對象所佔用的空間

Retained Heap 表示對象自身及其直接或間接引用對象所佔用的空間

Dominator_tree 視角

在這個視角下,通過堆記憶體佔用量進行排序可以快速定位到持有大量對象實例的執行緒,觀察發現這些就是我們創建的應用執行緒,展開詳情可以看到持有的所有對象。

獲取 openjdk 源碼

openjdk 的源碼使用 Mercurial 這個反人類的源碼管理工具進行管理,如果需要直接從官方下載源碼,不僅需要採用特殊方式上網,還需要有強大的運氣。但是 GitHub 上有一個好心人做了 同步倉庫 ,你可以直接從這裡下載到源碼。

這個倉庫非常大,以天朝訪問 GitHub 的速度,我們有生之年不一定能夠下載完成,所以建議你最好在境外伺服器上下載,大約 3 分鐘就能下載完成,通過切換不同的分支拿到不同版本的 openjdk 源碼。

Tags: