目錄導航
-
- 前言
- Garbage Collect(垃圾回收)
-
- 如何确定一個對象是垃圾?
-
- 引用計數法
- 可達性分析
- 垃圾收集算法
-
- 标記-清除(Mark-Sweep)
- 複制(Copying)
- 标記-整理(Mark-Compact)
- 分代收集算法
- 記憶體配置設定政策
- 垃圾收集器分類
- 垃圾收集器
-
- Serial收集器
- ParNew收集器
- Parallel Scavenge收集器
- Serial Old收集器
- Parallel Old收集器
- CMS收集器
- G1收集器
- GZC收集器
- 常見問題
-
- 吞吐量和停頓時間
- 如何選擇合适的垃圾收集器?
- 對于G1收集
- 如何開啟需要的垃圾收集器
- 記憶體調優
-
- 虛拟機工具
- 工具指令測試
- 寫在最後
前言
性能優化專題共計四個部分,分别是:
- Tomcat 性能優化
- MySql 性能優化
- JVM 性能優化
- 性能測試
本節是性能優化專題第三部分 —— JVM 性能優化篇,共計六個小節,分别是:
- JVM介紹與入門
- 類檔案講解
- 位元組碼執行引擎
- GC算法與調優
- Java記憶體模型與鎖優化
- Linux性能監控與調優
通過這六節的學習,你将學到:
➢ 了解JVM記憶體模型以及每個分區詳解。
➢ 熟悉運作時資料區,特别是堆記憶體結構和特點。
➢ 熟悉GC三種收集方法的原理和特點。
➢ 熟練使用GC調優工具,快速診斷線上問題。
➢ 生産環境CPU負載升高怎麼處理?
➢ 生産環境給應用配置設定多少線程合适?
➢ JVM位元組碼是什麼東西?
Garbage Collect(垃圾回收)
之前說堆記憶體中有垃圾回收,比如Young區的Minor GC,Old區的Major GC,Young區和Old區的Full GC。
但是對于一個對象而言,怎麼确定它是垃圾?是否需要被回收?怎樣對它進行回收?等等這些問題我們還需要詳細探索。
因為Java是自動做記憶體管理和垃圾回收的,如果不了解垃圾回收的各方面知識,一旦出現問題我們很難進行排查和解決,自動垃圾回收機制就是尋找Java堆中的對象,并對對象進行分類判别,尋找出正在使用的對象和已經不會使用的對象,然後把那些不會使用的對象從堆上清除 。
關于運作時資料區各個部門的垃圾回收問題
程式計數器、 虛拟機棧、 本地方法棧3個區域随線程而生,随線程而滅;棧中的棧幀随着方法的進入和退出而有條不紊地執行着出棧和入棧操作。 每一個棧幀中配置設定多少記憶體基本上是在類結構确定下來時就已知的(盡管在運作期會由JIT編譯器進行一些優化,但在本章基于概念模型的讨論中,大體上可以認為是編譯期可知的),是以這幾個區域的記憶體配置設定和回收都具備确定性,在這幾個區域内就不需要過多考慮回收的問題,因為方法結束或者線程結束時,記憶體自然就跟随着回收了。 而Java堆和方法區則不一樣,一個接口中的多個實作類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們隻有在程式處于運作期間時才能知道會建立哪些對象,這部分記憶體的配置設定和回收都是動态的,垃圾收集器所關注的是這部分記憶體。
回收方法區
很多人認為方法區(或者HotSpot虛拟機中的永久代)是沒有垃圾收集的,Java虛拟機規範中确實說過可以不要求虛拟機在方法區實作垃圾收集,而且在方法區進行垃圾收集的“成本效益”一般比較低:在堆中,尤其是在新生代中,正常應用進行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠低于此。
永久代的垃圾收集主要回收兩部分内容:廢棄常量和無用的類。回收廢棄常量與回收Java堆中的對象非常類似。以常量池中字面量的回收為例,假如一個字元串“abc”已經進入了常量池中,但是目前系統沒有任何一個String對象是叫做“abc”的,換句話說是沒有任何String對象引用常量池中的“abc”常量,也沒有其他地方引用了這個字面量,如果在這時候發生記憶體回收,而且必要的話,這個“abc”常量就會被系統“請”出常量池。常量池中的其他類(接口)、方法、字段的符号引用也與此類似。
如何确定一個對象是垃圾?
要想進行垃圾回收,得先知道什麼樣的對象是垃圾。
引用計數法
對于某個對象而言,隻要應用程式中持有該對象的引用,就說明該對象不是垃圾,如果一個對象沒有任何指針對其引用,它就是垃圾。
弊端 :如果AB互相持有引用,導緻永遠不能被回收。
是以,現在的GC通常不采用此方法,我們以jdk1.8為例,做一個測試:
public class RefCountGC {
public Object instance = null;
private byte[] bigSize = new byte[2 * 1034 * 1024];
public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();
obj1.instance = obj2;
obj2.instance = obj1;
obj1 = null;
obj2 = null;
System.gc();
}
}
通過兩個對象互相引用看是否被回收,我們知道,在引用計數法裡,互相引用的對象是不會被回收的。
此時我們将此類的VM參數加入:
-verbose:gc -XX:+PrintGCDetails
# java -verbose[:class|gc|jni] 在輸出裝置上顯示虛拟機運作資訊。開啟了GC日志輸出
運作結果:

由4282k變為512k,說明被回收,也說明了引用計數法是不常用的。
可達性分析
通過GC Root的對象,開始向下尋找,看某個對象是否可達
能作為GC Root:類加載器、Thread、虛拟機棧的本地變量表、static成員、常量引用、本地方法棧的變量等。
- 虛拟機棧(棧幀中的本地變量表)中引用的對象。
- 方法區中類靜态屬性引用的對象。
- 方法區中常量引用的對象。
- 本地方法棧中JNI(即一般說的Native方法)引用的對象。
垃圾收集算法
已經能夠确定一個對象為垃圾之後,接下來要考慮的就是回收,怎麼回收呢?得要有對應的算法,下面介紹常見的垃圾回收算法。
标記-清除(Mark-Sweep)
标記
找出記憶體中需要回收的對象,并且把它們标記出來
此時堆中所有的對象都會被掃描一遍,進而才能确定需要回收的對象,比較耗時
清除
清除掉被标記需要回收的對象,釋放出對應的記憶體空間
缺點:
标記清除之後會産生大量不連續的記憶體碎片,空間碎片太多可能會導緻以後在程式運作過程中需要配置設定較大對象時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。
(1)标記和清除兩個過程都比較耗時,效率不高
(2)會産生大量不連續的記憶體碎片,空間碎片太多可能會導緻以後在程式運作過程中需要配置設定較大對象時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。
複制(Copying)
将記憶體劃分為兩塊相等的區域,每次隻使用其中一塊,如下圖所示:
當其中一塊記憶體使用完了,就将還存活的對象複制到另外一塊上面,然後把已經使用過的記憶體空間一次清除掉。
缺點: 空間使用率降低。
标記-整理(Mark-Compact)
複制收集算法在對象存活率較高時就要進行較多的複制操作,效率将會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行配置設定擔保,以應對被使用的記憶體中所有對象都有100%存活的極端情況,是以老年代一般不能直接選用這種算法。
标記過程仍然與"标記-清除"算法一樣,但是後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的記憶體。
其實上述過程相對"複制算法"來講,少了一個"保留區“”讓所有存活的對象都向一端移動,清理掉邊界意外的記憶體。![]()
性能優化專題 - JVM 性能優化 - 04 - GC算法與調優
分代收集算法
既然上面介紹了3中垃圾收集算法,那麼在堆記憶體中到底用哪一個呢?
為了增加垃圾回收的效率,JVM會根據對象存活周期的不同将記憶體分為幾塊,堆中分為新生代和老年代。
這樣可以根據各個年代的特點采用最适當的收集算法。
在新生代中,每次垃圾收集時都發現有大批對象死去,隻有少量存活,那就選用複制算法,隻需要付出少量存活對象的複制成本就可以完成收集。
而老年代中因為對象存活率高、沒有額外空間對它進行配置設定擔保,就必須使用"标記-清除"或者"标記-整 理"算法來進行回收。
Young區:複制算法(對象在被配置設定之後,可能生命周期比較短,Young區複制效率比較高)
Old區:标記清除或标記整理(Old區對象存活時間比較長,複制來複制去沒必要,不如做個标記再清理)
記憶體配置設定政策
- 優先配置設定Eden區
public class EdenTest {
public static void main(String[] args) {
byte[] data = new byte[1 * 1024 * 1024];
}
}
我們使用VM參數開啟GC日志輸出:
-verbose:gc -XX:+PrintGCDetails
得出結果為:
堆記憶體配置設定中,新生代的Eden區占用80%空間,接下來我們改變代碼為:
public class EdenTest {
public static void main(String[] args) {
byte[] data = new byte[2 * 1024 * 1024];
}
}
得到結果為:
這裡我們看到Eden區由原來的80%變為99%,說明,JVM在堆中的記憶體配置設定,優先放入Eden區中。
我個人機器測試的環境為:windows10、8G記憶體。這裡配置設定的記憶體因人而異,看你的記憶體的大小而定,否則設定過大會出現直接配置設定到老年代的現象。
- 大對象直接配置設定到老年代
public class BigObjectIntoOldGen {
public static void main(String[] args) {
byte[] d1 = new byte[6 * 1024 * 1024];
}
}
-verbose:gc -XX:+PrintGCDetails -Xmx20M -Xms20M -Xmn10M -XX:PretenureSizeThreshold=6M
-Xmx:最大堆大小
-Xms:初始堆大小
-Xmn:年輕代大小
-XX:PretenureSizeThreshold:大于這個值的參數直接在老年代配置設定。
列印結果:
老年代占有60%,通過此參數的配置,對象的被直接配置設定到了老年代。
- 長期存活的對象配置設定老年代
-XX:MaxTenuringThreshold=15
每次GC還活着的對象,通過設定門檻值,強行通過指令配置設定到老年代。
- 空間配置設定擔保
-XX:+HandlePromotionFailure
檢查老年代最大可用的連續空間是否大于曆次晉升到老年代對象的平均大小。
我們測試一下:
public class SpaceGuarantee {
public static void main(String[] args) {
byte[] d1 = new byte[2 * 1024 * 1024];
byte[] d2 = new byte[2 * 1024 * 1024];
byte[] d3 = new byte[2 * 1024 * 1024];
byte[] d4 = new byte[4 * 1024 * 1024];
System.gc();
}
}
VM參數配置:
-verbose:gc -XX:+PrintGCDetails -Xmx20M -Xms20M -Xmn10M
列印結果:
新生代設定為10m,當記憶體配置設定到d4數組時,已經配置設定了6m給老生代,剩餘4m新生代進行配置設定。
-
動态對象年齡對象
如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代
-XX:TargetSurvivorRatio
垃圾收集器分類
- 串行收集器->Serial和Serial Old
隻能有一個垃圾回收線程執行,使用者線程暫停。适用于記憶體比較小的嵌入式裝置 。
- 并行收集器[吞吐量優先]->Parallel Scanvenge、Parallel Old
多條垃圾收集線程并行工作,但此時使用者線程仍然處于等待狀态。适用于科學計算、背景處理等若互動場景 。
- 并發收集器[停頓時間優先]->CMS、G1
使用者線程和垃圾收集線程同時執行(但并不一定是并行的,可能是交替執行的),垃圾收集線程在執行的時候不會停頓使用者線程的運作。 适用于相對時間有要求的場景,比如Web 。
垃圾收集器
HotSpot有哪些收集器呢?
下圖橙色部分代表新生代,綠色部分代表老生代
如果說收集算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實作。
Serial收集器
Serial收集器是最基本、發展曆史最悠久的收集器,曾經(在JDK1.3.1入之前)是虛拟機新生代收集的唯一選擇。
它是一種單線程收集器,不僅僅意味着它隻會使用一個CPU或者一條收集線程去完成垃圾收集工作,更重要的是其到
在進行垃圾收集的時候需要暫停其他線程。
優點:簡單高效,擁有很高的單線程收集效率
缺點:收集過程需要暫停所有線程
算法:複制算法 JVM
适用範圍:新生代
應用:Client模式下的預設新生代收集器
ParNew收集器
可以把這個收集器了解為Serial收集器的多線程版本。
優點:在多CPU時,比Serial效率高。
缺點:收集過程暫停所有應用程式線程,單CPU咕時比Serial效率差。
算法:複制算法
适用範圍:新生代
應用:運作在Server模式下的虛拟機中首選的新生代收集器
Parallel Scavenge收集器
Parallel Scavenge收集器是一個新生代收集器,它也是适用複制算法的收集器,又是并行的多線程收集器,看上去和ParNew一樣,但是Parallel Scanvenge更關注系統的吞吐量 。
吞吐量=運作使用者代碼的時間/(運作使用者代碼的時間+垃圾收集時間)
比如虛拟機總共運作了100分鐘,垃圾收集時間用了1分鐘,吞吐量=(100-1)/100=99%。
若吞吐量越大,意味着垃圾收集的時間越短,則使用者代碼可以充分利用CPU資源,盡快完成程式的運算任務。
-XX:MaxGCPauseMillis
# 控制最大的垃圾收集停頓時間
-XX:GCRatio
# 直接設定吞吐量的大小。
Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,也是一個單線程收集器,不同的是采用"标記-整理算法",運作過程和Serial收集器一樣。
Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多線程和"标記-整理算法"進行垃圾回收。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一種以擷取 最短回收停頓時間 為目标的收集器。
采用的是"标記-清除算法",整個過程分為5步:
(1)初始标記: CMS initial mark 标記GC Roots能關聯到的對象 Stop The World—>速度很快。
- 标記老年代中所有的GC Roots對象,如下圖節點1;
- 标記年輕代中活着的對象引用到的老年代的對象,如下圖節點2、3;
性能優化專題 - JVM 性能優化 - 04 - GC算法與調優
(2)并發标記 :CMS concurrent mark 進行GC Roots Tracing。
從“初始标記”階段标記的對象開始找出所有存活的對象;
(3)預清理階段。這個階段就是用來處理前一個階段因為引用關系改變導緻沒有标記到的存活對象的,它會掃描所有标記為Direty的Card 如下圖所示。
在并發标記階段,節點3的引用指向了6;則會把節點3的card标記為Dirty。
預清理:預清理,也是用于标記老年代存活的對象,目的是為了讓重新标記階段的STW盡可能短
(3)重新标記: CMS remark 修改并發标記因使用者程式變動的内容 Stop The World。
該階段的任務是完成标記整個年老代的所有的存活對象。
(4)并發清除 :CMS concurrent sweep。
這個階段主要是清除那些沒有标記的對象并且回收空間。
由于整個過程中,并發标記和并發清除,收集器線程可以與使用者線程一起工作,是以總體上來說,CMS收集
器的記憶體回收過程是與使用者線程一起并發地執行的。
優點:并發收集、低停頓 缺點:産生大量空間碎片、并發階段會降低吞吐量。
G1收集器
G1收集器在JDK 7正式作為商用的收集器。
與前幾個收集器相比,G1有以下特點
- 并行與并發
- 分代收集(仍然保留了分代的概念)
- 空間整合(整體上屬于“标記-整理”算法,不會導緻空間碎片)
- 可預測的停頓(比CMS更先進的地方在于能讓使用者明确指定一個長度為M毫秒的時間片段内,消耗在垃圾收集上的時間不得超過N毫秒)
使用G1收集器時,Java堆的記憶體布局與就與其他收集器有很大差别,它将整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是實體隔離的了,它們都是一部分Region(不需要連續)的集合。
工作過程可以分為如下幾步:
- 初始标記(Initial Marking) 标記以下GC Roots能夠關聯的對象,并且修改TAMS的值,需要暫停使用者線程。
- 并發标記(Concurrent Marking) 從GC Roots進行可達性分析,找出存活的對象,與使用者線程并發執行。
- 最終标記(Final Marking) 修正在并發标記階段因為使用者程式的并發執行導緻變動的資料,需暫停使用者線程。
- 篩選回收(Live Data Counting and Evacuation) 對各個Region的回收價值和成本進行排序,根據使用者所期望的。
G1的記憶體模型:
G1的分代模型:
G1的分區模型:
G1的收集集合:
GZC收集器
ZGC原理:
ZGC在指針上做标記,在通路指針時加入Load Barrier(讀屏障),比如當對象正被GC移動,指針上的顔色就會不對,這個屏障就會先把指針更新為有效位址再傳回,也就是,永遠隻有單個對象讀取時有機率被減速,而不存在為了保持應用與GC一緻而粗暴整體的Stop The World。
Colored Pointer 和 Load Barrier(并發執行的保證機制)
GZC的記憶體結構:
ZGC将堆劃分為Region作為清理,移動,以及并行GC線程工作配置設定的機關。分為有2MB,32MB,N× 2MB 三種Size Groups,動态地建立和銷毀Region,動态地決定Region的大小。
GZC的回收過程:
- Pause Mark Start :初始停頓标記
停頓JVM,标記Root對象,1、2、4 三個被标記為live
- Concurrent Mark :并發标記
并發地遞歸标記其他對象,5、8也被标記為live
- Relocate :移動對象
對比發現3、6、7是過期對象,也就是中間的兩個灰色region需要被壓縮清理,是以陸續将4、5、8 對象移動到最右邊的新Region。移動過程中,有個forward table記錄這種轉向
- Remap : 修正指針
最後将指針更新指向新位址
常見問題
吞吐量和停頓時間
停頓時間->垃圾收集器做垃圾回收終端應用執行響應的時間
吞吐量->運作使用者代碼時間/(運作使用者代碼時間+垃圾收集時間)
停頓時間越短就越适合需要和使用者互動的程式,良好的響應速度能提升使用者體驗;
高吞吐量則可以高效地利用CPU時間,盡快完成程式的運算任務,主要适合在背景運算而不需要太多互動的任務。
這兩個名額也是評價垃圾回收器好處的标準,其實調優也就是在觀察者兩個變量。
如何選擇合适的垃圾收集器?
- 優先調整堆的大小讓伺服器自己來選擇
- 如果記憶體小于100M,使用串行收集器
- 如果是單核,并且沒有停頓時間要求,使用串行或JVM自己選
- 如果允許停頓時間超過1秒,選擇并行或JVM自己選
- 如果響應時間最重要,并且不能超過1秒,使用并發收集器
對于G1收集
JDK 7開始使用,JDK 8非常成熟,JDK 9預設的垃圾收集器,适用于新老生代。
G1收集器的使用場景?
(1)50%以上的堆被存活對象占用
(2)對象配置設定和晉升的速度變化非常大
(3)垃圾回收時間比較長
如何開啟需要的垃圾收集器
(1)串行
-XX:+UseSerialGC
-XX:+UseSerialOldGC
(2)并行(吞吐量優先):
-XX:+UseParallelGC
-XX:+UseParallelOldGC
(3)并發收集器(響應時間優先)
-XX:+UseConcMarkSweepGC
-XX:+UseG1GC
記憶體調優
虛拟機工具
在調優之前,我們需要了解以下幾個常見的JVM分析工具:
- 指令集:
- jps: JavaVirtual Machine Process Status Tool。 jps是jdk提供的一個檢視目前java程序的小工具。非常簡單實用。
- jstat:Java Virtual Machine statistics monitoring tool。Jstat是JDK自帶的一個輕量級小工具。它位于java的bin目錄下,主要利用JVM内建的指令對Java應用程式的資源和性能進行實時的指令行的監控,包括了對Heap size和垃圾回收狀況的監控。
- jinfo:Java Configuration Info。實時調整和檢視虛拟機參數。
- jmap:Java Virtual Machine Memory Map。它可以生成 java 程式的 dump 檔案, 也可以檢視堆内對象示例的統計資訊、檢視 ClassLoader 的資訊以及 finalizer 隊列。
- jhat:Java Heap Analysis Tool。是用來分析java堆的指令,可可以将對中的對象以html的形式展示,包括對象的數量、大小等資訊,并支援對象查詢語言 (OQL)。
- jstack:Java Virtual Machine Stack Trace for Java。jstack是java虛拟機自帶的一種堆棧跟蹤工具。jstack用于列印出給定的java程序ID或core file或遠端調試服務的Java堆棧資訊。
-
JMX:Java Management Extensions。可以友善的管理正在運作中的Java程式。常用于管理線程,記憶體,日志Level,服務重新開機,系統環境等。
JConsole和JVisualVM中能夠監控到JAVA應用程式和JVM的相關資訊都是通過JMX實作的。
-
工具類:
EclipseMemoryAnalyzer
工具指令測試
-
jstack
首先我們上一段死鎖代碼,通過jstack分析堆資訊。
//運作主類
public class DeadLockDemo {
public static void main(String[] args) {
DeadLock d1=new DeadLock(true);
DeadLock d2=new DeadLock(false);
Thread t1=new Thread(d1);
Thread t2=new Thread(d2);
t1.start();
t2.start();
}
}
//定義鎖對象
class MyLock{
public static Object obj1= new Object();
public static Object obj2= new Object();
}
//死鎖代碼
class DeadLock implements Runnable{
private boolean flag;
DeadLock(boolean flag){
this.flag=flag;
}
public void run() {
if(flag) {
while(true) {
synchronized(MyLock.obj1) {
System.out.println(Thread.currentThread().getName()+"----if獲得obj1鎖");
synchronized(MyLock.obj2) {
System.out.println(Thread.currentThread().getName()+"----if獲得obj2鎖");
}
}
}
} else {
while(true){
synchronized(MyLock.obj2) {
System.out.println(Thread.currentThread().getName()+"----否則獲得obj2鎖");
synchronized(MyLock.obj1) {
System.out.println(Thread.currentThread().getName()+"----否則獲得obj1鎖");
}
}
}
}
}
}
運作此DEMO,然後通過jps檢視程序号:
輸出日志:
通過堆檔案也可以定位到問題所在:
- jstat
首先我們準備了一個關于Netty用戶端與服務端的demo,這裡略過内容,詳情代碼參考文末github位址連結。
這裡值得一提的是服務端的VM參數配置:
-Xmx1024m
-Xms1024m
-XX:+PrintGCDetails
-XX:+HeapDumpOnOutOfMemoryError
-Xloggc:e:\gc.log
#指定GC log的位置,以檔案輸出
-XX:HeapDumpPath=e:\server.dump
# Heap Dump 是 Java程序所使用的記憶體情況在某一時間的一次快照。這裡以檔案的形式持久化到磁盤中。
分别運作服務端與用戶端後,我們發現:
這裡我通過不斷地使用戶端給服務端發送消息,導緻記憶體溢出。我們依據自定義目錄生成的dump檔案進行分析:
打開mat軟體:選擇:
然後選擇Leak Suspects Report,檢視記憶體洩露分析的相關内容:
這裡就看到概覽和問題報告:
我們可以通過不同的視圖檢視堆記憶體引用以及對象引用的占用情況,進而進行一系列分析與調優。
這裡給出mat關于軟體使用的官方文檔說明:https://help.eclipse.org/2020-12/index.jsp?topic=/org.eclipse.mat.ui.help/welcome.html
寫在最後
本節代碼下載下傳位址為:https://github.com/harrypottry/jvmDemo
更多架構知識,歡迎關注本套系列文章:Java架構師成長之路