關(guān)于 JVM 垃圾回收機(jī)制面試中主要涉及這三個(gè)考題:
JVM 中有哪些垃圾回收算法?它們各自有什么優(yōu)劣?
CMS 垃圾回收器是怎么工作的?有哪些階段?
服務(wù)卡頓的元兇到底是誰(shuí)?
JVM 是有專門的線程在做這件事情。當(dāng)我們的內(nèi)存空間達(dá)到一定條件時(shí),會(huì)自動(dòng)觸發(fā)。這個(gè)過程就叫作 GC,負(fù)責(zé) GC 的組件,就叫作垃圾回收器。
JVM 規(guī)范并沒有規(guī)定垃圾回收器怎么實(shí)現(xiàn),它只需要保證不要把正在使用的對(duì)象給回收掉就可以。在現(xiàn)在的服務(wù)器環(huán)境中,經(jīng)常被使用的垃圾回收器有`CMS 和 G1
,但 JVM 還有其他幾個(gè)常見的垃圾回收器。
按照語(yǔ)義上的意思,垃圾回收,首先就需要找到這些垃圾,然后回收掉。但是 GC 過程正好相反,它是先找到活躍的對(duì)象,然后把其他不活躍的對(duì)象判定為垃圾,然后刪除。所以垃圾回收只與活躍的對(duì)象有關(guān),和堆的大小無關(guān)。
首先介紹幾種非常重要的回收算法
標(biāo)記(Mark)
根據(jù) GC Roots 遍歷所有的可達(dá)對(duì)象,這個(gè)過程,就叫作標(biāo)記。
如圖所示,圓圈代表的是對(duì)象。綠色的代表 GC Roots,紅色的代表可以追溯到的對(duì)象。可以看到標(biāo)記之后,仍然有多個(gè)灰色的圓圈,它們都是被回收的對(duì)象。
清除(Sweep)
清除階段就是把未被標(biāo)記的對(duì)象回收掉。
這種簡(jiǎn)單的清除方式,有一個(gè)明顯的弊端,那就是內(nèi)存碎片問題。
比如我申請(qǐng)了 1k、2k、3k、4k、5k 的內(nèi)存。由于某種原因 ,2k 和 4k 的內(nèi)存,我不再使用,就需要交給垃圾回收器回收。
這個(gè)時(shí)候,我應(yīng)該有足足 6k 的空閑空間。接下來,我打算申請(qǐng)另外一個(gè) 5k 的空間,結(jié)果系統(tǒng)告訴我內(nèi)存不足了。系統(tǒng)運(yùn)行時(shí)間越長(zhǎng),這種碎片就越多。
在很久之前使用 Windows 系統(tǒng)時(shí),有一個(gè)非常有用的功能,就是內(nèi)存整理和磁盤整理,運(yùn)行之后有可能會(huì)顯著提高系統(tǒng)性能。這個(gè)出發(fā)點(diǎn)是一樣的。
復(fù)制(Copy)
解決碎片問題只有進(jìn)行內(nèi)存整理。比較好的思路可以完成這個(gè)整理過程,就是提供一個(gè)對(duì)等的內(nèi)存空間,將存活的對(duì)象復(fù)制過去,然后清除原內(nèi)存空間。復(fù)制算法是非常有效的。比如:HashMap 的擴(kuò)容也是使用同樣的思路
整個(gè)過程如圖所示:
它的弊端也非常明顯。它浪費(fèi)了幾乎一半的內(nèi)存空間來做這個(gè)事情。
整理(Compact)
不用分配一個(gè)對(duì)等的額外空間,也是可以完成內(nèi)存的整理工作。
你可以把內(nèi)存想象成一個(gè)非常大的數(shù)組,根據(jù)隨機(jī)的 index 刪除了一些數(shù)據(jù)。那么對(duì)整個(gè)數(shù)組的清理,其實(shí)是不需要另外一個(gè)數(shù)組來進(jìn)行支持的,繼續(xù)使用這個(gè)數(shù)組就可以實(shí)現(xiàn)。
它的主要思路,就是移動(dòng)所有存活的對(duì)象,且按照內(nèi)存地址順序依次排列,然后將末端內(nèi)存地址以后的內(nèi)存全部回收。
從效率上來說,一般整理算法是要低于復(fù)制算法的。
這幾種算法的特點(diǎn)
復(fù)制算法(Copy)復(fù)制算法是所有算法里面效率最高的,缺點(diǎn)是會(huì)造成一定的空間浪費(fèi)。
標(biāo)記-清除(Mark-Sweep)效率一般,缺點(diǎn)是會(huì)造成內(nèi)存碎片問題。
標(biāo)記-整理(Mark-Compact)效率比前兩者要差,但沒有空間浪費(fèi),也消除了內(nèi)存碎片問題。
分代
JVM 是計(jì)算節(jié)點(diǎn),而不是存儲(chǔ)節(jié)點(diǎn)。最理想的情況,就是對(duì)象在用完之后,它的生命周期立馬就結(jié)束了。而那些被頻繁訪問的資源,我們希望它能夠常駐在內(nèi)存里。
研究表明,大部分對(duì)象,可以分為兩類:
大部分對(duì)象的生命周期都很短;
其他對(duì)象則很可能會(huì)存活很長(zhǎng)時(shí)間。
大部分死的快,其他的活的長(zhǎng)。這個(gè)假設(shè)我們稱之為弱代假設(shè)(weak generational hypothesis)。
從圖中可以看到,大部分對(duì)象是朝生夕滅的,其他的則活的很久。
現(xiàn)在的垃圾回收器,都會(huì)在物理上或者邏輯上,把這兩類對(duì)象進(jìn)行區(qū)分。我們把死的快的對(duì)象所占的區(qū)域,叫作年輕代(Young generation)。把其他活的長(zhǎng)的對(duì)象所占的區(qū)域,叫作老年代(Old generation)。
年輕代
年輕代使用的垃圾回收算法是復(fù)制算法。因?yàn)槟贻p代發(fā)生 GC 后,只會(huì)有非常少的對(duì)象存活,復(fù)制這部分對(duì)象是非常高效的。
我們前面也了解到復(fù)制算法會(huì)造成一定的空間浪費(fèi),所以年輕代中間也會(huì)分很多區(qū)域。
如圖所示,年輕代分為:一個(gè)伊甸園空間(Eden ),兩個(gè)幸存者空間(Survivor )。
當(dāng)年輕代中的 Eden 區(qū)分配滿的時(shí)候,就會(huì)觸發(fā)年輕代的 GC(Minor GC)。具體過程如下:
在 Eden 區(qū)執(zhí)行了第一次 GC 之后,存活的對(duì)象會(huì)被移動(dòng)到其中一個(gè) Survivor 分區(qū)(以下簡(jiǎn)稱from);
Eden 區(qū)再次 GC,這時(shí)會(huì)采用復(fù)制算法,將 Eden 和 from 區(qū)一起清理。存活的對(duì)象會(huì)被復(fù)制到 to 區(qū);接下來,只需要清空 from 區(qū)就可以了。
所以在這個(gè)過程中,總會(huì)有一個(gè) Survivor 分區(qū)是空置的。Eden、from、to 的默認(rèn)比例是 8:1:1,所以只會(huì)造成 10% 的空間浪費(fèi)。這個(gè)比例,是由參數(shù) -XX:SurvivorRatio
進(jìn)行配置的(默認(rèn)為 8)。
一般情況下,我們只需要了解到這一層面就 OK 了。但是在平常的面試中,還有一個(gè)點(diǎn)會(huì)經(jīng)常提到,雖然頻率不太高,它就是 TLAB,我們?cè)谶@里也簡(jiǎn)單介紹一下。
這個(gè)道理和 Java 語(yǔ)言中的 ThreadLocal 類似,避免了對(duì)公共區(qū)的操作,以及一些鎖競(jìng)爭(zhēng)。
TLAB 的全稱是 Thread Local Allocation Buffer,JVM 默認(rèn)給每個(gè)線程開辟一個(gè) buffer 區(qū)域,用來加速對(duì)象分配。這個(gè) buffer 就放在 Eden 區(qū)中。
對(duì)象的分配優(yōu)先在 TLAB上 分配,但 TLAB 通常都很小,所以對(duì)象相對(duì)比較大的時(shí)候,會(huì)在 Eden 區(qū)的共享區(qū)域進(jìn)行分配。
TLAB 是一種優(yōu)化技術(shù),類似的優(yōu)化還有對(duì)象的棧上分配(這可以引出逃逸分析的話題,默認(rèn)開啟)。這屬于非常細(xì)節(jié)的優(yōu)化,不做過多介紹,但偶爾面試也會(huì)被問到。
Java對(duì)象分配流程
棧上分配
將線程私有的對(duì)象打散分配在棧上,優(yōu)點(diǎn)是可以在函數(shù)調(diào)用結(jié)束后自行銷毀對(duì)象,不需要垃圾回收器的介入,并且棧上分配速度快,提高系統(tǒng)性能,但缺點(diǎn)是棧空間小,對(duì)于大對(duì)象無法實(shí)現(xiàn)棧上分配。棧上分配的前提條件是開啟了逃逸分析(-XX:+DoEscapeAnalysis)
:判斷對(duì)象的作用域是否超出函數(shù)體,只有作用域沒有超出函數(shù)體的對(duì)象才能棧上分配。如下,user的作用域超出了函數(shù)setUser的范圍,是逃逸對(duì)象,不能進(jìn)行棧上分配。棧上分配還有一個(gè)前提是開啟標(biāo)量替換 (-XX:-EliminateAllocations)
,逃逸分析和標(biāo)量替換都是jvm默認(rèn)開啟的
標(biāo)量替換:
簡(jiǎn)單地說,就是用標(biāo)量替換聚合量。標(biāo)量是指不可分割的量,如java中基本數(shù)據(jù)類型和reference類型,相對(duì)的一個(gè)數(shù)據(jù)可以繼續(xù)分解,稱為聚合量;如果把一個(gè)對(duì)象拆散,將其成員變量恢復(fù)到基本類型來訪問就叫做標(biāo)量替換;如果逃逸分析發(fā)現(xiàn)一個(gè)對(duì)象不會(huì)被外部訪問,并且該對(duì)象可以被拆散,那么經(jīng)過優(yōu)化之后,并不直接生成該對(duì)象,而是在棧上創(chuàng)建若干個(gè)成員變量;
private User user;
public void setUser(){
user = new User();
user.setId(1);
user.setName("blueStarWei");
}
TLAB 分配
Thread Local Allocation Buffer, 線程本地分配緩存。一塊線程專用的內(nèi)存分配區(qū)域。TLAB占用的是eden區(qū)的空間(注意是堆上)。在TLAB啟用的情況下(默認(rèn)開啟),JVM會(huì)為每一個(gè)線程分配一塊TLAB區(qū)域。
優(yōu)點(diǎn)是可以加速對(duì)象的分配。因?yàn)門LAB是線程專有區(qū)域,會(huì)減少線程同步操作,使分配的效率提高。考慮到對(duì)象分配幾乎是Java中最常用的操作,因此JVM使用了TLAB這樣的線程專有區(qū)域來避免多線程沖突,提高對(duì)象分配的效率。
老年代
老年代一般使用“標(biāo)記-清除”、“標(biāo)記-整理”算法,因?yàn)槔夏甏膶?duì)象存活率一般是比較高的,空間又比較大,拷貝起來并不劃算,還不如采取就地收集的方式。
那么,對(duì)象是怎么進(jìn)入老年代的呢?有多種途徑。
1.提升(Promotion)
如果對(duì)象夠老,會(huì)通過“提升”進(jìn)入老年代。
關(guān)于對(duì)象老不老,是通過它的年齡(age)來判斷的。每當(dāng)發(fā)生一次 Minor GC,存活下來的對(duì)象年齡都會(huì)加 1。直到達(dá)到一定的閾值,就會(huì)把這些“老頑固”給提升到老年代
這些對(duì)象如果變的不可達(dá),直到老年代發(fā)生 GC 的時(shí)候,才會(huì)被清理掉。
這個(gè)閾值,可以通過參數(shù) ‐XX:+MaxTenuringThreshold
進(jìn)行配置,最大值是 15,因?yàn)樗怯?4bit 存儲(chǔ)的(所以網(wǎng)絡(luò)上那些要把這個(gè)值調(diào)的很大的文章,是沒有什么根據(jù)的)。
2.分配擔(dān)保
看一下年輕代的圖,每次存活的對(duì)象,都會(huì)放入其中一個(gè)幸存區(qū),這個(gè)區(qū)域默認(rèn)的比例是 10%。但是我們無法保證每次存活的對(duì)象都小于 10%,當(dāng) Survivor 空間不夠,就需要依賴其他內(nèi)存(指老年代)進(jìn)行分配擔(dān)保。這個(gè)時(shí)候,對(duì)象也會(huì)直接在老年代上分配。
3.大對(duì)象直接在老年代分配
超出某個(gè)大小的對(duì)象將直接在老年代分配。這個(gè)值是通過參數(shù)-XX:PretenureSizeThreshold
進(jìn)行配置的。默認(rèn)為 0,意思是全部首選 Eden 區(qū)進(jìn)行分配。
4.動(dòng)態(tài)對(duì)象年齡判定
有的垃圾回收算法,并不要求 age 必須達(dá)到 15 才能晉升到老年代,它會(huì)使用一些動(dòng)態(tài)的計(jì)算方法。比如,如果幸存區(qū)中相同年齡對(duì)象大小的和,大于幸存區(qū)的一半,大于或等于 age 的對(duì)象將會(huì)直接進(jìn)入老年代。
這些動(dòng)態(tài)判定一般不受外部控制,我們知道有這么回事就可以了。通過下圖可以看一下一個(gè)對(duì)象的分配邏輯。下圖中的SLAB改成TLAB
卡片標(biāo)記(card marking)
你可以看到,對(duì)象的引用關(guān)系是一個(gè)巨大的網(wǎng)狀。有的對(duì)象可能在 Eden 區(qū),有的可能在老年代,那么這種跨代的引用是如何處理的呢?由于 Minor GC 是單獨(dú)發(fā)生的,如果一個(gè)老年代的對(duì)象引用了它,如何確保能夠讓年輕代的對(duì)象存活呢?
老年代是被分成眾多的卡頁(yè)(card page)的(一般數(shù)量是 2 的次冪)。
卡表(Card Table)就是用于標(biāo)記卡頁(yè)狀態(tài)的一個(gè)集合,每個(gè)卡表項(xiàng)對(duì)應(yīng)一個(gè)卡頁(yè)。
如果年輕代有對(duì)象分配,而且老年代有對(duì)象指向這個(gè)新對(duì)象, 那么這個(gè)老年代對(duì)象所對(duì)應(yīng)內(nèi)存的卡頁(yè),就會(huì)標(biāo)識(shí)為 dirty,卡表只需要非常小的存儲(chǔ)空間就可以保留這些狀態(tài)。
垃圾回收時(shí),就可以先讀這個(gè)卡表,進(jìn)行快速判斷。
HotSpot 垃圾回收器
接下來介紹 HotSpot 的幾個(gè)垃圾回收器,每種回收器都有各自的特點(diǎn)。我們?cè)谄匠5?GC 優(yōu)化時(shí),一定要搞清楚現(xiàn)在用的是哪種垃圾回收器。
在此之前,我們把上面的分代垃圾回收整理成一張大圖,在介紹下面的收集器時(shí),你可以對(duì)應(yīng)一下它們的位置。
年輕代垃圾回收器
(1)Serial 垃圾收集器
處理 GC 的只有一條線程,并且在垃圾回收的過程中暫停一切用戶線程。
最簡(jiǎn)單的垃圾回收器,因?yàn)楹?jiǎn)單,所以高效,它通常用在客戶端應(yīng)用上。因?yàn)榭蛻舳藨?yīng)用不會(huì)頻繁創(chuàng)建很多對(duì)象,用戶也不會(huì)感覺出明顯的卡頓。相反,它使用的資源更少,也更輕量級(jí)。
(2)ParNew 垃圾收集器
ParNew 是 Serial 的多線程版本。由多條 GC 線程并行地進(jìn)行垃圾清理。清理過程依然要停止用戶線程。
ParNew 追求“低停頓時(shí)間”,與 Serial 唯一區(qū)別就是使用了多線程進(jìn)行垃圾收集,在多 CPU 環(huán)境下性能比 Serial 會(huì)有一定程度的提升;但線程切換需要額外的開銷,因此在單 CPU 環(huán)境中表現(xiàn)不如 Serial。
(3)Parallel Scavenge 垃圾收集器
另一個(gè)多線程版本的垃圾回收器。它與 ParNew 的主要區(qū)別是:
Parallel Scavenge:追求 CPU 吞吐量,能夠在較短時(shí)間內(nèi)完成指定任務(wù),適合沒有交互的后臺(tái)計(jì)算。弱交互強(qiáng)計(jì)算。
ParNew:追求降低用戶停頓時(shí)間,適合交互式應(yīng)用。強(qiáng)交互弱計(jì)算。
老年代垃圾收集器
(1)Serial Old 垃圾收集器
與年輕代的 Serial 垃圾收集器對(duì)應(yīng),都是單線程版本,同樣適合客戶端使用。
年輕代的 Serial,使用復(fù)制算法。
老年代的 Old Serial,使用標(biāo)記-整理算法。
(2)Parallel Old
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
(3)CMS 垃圾收集器
CMS(Concurrent Mark Sweep)收集器是以獲取最短 GC 停頓時(shí)間為目標(biāo)的收集器,它在垃圾收集時(shí)使得用戶線程和 GC 線程能夠并發(fā)執(zhí)行,因此在垃圾收集過程中用戶也不會(huì)感到明顯的卡頓。我們會(huì)在后面的課時(shí)詳細(xì)介紹它。
長(zhǎng)期來看,CMS 垃圾回收器,是要被 G1 等垃圾回收器替換掉的。在 Java8 之后,使用它將會(huì)拋出一個(gè)警告。
Java HotSpot(TM) 64-Bit Server VM warning: Option UseConcMarkSweepGC was
deprecated in version 9.0 and will likely be removed in a future release.
配置參數(shù)
除了上面幾個(gè)垃圾回收器,我們還有 G1、ZGC 等更加高級(jí)的垃圾回收器,它們都有專門的配置參數(shù)來使其生效。
通過 -XX:+PrintCommandLineFlags 參數(shù),可以查看當(dāng)前 Java 版本默認(rèn)使用的垃圾回收器。你可以看下我的系統(tǒng)中 Java13 默認(rèn)的收集器就是 G1。
java -XX:+PrintCommandLineFlags -version
-XX:G1ConcRefinementThreads=4 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
java version "13.0.1" 2019-10-15
Java(TM) SE Runtime Environment (build 13.0.1+9)
Java HotSpot(TM) 64-Bit Server VM (build 13.0.1+9, mixed mode, sharing)
以下是一些配置參數(shù):
-XX:+UseSerialGC 年輕代和老年代都用串行收集器
-XX:+UseParNewGC 年輕代使用 ParNew,老年代使用 Serial Old
-XX:+UseParallelGC 年輕代使用 ParallerGC,老年代使用 Serial Old
-XX:+UseParallelOldGC 新生代和老年代都使用并行收集器
-XX:+UseConcMarkSweepGC,表示年輕代使用 ParNew,老年代的用 CMS
-XX:+UseG1GC 使用 G1垃圾回收器
-XX:+UseZGC 使用 ZGC 垃圾回收器
為了讓你有個(gè)更好的印象,請(qǐng)看下圖。它們的關(guān)系還是比較復(fù)雜的。尤其注意 -XX:+UseParNewGC 這個(gè)參數(shù),已經(jīng)在 Java9 中就被拋棄了。很多程序(比如 ES)會(huì)報(bào)這個(gè)錯(cuò)誤,不要感到奇怪。
有這么多垃圾回收器和參數(shù),那我們到底用什么?在什么地方優(yōu)化呢?
目前,雖然 Java 的版本比較高,但是使用最多的還是 Java8。從 Java8 升級(jí)到高版本的 Java 體系,是有一定成本的,所以 CMS 垃圾回收器還會(huì)持續(xù)一段時(shí)間。
線上使用最多的垃圾回收器,就有 CMS 和 G1,以及 Java8 默認(rèn)的 Parallel Scavenge。
CMS 的設(shè)置參數(shù):-XX:+UseConcMarkSweepGC。
Java8 的默認(rèn)參數(shù):-XX:+UseParallelGC。
Java13 的默認(rèn)參數(shù):-XX:+UseG1GC。
STW(Stop the world)
你有沒有想過,如果在垃圾回收的時(shí)候(不管是標(biāo)記還是整理復(fù)制),又有新的對(duì)象進(jìn)入怎么辦?
為了保證程序不會(huì)亂套,最好的辦法就是暫停用戶的一切線程。也就是在這段時(shí)間,你是不能 new 對(duì)象的,只能等待。表現(xiàn)在 JVM 上就是短暫的卡頓,什么都干不了。這個(gè)頭疼的現(xiàn)象,就叫作 Stop the world。簡(jiǎn)稱 STW。
標(biāo)記階段,大多數(shù)是要 STW 的。如果不暫停用戶進(jìn)程,在標(biāo)記對(duì)象的時(shí)候,有可能有其他用戶線程會(huì)產(chǎn)生一些新的對(duì)象和引用,造成混亂。
現(xiàn)在的垃圾回收器,都會(huì)盡量去減少這個(gè)過程。但即使是最先進(jìn)的 ZGC,也會(huì)有短暫的 STW 過程。我們要做的就是在現(xiàn)有基礎(chǔ)設(shè)施上,盡量減少 GC 停頓。
你可能對(duì) STW 的影響沒有什么概念,我舉個(gè)例子來說明下。
某個(gè)高并發(fā)服務(wù)的峰值流量是 10 萬次/秒,后面有 10 臺(tái)負(fù)載均衡的機(jī)器,那么每臺(tái)機(jī)器平均下來需要 1w/s。假如某臺(tái)機(jī)器在這段時(shí)間內(nèi)發(fā)生了 STW,持續(xù)了 1 秒,那么本來需要 10ms 就可以返回的 1 萬個(gè)請(qǐng)求,需要至少等待 1 秒鐘。
在用戶那里的表現(xiàn),就是系統(tǒng)發(fā)生了卡頓。如果我們的 GC 非常的頻繁,這種卡頓就會(huì)特別的明顯,嚴(yán)重影響用戶體驗(yàn)。
雖然說 Java 為我們提供了非常棒的自動(dòng)內(nèi)存管理機(jī)制,但也不能濫用,因?yàn)樗怯?STW 硬傷的。