深度剖析 | 【JVM深層系列】[HotSpotVM研究系列] JVM調優的"標準引數"的各種陷阱和坑點分析(攻克盲點及混淆點)「 1 」

洛神灬殤發表於2022-12-16

【易錯問題】Major GC和Full GC的區別是什麼?觸發條件呢?

相信大多數人的理解是Major GC只針對老年代,Full GC會先觸發一次Minor GC,不知對否?我參考了R大的分析和介紹,總結了一下相關的說明和分析結論。

在基於HotSpotVM的基礎角度

針對HotSpot VM的實現,它裡面的GC其實準確分類只有兩大種:

Partial GC(部分回收模式)

Partial GC代表著並不收集整個GC堆的模式

  • Young Generation GC(新生代回收模式):它主要是進行回收新生代範圍內的記憶體物件的GC回收器。
  • Old/Tenured Generation GC(老年代回收模式):它主要是針對於回收老年代Old/Tenured Generation範圍內的GC垃圾回收器(CMS的Concurrent Collection是這個模式)。
  • Mixed Generation GC(混合代回收模式):收集整個young gen以及部分old gen的GC。只有G1有這個模式

Full GC(全體回收模式)

Full GC代表著收集整個JVM的執行時堆+方法區+直接堆外記憶體的總體範圍內。(甚至可以理解為JVM程式範圍內的絕大部分範圍的資料區域)。

它會涵蓋了所有的模式和區域包含:Young Gen(新生代)、Tenured Gen(老生代)、Perm/Meta Gen(元空間)(JDK8前後的版本)等全域性範圍的GC垃圾回收模式。

在一般情況下Major GC通常是跟Full GC是等價的,收集整個GC堆。但如果從HotSpot VM底層的細節出發,如果再有人說“Major GC”的時候一定要問清楚他想要指的是上面的Full GC還是Old/Tenured GC。

基於最簡單的分代式GC策略

觸發條件是:Young GC

按HotSpot VM的Serial GC的實現來看,當Young gen中的Eden區分達到閾值(屬於一定的百分比進行控制)的時候觸發。

注意:Young GC中有部分存活物件會晉升到Old/Tenured Gen,所以Young GC後Old Gen的佔用量通常會有所升高

觸發條件是:Full GC
  1. 當準備要觸發一次Young GC時,如果發現統計資料說之前Young Old/Tenured Gen剩餘的空間大,則不會觸發Young GC,而是轉為觸發Full GC(因為HotSpot VM的GC裡,除了CMS的Concurrent collection之外,其它能收集Old/Tenured Gen的GC都會同時收集整個GC堆,包括Young gen,所以不需要事先觸發一次單獨的Young GC);

  2. 如果有Perm/Meta gen的話,要在Perm/Meta gen分配空間但已經沒有足夠空間時,也要觸發一次full GC。

  3. System.gc()方法或者Heap Dump自帶的GC,預設也是觸發Full GC。HotSpot VM裡其它非併發GC的觸發條件複雜一些,不過大致的原理與上面說的其實一樣。

注意:Parallel Scavenge(-XX:+UseParallelGC)框架下,預設是在要觸發Full GC前先執行一次Young GC,並且兩次GC之間能讓應用程式稍微執行一小下,以期降低Full GC的暫停時間(因為young GC會盡量清理了Young Gen的垃圾物件,減少了Full GC的掃描工作量)。控制這個行為的VM引數是-XX:+ScavengeBeforeFullGC。

觸發條件是:Concurrent GC

Concurrent GC的觸發條件就不太一樣。以CMS GC為例,它主要是定時去檢查Old Gen的使用量,當使用量超過了觸發比例就會啟動一次CMS GC,對Old gen做併發收集。

GC回收器對應的GC模式列舉

在Hotspot JVM實現的Serial GC, Parallel GC, CMS, G1 GC中大致可以對應到某個Young GC和Old GC演算法組合;

  • Serial GC演算法:Serial Young GC + Serial Old GC (實際上它是全域性範圍的Full GC);
  • Parallel GC演算法:Parallel Young GC + 非並行的PS MarkSweep GC / 並行的Parallel Old GC(這倆實際上也是全域性範圍的Full GC),選PS MarkSweep GC 還是 Parallel Old GC 由引數UseParallelOldGC來控制;
  • CMS演算法:ParNew(Young)GC + CMS(Old)GC (piggyback on ParNew的結果/老生代存活下來的object只做記錄,不做compaction)+ Full GC for CMS演算法(應對核心的CMS GC某些時候的不趕趟,開銷很大);
  • G1 GC:Young GC + mixed GC(新生代,再加上部分老生代)+ Full GC for G1 GC演算法(應對G1 GC演算法某些時候的不趕趟,開銷很大);

GC回收模式的觸發總結

  • 搞清楚了上面這些組合,我們再來看看各類GC演算法的觸發條件。簡單說,觸發條件就是某GC演算法對應區域滿了,或是預測快滿了。比如,
    • 各種Young GC的觸發原因都是eden區滿了;
    • Serial Old GC/PS MarkSweep GC/Parallel Old GC的觸發則是在要執行Young GC時候預測其promote的object的總size超過老生代剩餘size;
    • CMS GC的initial marking的觸發條件是老生代使用比率超過某值;
    • G1 GC的initial marking的觸發條件是Heap使用比率超過某值;
    • Full GC for CMS演算法和Full GC for G1 GC演算法的觸發原因很明顯,就是4.3 和 4.4 的fancy演算法不趕趟了,只能全域性範圍大搞一次GC了(相信我,這很慢!這很慢!這很慢!);

【坑點與坑點】-XX:+DisableExplicitGC 與 NIO的direct memory的關係

很多人都見過JVM調優建議裡使用這個引數,對吧?但是為什麼要用它,什麼時候應該用而什麼時候用了會掉坑裡呢?

  1. 首先,要了解的是這個引數的作用。在Oracle/Sun JDK這個具體實現上,System.gc()的預設效果是引發一次stop-the-world的Full GC,由上面所知就是針對於整個GC堆做記憶體垃圾收集。

  2. 再次,如果採用了用了-XX:+DisableExplicitGC引數後,System.gc()的呼叫就會變成一個空呼叫,完全不會觸發任何GC(但是“函式呼叫”本身的開銷還是存在的哦~)。

    • 為啥要用這個引數呢?最主要的原因是為了防止某些小白同學在程式碼裡到處寫System.gc()的呼叫而干擾了程式的正常執行吧。
      1. 有些應用程式本來可能正常跑一天也不會出一次Full GC,但就是因為有人在程式碼裡呼叫了System.gc()而不得不間歇性被暫停。
      2. 有些時候這些呼叫是在某些庫或框架裡寫的,改不了它們的程式碼但又不想被這些呼叫干擾也會用這引數。

-XX:+DisableExplicitGC看起來這引數應該總是開著嘛。有啥坑呢?

下述三個條件同時滿足時會發生的

  1. 應用本身在GC堆內的物件行為良好,正常情況下很久都不發生Full GC。
  2. 應用大量使用了NIO的direct memory,經常、反覆的申請DirectByteBuffer。
  3. 使用了-XX:+DisableExplicitGC。

能觀察到的現象是:

java.lang.OutOfMemoryError: Direct buffer memory  
    at java.nio.Bits.reserveMemory(Bits.java:633)  
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:98)  
    at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)  
用一個案例來分析這現象:
import java.nio.*;  
public class DisableExplicitGCDemo {  
  public static void main(String[] args) {  
    for (int i = 0; i < 100000; i++) {  
      ByteBuffer.allocateDirect(128);  
    }  
    System.out.println("Done");  
  }  
}  

然後編譯、執行。

$ java -version  
java version "1.6.0_25"  
Java(TM) SE Runtime Environment (build 1.6.0_25-b06)  
Java HotSpot(TM) 64-Bit Server VM (build 20.0-b11, mixed mode)  
$ javac DisableExplicitGCDemo.java   
$ java -XX:MaxDirectMemorySize=10m -XX:+PrintGC -XX:+DisableExplicitGC DisableExplicitGCDemo
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory  
    at java.nio.Bits.reserveMemory(Bits.java:633)  
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:98)  
    at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)  
    at DisableExplicitGCDemo.main(DisableExplicitGCDemo.java:6)  
$ java -XX:MaxDirectMemorySize=10m -XX:+PrintGC DisableExplicitGCDemo  
[GC 10996K->10480K(120704K), 0.0433980 secs]  
[Full GC 10480K->10415K(120704K), 0.0359420 secs]  
Done  
  • 可以看到,同樣的程式,不帶-XX:+DisableExplicitGC時能正常完成執行,而帶上這個引數後卻出現了OOM。-XX:MaxDirectMemorySize=10m限制了DirectByteBuffer能分配的空間的限額,以便問題更容易展現出來。不用這個引數就得多跑一會兒了。

  • 迴圈不斷申請DirectByteBuffer但並沒有引用,所以這些DirectByteBuffer應該剛建立出來就已經滿足被GC的條件,等下次GC執行的時候就應該可以被回收。

  • 實際上卻沒這麼簡單。DirectByteBuffer是種典型的“冰山”物件,也就是說它的Java物件雖然很小很無辜,但它背後卻會關聯著一定量的native memory資源,而這些資源並不在GC的控制之下,需要自己注意控制好。

對JVM如何使用native memory不熟悉的同學可以研究一下這篇演講,“Where Does All the Native Memory Go”。

【盲點問題】DirectByteBuffer的回收問題

Oracle/Sun JDK的實現裡,DirectByteBuffer有幾處值得注意的地方。

  1. DirectByteBuffer沒有finalizer,它的native memory的清理工作是透過sun.misc.Cleaner自動完成的。
  2. sun.misc.Cleaner是一種基於PhantomReference的清理工具,比普通的finalizer輕量些。

"A cleaner tracks a referent object and encapsulates a thunk of arbitrary cleanup code. Some time after the GC detects that a cleaner's referent has become phantom-reachable, the reference-handler thread will run the cleaner."

原始碼註釋
/** 
 * General-purpose phantom-reference-based cleaners. 
 * 
 * <p> Cleaners are a lightweight and more robust alternative to finalization. 
 * They are lightweight because they are not created by the VM and thus do not 
 * require a JNI upcall to be created, and because their cleanup code is 
 * invoked directly by the reference-handler thread rather than by the 
 * finalizer thread.  They are more robust because they use phantom references, 
 * the weakest type of reference object, thereby avoiding the nasty ordering 
 * problems inherent to finalization. 
 * 
 * <p> A cleaner tracks a referent object and encapsulates a thunk of arbitrary 
 * cleanup code.  Some time after the GC detects that a cleaner's referent has 
 * become phantom-reachable, the reference-handler thread will run the cleaner. 
 * Cleaners may also be invoked directly; they are thread safe and ensure that 
 * they run their thunks at most once. 
 * 
 * <p> Cleaners are not a replacement for finalization.  They should be used 
 * only when the cleanup code is extremely simple and straightforward. 
 * Nontrivial cleaners are inadvisable since they risk blocking the 
 * reference-handler thread and delaying further cleanup and finalization. 
 * 
 * 
 * @author Mark Reinhold 
 * @version %I%, %E% 
 */  

Oracle/Sun JDK中的HotSpot VM只會在Old Gen GC(Full GC/Major GC或者Concurrent GC都算)的時候才會對Old Gen中的物件做Reference Processing,而在Young GC/Minor GC時只會對Young Gen裡的物件做Reference processing。Full GC會對Old Gen做Reference processing,進而能觸發Cleaner對已死的DirectByteBuffer物件做清理工作。

  • 如果很長一段時間裡沒做過GC或者只做了Young GC的話則不會在Old Gen觸發Cleaner的工作,那麼就可能讓本來已經死了的、但已經晉升到Old Gen的DirectByteBuffer關聯的Native Memory得不到及時釋放

  • 為DirectByteBuffer分配空間過程中會顯式呼叫System.gc(),以透過Full GC來強迫已經無用的DirectByteBuffer物件釋放掉它們關聯的native memory

// These methods should be called whenever direct memory is allocated or  
// freed.  They allow the user to control the amount of direct memory  
// which a process may access.  All sizes are specified in bytes.  
static void reserveMemory(long size) {   
    synchronized (Bits.class) {  
        if (!memoryLimitSet && VM.isBooted()) {  
            maxMemory = VM.maxDirectMemory();  
            memoryLimitSet = true;  
        }  
        if (size <= maxMemory - reservedMemory) {  
            reservedMemory += size;  
            return;  
        }  
    }  
    System.gc();  
    try {  
        Thread.sleep(100);  
    } catch (InterruptedException x) {  
        // Restore interrupt status  
        Thread.currentThread().interrupt();  
    }  
    synchronized (Bits.class) {  
        if (reservedMemory + size > maxMemory)  
            throw new OutOfMemoryError("Direct buffer memory");  
           reservedMemory += size;
    }  
}  

總結分析

這幾個實現特徵使得Oracle/Sun JDK依賴於System.gc()觸發GC來保證DirectByteMemory的清理工作能及時完成。

如果開啟了-XX:+DisableExplicitGC,清理工作就可能得不到及時完成,於是就有機會見到direct memory的OOM,也就是上面的例子演示的情況。我們這邊在實際生產環境中確實遇到過這樣的問題。

如果你在使用Oracle/Sun JDK,應用裡有任何地方用了direct memory,那麼使用-XX:+DisableExplicitGC要小心。如果用了該引數而且遇到direct memory的OOM,可以嘗試去掉該引數看是否能避開這種OOM。如果擔心System.gc()呼叫造成Full GC頻繁,可以嘗試下面提到 -XX:+ExplicitGCInvokesConcurrent 引數

相關文章