原文地址:JVM Anatomy Park #21: Heap Uncommit
問題
我想要回我的內(nèi)存脐区。這不是問題弥搞。
理論
JVM 有很多使用內(nèi)存的地方论巍,存儲內(nèi)部 VM 狀態(tài)在本地內(nèi)存兆衅,為 Java 對象提供存儲空間(“Java Heap”)郭变。我們在《本地內(nèi)存追蹤》中已經(jīng)了解了本地內(nèi)存,但是很多程序主要的內(nèi)存使用還是 Java 堆涯保。
Java 堆通常由自動內(nèi)存管理器管理,有時被稱為垃圾回收器周伦。[1] GC 會從底層 OS 內(nèi)存管理器中分配大塊的內(nèi)存夕春,并且將它們分塊以便接受分配。這意味著即使在堆內(nèi)存中僅有幾個 Java 對象专挪,但是從 OS 的角度及志,JVM 進程已經(jīng)獲取了 Java 堆所需的內(nèi)存。[2]
因此寨腔,如果我們想要將 Java 堆未使用的部分還給 OS速侈,那么我們需要 GC 協(xié)助。
有兩種方式實現(xiàn)協(xié)助:執(zhí)行更頻繁的 GC迫卢,而不是“擴大” Java 堆至 -Xmx
倚搬;或者顯式的歸還未使用的 Java 堆,即使在 Java 堆擴大到 -Xmx
之后乾蛤。第一種方式只能提供有限的幫助每界,而且通常只在程序生命周期的早期階段有效 —— 最終,應(yīng)用程序?qū)峙浜芏嗉衣簟T诒疚闹姓2悖覀儗P(guān)注第二種方式,當(dāng)堆已經(jīng)擴大了該怎么辦上荡。
現(xiàn)代 GCs 在這方面做了什么趴樱?
實驗設(shè)置
內(nèi)存占用測量比較麻煩,因為我們需要定義“占用”酪捡。由于我們討論的是 OS 層面的內(nèi)存占用叁征,所以可以測量 JVM 進程的 RSS,這包含本地 VM 內(nèi)存和 Java 堆逛薇。
另一個重要的問題是何時測量內(nèi)存占用航揉。應(yīng)用程序生命周期不同階段的數(shù)據(jù)量是不同的,這是顯而易見的金刁。當(dāng)應(yīng)用程序故意優(yōu)化內(nèi)存占用的時候尤其如此帅涂,比如當(dāng)實際操作發(fā)生時才觸發(fā)的懶(延遲)加載议薪。最常見的錯誤就是啟動這種程序,立即對內(nèi)存占用進行快照媳友,然后實際開始工作了就超出了之前的內(nèi)存評估斯议。
自動內(nèi)存管理器通常會對應(yīng)用程序的狀態(tài)做出反應(yīng):基于分配壓力觸發(fā) GC,釋放可用空間醇锚,空閑等哼御。僅僅測量活躍階段也可能不太準確。通過觀察可以進一步證實這一點兒焊唬,大多數(shù)應(yīng)用程序(高負載服務(wù)器除外)大部分時間都是空閑的恋昼,或者運行在低負載狀態(tài)。
所有這一切意味著我們需要測量程序生命周期不同階段的內(nèi)存占用赶促,這樣才能得出全面的結(jié)論液肌。讓我們以 spring-boot-petclinic 項目為例,使用不同的 GC 執(zhí)行程序鸥滨。下面是我們使用的配置:
- Serial GC:小堆應(yīng)用程序的首選 GC嗦哆。具有很低的本地開銷,更積極的 GC 策略婿滓,等老速;
- G1 GC:OpenJDK 的主力,從 JDK 9 開始成為默認 GC凸主;
- Shenandoah GC:來自 Red Hat 的并發(fā) GC橘券。通過它可以展示 footprint-savvy GC 的一些行為。[3]為了這個實驗的目的卿吐,Shenandoah 以兩種模式運行:default模式约郁,以及將內(nèi)存占用降至最低的compact模式。[4]
這個實驗由這個簡單的腳本驅(qū)動但两。我們使用最新發(fā)布的 OpenJDK 11鬓梅,但是使用 OpenJDK 8 也一樣,因為在 8 和 11 中測試用例中的 GC 行為沒有明顯變化谨湘。
結(jié)果與討論
Start+Idle
讓我們看一下 RSS 圖绽快。我們能看到什么?
在啟動階段紧阔,所有的 GC 都嘗試處理很小的初始堆內(nèi)存坊罢,很多將會執(zhí)行頻繁的 GC。這將保持堆內(nèi)存不會擴展很大擅耽。在初始啟動階段之后活孩,工作負載穩(wěn)定在某個特定的內(nèi)存占用級別。由于缺少 GC 觸發(fā)的條件乖仇,在啟動階段內(nèi)存占用量基本上是由觸發(fā) GC 的啟發(fā)式算法決定的憾儒,即使存儲的數(shù)據(jù)量是一樣的询兴。當(dāng)啟發(fā)式算法從一百多個 GC 配置組合猜測用戶的期望時,這將會變得特別奇怪起趾。
Load
與上圖相同:
當(dāng)負載來了之后诗舰,GC 啟發(fā)式算法又需要決定一些事情。依賴 GC 的實現(xiàn)和配置训裆,需要決定擴展堆內(nèi)存眶根,或者執(zhí)行更頻繁的 GC 循環(huán)。
Serial GC 決定執(zhí)行更頻繁的 GC边琉。G1 擴大內(nèi)存到了最大堆內(nèi)存的 3/4属百,開始執(zhí)行比較頻繁的 GC 以應(yīng)對分配壓力。default 模式下的 Shenandoah变姨,在密集的堆內(nèi)存中執(zhí)行并發(fā) GC族扰,它選擇盡可能的擴展內(nèi)存,在不頻繁 GC 的情況下維持應(yīng)用程序的并發(fā)性钳恕。compact 模式下的 Shenandoah,被要求維護比較低的內(nèi)存占用蹄衷,它選執(zhí)行更頻繁的 GC忧额。
實際的 GC 頻率日志證實了這一點:
更頻繁的 GC 也意味著更多的 CPU 占用:
雖然圖中有很多毛刺,但是我們可以清晰的看到“Shenandoah (compact)”耗費了更多時間愧口。這是維持密集內(nèi)存占用而必須付出的代價睦番。或者換句話說耍属,這是吞吐量-延遲-內(nèi)存占用的權(quán)衡托嚣。當(dāng)然有一些可調(diào)的配置可以控制不同的權(quán)衡需求,這個實驗僅僅展示了兩者在默認配置下的不同點:傾向于吞吐量厚骗,或者傾向于內(nèi)存占用示启。因為 Shenandoah 是并發(fā) GC,即使連續(xù)執(zhí)行 GC 也不會讓程序停頓太久领舰。
Idle
與上圖相同:
當(dāng)應(yīng)用程序開始空閑了夫嗓,GC 可以決定歸還某些資源。最明顯的做法是歸還堆中空閑的部分冲秽。**如果堆已經(jīng)被分割成獨立的內(nèi)存塊舍咖,那么就相對簡單了,例如像 G1 和 Shenandoah 這種分塊的收集器锉桑。盡管如此排霉,GC 需要決定是否、何時執(zhí)行歸還民轴。
許多 OpenJDK GC 僅僅在 GC 周期中執(zhí)行 GC 相關(guān)的操作攻柠。但有趣的事情發(fā)生了球订。大部分 OpenJDK GC 是基于分配觸發(fā)的,這意味著只有堆占用達到某個條件才會啟動 GC 周期辙诞。如果應(yīng)用程序突然進入空閑狀態(tài)辙售,這意味著分配也停止了,所以無論當(dāng)前的占用水平如何飞涂,都將一直持續(xù)下去旦部。這對萬物靜止 GC 是有意義的,因為我們并不想隨便開始長時間的停頓较店。
實際上沒有必要將內(nèi)存歸還與 GC 周期關(guān)聯(lián)士八。在 Shenandoah 中有一個異步周期性歸還邏輯,我們將會看到這觸發(fā)了空閑階段第一次較大的內(nèi)存下降梁呈。在本實驗中婚度,我們特意將回收延遲設(shè)置為 5 秒,我們可以看到它確實是在空閑幾秒鐘后發(fā)生的官卡。這對上一個 GC 周期清空的(但是現(xiàn)在還沒分配的)內(nèi)存塊進行了回收蝗茁。
但是還有一個需要注意的地方:由于應(yīng)用程序突然進入空閑階段,所以還會存在一些未回收的垃圾寻咒。這提供了一個實現(xiàn)周期性 GC 的動機哮翘,清除遺留的垃圾。周期性 GC 是空閑階段第二次內(nèi)存回收的原因毛秘。周期性 GC 釋放了新的內(nèi)存塊饭寺,稍后會被周期性歸還。
如果 GC 周期已經(jīng)足夠頻繁了(參見“Shenandoah (compact)”)叫挟,這些操作無關(guān)緊要艰匙,因為內(nèi)存占用已經(jīng)很低了,沒有額外占用的內(nèi)存抹恳。
Full GC
與上圖相同:
在并發(fā) GC 實現(xiàn)中執(zhí)行周期性 GC 不那么麻煩:如果負載在 GC 周期期間恢復(fù)员凝,也不會造成不好的影響。STW GC 就不同了奋献,它不確定執(zhí)行 major GC 是否是個好主意绊序。在最壞的情況下,我們需要顯式地讓 JVM 執(zhí)行 GC秽荞,至少 G1 會可靠地響應(yīng)這個請求骤公。注意在 Full GC 之后,大部分收集器的內(nèi)存占用降至了同一水平扬跋,在沒有用戶干預(yù)的情況下阶捆,周期性 GC 和周期性歸還更早回到最低水平。
結(jié)論
周期性 GC。執(zhí)行周期性 GC 有助于清除遺留的垃圾洒试。并發(fā) GC 通常會執(zhí)行周期性 GC:Shenandoah 和 ZGC 都這樣做倍奢。G1 將通過 JEP 346 在 JDK 12 中獲得這一特性。否則垒棋,可以使用外部或內(nèi)部的代理在合適的時間點周期性調(diào)用 GC卒煞,最困難的是如何定義合適的時間點。請參見Jelastic GC Agent叼架。
堆內(nèi)存歸還畔裕。許多 GC 已經(jīng)實現(xiàn)了在合適的時機歸還堆內(nèi)存:Shenandoah 異步執(zhí)行堆內(nèi)存歸還,即使沒有 GC 請求乖订;G1 在顯式 GC 請求中執(zhí)行堆內(nèi)存歸還扮饶;Serial 和 Parallel 在某些條件下也會執(zhí)行。ZGC 也準備這樣做乍构,讓我們期待 JDK 12甜无。G1 通過 JEP 346 實現(xiàn)周期性 GC 來處理同步性。當(dāng)然這里有一個權(quán)衡:歸還內(nèi)存可能會耗費一些時間哥遮,所以實際的實現(xiàn)會在歸還內(nèi)存之前會增加一個超時時間岂丘。
以內(nèi)存占用為目標的 GC。許多 GC 提供了靈活的配置項眠饮,可以使 GC 執(zhí)行更頻繁奥帘,從而優(yōu)化內(nèi)存占用。即使是增加周期性 GC 頻率這樣的操作君仆,也有助于盡早清楚垃圾翩概。某些 GC 可能會提供預(yù)先封裝的配置包牲距,使得 GC 可以做出有利于內(nèi)存占用的選擇返咱,其中包括配置更頻繁的 GC 周期,以及更頻繁的內(nèi)存歸還周期牍鞠,比如 Shenandoah 的 “compact” 模式咖摹。
每次你換了一個 GC 實現(xiàn),而且滿足內(nèi)存占用預(yù)期的時候难述,請務(wù)必理解為什么會這樣萤晴,這是怎么做到的。這有助于清楚地了解付出的成本胁后,以及是否可以在不改變 GC 實現(xiàn)的情況下實現(xiàn)同樣的效果店读。
[1] “垃圾收集器”這個詞不太恰當(dāng),因為 GC 通常也會負責(zé)分配內(nèi)存攀芯。參見 Epsilon GC
[2] 這里有點兒復(fù)雜屯断。例如,Linux 在第一次使用的時候才會分配實際的內(nèi)存,即使程序認為內(nèi)存是可用的殖演,并且由進程占用氧秘。
[3] 完全披露:我在 Shenandoah 中實現(xiàn)了大部分堆內(nèi)存歸還邏輯,本文基本上是之前實驗的重演趴久。如果本文看起來像 Shenandoah 的廣告丸相,這是因為它就是廣告。
[4] 通過 -XX:ShenandoahGCHeuristics=compact
啟用