深入淺出JVM垃圾回收器
引言
程序的運(yùn)行必然需要申請(qǐng)內(nèi)存資源,使用結(jié)束后的內(nèi)存資源如果不及時(shí)釋放就會(huì)造成內(nèi)存中的垃圾越來越多,最終造成內(nèi)存溢出,而垃圾回收就是把無用的內(nèi)存垃圾清理掉,這樣內(nèi)存就可以被程序反復(fù)使用。
垃圾回收(Garbage Collection 簡(jiǎn)稱GC)是Java體系最重要的組成部分之一,和C/C++的手工內(nèi)存管理方式不同,JVM虛擬機(jī)提供了一套全自動(dòng)的內(nèi)存管理方案,以減少開發(fā)人員在內(nèi)存管理方面的相關(guān)工作。
(一) 常見的垃圾回收算法和垃圾回收器
1. 常見的垃圾回收算法
a) 標(biāo)記清除算法(Mark-Sweep)
最早出現(xiàn)也是最基礎(chǔ)的垃圾回收算法,算法整體分為兩個(gè)階段“標(biāo)記”和“清除”,首先標(biāo)記出所有需要回收的對(duì)象,在標(biāo)記完成后,統(tǒng)一回收掉所有被標(biāo)記的對(duì)象,該算法簡(jiǎn)單快速,但是缺點(diǎn)明顯:一是標(biāo)記和清除兩個(gè)過程的效率都不高。二是清除之后會(huì)產(chǎn)生大量不連續(xù)的內(nèi)存碎片。內(nèi)存碎片過多可能導(dǎo)致以后在程序運(yùn)行過程中需要分配較大對(duì)象時(shí),無法找到足夠的連續(xù)內(nèi)存而提前觸發(fā)另一次垃圾回收動(dòng)作。圖1中展示了標(biāo)記-清楚算法的過程。
圖 1
b) 標(biāo)記復(fù)制算法(Copying)
為了解決標(biāo)記清除算法的大對(duì)象回收效率和內(nèi)存碎片化問題。提出了另一種“半?yún)^(qū)復(fù)制”的算法,核心思想就是將原有的內(nèi)存空間分為兩塊,每次只使用一半?yún)^(qū)域。垃圾回收時(shí)將使用的對(duì)象復(fù)制到未使用的半?yún)^(qū)中,之后清除當(dāng)前使用半?yún)^(qū)的所有對(duì)象,最后交換兩個(gè)內(nèi)存角色,完成回收工作。雖然解決了內(nèi)存碎片化的問題,但是如果活動(dòng)對(duì)象較多,就會(huì)導(dǎo)致復(fù)制的對(duì)象過多,復(fù)制的成本很高且僅能使用一半的內(nèi)存,因此單純的復(fù)制算法也有很多問題。圖2展示了復(fù)制算法的過程。
圖 2
c) 標(biāo)記壓縮算法(Mark-Compact)
標(biāo)記壓縮算法的標(biāo)記過程與標(biāo)記清除算法一樣,但是后續(xù)步驟不是直接對(duì)可回收對(duì)象進(jìn)行清理,而是讓所有存活的對(duì)象都向空閑內(nèi)存的一端移動(dòng),然后直接清理掉邊界以外的所有內(nèi)存,這種方法避免了內(nèi)存碎片的產(chǎn)生,又不需要兩塊相同的內(nèi)存空間,標(biāo)記壓縮算法的最終效果等于標(biāo)記清除執(zhí)行后再進(jìn)行一次內(nèi)存碎片整理。圖3展示了標(biāo)記壓縮算法的過程。
圖3
2.分代回收理論(GenerationalCollecting)
分代回收是一種回收思想,目前被虛擬機(jī)廣泛使用,在前面介紹的算法中,沒有一種算法可以完全替代其他算法,分代收集就是基于這種思想,將內(nèi)存根據(jù)對(duì)象的特點(diǎn)分成幾塊,根據(jù)每塊內(nèi)存的特點(diǎn)使用不同的回收算法,提高內(nèi)存的回收效率。主流的JVM虛擬機(jī)里面一般會(huì)把JAVA堆內(nèi)存劃分為年輕代(Young Generation)和老年代(Old Generation)兩個(gè)區(qū)域,每次垃圾收集時(shí)會(huì)有大批的對(duì)象被回收,少量存活的對(duì)象將逐步轉(zhuǎn)移到老年代存放。圖4展示了主流JVM虛擬機(jī)的內(nèi)存的分代情況。
圖 4
3. 常見的垃圾回收器
再說垃圾回收器之前,需要再說一下為什么需要不斷優(yōu)化垃圾回收器,一切都源于一個(gè)詞語“Stop The World”簡(jiǎn)稱STW,JVM虛擬機(jī)會(huì)自動(dòng)發(fā)起和自動(dòng)完成回收垃圾的工作,用戶在不可干預(yù)的情況下,需要暫停所有正常工作線程來等待垃圾回收的完成。試想下每工作幾小時(shí)就需要暫停幾分鐘,這樣的程序是無法讓人接受的。
垃圾回收的算法為垃圾回收器提供了理論基礎(chǔ),垃圾回收器就是這些理論算法的具體實(shí)現(xiàn)。圖5展示了七種不同的垃圾回收器,如果兩回收器之前存在連線,就說明可以搭配使用。
圖5
a) 串行回收器(Serial + Serial Old)
最古老的垃圾回收器,也是最基本的垃圾回收器之一,是一個(gè)單線程的垃圾回收器,在年輕代工作時(shí)使用的是標(biāo)記復(fù)制算法,在老年代工作時(shí)使用的是標(biāo)記壓縮算法。在CPU性能受限的情況下,它的性能表現(xiàn)依然很優(yōu)秀。圖6展示了串行垃圾回收器的回收過程。
圖6
b) 并發(fā)回收器(ParNew和CMS)
ParNew回收器是一款只能工作在年輕代的并行收集器 ,它是Serial收集器的多線程版本,由于使用多線程進(jìn)行垃圾回收,在計(jì)算能力較強(qiáng)的CPU上,產(chǎn)生的停頓時(shí)間要小于串行回收器。圖7展示了ParNew并行的回收的過程。
圖7
CMS(Concurrent Mark Sweep)是一款只能工作在老年代的收集器,第一款設(shè)計(jì)較為的復(fù)雜的收集器,也是JVM虛擬機(jī)追求低停頓的第一次嘗試,但是也有明顯的缺點(diǎn),圖8展示了CMS收集器的回收過程。總的來說有三點(diǎn):首先CMS收集器對(duì)CPU性能比較敏感,如果CPU性能不足或者本身的負(fù)載就很高,那這會(huì)讓整個(gè)垃圾回收的過程變長(zhǎng)。其次,在并發(fā)標(biāo)記和并發(fā)清除的階段,用戶線程會(huì)有新的垃圾產(chǎn)生,就會(huì)產(chǎn)生“浮動(dòng)垃圾(Floating Garbage)”,所以就不能像其他回收器那樣等到老年代100%再進(jìn)行回收,需要預(yù)留一部分內(nèi)存提供給用戶線程使用。最后,CMS是一個(gè)基于標(biāo)記清除算法實(shí)現(xiàn)的回收器,這就會(huì)產(chǎn)生大量的內(nèi)存碎片,如果有大對(duì)象需要處理,碎片過多時(shí)就需要對(duì)Old區(qū)再進(jìn)行一次垃圾回收進(jìn)行內(nèi)存整理。ParNew和CMS垃圾回收器一般搭配來進(jìn)行使用,不過這兩個(gè)收集器已經(jīng)在JDK9中被標(biāo)記為廢棄,JDK14該回收器將被正式刪除。
圖8
c) 并行回收(ParallelGC+ParallelOldGC)
ParallelGC和ParallelOldGC是JDK8中默認(rèn)使用的兩個(gè)回收器分別用在年輕代和老年代, 并且他們都是多線程回收器。ParallelGC采用的是復(fù)制算法進(jìn)行垃圾回收,它和ParNew不同的是可以控制系統(tǒng)的吞吐量和最大停頓時(shí)間,并且增加了自調(diào)優(yōu)的功能,相當(dāng)于ParNew的升級(jí)版本。ParallelOldGC使用的是標(biāo)記壓縮算法,這個(gè)回收器在JDK6時(shí)開始提供使用。圖9展示了ParallelGC和ParallelOldGC的回收過程。
圖9
d)分區(qū)回收器(Garbage First)
隨著大數(shù)據(jù)時(shí)代的來臨,JVM虛擬機(jī)的內(nèi)存也越來越大,在相同條件下,內(nèi)存空間越大,一次GC所需的時(shí)間就越長(zhǎng),產(chǎn)生的停頓就越長(zhǎng)。為了更好的控制GC產(chǎn)生的STW時(shí)間。Garbage First回收器(簡(jiǎn)稱G1)出現(xiàn)了,JDK6時(shí)開始推出試驗(yàn)版本,JDK7 Update4中逐漸的成熟起來,終于在JDK8 Update40以后G1提供并發(fā)的類卸載功能成為了可以替代CMS的回收器,JDK9版本中G1被設(shè)置成默認(rèn)的垃圾回收器。G1回收器引入了分區(qū)(Region)的概念,將整個(gè)內(nèi)存空間分為不同大小的小分區(qū),每個(gè)小分區(qū)單獨(dú)使用,獨(dú)立回收。不過G1也還是遵循了分代回收的理論,還是會(huì)區(qū)分年輕代和老年代的概念,從整體看G1是基于標(biāo)記壓縮算法實(shí)現(xiàn)的,但是從局部看每個(gè)分區(qū)之間又是基于標(biāo)記復(fù)制算法實(shí)現(xiàn)的。
圖10
(二) 垃圾回收器內(nèi)存分配詳解
1. 分代垃圾回收器
分代的垃圾回收器是如何進(jìn)行內(nèi)存分配和管理的呢?我們?cè)賮砘仡櫹路执枷搿H鐖D11所示,整個(gè)的JVM空間被分成2個(gè)區(qū)域年輕代(Young Generation)和老年代(Old Generation),而Young區(qū)又被分成了伊甸園區(qū)(Eden,統(tǒng)簡(jiǎn)稱Eden)和生存區(qū)(Survivor),而Survivor又被分為From(Survivor0,統(tǒng)簡(jiǎn)稱“S0”)和To(Survivor1,統(tǒng)簡(jiǎn)稱“S1”)兩個(gè)區(qū)域。年輕代和老年代比例為1:2(默認(rèn)參數(shù)),在年輕代中內(nèi)存中又被分成了三份(默認(rèn)為8:1:1)。
G行已經(jīng)開始逐步開始從JDK6向JDK8進(jìn)行替換,關(guān)于這部分內(nèi)容主要針對(duì)JDK8版本進(jìn)行說明。
圖 11
幾乎所有新生成的對(duì)象首先都是放在年輕代,大部分對(duì)象在 Eden 區(qū)中生成,當(dāng)Eden區(qū)內(nèi)存空間不足時(shí),則會(huì)發(fā)起一次GC,回收器會(huì)將Eden區(qū)存活對(duì)象復(fù)制到S0,然后清空Eden區(qū)。如圖12展示的過程。
圖 12
下一次Eden區(qū)空間不足時(shí),會(huì)將Eden區(qū)和S0區(qū)的存活對(duì)象復(fù)制到S1區(qū),然后清空Eden區(qū)和S0區(qū)。如圖13展示的過程。
圖 13
這時(shí)候會(huì)又出一個(gè)問題,對(duì)象什么時(shí)候去老年代呢?對(duì)象每次在S0和S1之間復(fù)制一次,這個(gè)對(duì)象的年齡就長(zhǎng)一歲,當(dāng)15歲(默認(rèn)為15歲,可通過參數(shù)調(diào)整)之后這個(gè)對(duì)象就會(huì)被復(fù)制到老年代去。如圖14展示的過程。
圖 14
如此這樣循環(huán)往復(fù),當(dāng)老年代也空間不足時(shí),回收器就會(huì)用對(duì)老年代進(jìn)行回收來釋放內(nèi)存空間,也就是通常說的Full GC。
2. 分區(qū)垃圾回收器
傳統(tǒng)的GC收集器將連續(xù)的內(nèi)存空間劃分為新生代、老年代和永久代(JDK 8去除了永久代,引入了元空間Metaspace)。如下圖15所示,不過現(xiàn)在請(qǐng)大家忘記它吧。
圖 15
G1的內(nèi)存存儲(chǔ)地址是不連續(xù)的,G1 將連續(xù)的Java堆劃分為多個(gè)大小相等的獨(dú)立區(qū)域(Region),每一個(gè)Region都可以根據(jù)需要,扮演新生代的Eden空間、Survivor空間,或者老年代Old空間,每個(gè)Region的大小可以取值范圍為1MB~32MB,且應(yīng)為2的N次冪,并且新增一個(gè)區(qū)域叫巨大對(duì)象(humongous object,H-obj),只要大小超過了一個(gè)Region容量一半即可判定為大對(duì)象,直接放入大對(duì)象區(qū)。對(duì)于那些超過了整個(gè)Region容量的超級(jí)大對(duì)象,將會(huì)被存放在N個(gè)連續(xù)的Humongous Region之中。如下圖16所示G1內(nèi)存的分配情況。
圖 16
在分配一般對(duì)象時(shí),當(dāng)所有Eden Region使用達(dá)到最大閾值并且無法申請(qǐng)足夠內(nèi)存時(shí),會(huì)觸發(fā)一次年輕代Region的GC。每次GC會(huì)回收所有Eden以及Survivor,并且將存活對(duì)象復(fù)制到空白的Survivor區(qū)。如下圖17所示。
圖 17
那內(nèi)存什么時(shí)候進(jìn)入老年代的Region呢?在G1回收器中有兩種情況會(huì)進(jìn)入到老年代Region:
同分代回收的規(guī)則,內(nèi)存每在年輕代的Region被復(fù)制一次,年齡就長(zhǎng)一歲,當(dāng)15歲(默認(rèn)為15歲,可通過參數(shù)調(diào)整)之后這個(gè)對(duì)象就會(huì)被復(fù)制到老年代的Region。
動(dòng)態(tài)年齡判斷規(guī)則,某次年輕代GC 過后,發(fā)現(xiàn) Survivor 區(qū)中相同年齡的對(duì)象達(dá)到了 Survivor 的 50%,那么該年齡及以上的對(duì)象,會(huì)被直接移動(dòng)到老年代中。例如Survivor 區(qū)中存在年齡分別為 1、2、3、4 的對(duì)象,而年齡為 3 的對(duì)象超過了 Survivor 區(qū)的 50%,那么年齡大于等于 3 的對(duì)象,就會(huì)被全部移動(dòng)到老年代的Region。
最后再談下分區(qū)回收獨(dú)有的混合回收(Mixed GC),在G1中不存在單獨(dú)回收老年代Region的行為,而是當(dāng)要發(fā)生老年代的回收時(shí),同時(shí)也會(huì)對(duì)新生代以及大對(duì)象進(jìn)行回收,因此這個(gè)階段稱之為混合回收。當(dāng)老年代Region的使用率占比達(dá)到 45%時(shí),就會(huì)觸發(fā)混合回收。
不過在G1中Full GC還是存在的,如果空閑的 Region 大小無法放得下存活對(duì)象的內(nèi)存大小時(shí)系統(tǒng)就不得不暫停應(yīng)用程序,進(jìn)行一次 Full GC。進(jìn)行 Full GC 時(shí)采用的是單線程進(jìn)行標(biāo)記、清理和整理內(nèi)存,這個(gè)過程是非常漫長(zhǎng)的,因此應(yīng)該盡量避免 Full GC 的觸發(fā)。
(三) 垃圾回收器的優(yōu)化思路
垃圾回收器的優(yōu)化思路
垃圾回收器的選擇是JVM優(yōu)化的一個(gè)重要配置,選擇合適的垃圾回收器可以讓JVM性能有一個(gè)很大的提升。其實(shí)JVM調(diào)優(yōu)主要是調(diào)整兩個(gè)指標(biāo):
JVM虛擬機(jī)停頓時(shí)間(Stop The World)
吞吐量是指CPU用于運(yùn)行用戶代碼的時(shí)間與CPU總消耗時(shí)間的比值,即吞吐量 = 運(yùn)行用戶代碼時(shí)間 /( 運(yùn)行用戶代碼時(shí)間 + 垃圾收集時(shí)間 )
下面分享下關(guān)于回收器選擇上的一些經(jīng)驗(yàn)。
1. 小內(nèi)存,默認(rèn)優(yōu)先:
大部分應(yīng)用JVM堆內(nèi)存都在4G以內(nèi),優(yōu)先使用JDK8默認(rèn)的垃圾回收器。如今大部分系統(tǒng)都運(yùn)行在虛擬機(jī)上,G1固然是更先進(jìn)的垃圾回收器,但是G1在垃圾回收時(shí)產(chǎn)生的內(nèi)存占用也更高,所以小內(nèi)容使用G1作為回收器會(huì)增加GC的次數(shù),吞吐量會(huì)下降。
2. 大內(nèi)存,G1優(yōu)先:
當(dāng)內(nèi)存大于8G后,應(yīng)該優(yōu)先考慮G1垃圾回收器,因?yàn)楫?dāng)內(nèi)存增大后,在進(jìn)行垃圾回收時(shí)會(huì)將對(duì)象從s0復(fù)制到s1內(nèi)存越大,復(fù)制的時(shí)間越長(zhǎng),會(huì)增加系統(tǒng)STW的時(shí)間,導(dǎo)致系統(tǒng)的停頓時(shí)間過長(zhǎng)。
總結(jié)
隨著Java的不斷發(fā)展,有很多新的回收器出現(xiàn),如:shenandoahGC和ZGC,同為新一代的低延遲收集器, 分別由RedHat和Oracle開發(fā), 不過還在實(shí)驗(yàn)階段, 尚未使用于生產(chǎn)環(huán)境,針對(duì)不同類型的應(yīng)用Java提供了多種垃圾回收策略。
本文對(duì)Java虛擬機(jī)垃圾回收器及其回收策略進(jìn)行逐一介紹,同時(shí)對(duì)垃圾回收的優(yōu)化思路做一些簡(jiǎn)單討論,以期讀者能對(duì)Java虛擬機(jī)的垃圾回收增加理解,同時(shí)對(duì)垃圾回收的優(yōu)化有一些初步認(rèn)識(shí),為后續(xù)工作中的Java應(yīng)用調(diào)優(yōu)打下基礎(chǔ)。