詳解JVM 內存結構與實戰調優總結

詳解JVM 內存結構與實戰調優總結

GC優化案例做個總結:

  • 1在進行GC優化之前,需要確認項目的架構和代碼等已經沒有優化空間。我們不能指望一個系統架構有缺陷或者代碼層次優化沒有窮盡的應用,通過GC優化令其性能達到一個質的飛躍。
  • 2其次,虛擬機內部已有很多優化來保證應用的穩定運行,所以不要爲了調優而調優,不當的調優可能適得其反。
  • 3最後,GC優化是一個系統而複雜的工作,沒有萬能的調優策略可以滿足所有的性能指標。

GC優化必須建立在我們深入理解各種垃圾回收器的基礎上,纔能有事半功倍的效果。

一 JVM 內存結構

當代主流虛擬機(Hotspot VM)的垃圾回收都採用“分代回收”的算法。“分代回收”是基於這樣一個事實:對象的生命週期不同,所以針對不同生命週期的對象可以採取不同的回收方式,以便提高回收效率。

Hotspot VM將內存劃分爲不同的物理區,就是“分代”思想的體現。

JVM內存主要由新生代、老年代、永久代構成。

  • ① 新生代(Young Generation):大多數對象在新生代中被創建,其中很多對象的生命週期很短。每次新生代的垃圾回收(又稱Minor GC)後只有少量對象存活,所以選用複製算法,只需要少量的複製成本就可以完成回收。

新生代內又分三個區:一個Eden區,兩個Survivor區(一般而言),大部分對象在Eden區中生成。當Eden區滿時,還存活的對象將被複制到兩個Survivor區(中的一個)。當這個Survivor區滿時,此區的存活且不滿足“晉升”條件的對象將被複制到另外一個Survivor區。對象每經歷一次Minor GC,年齡加1,達到“晉升年齡閾值”後,被放到老年代,這個過程也稱爲“晉升”。顯然,“晉升年齡閾值”的大小直接影響着對象在新生代中的停留時間,在Serial和ParNew GC兩種回收器中,“晉升年齡閾值”通過參數MaxTenuringThreshold設定,默認值爲15。

  • ② 老年代(Old Generation):在新生代中經歷了N次垃圾回收後仍然存活的對象,就會被放到年老代,該區域中對象存活率高。老年代的垃圾回收(又稱Major GC)通常使用“標記-清理”或“標記-整理”算法。整堆包括新生代和老年代的垃圾回收稱爲Full GC(HotSpot VM裏,除了CMS之外,其它能收集老年代的GC都會同時收集整個GC堆,包括新生代)。

  • ③ 永久代(Perm Generation):主要存放元數據,例如Class、Method的元信息,與垃圾回收要回收的Java對象關係不大。相對於新生代和年老代來說,該區域的劃分對垃圾回收影響比較小。

常見垃圾回收器

不同的垃圾回收器,適用於不同的場景。常用的垃圾回收器:

串行(Serial)回收器是單線程的一個回收器,簡單、易實現、效率高。
並行(ParNew)回收器是Serial的多線程版,可以充分的利用CPU資源,減少回收的時間。
吞吐量優先(Parallel Scavenge)回收器,側重於吞吐量的控制。
併發標記清除(CMS,Concurrent Mark Sweep)回收器是一種以獲取最短回收停頓時間爲目標的回收器,該回收器是基於“標記-清除”算法實現的。

GC日誌

每一種回收器的日誌格式都是由其自身的實現決定的,換而言之,每種回收器的日誌格式都可以不一樣。但虛擬機設計者爲了方便用戶閱讀,將各個回收器的日誌都維持一定的共性。JavaGC日誌 中簡單介紹了這些共性。

參數基本策略

各分區的大小對GC的性能影響很大。如何將各分區調整到合適的大小,分析活躍數據的大小是很好的切入點。

活躍數據的大小是指,應用程序穩定運行時長期存活對象在堆中佔用的空間大小,也就是Full GC後堆中老年代佔用空間的大小。可以通過GC日誌中Full GC之後老年代數據大小得出,比較準確的方法是在程序穩定後,多次獲取GC數據,通過取平均值的方式計算活躍數據的大小。

參數 倍數
總大小 3-4 倍活躍數據的大小
新生代 1-1.5 活躍數據的大小
老年代 2-3 倍活躍數據的大小
永久代 1.2-1.5 倍Full GC後的永久代空間佔用

例如,根據GC日誌獲得老年代的活躍數據大小爲300M,那麼各分區大小可以設爲:

總堆:1200MB = 300MB × 4* 新生代:450MB = 300MB × 1.5* 老年代: 750MB = 1200MB - 450MB*

這部分設置僅僅是堆大小的初始值,後面的優化中,可能會調整這些值,具體情況取決於應用程序的特性和需求。

二 優化步驟

GC優化一般步驟可以概括爲:確定目標、優化參數、驗收結果。

確定目標
明確應用程序的系統需求是性能優化的基礎,系統的需求是指應用程序運行時某方面的要求,譬如: - 高可用,可用性達到幾個9。 - 低延遲,請求必須多少毫秒內完成響應。 - 高吞吐,每秒完成多少次事務。

明確系統需求之所以重要,是因爲上述性能指標間可能衝突。比如通常情況下,縮小延遲的代價是降低吞吐量或者消耗更多的內存或者兩者同時發生。

由於筆者所在團隊主要關注高可用和低延遲兩項指標,所以接下來分析,如何量化GC時間和頻率對於響應時間和可用性的影響。通過這個量化指標,可以計算出當前GC情況對服務的影響,也能評估出GC優化後對響應時間的收益,這兩點對於低延遲服務很重要。

舉例:假設單位時間T內發生一次持續25ms的GC,接口平均響應時間爲50ms,且請求均勻到達,根據下圖所示:

image
那麼有(50ms+25ms)/T比例的請求會受GC影響,其中GC前的50ms內到達的請求都會增加25ms,GC期間的25ms內到達的請求,會增加0-25ms不等,如果時間T內發生N次GC,受GC影響請求佔比=(接口響應時間+GC時間)×N/T 。可見無論降低單次GC時間還是降低GC次數N都可以有效減少GC對響應時間的影響。

優化

通過收集GC信息,結合系統需求,確定優化方案,例如選用合適的GC回收器、重新設置內存比例、調整JVM參數等。

進行調整後,將不同的優化方案分別應用到多臺機器上,然後比較這些機器上GC的性能差異,有針對性的做出選擇,再通過不斷的試驗和觀察,找到最合適的參數。

驗收優化結果

將修改應用到所有服務器,判斷優化結果是否符合預期,總結相關經驗。

接下來,我們通過三個案例來實踐以上的優化流程和基本原則(本文中三個案例使用的垃圾回收器均爲ParNew+CMS,CMS失敗時Serial Old替補)。

三 JVM調試工具

通過案例,我們知道要有一個更系統的調優方案,首先就要系統得了解調優工具。

3.1 jstat

jstat命令可以查看堆內存各部分的使用量,以及加載類的數量。命令的格式如下:

jstat [-命令選項] [vmid] [間隔時間/毫秒] [查詢次數]

查看class加載統計

F:\t>jstat -class 12076
Loaded  Bytes  Unloaded  Bytes     Time
 5962 10814.2        0     0.0       3.75

查看編譯統計

F:\t>jstat -compiler 12076
Compiled Failed Invalid   Time   FailedType FailedMethod
   3115      0       0     3.43          0

垃圾回收統計

F:\t>jstat -gc 12076
S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU
  CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
3584.0 6656.0 3412.1  0.0   180224.0 89915.4   61440.0     5332.1   27904.0 2626
7.3 3840.0 3420.8      6    0.036   1      0.026    0.062
#也可以指定打印的間隔和次數,每1秒中打印一次,共打印5次
F:\t>jstat -gc 12076 1000 5
S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU
  CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
3584.0 6656.0 3412.1  0.0   180224.0 89915.4   61440.0     5332.1   27904.0 2626
7.3 3840.0 3420.8      6    0.036   1      0.026    0.062
3584.0 6656.0 3412.1  0.0   180224.0 89915.4   61440.0     5332.1   27904.0 2626
7.3 3840.0 3420.8      6    0.036   1      0.026    0.062
3584.0 6656.0 3412.1  0.0   180224.0 89915.4   61440.0     5332.1   27904.0 2626
7.3 3840.0 3420.8      6    0.036   1      0.026    0.062
3584.0 6656.0 3412.1  0.0   180224.0 89915.4   61440.0     5332.1   27904.0 2626
7.3 3840.0 3420.8      6    0.036   1      0.026    0.062
3584.0 6656.0 3412.1  0.0   180224.0 89915.4   61440.0     5332.1   27904.0 2626
7.3 3840.0 3420.8      6    0.036   1      0.026    0.062

3.2 Jmap的使用以及內存溢出分析

前面通過jstat可以對jvm堆的內存進行統計分析,而jmap可以獲取到更加詳細的內容,如:內存使用情況的彙總、對內存溢出的定位與分析。

查看內存使用情況

[root@node01 ~]# jmap -heap 6219
Attaching to process ID 6219, please wait... 
Debugger attached successfully.
Server compiler detected.
JVM version is 25.141-b15
using thread-local object allocation.
Parallel GC with 2 thread(s)
Heap Configuration: #堆內存配置信息
MinHeapFreeRatio         = 0
MaxHeapFreeRatio         = 100
MaxHeapSize              = 488636416 (466.0MB)
NewSize                  = 10485760 (10.0MB)
MaxNewSize               = 162529280 (155.0MB)
OldSize                  = 20971520 (20.0MB)
NewRatio                 = 2
SurvivorRatio            = 8
MetaspaceSize            = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize         = 17592186044415 MB
G1HeapRegionSize         = 0 (0.0MB)
Heap Usage: # 堆內存的使用情況
PS Young Generation #年輕代
Eden Space:
capacity = 123731968 (118.0MB)
used     = 1384736 (1.320587158203125MB)
free     = 122347232 (116.67941284179688MB)
1.1191416594941737% used
From Space:
capacity = 9437184 (9.0MB)
used     = 0 (0.0MB)
free     = 9437184 (9.0MB)
0.0% used
To Space:
capacity = 9437184 (9.0MB)
used     = 0 (0.0MB)
free     = 9437184 (9.0MB)
0.0% used
PS Old Generation #年老代
capacity = 28311552 (27.0MB)
used     = 13698672 (13.064071655273438MB)
free     = 14612880 (13.935928344726562MB)
48.38545057508681% used
13648 interned Strings occupying 1866368 bytes.


查看內存中對象數量及大小

#查看所有對象,包括活躍以及非活躍的
jmap -histo <pid> | more
#查看活躍對象 
jmap -histo:live <pid> | more
[root@node01 ~]# jmap -histo:live 6219 | more
num     #instances         #bytes  class name
----------------------------------------------1:         37437        7914608  [C
2:         34916         837984  java.lang.String
3:           884         654848  [B
4:         17188         550016  java.util.HashMap$Node
5:          3674         424968  java.lang.Class
6:          6322         395512  [Ljava.lang.Object;
7:          3738         328944  java.lang.reflect.Method
8:          1028         208048  [Ljava.util.HashMap$Node;
9:          2247         144264  [I
10:          4305         137760  java.util.concurrent.ConcurrentHashMap$Node
11:          1270         109080  [Ljava.lang.String;
12:            64          84128  [Ljava.util.concurrent.ConcurrentHashMap$Node;
13:          1714          82272  java.util.HashMap
14:          3285          70072  [Ljava.lang.Class;
15:          2888          69312  java.util.ArrayList
16:          3983          63728  java.lang.Object
17:          1271          61008  org.apache.tomcat.util.digester.CallMethodRule
18:          1518          60720  java.util.LinkedHashMap$Entry
19:          1671          53472  com.sun.org.apache.xerces.internal.xni.QName
20:            88          50880  [Ljava.util.WeakHashMap$Entry;
21:           618          49440  java.lang.reflect.Constructor
22:          1545          49440  java.util.Hashtable$Entry
23:          1027          41080  java.util.TreeMap$Entry
24:           846          40608  org.apache.tomcat.util.modeler.AttributeInfo
25:           142          38032  [S
26:           946          37840  java.lang.ref.SoftReference
27:           226          36816  [[C
。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。
#對象說明
B  byte
C  char
D  double
F  float
I  int
J  long
Z  boolean
[  數組,如[I表示int[]
[L+類名 其他對象

將內存使用情況dump到文件中

#用法:
jmap -dump:format=b,file=dumpFileName <pid>
#示例
jmap -dump:format=b,file=/tmp/dump.dat 6219

image

可以看到已經在/tmp下生成了dump.dat的文件。

通過jhat對dump文件進行分析

在上一小節中,我們將jvm的內存dump到文件中,這個文件是一個二進制的文件,不方便查看,這時我們可以藉助於jhat工具進行查看。

#用法:
jhat -port <port> <file>
#示例:
[root@node01 tmp]# jhat -port 9999 /tmp/dump.dat 
Reading from /tmp/dump.dat...
Dump file created Mon Sep 10 01:04:21 CST 2018
Snapshot read, resolving...
Resolving 204094 objects...
Chasing references, expect 40 dots........................................
Eliminating duplicate references........................................
Snapshot resolved.
Started HTTP server on port 9999
Server is ready.

打開瀏覽器進行訪問, 在最後由OQL查詢功能.

3.3 Jmp使用以及內存溢出分析

使用MAT對內存溢出的定位與分析

內存溢出在實際的生產環境中經常會遇到,比如,不斷地將數據寫入到一個集合中,出現了死循環,讀取超大的文件等等,都可能會造成內存溢出。

如果出現了內存溢出,首先我們需要定位到發生內存溢出的環節,並且進行分析,是正常還是非正常情況,如果是正常的需求,就應該考慮加大內存的設置,如果是非正常需求,那麼就要對代碼進行修改,修復這個bug。首先,我們得先學會如何定位問題,然後再進行分析。如何定位問題呢,我們需要藉助於jmap與MAT工具進行定位分析。

接下來,我們模擬內存溢出的場景。

模擬內存溢出

編寫代碼,向List集合中添加100萬個字符串,每個字符串由1000個UUID組成。如果程序能夠正常執行,最後打印ok。

public class TestJvmOutOfMemory {
public static void main(String[] args) { 
    List<Object> list = new ArrayList<>();
    for (int i = 0; i < 10000000; i++) {
            String str = "";
            for (int j = 0; j < 1000; j++) {
            str += UUID.randomUUID().toString();
            }
             list.add(str);
        }
    System.out.println("ok");
    }
}

爲了演示效果,我們將設置執行的參數

#參數如下:
-Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid5348.hprof ...
Heap dump file created [8137186 bytes in 0.032 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at
java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at cn.itcast.jvm.TestJvmOutOfMemory.main(TestJvmOutOfMemory.java:14)
Process finished with exit code 1


可以看到,當發生內存溢出時,會dump文件到java_pid5348.hprof。
導入到MA T工具中進行分析,

image

可以看到,有91.03%的內存由Object[]數組佔有,所以比較可疑。
分析:這個可疑是正確的,因爲已經有超過90%的內存都被它佔有,這是非常有可能出現內存溢出的。

image
可以看到集合中存儲了大量的uuid字符串

3.4 Jsatck的使用

有些時候我們需要查看下jvm中的線程執行情況,比如,發現服務器的CPU的負載突然增高了、出現了死鎖、死循環等,我們該如何分析呢?

由於程序是正常運行的,沒有任何的輸出,從日誌方面也看不出什麼問題,所以就需要看下jvm的內部線程的執行情況,然後再進行分析查找出原因。

這個時候,就需要藉助於jstack命令了,jstack的作用是將正在運行的jvm的線程情況進行快照,並且打印出來。

#用法:jstack <pid>
[root@node01 bin]# jstack 2203

3.5 VisualVM工具的使用

VisualVM,能夠監控線程,內存情況,查看方法的CPU時間和內存中的對 象,已被GC的對象,反向查看分配的堆棧(如100個String對象分別由哪幾個對象分配出來的)。

VisualVM使用簡單,幾乎0配置,功能還是比較豐富的,幾乎囊括了其它JDK自帶命令的所有功能。

內存信息
線程信息
Dump堆(本地進程
Dump線程(本地進程)
打開堆Dump。堆Dump可以用jmap來生成
打開線程Dump
生成應用快照(包含內存信息、線程信息等等)
性能分析。CPU分析(各個方法調用時間,檢查哪些方法耗時多),內存分析(各類對象佔用的內存,檢查哪些類佔用內存多)
......

啓動

在jdk的安裝目錄的bin目錄下,找到jvisualvm.exe,雙擊打開即可。
image

查看 CPU、內存、類、線程運行信息
參看線程信息
也可以點擊右上角Dump按鈕,將線程的信息導出,其實就是執行的jstack命令。
監控遠程JVM

VisualJVM不僅是可以監控本地jvm進程,還可以監控遠程的jvm進程,需要藉助於JMX技術實現。

什麼是JMX

JMX(Java Management Extensions,即Java管理擴展)是一個爲應用程序、設備、系統等植入管理功能的框架。JMX可以跨越一系列異構操作系統平臺、系統體系結構和網絡傳輸協議,靈活地開發無縫集成的系統、網絡和服務管理應用。

監控Tomcat

想要監控遠程的tomcat,就需要在遠程的tomcat進行對JMX配置,

3.6 可視化GC日誌分析工具

GC日誌輸出參數

前面通過-XX:+PrintGCDetails可以對GC日誌進行打印,我們就可以在控制檯查看,這樣雖然可以查看GC的信息,但是並不直觀,可以藉助於第三方的GC日誌分析工具進行查看。

在日誌打印輸出涉及到的參數如下:

-XX:+PrintGC 輸出GC日誌 

-XX:+PrintGCDetails 輸出GC的詳細日誌 

-XX:+PrintGCTimeStamps 輸出GC的時間戳(以基準時間的形式) 

-XX:+PrintGCDateStamps 輸出GC的時間戳(以日期的形式,如 2013-05-04T21:53:59.234+0800) 

-XX:+PrintHeapAtGC 在進行GC的前後打印出堆的信息 

-Xloggc:../logs/gc.log 日誌文件的輸出路徑 

測試

-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xmx256m -XX:+PrintGCDetails 
-XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC
-Xloggc:F://test//gc.log 

運行後就可以在E盤下生成gc.log文件。

使用GC Easy

它是一款在線的可視化工具,易用、功能強大,網站:http://gceasy.io/

四 CPU 佔用過高排查實戰

  • 1、先通過 top 命令找到消耗 cpu 很高的進程 id

假設id 是 2022, top 命令是我們在 Linux 下最常用的命令之一,它可以實時顯示正在執行進程的 CPU 使用率、內存使用率以及系統負載等信息。其中上半部分顯示的是系統的統計信息,下半部分顯示的是進程的使用率統計信息。

  • 2、執行 top -p 2022 單獨監控該進程

  • 3、在第 2 步的監控界面輸入 H,獲取當前進程下的所有線程信息

  • 4、找到消耗 cpu 特別高的線程編號,假設是 2734(要等待一陣)

  • 5、執行 jstack 123456 對當前的進程做 dump,輸出所有的線程信息

  • 6、 將第 4 步得到的線程編號 11354 轉成 16 進制是 0x7b

  • 7、 根據第 6 步得到的 0x7b 在第 5 步的線程信息裏面去找對應線程內容

  • 8、 解讀線程信息,定位具體代碼位置

發現找是 VM 的線程佔用過高,我們發現我開啓的參數中,有垃圾回收的日誌顯示,所以我們要換一個思路,可能是我們的業務線程沒問題,而是垃圾回收的導致的。

jstat(代碼中有打印 GC 參數,生產上可以使用這個 jstat –gc 來統計,達到類似的效果):
是用於監視虛擬機各種運行狀態信息的命令行工具。它可以顯示本地或者遠程虛擬機進程中的類裝載、內存、垃圾收集、JIT 編譯等運行數據,在沒有 GUI圖形界面,只提供了純文本控制檯環境的服務器上,它將是運行期定位虛擬機性能問題的首選工具。

假設需要每 250 毫秒查詢一次進程 13616 垃圾收集狀況,一共查詢 10 次,那命令應當是:jstat-gc 13616 250010。
image

使用這個大量的 FullGC 了,並且還拋出了 OUT Of Memory。

image

怎麼辦?OOM 了. 我們可以看到,這個裏面 CPU 佔用過高是什麼導致的?

是業務線程嗎?不是的,這個是 GC 線程佔用過高導致的。JVM 在瘋狂的進行垃圾回收,JVM 中默認的垃圾回收器是多線程的,所以多線程在瘋狂回收,導致 CPU 佔用過高。

內存佔用過高思路

用於生成堆轉儲快照(一般稱爲 heapdump 或 dump 文件)。jmap 的作用並不僅僅是爲了獲取 dump 文件,它還可以查詢 finalize 執行隊列、Java 堆和永久代的詳細信息,如空間使用率、當前用的是哪種收集器等。和 jinfo 命令一樣,jmap 有不少功能在Windows 平臺下都是受限的,除了生成 dump 文件的-dump 選項和用於查看每個類的實例;

空間佔用統計的-histo 選項在所有操作系統都提供之外。

image

把 JVM 中的對象全部打印出來, 但是這樣太多了,那麼我們選擇前 20 的對象展示出來,jmap –histo 1196 | head -20

image

image

定位問題的關鍵,就是這條命令。很多個 88 萬個對象。

問題總結(找到問題)
一般來說,前面這幾行,就可以看出,到底是哪些對象佔用了內存。

這些對象回收不掉嗎?是的,這些對象回收不掉,這些對象回收不掉,導致了 FullGC,裏面還有 OutOfMemory。

image

任務數多於線程數,那麼任務會進入阻塞隊列,就是一個隊列,你進去,排隊,有機會了,你就上來跑。

但是,因爲代碼中任務數一直多於線程數,所以每 0.1S,就會有 50 個任務進入阻塞對象,50 個任務底下有對象,至少對象送進去了,但是沒執行。所以導致對象一直都在,同時還回收不了。

爲什麼回收不了?因爲Executor 是一個 GCroots。

image

所以堆中,就會有對象 80 萬個,阻塞隊列中 80 萬個任務,futureTask。並且這些對象還回收不了。

小結

在 JVM 出現性能問題的時候。(表現上是 CPU100%,內存一直佔用)

1、 如果 CPU 的 100%,要從兩個角度出發,一個有可能是業務線程瘋狂運行,比如說想很多死循環。還有一種可能性,就是 GC 線程在瘋狂的回收,因爲 JVM 中垃圾回收器主流也是多線程的,所以很容易導致 CPU 的 100%

2、 在遇到內存溢出的問題的時候,一般情況下我們要查看系統中哪些對象佔用得比較多。在實際的業務代碼中,找到對應的對象,分析對應的類,找到爲什麼這些對象不能回收的原因,就是可達性分析算法,JVM 的內存區域,還有垃圾回收器的基礎。

四 由億級系統引起的思考:億級流量電商系統 JVM 調優

image

根據淘寶的一個官方的數據分析,每天點擊量在億級的系統,用戶一次瀏覽點擊 20~40 次之間,推測出每日活躍用戶(日活用戶)在 500 萬左右。

同時結合淘寶的一個點擊數據,可以發現,能夠付費的也就是橙色的部分(cart)的用戶,比例只有 10%左右。

90%的用戶僅僅是瀏覽,那麼我們可以通過圖片緩存、Redis 緩存等技術,我們可以把 90%的用戶解決掉。

10%的付費用戶,大概算出來是每日成交 50 萬單左右。

image

GC 預估

如果是普通業務,一般處理時間比較平緩,大概在 3,4 個小時處理,算出來每秒只有幾十單,這個一般的應用可以處理過來(不需要 JVM 預估調優)另外電商系統中有大促場景(秒殺、限時搶購等),一般這種業務是幾種在幾分鐘。我們算出來大約每秒 2000 單左右的數據,承受大促場景的使用 4 臺服務器(使用負載均衡)。每臺訂單服務器也就是大概 500 單/秒。

我們測試發現,每個訂單處理過程中會佔據 0.2MB 大小的空間(什麼訂單信息、優惠券、支付信息等等),那麼一臺服務器每秒產生 100M 的內存空間,這些對象基本上 1 秒後都會變成垃圾對象。

image

加入我們設置堆的空間最大值爲 3 個 G,我們按照默認情況下的設置,新生代 1/3 的堆空間,老年代 2/3 的堆空間。Eden:S0:S1=8:1:1,我們推測出,old 區=2G,Eden 區=800M,S0=S1=100M。

根據對象的分配原則(對象優先在 Eden 區進行分配),由此可得,8 秒左右 Eden 區空間滿了。

每 8 秒觸發一個 MinorGC(新生代垃圾回收),這次 MinorGC 時,JVM 要 STW,但是這個時候有 100M 的對象是不能回收的(線程暫停,對象需要 1 秒後都會變成垃圾對象),那麼就會有 100M 的對象在本次不能被回收(只有下次才能被回收掉)。

所以經過本次垃圾回收後。本次存活的 100M 對象會進入 S0 區,但是由於另外一個 JVM 對象分配原則(如果在 Survivor 空間中相同年齡所有對象大小的總和大於 Survivor 空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到 MaxTenuringThreshold 中要求的年齡)。

所以這樣的對象本質上不會進去 Survivor 區,而是進入老年代。

image

所以我們推算,大概每個 8 秒會有 100M 的對象進入老年代。大概 20*8=160 秒,也就是 2 分 40 秒左右 old 區就會滿掉,就會觸發一次 FullGC,一般來說,這次 FullGC 是可以避免的,同時由於 FullGC 不單單回收老年代+新生代,還要回收元空間,這些 FullGC 的時間可能會比較長(老年代回收的朝生夕死的對象,使用標記清除/標記整理算法決定了效率並不高,同時元空間也要回收一次,進一步加大 GC 時間)。

所以問題的根本就是做到如何避免沒有必要的 FullGC。

GC 預估調優

我們在項目中加入 VM 參數:

-Xms3072M -Xmx3072M -Xmn2048M -XX:SurvivorRatio=7

-Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M

-XX:MaxTenuringThreshold=2

-XX:ParallelGCThreads=8

-XX:+UseConcMarkSweepGC

1、首先看一下堆空間:old 區=1G,Eden 區=1.4G,S0=S1=300M

image

2 、那麼第一點,Eden 區大概需要 14 秒才能填滿,填滿之後,100M 的存活對象會進入 S0 區(由於這個區域變大,不會觸發動態年齡判斷)

image

3、再過 14 秒,Eden 區,填滿之後,還是剩餘 100M 的對象要進入 S1 區。但是由於原來的 100M 已經是垃圾了(過了 14 秒了),所以,S1 也只會有 Eden 區過來的 100M 對象,S0 的 100M 已經別回收,也不會觸發動態年齡判斷。

image

4、反反覆覆,這樣就沒有對象會進入 old 區,就不會觸發 FullGC,同時我們的 MinorGC 的頻次也由之前的 8 秒變爲 14 秒,雖然空間加大,但是換來的還是 GC 的總時間會減少。

5、-Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M

棧一般情況下很少用到 1M。所以爲了線程佔用內存更少,我們可以減少到 256K元空間一般啓動後就不會有太多的變化,我們可以設定爲 128M,節約內存空間。

6、-XX:MaxTenuringThreshold=2

這個是分代年齡(年齡爲 2 就可以進入老年代),因爲我們基本上都使用的是 Spring 架構,Spring 中很多的 bean 是長期要存活的,沒有必要在 Survivor 區過渡太久,所以可以設定爲 2,讓大部分的 Spring 的內部的一些對象進入老年代。

7、-XX:ParallelGCThreads=8

線程數可以根據你的服務器資源情況來設定(要速度快的話可以設置大點,根據 CPU 的情況來定,一般設置成 CPU 的整數倍)。

image

8、-XX:+UseConcMarkSweepGC

因爲這個業務響應時間優先的,所以還是可以使用 CMS 垃圾回收器或者 G1 垃圾回收器。

image

五 案例一 Major GC和Minor GC頻繁

確定目標

服務情況:Minor GC每分鐘100次 ,Major GC每4分鐘一次,單次Minor GC耗時25ms,單次Major GC耗時200ms,接口響應時間50ms。

由於這個服務要求低延時高可用,結合上文中提到的GC對服務響應時間的影響,計算可知由於Minor GC的發生,12.5%的請求響應時間會增加,其中8.3%的請求響應時間會增加25ms,可見當前GC情況對響應時間影響較大。

(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。

優化目標:降低TP99、TP90時間。

優化

首先優化Minor GC頻繁問題。通常情況下,由於新生代空間較小,Eden區很快被填滿,就會導致頻繁Minor GC,因此可以通過增大新生代空間來降低Minor GC的頻率。例如在相同的內存分配率的前提下,新生代中的Eden區增加一倍,Minor GC的次數就會減少一半。

這時很多人有這樣的疑問,擴容Eden區雖然可以減少Minor GC的次數,但會增加單次Minor GC時間麼?根據上面公式,如果單次Minor GC時間也增加,很難保證最後的優化效果。我們結合下面情況來分析,單次Minor GC時間主要受哪些因素影響?是否和新生代大小存在線性關係? 首先,單次Minor GC時間由以下兩部分組成:T1(掃描新生代)和 T2(複製存活對象到Survivor區)如下圖。(注:這裏爲了簡化問題,我們認爲T1只掃描新生代判斷對象是否存活的時間,其實該階段還需要掃描部分老年代,後面案例中有詳細描述。)

image

擴容前:新生代容量爲R ,假設對象A的存活時間爲750ms,Minor GC間隔500ms,那麼本次Minor GC時間= T1(掃描新生代R)+T2(複製對象A到S)。

擴容後:新生代容量爲2R ,對象A的生命週期爲750ms,那麼Minor GC間隔增加爲1000ms,此時Minor GC對象A已不再存活,不需要把它複製到Survivor區,那麼本次GC時間 = 2 × T1(掃描新生代R),沒有T2複製時間。

可見,擴容後,Minor GC時增加了T1(掃描時間),但省去T2(複製對象)的時間,更重要的是對於虛擬機來說,複製對象的成本要遠高於掃描成本,所以,單次Minor GC時間更多取決於GC後存活對象的數量,而非Eden區的大小。因此如果堆中短期對象很多,那麼擴容新生代,單次Minor GC時間不會顯著增加。下面需要確認下服務中對象的生命週期分佈情況:

image

通過上圖GC日誌中兩處紅色框標記內容可知: 1. new threshold = 2(動態年齡判斷,對象的晉升年齡閾值爲2),對象僅經歷2次Minor GC後就晉升到老年代,這樣老年代會迅速被填滿,直接導致了頻繁的Major GC。 2. Major GC後老年代使用空間爲300M+,意味着此時絕大多數(86% = 2G/2.3G)的對象已經不再存活,也就是說生命週期長的對象佔比很小。

由此可見,服務中存在大量短期臨時對象,擴容新生代空間後,Minor GC頻率降低,對象在新生代得到充分回收,只有生命週期長的對象才進入老年代。這樣老年代增速變慢,Major GC頻率自然也會降低。

優化結果
通過擴容新生代爲爲原來的三倍,單次Minor GC時間增加小於5ms,頻率下降了60%,服務響應時間TP90,TP99都下降了10ms+,服務可用性得到提升。

調整前:image

調整後:
image

小結

如何選擇各分區大小應該依賴應用程序中對象生命週期的分佈情況:如果應用存在大量的短期對象,應該選擇較大的年輕代;如果存在相對較多的持久對象,老年代應該適當增大。

更多思考

關於上文中提到晉升年齡閾值爲2,很多同學有疑問,爲什麼設置了MaxTenuringThreshold=15,對象仍然僅經歷2次Minor GC,就晉升到老年代?這裏涉及到“動態年齡計算”的概念。

動態年齡計算:Hotspot遍歷所有對象時,按照年齡從小到大對其所佔用的大小進行累積,當累積的某個年齡大小超過了survivor區的一半時,取這個年齡和MaxTenuringThreshold中更小的一個值,作爲新的晉升年齡閾值。在本案例中,調優前:Survivor區 = 64M,desired survivor = 32M,此時Survivor區中age<=2的對象累計大小爲41M,41M大於32M,所以晉升年齡閾值被設置爲2,下次Minor GC時將年齡超過2的對象被晉升到老年代。

JVM引入動態年齡計算,主要基於如下兩點考慮:

1 如果固定按照MaxTenuringThreshold設定的閾值作爲晉升條件: a)MaxTenuringThreshold設置的過大,原本應該晉升的對象一直停留在Survivor區,直到Survivor區溢出,一旦溢出發生,Eden+Svuvivor中對象將不再依據年齡全部提升到老年代,這樣對象老化的機制就失效了。 b)MaxTenuringThreshold設置的過小,“過早晉升”即對象不能在新生代充分被回收,大量短期對象被晉升到老年代,老年代空間迅速增長,引起頻繁的Major GC。分代回收失去了意義,嚴重影響GC性能。

2 相同應用在不同時間的表現不同:特殊任務的執行或者流量成分的變化,都會導致對象的生命週期分佈發生波動,那麼固定的閾值設定,因爲無法動態適應變化,會造成和上面相同的問題。

總結來說,爲了更好的適應不同程序的內存情況,虛擬機並不總是要求對象年齡必須達到Maxtenuringthreshhold再晉級老年代。

六 案例二 請求高峯期發生GC,導致服務可用性下降

確定目標
GC日誌顯示,高峯期CMS在重標記(Remark)階段耗時1.39s。Remark階段是Stop-The-World(以下簡稱爲STW)的,即在執行垃圾回收時,Java應用程序中除了垃圾回收器線程之外其他所有線程都被掛起,意味着在此期間,用戶正常工作的線程全部被暫停下來,這是低延時服務不能接受的。本次優化目標是降低Remark時間。

image

優化

解決問題前,先回顧一下CMS的四個主要階段,以及各個階段的工作內容。下圖展示了CMS各個階段可以標記的對象,用不同顏色區分。

    1. Init-mark初始標記(STW) ,該階段進行可達性分析,標記GC ROOT能直接關聯到的對象,所以很快。
    1. Concurrent-mark併發標記,由前階段標記過的綠色對象出發,所有可到達的對象都在本階段中標記。
    1. Remark重標記(STW) ,暫停所有用戶線程,重新掃描堆中的對象,進行可達性分析,標記活着的對象。因爲併發標記階段是和用戶線程併發執行的過程,所以該過程中可能有用戶線程修改某些活躍對象的字段,指向了一個未標記過的對象,如下圖中紅色對象在併發標記開始時不可達,但是並行期間引用發生變化,變爲對象可達,這個階段需要重新標記出此類對象,防止在下一階段被清理掉,這個過程也是需要STW的。特別需要注意一點,這個階段是以新生代中對象爲根來判斷對象是否存活的。
    1. 併發清理,進行併發的垃圾清理。

image

可見,Remark階段主要是通過掃描堆來判斷對象是否存活。那麼準確判斷對象是否存活,需要掃描哪些對象?CMS對老年代做回收,Remark階段僅掃描老年代是否可行?

結論是不可行,原因如下:

image

如果僅掃描老年代中對象,即以老年代中對象爲根,判斷對象是否存在引用,上圖中,對象A因爲引用存在新生代中,它在Remark階段就不會被修正標記爲可達,GC時會被錯誤回收。 新生代對象持有老年代中對象的引用,這種情況稱爲“跨代引用”。因它的存在,Remark階段必須掃描整個堆來判斷對象是否存活,包括圖中灰色的不可達對象。

灰色對象已經不可達,但仍然需要掃描的原因:新生代GC和老年代的GC是各自分開獨立進行的,只有Minor GC時纔會使用根搜索算法,標記新生代對象是否可達,也就是說雖然一些對象已經不可達,但在Minor GC發生前不會被標記爲不可達,CMS也無法辨認哪些對象存活,只能全堆掃描(新生代+老年代)。由此可見堆中對象的數目影響了Remark階段耗時。 分析GC日誌可以得出同樣的規律,Remark耗時>500ms時,新生代使用率都在75%以上。這樣降低Remark階段耗時問題轉換成如何減少新生代對象數量。

新生代中對象的特點是“朝生夕滅”,這樣如果Remark前執行一次Minor GC,大部分對象就會被回收。CMS就採用了這樣的方式,在Remark前增加了一個可中斷的併發預清理(CMS-concurrent-abortable-preclean),該階段主要工作仍然是併發標記對象是否存活,只是這個過程可被中斷。此階段在Eden區使用超過2M時啓動,當然2M是默認的閾值,可以通過參數修改。如果此階段執行時等到了Minor GC,那麼上述灰色對象將被回收,Reamark階段需要掃描的對象就少了。

除此之外CMS爲了避免這個階段沒有等到Minor GC而陷入無限等待,提供了參數CMSMaxAbortablePrecleanTime ,默認爲5s,含義是如果可中斷的預清理執行超過5s,不管發沒發生Minor GC,都會中止此階段,進入Remark。 根據GC日誌紅色標記2處顯示,可中斷的併發預清理執行了5.35s,超過了設置的5s被中斷,期間沒有等到Minor GC ,所以Remark時新生代中仍然有很多對象。

對於這種情況,CMS提供CMSScavengeBeforeRemark參數,用來保證Remark前強制進行一次Minor GC。

優化結果

經過增加CMSScavengeBeforeRemark參數,單次執行時間>200ms的GC停頓消失,從監控上觀察,GCtime和業務波動保持一致,不再有明顯的毛刺。

image

小結

通過案例分析瞭解到,由於跨代引用的存在,CMS在Remark階段必須掃描整個堆,同時爲了避免掃描時新生代有很多對象,增加了可中斷的預清理階段用來等待Minor GC的發生。只是該階段有時間限制,如果超時等不到Minor GC,Remark時新生代仍然有很多對象,我們的調優策略是,通過參數強制Remark前進行一次Minor GC,從而降低Remark階段的時間。

更多思考

案例中只涉及老年代GC,其實新生代GC存在同樣的問題,即老年代可能持有新生代對象引用,所以Minor GC時也必須掃描老年代。

JVM是如何避免Minor GC時掃描全堆的? 經過統計信息顯示,老年代持有新生代對象引用的情況不足1%,根據這一特性JVM引入了卡表(card table)來實現這一目的。如下圖所示:

image

卡表的具體策略是將老年代的空間分成大小爲512B的若干張卡(card)。卡表本身是單字節數組,數組中的每個元素對應着一張卡,當發生老年代引用新生代時,虛擬機將該卡對應的卡表元素設置爲適當的值。如上圖所示,卡表3被標記爲髒(卡表還有另外的作用,標識併發標記階段哪些塊被修改過),之後Minor GC時通過掃描卡表就可以很快的識別哪些卡中存在老年代指向新生代的引用。這樣虛擬機通過空間換時間的方式,避免了全堆掃描。

總結來說,CMS的設計聚焦在獲取最短的時延,爲此它“不遺餘力”地做了很多工作,包括儘量讓應用程序和GC線程併發、增加可中斷的併發預清理階段、引入卡表等,雖然這些操作犧牲了一定吞吐量但獲得了更短的回收停頓時間。

七 案例三 發生Stop-The-World的GC

確定目標

GC日誌如下圖(在GC日誌中,Full GC是用來說明這次垃圾回收的停頓類型,代表STW類型的GC,並不特指老年代GC),根據GC日誌可知本次Full GC耗時1.23s。這個在線服務同樣要求低時延高可用。本次優化目標是降低單次STW回收停頓時間,提高可用性。

image

優化

首先,什麼時候可能會觸發STW的Full GC呢? 1. Perm空間不足; 2. CMS GC時出現promotion failed和concurrent mode failure(concurrent mode failure發生的原因一般是CMS正在進行,但是由於老年代空間不足,需要儘快回收老年代裏面的不再被使用的對象,這時停止所有的線程,同時終止CMS,直接進行Serial Old GC); 3. 統計得到的Young GC晉升到老年代的平均大小大於老年代的剩餘空間; 4. 主動觸發Full GC(執行jmap -histo:live [pid])來避免碎片問題。

然後,我們來逐一分析一下: - 排除原因2:如果是原因2中兩種情況,日誌中會有特殊標識,目前沒有。 - 排除原因3:根據GC日誌,當時老年代使用量僅爲20%,也不存在大於2G的大對象產生。 - 排除原因4:因爲當時沒有相關命令執行。 - 鎖定原因1:根據日誌發現Full GC後,Perm區變大了,推斷是由於永久代空間不足容量擴展導致的。

找到原因後解決方法有兩種: 1. 通過把-XX:PermSize參數和-XX:MaxPermSize設置成一樣,強制虛擬機在啓動的時候就把永久代的容量固定下來,避免運行時自動擴容。 2. CMS默認情況下不會回收Perm區,通過參數CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以讓CMS在Perm區容量不足時對其回收。

由於該服務沒有生成大量動態類,回收Perm區收益不大,所以我們採用方案1,啓動時將Perm區大小固定,避免進行動態擴容。

優化結果

調整參數後,服務不再有Perm區擴容導致的STW GC發生。

小結

對於性能要求很高的服務,建議將MaxPermSize和MinPermSize設置成一致(JDK8開始,Perm區完全消失,轉而使用元空間。而元空間是直接存在內存中,不在JVM中),Xms和Xmx也設置爲相同,這樣可以減少內存自動擴容和收縮帶來的性能損失。虛擬機啓動的時候就會把參數中所設定的內存全部化爲私有,即使擴容前有一部分內存不會被用戶代碼用到,這部分內存在虛擬機中被標識爲虛擬內存,也不會交給其他進程使用。

性能調優實戰一:網站流量瀏覽量暴增後,網站反應頁面響很慢

  • 1、問題推測:

在測試環境測速度比較快,但是一到生產就變慢,所以推測可能是因爲垃圾收集導致的業務線程停頓。

  • 2、定位:

爲了確認推測的正確性,在線上通過 jstat -gc 指令 看到 JVM 進行 GC 次數頻率非常高,GC 所佔用的時間非常長,所以基本推斷就是因爲 GC 頻率非常高,所以導致業務線程經常停頓,從而造成網頁反應很慢。

  • 3、解決方案:

因爲網頁訪問量很高,所以對象創建速度非常快,導致堆內存容易填滿從而頻繁 GC,所以這裏問題在於新生代內存太小,所以這裏可以增加 JVM 內存就行了,所以初步從原來的 2G 內存增加到 16G 內存。

  • 4、第二個問題:

增加內存後的確平常的請求比較快了,但是又出現了另外一個問題,就是不定期的會間斷性的卡頓,而且單次卡頓的時間要比之前要長很多。

  • 5、問題推測:

之前的優化加大了內存,所以推測可能是因爲內存加大了,從而導致單次 GC 的時間變長從而導致間接性的卡頓。

  • 6、定位

:還是通過 jstat -gc 指令 查看到 的確 FGC 次數並不是很高,但是花費在 FGC 上的時間是非常高的,根據 GC 日誌 查看到單次 FGC 的時間有達到幾十秒的。

  • 7、解決方案:

因爲 JVM 默認使用的是 PS+PO 的組合,PS+PO 垃圾標記和收集階段都是 STW,所以內存加大了之後,需要進行垃圾回收的時間就變長了,所以這裏要想避免單次 GC 時間過長,所以需要更換併發類的收集器,因爲當前的 JDK 版本爲 1.7,所以最後選擇 CMS 垃圾收集器,根據之前垃圾收集情況設置了一個預期的停頓的時間,上線後網站再也沒有了卡頓問題。

性能調優實戰二:後臺系統導出數據操作引發的 OOM

問題描述:公司的後臺系統,偶發性的引發 OOM 異常,堆內存溢出。

  • 1、偶發還是頻發。

因爲是偶發性的,所以第一次簡單的認爲就是堆內存不足導致,所以單方面的加大了堆內存從 4G 調整到 8G。

  • 2、查看堆內存

問題依然沒有解決,只能從堆內存信息下手,通過開啓了 -XX:+HeapDumpOnOutOfMemoryError 參數 獲得堆內存的 dump 文件。

  • 3、查看線程信息

VisualVM 對堆 dump 文件進行分析,通過 VisualVM 查看到佔用內存最大的對象是 String 對象,本來想跟蹤着 String 對象找到其引用的地方,但 dump 文件太大,跟蹤進去的時候總是卡死,而 String 對象佔用比較多也比較正常,最開始也沒有認定就是這裏的問題,於是就從線程信息裏面找突破點。

  • 4、發現異常信息

通過線程進行分析,先找到了幾個正在運行的業務線程,然後逐一跟進業務線程看了下代碼,發現有個引起我注意的方法,導出訂單信息。

  • 5、查看異常信息

因爲訂單信息導出這個方法可能會有幾萬的數據量,首先要從數據庫裏面查詢出來訂單信息,然後把訂單信息生成 excel,這個過程會產生大量的 String 對象。

  • 6、發現:按鈕可以一直點擊

爲了驗證自己的猜想,於是準備登錄後臺去測試下,結果在測試的過程中發現導出訂單的按鈕前端居然沒有做點擊後按鈕置灰交互事件,結果按鈕可以一直點,因爲導出訂單數據本來就非常慢,使用的人員可能發現點擊後很久後頁面都沒反應,結果就一直點,結果就大量的請求進入到後臺,堆內存產生了大量的訂單對象和 EXCEL 對象,而且方法執行非常慢,導致這一段時間內這些對象都無法被回收,所以最終導致內存溢出。

  • 7、點擊按鈕後置灰

最終沒有調整任何 JVM參數,只是在前端的導出訂單按鈕上加上了置灰狀態,等後端響應之後按鈕纔可以進行點擊,然後減少了查詢訂單信息的非必要字段來減少生成對象的體積,然後問題就解決了。

參考:美團技術團隊

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章