前言
大家在面試的時候不同程度會被問到JVM的垃圾回收,看面試官水平,有些就背個書就行,比如GC的工作原理,有哪些GC算法和回收器,分別優(yōu)點和缺點等等,有些面試官估計自己也就背書水平,都沒個追問;有些面試官就能追問,一追問就歇菜,比如低延遲的垃圾回收器有哪些以及其原理,跨代引用及解決方案,三色標記及漏標問題處理,等等。
還是那句話,雖然都是些理論的問題,但是在實際開發(fā)過程中真的能遇到這些問題來解決實際問題,所以多多了解JVM的實現(xiàn)原理總沒有錯,既能抗極限面試,又能在適時的時候幫忙解決實際問題,得到領導和同事的贊賞,何樂不為?
下面進入正題,先來個開胃菜,熱熱身。GC的工作原理就不說了,要準備面試的同學必須倒背如流,不然面試官要說出門右轉(zhuǎn)了...
垃圾回收算法
垃圾回收算法的實現(xiàn)設計到大量的程序細節(jié),并且每一個平臺的虛擬機操作內(nèi)存的方式都有不同,所以不需要去了解算法的具體實現(xiàn)。
復制算法
將可用內(nèi)存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內(nèi)存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內(nèi)存空間一次清理掉。這樣使得每次都是對整個半?yún)^(qū)進行內(nèi)存回收,內(nèi)存分配時也就不用考慮內(nèi)存碎片等復雜情況,只要按順序分配內(nèi)存即可,實現(xiàn)簡單,運行高效。
只是這種算法的代價是將內(nèi)存縮小為了原來的一半。但是要注意:內(nèi)存移動是必須實打?qū)嵉囊苿樱◤椭疲?,所以對應的引用(直接指針)需要調(diào)整。
復制回收算法適合于新生代,因為大部分對象朝生夕死,那么復制過去的對象比較少,效率自然就高,另外一半的一次性清理是很快的。
Appel式回收
一種更加優(yōu)化的復制回收分代策略:具體做法是分配一塊較大的 Eden 區(qū)和兩塊較小的 Survivor 空間(一般稱作做From區(qū)和To區(qū),也可以叫做S0和S1)
基于經(jīng)驗統(tǒng)計,新生代中的對象98%是“朝生夕死”的,所以并不需要按照 1:1 的比例來劃分內(nèi)存空間,而是將內(nèi)存分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden和其中一塊Survivor[1]。當回收時,將 Eden 和 Survivor 中還存活著的對象一次性地復制到另外一塊 Survivor 空間上, 最后清理掉 Eden 和剛才用過的 Survivor 空間。
HotSpot 虛擬機默認 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用內(nèi)存空間為整個新生代容量的 90%(80%+10%),只有10%的內(nèi)存會被 “浪費”。當然,98%的對象可回收只是一般場景下的數(shù)據(jù),我們沒有辦法保證每次回收都只有不多于10%的對象存活,當 Survivor 空間不夠用時,需要依賴其他內(nèi)存(這里指老年代)進行分配擔保(Handle Promotion)
標記清除
算法分為“標記”和“清除”兩個階段:首先掃描所有對象標記出需要回收的對象,在標記完成后掃描回收所有被標記的對象,所以需要掃描兩遍。回收效率略低,如果大部分對象是朝生夕死,那么回收效率降低,因為需要大量標記對象和回收對象,對比復制回收效率要低。
它的主要問題,標記清除之后會產(chǎn)生大量不連續(xù)的內(nèi)存碎片,空間碎片太多可能會導致以后在程序運行過程中需要分配較大對象時,無法找到足夠的連續(xù)內(nèi)存而不得不提前觸發(fā)另一次垃圾回收動作。回收的時候如果需要回收的對象越多,需要做的標記和清除的工作越多,所以標記清除算法適用于老年代。
標記整理
首先標記出所有需要回收的對象,在標記完成后,后續(xù)步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內(nèi)存。標記整理算法雖然沒有內(nèi)存碎片,但是效率偏低。
我們看到標記整理與標記清除算法的區(qū)別主要在于對象的移動。對象移動不單單會加重系統(tǒng)負擔,同時需要全程暫停用戶線程才能進行,同時所有引用對象的地方都需要更新(直接指針需要調(diào)整)。所以看到,老年代采用的標記整理算法與標記清除算法,各有優(yōu)點,各有缺點。
垃圾回收器
回收器名稱回收對象和算法回收器類型
Serial新生代,復制算法線程(串行)
Parallel Scavenge新生代,復制算法并行的多線程回收器
ParNew新生代,復制算法并行的多線程回收器
Serial Old老年代,標記整理算法單線程(串行)
Parallel Old老年代,標記整理算法并行的多線程回收器
CMS老年代,標記清除算法并發(fā)的多線程回收器
G1新生代,老年代;標記整理 + 化整為零并發(fā)的多線程回收器
目前最常用的兩種垃圾回收器,也不用多說,肯定是CMS和G1,一般面試官會問下CMS和G1的區(qū)別以及各自的特點,不太會深入問實現(xiàn)原理,畢竟Java面試可問的知識點實在太多了,都一個個深入問1個小時的面試時間根本不夠。
串行的垃圾回收器就不說了,這里專門講下并發(fā)的垃圾回收器
CMS(Concurrent Mark Sweep)回收器
顧名思義,這是并發(fā)的垃圾回收器,這種回收器是一種以獲取最短的回收停頓時間為目的的垃圾收集器,目前很大一部分Java的互聯(lián)網(wǎng)應用或者B/S系統(tǒng)的服務器上,由于這類應用尤其在意相應速度,希望系統(tǒng)停頓時間越短越好,這樣用戶體驗也會更好,CMS就非常符合這類應用的需求。
從名字就可以看出,這種回收器是基于標記清除的算法實現(xiàn),它的運作過程相對串行的垃圾回收器相對復雜點,分為以下4個步驟
初始標記:很短,僅僅只是標記下GC Root能直接關聯(lián)的對象,速度極快。
并發(fā)標記:和用戶應用同時進行,進行GC Root跟蹤的過程,標記GC Root開始關聯(lián)的所有對象,開始遍歷整個可達分析的路徑對象,這個時間比較長,所以并發(fā)。
重新標記:短暫,為了修正并發(fā)標記期間因用戶程序繼續(xù)運作而導致標記產(chǎn)生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標 記階段稍長一些,但遠比并發(fā)標記的時間短。
并發(fā)清除:由于整個過程中耗時最長的并發(fā)標記和并發(fā)清除過程收集器線程都可以與用戶線程一起工作,所以,一般來說,CMS 的內(nèi)存回收過程是與用戶線程一起執(zhí)行的。-XX:+UseConcMarkSweepGC ,表示新生代使用ParNew,老年代的用 CMS。
CPU 敏感:CMS 對處理器資源敏感,畢竟采用了并發(fā)的收集、當處理核心數(shù)不足 4 個時,CMS 對用戶的影響較大。
浮動垃圾:由于 CMS 并發(fā)清理階段用戶線程還在運行著,伴隨程序運行自然就還會有新的垃圾不斷產(chǎn)生,這一部分垃圾出現(xiàn)在標記過程之后,CMS無法在當次收集中處理掉它們,只好留待下一次GC時再清理掉。這一部分垃圾就稱為“浮動垃圾”。由于浮動垃圾的存在,因此需要預留出一部分內(nèi)存,意味著 CMS 收集不能像其它收集器那樣等待老年代快滿的時候再回收。在1.6的版本中老年代空間使用率閾值(92%)如果預留的內(nèi)存不夠存放浮動垃圾,就會出現(xiàn) Concurrent Mode Failure,這時虛擬機將臨時啟用 Serial Old 來替代 CMS。
會產(chǎn)生空間碎片:標記 - 清除算法會導致產(chǎn)生不連續(xù)的空間碎片總體來說,CMS是JVM 推出了第一款并發(fā)垃圾收集器,所以還是非常有代表性。但是最大的問題是 CMS 采用了標記清除算法,所以會有內(nèi)存碎片,當碎片較多時,給大對象的分配帶來很大的麻煩,為了解決這個問題,CMS 提供一個 參數(shù):-XX:+UseCMSCompactAtFullCollection,一般是開啟的,如果分配不了大對象,就進行內(nèi)存碎片的整理過程。這個地方一般會使用 Serial Old ,因為 Serial Old 是一個單線程,所以如果內(nèi)存空間很大、且對象較多時,CMS 發(fā)生這樣情況會很卡。
總結(jié):CMS 問題比較多,所以JDK沒有一個版本默認垃圾回收器是CMS,只能手動指定。但是它畢竟是第一個并發(fā)垃圾回收器,對于了解并發(fā)垃圾回收具有一定意義,所以我們必須了解。為什么 CMS 采用標記-清除,在實現(xiàn)并發(fā)的垃圾回收時,如果采用標記整理算法,那么還涉及到對象的移動(對象的移動必定涉及到引用的變化,這個需要暫停業(yè)務線程來處理棧信息,這樣使得并發(fā)收集的暫停時間更長),所以使用簡單的標記-清除算法才可以降低 CMS的STW的時間。
該垃圾回收器適合回收堆空間幾個 G至20G。
G1(Garbage First)
隨著JVM內(nèi)存的增大,STW的時間成為JVM 急迫解決的問題,但是如果按照傳統(tǒng)的分代模型,總跳不出STW時間不可預測這點。
為了實現(xiàn)STW的時間可預測,首先要有一個思想上的改變。
G1將堆內(nèi)存“化整為零”,將堆內(nèi)存劃分成多個大小相等獨立區(qū)域(Region),每一個Region 都可以根據(jù)需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。
回收器能夠?qū)Π缪莶煌巧?Region 采用不同的策略去處理,這樣無論是新創(chuàng)建的對象還是已經(jīng)存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。
Region:Region可能是Eden,也有可能是Survivor,也有可能是Old,另外 Region 中還有一類特殊的Humongous區(qū)域,專門用來存儲大對象。G1認為只要大小超過了一個Region容量一半的對象即可判定為大對象。每個Region的大小可以通過參數(shù)-XX:G1HeapRegionSize 設定,取值范圍為 1MB至32MB,且應為2的N次冪。而對于那些超過了整個 Region 容量的超級大對象,將會被存放在 N 個連續(xù)的 Humongous Region 之中,G1 的進行回收大多數(shù)情況下都把 Humongous Region 作為老年代的一部分來進行看待。
開啟參數(shù)?-XX:+UseG1GC分區(qū)大小?-XX:+G1HeapRegionSize一般建議逐漸增大該值,隨著 size 增加,垃圾的存活時間更長,GC 間隔更長,但每次 GC 的時間也會更長。
最大GC暫停時間?-XX:MaxGCPauseMillis設置最大GC暫停時間的目標(單位毫秒),這是個軟目標,JVM會盡最大可能實現(xiàn)它。
運行過程如下:
初始標記:僅僅只是標記一下GC Roots能直接關聯(lián)到的對象,并且修改 TAMS 指針的值,讓下一階段用戶線程并發(fā)運行時,能正確地在可用的 Region 中分配新對象。這個階段需要停頓線程,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際并沒有額外的停頓。要達到GC與用戶線程并發(fā)運行,必須要解決回收過程中新對象的分配,所以G1為每一個Region 區(qū)域設計了兩個名為TAMS(Top at Mark Start)的指針,從 Region 區(qū)域劃出一部分空間用于記錄并發(fā)回收過程中的新對象。這樣的對象認為它們是存活的,不納入垃圾回收范圍。
并發(fā)標記:從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆里的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程序并發(fā)執(zhí)行。當對象圖掃描完成以后,并發(fā)時有引用變動的對象,這些對象會漏標,漏標的對象會被一個叫做SATB(snapshot at the beginning)算法來解決。
最終標記:對用戶線程做另一個短暫的暫停,用于處理并發(fā)階段結(jié)后仍遺留下來的最后那少量的 SATB 記錄(漏標對象)。
篩選回收:負責更新Region的統(tǒng)計數(shù)據(jù),對各個Region的回收價值和成本進行排序,根據(jù)用戶所期望的停頓時間來制定回收計劃,可以自由選擇任意多個Region構(gòu)成回收集,然后把決定回收的那一部分 Region 的存活對象復制到空的Region中,再清理掉整個舊 Region 的全部空間。這里的操作涉及存活對象的移動,是必須暫停用戶線程,由多條收集器線程并行完成的。
總結(jié):并行與并發(fā):G1 能充分利用多 CPU、多核環(huán)境下的硬件優(yōu)勢,使用多個 CPU(CPU 或者 CPU 核心)來縮短 Stop-The-World 停頓的時間,部分其他收集器 原本需要停頓 Java 線程執(zhí)行的 GC 動作,G1 收集器仍然可以通過并發(fā)的方式讓 Java 程序繼續(xù)執(zhí)行。
分代收集:與其他收集器一樣,分代概念在 G1 中依然得以保留。雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但它能夠采用不同的方式 去處理新創(chuàng)建的對象和已經(jīng)存活了一段時間、熬過多次 GC 的舊對象以獲取更好的收集效果。
空間整合:與 CMS 的“標記—清理”算法不同,G1 從整體來看是基于“標記—整理”算法實現(xiàn)的收集器,從局部(兩個 Region 之間)上來看是基于“復 制”算法實現(xiàn)的,但無論如何,這兩種算法都意味著 G1 運作期間不會產(chǎn)生內(nèi)存空間碎片,收集后能提供規(guī)整的可用內(nèi)存。這種特性有利于程序長時間運 行,分配大對象時不會因為無法找到連續(xù)內(nèi)存空間而提前觸發(fā)下一次 GC。
追求停頓時間:-XX:MaxGCPauseMillis 指定目標的最大停頓時間,G1 嘗試調(diào)整新生代和老年代的比例,堆大小,晉升年齡來達到這個目標時間。
并發(fā)標記
三色標記算法
說到并發(fā)標記,就不能不提下并發(fā)標記中的三色標記算法,它是一種描述追蹤式回收器的有效的辦法,利用它可以推演回收器的正確性。
在三色標記法之前有一個算法叫 Mark-And-Sweep(標記清除)。這個算法會設置一個標志位來記錄對象是否被使用。最開始所有的標記位都是0,如果發(fā)現(xiàn)對象是可達的就會置為1,一步步下去就會呈現(xiàn)一個類似樹狀的結(jié)果。等標記的步驟完成后,會將未被標記的對象統(tǒng)一清理,再次把所有的標記位 設置成0方便下次清理。
這個算法最大的問題是 GC 執(zhí)行期間需要把整個程序完全暫停,不能異步進行 GC 操作。因為在不同階段標記清掃法的標志位0和1有不同的含義,那么新增的對象無論標記為什么都有可能意外刪除這個對象。對實時性要求高的系統(tǒng)來說,這種需要長時間掛起的標記清掃法是不可接受的。所以就需要一個算法來解決 GC 運行時程序長時間掛起的問題,那就三色標記法。三色標記最大的好處是可以異步執(zhí)行,從而可以以中斷時間極少的代價或者完全沒有中斷來進行整個GC。
我們將對象分為三種類型:
黑色:根對象,或者該對象與它的子對象都被掃描過。
灰色:對本身被掃描,但是還沒掃描完該對象的子對象。
白色:未被掃描對象,如果掃描完所有對象之后,最終為白色的為不可達對象,既垃圾對象。
以上圖為例,簡單說下三色標記的實現(xiàn)原理,首先如上圖所示,線程1已完成所有標記,所有對象都被標記成黑色;線程2還處于半完成狀態(tài),其中對象B本身已被掃描,但是還沒有掃描該對象的子對象。
由于垃圾回收的線程和正常業(yè)務線程都在執(zhí)行中,并沒有中斷,如果此時業(yè)務代碼如下:A.c = C B.c = null 則如下圖
此時線程1由于已經(jīng)完成所有掃描,則對象C被遺漏標記為黑色;而線程2完成掃描,將對象B標記為黑色,線程2此時也完成所有掃描,問題就來了,對象C被漏標了。
對象C被漏標的直接后果就是被回收。然而它的確還需要被對象A引用,這就是三色標記中的漏標問題。
如何解決并發(fā)標記中的漏標問題?
CMS的解決方案
Incremental Update 增量更新算法:
當一個白色對象被一個黑色對象引用,將黑色對象重新標記為灰色,讓垃圾回收器重新掃描。
G1的解決方案
STAB(snapshot at the beginning)算法:
開始做一個快照,當B引用C的關系消失的時候要把這個引用推到GC的堆棧中,保證C還能被GC掃描到,最重要的是要把這個引用推到GC的堆棧,是灰色對象指向白色的引用,如果一旦某一個引用消失掉了,就會把它放到棧(GC方法運行時數(shù)據(jù)也是來自棧中),JVM其實還是能找到它的,下回直接掃描它就行了,那樣白色就不會漏標。
對應 G1 的垃圾回收過程中的:最終標記( Final Marking)對用戶線程做另一個短暫的暫停,用于處理并發(fā)階段結(jié)后仍遺留下來的最后那少量的 SATB 記錄(漏標對象)。
對比兩種方案
SATB 算法是關注引用的刪除。(B對C 的引用)
Incremental Update 算法關注引用的增加。(A->C 的引用)
G1 如果使用 Incremental Update 算法,因為變成灰色的成員還要重新掃,重新再來一遍,效率太低了。所以 G1 在處理并發(fā)標記的過程比 CMS 效率要高,這個主要是解決漏標的算法決定的。
目前各大公司基本都使用CMS或者G1作為服務的垃圾回收器,但是在使用過程中如果出現(xiàn)一些奇怪的問題,有時候重現(xiàn)還特別難,考慮下這些垃圾回收器的底層實現(xiàn)原理,沒有百分之一百的完美方案,只有最適合自己業(yè)務場景的方案。希望大家在日常工作中多多思考,遇到問題才能迎刃而解。