JVM調優(上)

原文鏈接:https://blog.51cto.com/zero01/2150115

JVM內存結構簡介(jdk1.8)

JVM層的GC調優是生產環境上必不可少的一個環節,因爲我們需要確定這個進程可以佔用多少內存,以及設定一些參數的閥值。以此來優化項目的性能和提高可用性,而且這也是在面試中經常會被問到的問題。

想要進行GC調優,我們首先需要簡單瞭解下JVM的內存結構,Java虛擬機的規範文檔如下:

https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

在介紹JVM內存結構之前,我們需要先知道運行時數據區這樣的一個東西,它與JVM的內存結構有着一定的關聯。不過它屬於是一個規範,所以與JVM內存結構是有着物理上的區別的。運行時數據區如下:
JVM層GC調優(上)

1.程序計數器(Program Count Register,簡稱PC Register):

  • JVM支持多線程同時執行,每一個線程都有自己的PC Register。當每一個新線程被創建時,它都將得到它自己的PC Register。線程正在執行的方法叫做當前方法。如果執行的是Java方法,那麼PC Register裏存放的就是當前正在執行的指令的地址,如果是native方法(C/C++編寫的方法),則是爲空。此內存區域是唯一一個在java虛擬機規範中沒有規定任何OutOfMemoryError情況的區域。

2.虛擬機棧(JVM Stacks):

  • Java虛擬機棧(Java Virtual Machine Stacks)是線程私有的,它的生命週期與線程相同。虛擬機描述的是Java方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀,用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程,實際上就是所謂的線程堆棧。
  • 局部變量表存放了各種基本類型、對象引用和returnAddress類型(指向了一條字節碼指令地址)。其中64位長度 long 和 double 佔兩個局部變量空間,其他只佔一個。
  • 該區域中規定的異常情況有兩種:1.線程請求的棧的深度大於虛擬機所允許的深度,將拋出StackOverflowError異常;2.如果虛擬機可以動態擴展,如果擴展時無法申請到足夠的內存,就拋出OutOfMemoryError異常。

3.堆Heap:

  • Java堆(Java Heap)是Java虛擬機所管理的內存中最大的一塊。堆是被所有線程共享的一塊內存區域,在虛擬機啓動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這裏分配內存。
  • Java堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可。堆中可細分爲新生代和老年代,再細分可分爲Eden空間、From Survivor空間、To Survivor空間。堆無法擴展時,會拋出OutOfMemoryError異常。

4.方法區(Method Area):

  • 方法區與Java堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。雖然Java虛擬機規範把方法區描述爲堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆),目的是與Java堆區分開來。
  • 當方法區無法滿足內存分配需求時,拋出OutOfMemoryError

5.運行時常量池(Run-Time Constant Pool):

  • 如上圖所描述的一樣,它是方法區的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項是常量池(Const Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後被放入方法區的運行時常量池中存儲。並非預置入Class文件中常量池的內容才進入方法運行時常量池,運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。
  • 同樣的,當方法區無法滿足內存分配需求時,也會拋出OutOfMemoryError

6.本地方法棧(Native Method Stacks):

  • 本地方法棧與虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧爲虛擬機執行Java方法(字節碼)服務,而本地方法棧則爲虛擬機使用到的Native方法服務。

瞭解了運行時方法區規範後,我們接下來看看JVM的內存結構圖:
JVM層GC調優(上)

如上圖,可以看到JVM內存被分爲了兩大區,非堆區用於存儲對象以外的數據:

  • Metaspace:存放Class、Package、Method、Field、字節碼、常量池、符號引用等等
    • CCS:這個區域存放32位指針的Class,也就是壓縮類空間,默認關閉,需要使用JVM參數開啓
    • CodeCache:存放JIT編譯後的本地代碼以及JNI使用的C/C++代碼

而堆區則用於存儲對象相關數據:

  • Young:新生代,存放新的或只經過幾次Minor GC的對象
    • Eden:存放最新創建的對象,一些較大的對象則會特殊處理
    • S0/S1:當對象經過第一次Minor GC後,如果仍然存活,就會存放到這裏。需要注意的是,S0和S1區域在同一時間上,只有其中一個是有數據的,而另一個則是空的。
  • Old:老年代,當S0或S1區域存滿對象了,就會把這些對象存放到這個old區域中

在圖中也可以看到,堆區還被分爲了年輕代(young)和老年代(old)。那麼爲什麼會有年輕代:

我們先來捋一捋,爲什麼需要把堆區分代?不分代不能完成它所做的事情麼?其實不分代也完全可以,分代的唯一理由就是優化GC性能。你先想想,如果沒有分代,那我們所有的對象都會存在同一個空間裏。當進行GC的時候,我們就要找到哪些對象是沒有用的,這樣一來就需要對整個堆區進行掃描。而我們的很多對象都是隻存活一瞬間的,所以GC就會比較頻繁,而每次GC都得掃描整個堆區,就會導致性能低下。不進行GC的話,又會導致內存空間很快被佔滿。

因爲GC性能的原因,所以我們才需要對堆區進行分代。如果進行分代的話,我們就可以把新創建的對象專門存放到一個單獨的區域中,當進行GC的時候就優先把這塊存放“短命”對象的區域進行回收,這樣就會騰出很大的空間出來,並且由於不用去掃描整個堆區,也能極大提高GC的性能。

年輕代中的GC:

從上圖中也可以看到年輕代被分爲了三部分:1個Eden區和2個Survivor區,一般我們都會簡稱爲S0、S1(同時它們還分爲from和to兩種角色),默認比例爲8:1。一般情況下,最新創建的對象都會被分配到Eden區(一些大對象會特殊處理),這些對象經過第一次Minor GC後,如果仍然存活,將會被移到Survivor區。對象在Survivor區中每熬過一次Minor GC,年齡就會增加1歲,當它的年齡增加到一定程度時,就會被移動到年老代中。

因爲年輕代中的對象基本都是"短命"的(80%以上),所以在年輕代的垃圾回收算法使用的是複製算法,複製算法的基本思想就是將內存分爲兩塊,每次只用其中一塊,當這一塊內存用完,就將還活着的對象複製到另外一塊上面。所以纔會有S0和S1區,複製算法的優點就是吞吐量高、可實現高速分配並且不會產生內存碎片,所以才適用於作爲年輕代的GC算法。

在GC開始的時候,對象只會存在於Eden區和名爲“From”的Survivor區,Survivor區“To”是空的。緊接着進行GC,Eden區中所有存活的對象都會被複制到“To”,而在“From”區中,仍存活的對象會根據他們的年齡值來決定去向。年齡達到一定值(年齡閾值,可以通過-XX:MaxTenuringThreshold來設置)的對象會被移動到年老代中,沒有達到閾值的對象會被複制到“To”區域。經過這次GC後,Eden區和From區已經被清空。這個時候,“From”和“To”會交換他們的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎樣,都會保證名爲To的Survivor區域是空的。Minor GC會一直重複這樣的過程,直到“To”區被填滿,“To”區被填滿之後,會將所有對象移動到年老代中。

JVM中的對象分配:

  • 對象優先在Eden區分配
  • 大對象則會直接進入老年代

我們瞭解完JVM內存結構後,再來看看一些常用的JVM參數:

1.設置年輕代的大小,和年輕代的最大值,具體的值需要根據實際業務場景進行判斷。如果存在大量臨時對象就可以設置大一些,否則小一些,一般爲整個堆大小的1/3或者1/4。爲了防止年輕代的堆收縮,兩個參數的值需設爲一樣大:

  • -XX:NewSize
  • -XX:MaxNewSize

2.設置Metaspace的大小,和Metaspace的最大值,同樣需設爲一樣大:

  • -XX:MetaspaceSize
  • -XX:MaxMetaspaceSize

3.設置Eden和其中一個Survivor的比例,這個值也比較重要:

  • -XX:SurvivorRatio

4.設置young和old區的比例:

  • -XX:NewRatio

5.這個參數用於顯示每次Minor GC時Survivor區中各個年齡段的對象的大小:

  • -XX:+PrintTenuringDistribution

6.用於設置晉升到老年代的對象年齡的最小值和最大值,每個對象在堅持過一次Minor GC之後,年齡就加1:

  • -XX:InitialTenuringThreshol
  • -XX:MaxTenuringThreshold

7.使用短直針,也就是啓用壓縮類空間(CCS):

  • -XX:+UseCompressedClassPointers

8.設置CCS空間的大小,默認是一個G:

  • -XX:CompressedClassSpaceSize

9.設置CodeCache的一個初始大小:

  • -XX:InitialCodeCacheSize

10.設置CodeCache的最大值:

  • -XX:ReservedCodeCacheSize

11.設置多大的對象會被直接放進老年代:

  • -XX:PretenureSizeThreshold

12.長期存活的對象會被放入Old區,使用以下參數設置就可以設置對象的最大存活年齡:

  • -XX:MaxTenuringThreshold

注:如果設置爲0的話,則年輕代對象不經過Survivor區,直接進入年老代。對於年老代比較多的應用,可以提高效率。如果將此值設置爲一個較大值,則年輕代對象會在Survivor區進行多次複製,這樣可以增加對象再年輕代的存活時間,增加在年輕代即被回收的概論,linux64的java6默認值是15:

13.設置Young區每發生GC的時候,就打印有效的對象的歲數情況:

  • -XX:+PrintTenuringDistribution

14.設置Survivor區發生GC後對象所存活的比例值:

  • -XX:TargetSurvivorRatio

常見垃圾回收算法

本小節我們來簡單介紹一些常見的垃圾回收算法,衆所周知Java區別與C/C++的一點就是,Java是可以自動進行垃圾回收的。所以在Java中的內存泄露概念和C/C++中的內存泄露概念不一樣。在Java中,一個對象的指針一直被應用程序所持有得不到釋放就屬於是內存泄露。而C/C++則是把對象指針給弄丟了,該對象就永遠無法得到釋放,這就是C/C++裏的內存泄露。

在進行垃圾回收的是時候,要如何確認一個對象是否是垃圾呢?在很久以前有一種方式就是使用引用計數,當一個對象指針被其他對象所引用時就會進行一個計數。在進行垃圾回收時,只要這個計數存在,那麼就會判斷該對象就是存活的。而沒有引用計數的對象,就會被判斷爲垃圾,可以進行回收。但是這種方法缺陷很明顯,計數會佔用資源不說,如果當一個A對象和一個B對象互相持有對方引用時,那麼這兩個對象的引用計數都不會爲0,就永遠不會被回收掉,這樣就會導致內存泄露的問題。

在Java中,則是採用枚舉根節點的方式:

  • 思想:枚舉根節點,做可達性分析
  • 根節點:可以是類加載器、Thread、虛擬機棧的本地變量表、static成員、常量引用、本地方法棧的變量等等

JVM層GC調優(上)

如上圖,JVM會從根節點開始遍歷引用,只要順着引用路線所遍歷到的對象都會判斷爲存活對象,即是具有可達性的,這些對象就不會被回收。而沒有被遍歷到的對象,也就是圖中的E和F對象,即便它們倆互相都還存在引用,也會被回收掉,因爲它們不存在根節點的引用路線中,即是不具有可達性的。


既然瞭解了JVM如何判斷一個對象是否爲垃圾後,我們就可以來看看一些垃圾回收算法了:

1.標記-清除:

  • 算法:該算法分爲“標記” 和 “清除” 兩個階段:首先標記出所有需要回收的對象,在標記完成後統一進行回收
  • 缺點:效率不高,標記和清除兩個過程的效率都不高。容易產生內存碎片,碎片太多就會導致提前GC。

2.複製算法:

  • 算法:它將可用內存按容量劃分爲大小相等的兩個塊,每次只使用其中的一塊。當這一塊內存用完了,就將還存活的對象複製到另一個塊上,然後再把已使用過的內存空間一次清理掉。
  • 優缺點:實現簡單,運行高效,吞吐量大,但是空間利用率低,一次只能利用50%

3.標記-整理:

  • 算法:標記過程仍然與 “標記-清除” 算法一樣,當後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉邊界以外的內存。
  • 優缺點:沒有了內存碎片,但是整理內存比較耗時

4.分代垃圾回收:

  • 算法:這就是目前JVM所使用的垃圾回收算法,可以看到以上所介紹到的算法都各自有優缺點。而JVM就是把這些算法都整合了起來,在不同的區域使用不同的垃圾回收算法。Young區使用複製算法,Old區則使用標記清除或者標記整理算法。

垃圾收集器

在上一小節瞭解了一些常見的垃圾回收算法後,我們再來看看JVM中常見的垃圾收集器:

  • 1.串行收集器Serial:Serial、Serial Old
  • 2.並行收集器Parallel:Parallel Scavenge、Parallel Old,吞吐量優先,是Server模式下的默認收集器。默認在內存大於2G,CPU核心數大於2核的環境下爲Server模式
  • 3.併發收集器Concurrent:CMS、G1,停頓時間優先

注:串行收集器幾乎不會在web應用中使用,所以主要介紹並行和併發收集器

串行 VS 並行 VS 併發:

  • 串行(Serial):指只有單個垃圾收集線程進行工作,也就是單線程的,當垃圾收集線程啓動的時候,用戶線程會處於一個等待狀態。適合內存較小的嵌入式開發中
  • 並行(Parallel):指多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態。適合科學計算、後臺處理等弱交互場景
  • 併發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不一定是並行的,可能會交替執行),垃圾收集線程在執行的時候不會停頓用戶程序的運行。適合對響應時間有要求、交互性強的場景,比如Web開發

停頓時間 VS 吞吐量:

  • 停頓時間:指垃圾收集器在進行垃圾回收時所中斷應用執行的時間。可以使用以下參數進行設置:
    • -XX:MaxGCPauseMillis
  • 吞吐量:指花在垃圾收集的時間和花在應用時間的佔比。可以使用以下參數進行設置:
    • -XX:GCTimeRatio=< n > 垃圾收集時間佔:1/1+n

開啓串行收集器:

  • -XX:+UseSerialGC(Young區)
  • -XX:+UseSerialOldGC(Old區)

開啓並行收集器:

  • -XX:+UseParallelGC(Young區)
  • -XX:+UseParallelOldGC(Old區)
  • -XX:ParallelGCThread=< N > 設置N個GC線程,N取決於CPU核心數

併發收集器在JDK1.8裏有兩個,一個是CMS,CMS因爲具有響應時間優先的特點,所以是低延遲、低停頓的,CMS是老年代收集器。開啓該收集器的參數如下:

  • -XX:+UseParNewGC(Young區)
  • -XX:+UseConcMarkSweepGC(Old區)

另一個是G1,開啓該收集器的參數如下:

  • -XX:+UseG1GC(Young區、Old區)

垃圾收集器搭配圖:
JVM層GC調優(上)

注:實線代表可搭配使用的,虛線表示當內存分配失敗的時候CMS會退化成SerialOld。JDK1.8中建議使用的是G1收集器

有這麼多的垃圾收集器,那麼我們要如何去選擇合適的垃圾收集器呢?這個是沒有具體答案的,都得按照實際的場景進行選擇,但一般都會按照以下原則來進行選擇:

  • 優先調整堆的大小讓服務器自己來選擇
  • 如果內存小於100M,使用串行收集器
  • 如何是單核,並且沒有停頓時間的要求,就可以使用串行或由JVM自己選擇
  • 如果允許停頓時間超過1秒,選擇並行或者JVM自己選擇
  • 如果響應時間最重要,並且不能超過1秒,則使用併發收集器

其中並行收集器是支持自適應的,通過設置以下幾個參數,並行收集器會以停頓時間優先去動態調整參數:

  • -XX:MaxGCPauseMillis=< N >
  • -XX:GCTimeRatio=< N >
  • -Xmx< N >

當內存不夠的時候並行收集器可以動態調整內存,雖然實際生產環境中用的比較少,至於每次動態調整多少內存,則使用以下參數進行設置:

  • -XX:YoungGenerationSizeIncrement=< Y > (增加,Young區,默認20%)
  • -XX:TenuredGenerationSizeIncrement=< T > (增加,Old區,默認20%)
  • -XX:AdaptiveSizeDecrementScaleFactor=< D >(減少,默認4%)

瞭解了並行收集器後,我們來簡單看看CMS收集器其他的一些特性以及相關調優參數。

CMS垃圾收集過程:

  • 1.CMS initial mark:初識標記Root,STW
  • 2.CMS concurrent mark:併發標記
  • 3.CMS-concurrent-preclean:併發預清理
  • 4.CMS remark:重新標記,STW
  • 5.CMS concurrent sweep:併發清除
  • 6.CMS concurrent-reset:併發重置

CMS的缺點:

  • CPU敏感
  • 會產生浮動垃圾
  • 會產生空間碎片

CMS的相關調優參數:

設置併發的GC線程數:

  • -XX:ConcGCThreads

開啓以下參數可以在Full GC之後對內存進行一個壓縮,以此減少空間碎片:

  • -XX:+UseCMSCompactAtFullCollection

這個參數則是設置多少次Full GC之後才進行壓縮:

  • -XX:CMSFullGCsBeforeCompaction

設置Old區存滿多少對象的時候觸發Full GC,默認值爲92%:

  • -XX:CMSInitiatingOccupancyFraction

啓用該參數表示不可動態調整以上參數的值:

  • -XX:+UseCMSInitiatingOccupancyOnly

啓用該參數表示在Full GC之前先做Young GC:

  • -XX:+CMSScavengeBeforeRemark

在jdk1.7之前可以使用以下參數,啓用回收Perm區:

  • -XX:+CMSClassUnloadingEnable

在jdk1.8後,推薦使用的垃圾收集器是G1。G1收集器在jdk1.7中第一次出現,所以到了jdk1.8裏就非常成熟了。

G1收集器官網介紹如下:

The Garbage-First (G1) garbage collector is fully supported in Oracle JDK 7 update 4 and later releases. The G1 collector is a server-style garbage collector, targeted for multi-processor machines with large memories. It meets garbage collection (GC) pause time goals with high probability, while achieving high throughput. Whole-heap operations, such as global marking, are performed concurrently with the application threads. This prevents interruptions proportional to heap or live-data size.

The first focus of G1 is to provide a solution for users running applications that require large heaps with limited GC latency. This means heap sizes of around 6GB or larger, and stable and predictable pause time below 0.5 seconds.

官方文檔地址:

http://www.oracle.com/technetwork/java/javase/tech/g1-intro-jsp-135488.html

原理概述:

G1 也是屬於分代收集器的,但是G1的分代是邏輯上的,而不是物理上的

G1 將整個對區域劃分爲若干個Region,每個Region的大小是2的倍數(1M,2M,4M,8M,16M,32M,通過設置堆的大小和Region數量計算得出。

Region區域劃分與其他收集類似,不同的是單獨將大對象分配到了單獨的region中,會分配一組連續的Region區域(Humongous start 和 humonous Contoinue 組成),所以一共有四類Region(Eden,Survior,Humongous和Old),G1 作用於整個堆內存區域,設計的目的就是減少Full GC的產生。在Full GC過程中由於G1 是單線程進行,會產生較長時間的停頓。

G1的OldGc標記過程可以和yongGc並行執行,但是OldGc一定在YongGc之後執行,即MixedGc在yongGC之後執行。

結構圖:
JVM層GC調優(上)

G1垃圾收集算法主要應用在多CPU大內存的服務中,在滿足高吞吐量的同時,儘可能的滿足垃圾回收時的暫停時間,該設計主要針對如下應用場景:

  • 垃圾收集線程和應用線程併發執行,和CMS一樣
  • 空閒內存壓縮時避免冗長的暫停時間
  • 應用需要更多可預測的GC暫停時間
  • 不希望犧牲太多的吞吐性能

G1的幾個概念:

  • Region:G1收集器所劃分的內存區域
  • SATB:Snapshot-At-TheBeginning,它是通過Root Tracing得到的,GC開始時候存活對象的快照
  • RSet:記錄了其他Region中的對象,引用本Region中對象的關係,屬於points-into結構(誰引用了我的對象)

G1中的Young GC過程,和以往的是一樣的:

  • 新對象進入Eden區
  • 存活對象拷貝到Survivor區
  • 存活時間達到年齡閾值時,對象晉升到Old區

但是G1中沒有Full GC,取而代之的是Mixed GC:

  • 它不是Full GC,所以觸發Mixed GC時回收的是所有的Young區和部分Old區的垃圾

G1裏還有一個概念叫全局併發標記(global concurrent marking),和CMS的併發標記是類似的:

  • 1.Initial marking phase:標記GC Root,STW
  • 2.Root region scanning phase:根區掃描
  • 3.Concurrent marking phase:併發標記存活對象
  • 4.Remark phase:重新標記,STW
  • Cleanup phase:部分STW

G1相關調優參數:

設置堆佔有率達到這個參數值則觸發global concurrent marking,默認值爲45%:

  • -XX:InitiatingHeapOccupancyPercent

設置在global concurrent marking結束之後,可以知道Region裏有多少空間要被回收,在每次YGC之後和再次發生Mixed GC之前,會檢查垃圾佔比是否達到此參數的值,只有達到了,下次纔會發生Mixed GC:

  • -XX:G1HeapWastePercent

設置Old區的Region被回收時的存活對象佔比:

  • -XX:G1MixedGCLiveThresholdPercent

設置一次global concurrent marking之後,最多執行Mixed GC的次數:

  • -XX:G1MixedGCCountTarget

設置一次Mixed GC中能被選入CSet的最多Old區的Region數量:

  • -XX:G1OldCSetRegionThresholdPercent

其他參數:

  • -XX:+UseG1GC //開啓G1收集器
  • -XX:G1HeapRegionSize=n //設置Region的大小,大小範圍:1-32M,數量上限:2048個
  • -XX:MaxGCPauseMillis=200 //設置最大停頓時間
  • -XX:G1NewSizePercent //設置Young區大小
  • -XX:G1MaxNewSizePercent //設置Young區最大佔整個Java Heap的大小,默認值爲60%
  • -XX:G1ReservePercent=10 //保留防止to space溢出
  • -XX:ParallelGCThreads=n //設置SWT線程數
  • -XX:ConcGCThreads=n //併發線程數=1/4*並行

注意事項:

  • 年輕代大小:避免使用-Xmn、-XX:NewRatio等顯式設置Young區大小,會覆蓋暫停時間目標
  • 暫停時間目標:暫停時間不要太嚴苛,其吞吐量目標是90%的應用程序時間和10%的垃圾回收時間,太嚴苛會直接影響到吞吐量

至於是否需要切換到G1收集器,可以根據以下原則進行選擇:

  • 50%以上的堆被存活對象佔用
  • 對象分配和晉升的速度變化非常大
  • 垃圾回收時間特別長,超過了1秒

關於在Web應用中,如何判斷一個垃圾收集器的好壞,主要是看以下兩點,以下兩點都需爲優纔是好的垃圾收集器:

  • 1.響應時間
  • 2.吞吐量

下一篇:

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