天天看點

JVM GC原理及調優的基本思路

JVM GC原理及調優的基本思路

和 Web 應用程式一樣,Tomcat 作為一個 Java 程式也跑在 JVM 中,是以如果我們要對 Tomcat 進行調優,需要先了解 JVM 調優的原理。而對于 JVM 調優來說,主要是 JVM 垃圾收集的優化,一般來說是因為有問題才需要優化,是以對于 JVM GC 來說,如果你觀察到 Tomcat 程序的 CPU 使用率比較高,并且在 GC 日志中發現 GC 次數比較頻繁、GC 停頓時間長,這表明你需要對 GC 進行優化了。 

 在對 GC 調優的過程中,我們不僅需要知道 GC 的原理,更重要的是要熟練使用各種監控和分析工具,具備 GC 調優的實戰能力。CMS 和 G1 是時下使用率比較高的兩款垃圾收集器,從 Java 9 開始,采用 G1 作為預設垃圾收集器,而 G1 的目标也是逐漸取代 CMS。是以今天我們先來簡單回顧一下兩種垃圾收集器 CMS 和 G1 的差別,接着通過一個例子幫你提高 GC 調優的實戰能力。

CMS vs G1

CMS 收集器将 Java 堆分為年輕代(Young)或年老代(Old)。這主要是因為有研究表明,超過 90%的對象在第一次 GC 時就被回收掉,但是少數對象往往會存活較長的時間。

CMS 還将年輕代記憶體空間分為幸存者空間(Survivor)和伊甸園空間(Eden)。新的對象始終在 Eden 空間上建立。一旦一個對象在一次垃圾收集後還幸存,就會被移動到幸存者空間。當一個對象在多次垃圾收集之後還存活時,它會移動到年老代。這樣做的目的是在年輕代和年老代采用不同的收集算法,以達到較高的收集效率,比如在年輕代采用複制 - 整理算法,在年老代采用标記 - 清理算法。是以 CMS 将 Java 堆分成如下區域:

JVM GC原理及調優的基本思路

與 CMS 相比,G1 收集器有兩大特點:

  • G1 可以并發完成大部分 GC 的工作,這期間不會“Stop-The-World”。
  • G1 使用非連續空間,這使 G1 能夠有效地處理非常大的堆。此外,G1 可以同時收集年輕代和年老代。G1 并沒有将 Java 堆分成三個空間(Eden、Survivor 和 Old),而是将堆分成許多(通常是幾百個)非常小的區域。這些區域是固定大小的(預設情況下大約為 2MB)。每個區域都配置設定給一個空間。 G1 收集器的 Java 堆如下圖所示:
JVM GC原理及調優的基本思路

圖上的 U 表示“未配置設定”區域。G1 将堆拆分成小的區域,一個最大的好處是可以做局部區域的垃圾回收,而不需要每次都回收整個區域比如年輕代和年老代,這樣回收的停頓時間會比較短。具體的收集過程是:

  • 将所有存活的對象将從收集的區域複制到未配置設定的區域,比如收集的區域是 Eden 空間,把 Eden 中的存活對象複制到未配置設定區域,這個未配置設定區域就成了 Survivor 空間。理想情況下,如果一個區域全是垃圾(意味着一個存活的對象都沒有),則可以直接将該區域聲明為“未配置設定”。
  • 為了優化收集時間,G1 總是優先選擇垃圾最多的區域,進而最大限度地減少後續配置設定和釋放堆空間所需的工作量。這也是 G1 收集器名字的由來——Garbage-First。

 GC 調優原則

GC 是有代價的,是以我們調優的根本原則是每一次 GC 都回收盡可能多的對象,也就是減少無用功。是以我們在做具體調優的時候,針對 CMS 和 G1 兩種垃圾收集器,分别有一些相應的政策。

JVM GC原理及調優的基本思路

CMS 收集器

對于 CMS 收集器來說,最重要的是合理地設定年輕代和年老代的大小。年輕代太小的話,會導緻頻繁的 Minor GC,并且很有可能存活期短的對象也不能被回收,GC 的效率就不高。而年老代太小的話,容納不下從年輕代過來的新對象,會頻繁觸發單線程 Full GC,導緻較長時間的 GC 暫停,影響 Web 應用的響應時間。 

 G1 收集器

對于 G1 收集器來說,我不推薦直接設定年輕代的大小,這一點跟 CMS 收集器不一樣,這是因為 G1 收集器會根據算法動态決定年輕代和年老代的大小。是以對于 G1 收集器,我們需要關心的是 Java 堆的總大小(-Xmx)。

此外 G1 還有一個較關鍵的參數是-XX:MaxGCPauseMillis = n,這個參數是用來限制最大的 GC 暫停時間,目的是盡量不影響請求處理的響應時間。G1 将根據先前收集的資訊以及檢測到的垃圾量,估計它可以立即收集的最大區域數量,進而盡量保證 GC 時間不會超出這個限制。是以 G1 相對來說更加“智能”,使用起來更加簡單。

記憶體調優實戰

下面我通過一個例子實戰一下 Java 堆設定得過小,導緻頻繁的 GC,我們将通過 GC 日志分析工具來觀察 GC 活動并定位問題。

1. 首先我們建立一個 Spring Boot 程式,作為我們的調優對象,代碼如下:

@RestController
public class GcTestController {

    private Queue<Greeting> objCache =  new ConcurrentLinkedDeque<>();

    @RequestMapping("/greeting")
    public Greeting greeting() {
        Greeting greeting = new Greeting("Hello World!");

        if (objCache.size() >= 200000) {
            objCache.clear();
        } else {
            objCache.add(greeting);
        }
        return greeting;
    }
}

@Data
@AllArgsConstructor
class Greeting {
   private String message;
}      

上面的代碼就是建立了一個對象池,當對象池中的對象數到達 200000 時才清空一次,用來模拟年老代對象。

2. 用下面的指令啟動測試程式:

java -Xmx32m -Xss256k -verbosegc -Xlog:gc*,gc+ref=debug,gc+heap=debug,gc+age=trace:file=gc-%p-%t.log:tags,uptime,time,level:filecount=2,filesize=100m -jar target/demo-0.0.1-SNAPSHOT.jar      

我給程式設定的堆的大小為 32MB,目的是能讓我們看到 Full GC。

除此之外,我還打開了 verbosegc 日志,請注意這裡我使用的版本是 Java 12,預設的垃圾收集器是 G1。

3. 使用 JMeter 壓測工具向程式發送測試請求,通路的路徑是/greeting。

JVM GC原理及調優的基本思路

4. 使用 GCViewer 工具打開 GC 日志,我們可以看到這樣的圖:

JVM GC原理及調優的基本思路

 我來解釋一下這張圖:

  • 圖中上部的藍線表示已使用堆的大小,我們看到它周期的上下震蕩,這是我們的對象池要擴充到 200000 才會清空。
  • 圖底部的綠線表示年輕代 GC 活動,從圖上看到當堆的使用率上去了,會觸發頻繁的 GC 活動。
  • 圖中的豎線表示 Full GC,從圖上看到,伴随着 Full GC,藍線會下降,這說明 Full GC 收集了年老代中的對象。

 基于上面的分析,我們可以得出一個結論,那就是 Java 堆的大小不夠。我來解釋一下為什麼得出這個結論:

  • GC 活動頻繁:年輕代 GC(綠色線)和年老代 GC(黑色線)都比較密集。這說明記憶體空間不夠,也就是 Java 堆的大小不夠。
  • Java 的堆中對象在 GC 之後能夠被回收,說明不是記憶體洩漏。

基于上面的分析,我們可以得出一個結論,那就是 Java 堆的大小不夠。我來解釋一下為什麼得出這個結論:

  • GC 活動頻繁:年輕代 GC(綠色線)和年老代 GC(黑色線)都比較密集。這說明記憶體空間不夠,也就是 Java 堆的大小不夠。
  • Java 的堆中對象在 GC 之後能夠被回收,說明不是記憶體洩漏。

我們通過 GCViewer 還發現累計 GC 暫停時間有 55.57 秒,如下圖所示:

JVM GC原理及調優的基本思路

 是以我們的解決方案是調大 Java 堆的大小,像下面這樣:

java -Xmx2048m -Xss256k -verbosegc -Xlog:gc*,gc+ref=debug,gc+heap=debug,gc+age=trace:file=gc-%p-%t.log:tags,uptime,time,level:filecount=2,filesize=100m -jar target/demo-0.0.1-SNAPSHOT.jar      

生成的新的 GC log 分析圖如下:

JVM GC原理及調優的基本思路

你可以看到,沒有發生 Full GC,并且年輕代 GC 也沒有那麼頻繁了,并且累計 GC 暫停時間隻有 3.05 秒。

JVM GC原理及調優的基本思路

總結

今天我們首先回顧了 CMS 和 G1 兩種垃圾收集器背後的設計思路以及它們的差別,接着分析了 GC 調優的總體原則。

對于 CMS 來說,我們要合理設定年輕代和年老代的大小。你可能會問該如何确定它們的大小呢?這是一個疊代的過程,可以先采用 JVM 的預設值,然後通過壓測分析 GC 日志。

如果我們看年輕代的記憶體使用率處在高位,導緻頻繁的 Minor GC,而頻繁 GC 的效率又不高,說明對象沒那麼快能被回收,這時年輕代可以适當調大一點。

如果我們看年老代的記憶體使用率處在高位,導緻頻繁的 Full GC,這樣分兩種情況:

  • 如果每次 Full GC 後年老代的記憶體占用率沒有下來,可以懷疑是記憶體洩漏
  • 如果 Full GC 後年老代的記憶體占用率下來了,說明不是記憶體洩漏,我們要考慮調大年老代。

對于 G1 收集器來說,我們可以适當調大 Java 堆,因為 G1 收集器采用了局部區域收集政策,單次垃圾收集的時間可控,可以管理較大的 Java 堆。