你是不是垃圾,心里沒點數嗎?
這一篇就來聊一聊GC,聊聊我們的垃圾回收器,我們知道Java的垃圾回收機制與C++的有所不同,作為Java程序員不用在程序中自己釋放內存,自己去管理內存,對于內存使用似乎是“肆無忌憚”一樣。
然而,這背后一切的原因就是JVM的GC已經幫我們做了這些事,能夠幫我們自動管理這些事,當內存緊張的時候,就會觸發垃圾回收機制,騰出足夠的空間來供我們程序使用。
但是,JVM的GC也不是萬能的,也有翻車的時候,比如碰到過內存泄露的時候,就會導致GC的內存回收的效率低下,甚至出現OOM的異常。
作為Java程序員,我們要做的就是要保證GC正常工作,基于這種情況下對于GC的工作的原理和GC的使用的場景就得有所了解。
下面就開始我們的正文,這里我畫了一個介紹這篇文章主要內容的思維導圖:
首先先來聊聊哪些對象應該被GC,JVM是怎么判斷一個對象是否存活的呢?
判斷對象存活
想要判斷對象是否存活,有兩種方法:
- 引用計數法
- 可達性分析算法
引用計數法
第一個引用計數法實現簡單,效率高。它的原理就是在對象內部維護一個計數器,當有地方引用它的時候,計數器+1,當有地方不再引用它的時候,計數器-1。
就這樣,當計數器為零的時候,表示沒有地方引用它,那么這個對應就應該要被GC回收了。
但是這種算法卻很少被Java虛擬機使用,主要原因是它有漏洞:無法解決循環引用的問題:
可達性分析算法
第二種就是可達性分析算法,它是以一組GCRoots為起點,根據引用鏈的關系向下搜索,若是某個對象與GCRoot之間沒有任何的引用鏈,則這個對象是不可達的,也是將來會被回收掉的。
這種算法被引用在主流的Java虛擬機中,比如HotSpot,那哪些對象可以作為GCRoots呢?主要有以下的對象可以作為GCRoots:
- 虛擬棧中引用的對象。
- 方法區中的靜態變量。
- 方法區中的常量。
- 以及本地方法棧JNI引用的對象(這個可以忽略,我們幾乎沒有接觸)。
以上比較常見的就是方法區中的靜態變量和常量的引用對象,知道了怎么判斷對象是否存活,下面就是用可達性分析算法,用到具體的垃圾回收算法上。
垃圾回收算法
對于垃圾回收算法,我這里就不做過于詳細的介紹,就簡單介紹一下,因為之前已經寫過一篇比較詳細的文章了,大家可以參考一下:還在學JVM?我都幫你總結好了(附腦圖)
常見的垃圾回收算法就這三種:
- 標記-清除
- 復制算法
- 標記-整理(壓縮算法)
我們知道年輕代基本都是朝生夕死,所以都是使用復制算法,復制的成本低,基于這種分代模型理論,也就出現了后面垃圾回收器的Eden區、From Survivor區、To Survivor空間(默認8:1:1)。
新生代中每次都只有Eden和其中的一個S區可用,當Eden區滿了,就會將存活的對象復制到其中的一個S區中,若是S區也滿了,此區域不滿足晉升條件的對象就會被復制到到另一個S區中。就這樣對象每經歷一次Minor GC年齡就會+1,當達到晉升年齡的閾值,對象還沒被垃圾回收掉,就會被放入;老年代。
而老年代使用的是標記-清除或者標記-整理,對于標記-清除我們知道它最大的缺點就是會產生內存碎片,但是他也有自己的好處(相比標記-整理),就是不用移動對象,所以效率相比標記-整理要高。
而標記-整理完整的過程應該是標記-整理-清除三個步驟,需要將存活的對象向一邊移動,然后清理掉不可達的對象,所以它的效率也會比較低,尤其是老年代這種區域,有大量的對象存活,那么對于移動對象所耗費的性能也是可觀的。
還有一點比較重要的是:新生對象的內存分配的角度。這個是很多技術博文都忽略的一點,這一點也是比較重要的,在《深入JVM虛擬機 第三版》中也有特別強調這一點。
從內存分配的角度來看:對于標記-整理和復制算法,都是整理的規整空間,所以他們倆對于新產生的對象進行分配內存的時候,是比較簡單高效的,特別是對于一些大對象的分配以及連續內存對象的分配(數組)。
標記-整理和復制算法只需要內存地址指針移動與對象一樣大小的位置,便可完成內存分配,這樣高效簡單。
而對于標記-清除法,因為產生了內存碎片,所以它必須要記住哪些地方是可用的,哪些地方是不可用的,這樣內存分配的效率就會低很多。
知道了具體的垃圾回收算法,下面就來聊聊具體的垃圾回收器。
垃圾回收器
根據分離代理模型,對于不同的區域設計出了不同的垃圾回收器,對于經典的垃圾回收器主要有這么幾種:
- Serial(新生代)
- SerialOld(老年代)
- PS(新生代)
- PO(老年代)
- ParNew(新生代)
- CMS(老年代)
- G1
對于以上幾種的垃圾回收器,可以選擇不同的老年代和年輕代進行搭配使用,主要有以下的搭配方式:
Serial
Serial系列的垃圾回收器,現在也基本沒人用了,它的使用原理就是使用單線程來進行垃圾回收,所以STW的時間也會比較長,實現簡單。老年代的SerialOld使用的是標記-整理的算法來回收垃圾。
來源于深入JVM虛擬機
在若是你的服務器還處在單核時代,內存只有那么幾十M到百來M,可能Serial是最優的搭配選擇。
對于Serial的相關JVM參數有:-XX:+UseSerialGC(使用Serial垃圾回收器)。
Parallel
當發展到多線程時代,PS和PO的搭配就出現了,PS和PO相對于Serial比較來說,就是垃圾回收的時候是使用的是多線程,其它的一樣,包括使用的垃圾回收算法也一樣,所以在多核時代,它相比Serial STW的時間變得更短了:
這里涉及到一個吞吐量的概念:吞吐量 = 用戶應用程序運行的時間 / (應用程序運行的時間 + 垃圾回收的時間),因為PS+PO的搭配是追求吞吐量的垃圾回收器。
因此PS+PO的組合比較適用于后臺快速完成計算任務,不需要太多的與用戶交互的場景。
與PS+PO有關的JVM參數如下所示:
- -XX: +UseParallelGC:開啟ParallelGC。
- -XX: +UseParallelOldGC:開啟老年代的ParallelGC,和上面的任意開啟一個就行。
- -XX: ParallelGCThreads:指定線程數。
- -XX:MaxGCPauseMillis:控制最大垃圾收集停頓時間(毫秒數)
- -XX:GCTimeRatio:直接設置吞吐量大小(大于0小于100的整數)
- -XX:+UseAdaptiveSizePolicy:當這個參數被激活后,就不需要制定新生代的大小(-Xmn)、Eden和S區的比例(-XX:SurvivorRatio)、晉升老年代對象的大小(-XX:PretenureSizeThreshold)等參數,虛擬機會自己動態的調整。
PS和PO是Java 8默認的垃圾回收器,不知道各位讀者的Java的版本是多少,已經使用Java 8好久了。
ParNew
ParNew實際上和Parallel的實現原理基本相同,唯一不同的是它可以和CMS搭配使用,而PS是沒辦法與CMS搭配使用,這也使得ParNew火起來,當JVM中設置了使用CMS作為老年代的回收器的時候,新生代的垃圾回收器默認就是ParNew。
CMS
CMS可以說是跨時代的一款垃圾回收器,它實現了垃圾回收與用戶線程并發進行,在它是一種以獲取最短垃圾停頓時間為目的的垃圾回收器。
特別適用于用戶頻繁交互的場景,它的實現過程分為以下四個階段:
- 初始標記
- 并發標記
- 重新標記
- 并發清理
其中初始標記和重新標記是需要STW的,而并發標記和并發清理垃圾回收線程與用戶線程并發執行。
初始標記階段僅僅是標記GC Root直接關聯的對象,并不會遍歷整個對象圖,所以速度很快。
并發標記階段就是從GC Root開始遍歷整個對象圖的過程,這個過程是四個階段最耗時的過程,因此此階段也是與用戶線程并發執行的,不需要停頓用戶線程。
重新標記階段是修正在并發標記階段因用戶線程運行產生一些對象的引用關系變動的標記記錄,因為在并發階段用戶線程與垃圾回收線程是并發執行的,那么就有可能之前已經標記的,它的引用關系又被改變了,這需要在這個階段重新修正。
并發清理因為不用移動用戶對象,因此可以與用戶線程一起并發執行,最后清理掉不可達的對象。
四個階段中其中最復雜的就是第三階段并發標記,其中涉及到的一個重要概念就是三色標記法,第三階段在標記的過程有可能對對象產生漏標或者多標的現象,那CMS又是怎么來解決這兩個問題的呢?
我們先來詳細了解一下三色標記法。三色標記法中將對象分為白色、灰色、黑色的過程。
- 白色:白色是對象默認的顏色,從GC Root開始掃描,如果不可達的對象的就是白色,在并發清理階段就會被清理掉。
- 灰色:灰色表示當前對象已經被掃描,但是當前對象所依賴的其他對象還沒有被掃描。
- 黑色:黑色表示當前對象和它所依賴的對象都已經被掃描過。
那它又是怎么產生多標和漏標的呢?下面來畫圖看看:
開始有三個對象,分別是對象1和對象2以及對象3,三個對象與GC Root之間都存在引用鏈,當開始進行標記,就會從GC Root開始掃描。
當掃描了對象1和對象2的時候,因為對象2沒有再依賴的引用,所以它會變成黑色,而對象1還引用著對象3,并且對象3還沒有掃描,所以對象1變成灰色。
若是,此時用戶線程將對象3與對象1之間的引用關系改變了,變成了對象2與對象3之間有引用關系,因為對象2已經掃描完了,對象3還沒掃描,此時應該是對象2是灰色的狀態,并且對象3是白色的狀態,對象3就會被回收掉,這就出現了漏標的情況。
多標的情況就是當對象1和對象3之間開始有引用鏈,并且都已經標記為黑色,此時用戶線程又把對象3設置為null,那么此時按理來說對象3應該被回收的,但是因為是黑色并不會被回收掉,所以出現了多標,多標的情況可以在下次垃圾回收的時候,進行重新標記,被重新回收,所以多標并不會是GC回收的過程出現bug。
而漏標就需要解決了,不然GC回收就會出現bug,對于漏標CMS給出的解決方案是增量更新的方法。它的原理就是假如對象3的引用變成了對象2,那么對象2就會變成灰色,并且對象2會被集合里面,在重新標記的階段以對象2為根節點向下掃描。
這樣CMS就解決漏標的問題,并且實現了整個GC Root對象圖的時候,能夠與用戶線程并發執行,大大減少了STW的時間。
那為什么CMS又選擇標記-清除算法呢?因為假如選擇標記-整理算法,在并發清理階段因為要進行整理,涉及對象的移動,此時就不能與用戶線程一起并發操作,這樣清理階段就必須STW,就違背了CMS設計初衷:獲取最短回收停頓時間。
與CMS有關的JVM參數如下所示:
- -XX:+UseConcMarkSweepGC:使用CMS垃圾收集器(當設置這個參數后,年輕代默認會開啟ParNew)。
- -XX:+UseCMSCompactAtFullCollection:用于在CMS收集器不得不進行FullGC時開啟內存碎片的合并整理過程,由于這個內存整理必須移動存活對象,清理階段是無法并發的,此參數從JDK9開始廢棄。
- -XX:CMSFullGCsBefore-Compaction:多少次FullGC之后壓縮一次,默認值為0,表示每次進入FullGC時都進行碎片整理,此參數從JDK9開始廢棄。
- -XX:CMSInitiatingOccupancyFraction:當老年代使用達到該比例時會觸發FullGC,默認是92。
- -XX:+UseCMSInitiatingOccupancyOnly:這個參數搭配上面那個用,表示是不是要一直使用上面的比例觸發FullGC,如果設置則只會在第一次FullGC的時候使用-XX:CMSInitiatingOccupancyFraction的值,之后會進行自動調整。
- -XX:+CMSScavengeBeforeRemark:在FullGC前啟動一次MinorGC,目的在于減少老年代對年輕代的引用,降低CMSGC的標記階段時的開銷,一般CMS的GC耗時80%都在標記階段。
- -XX:+CMSParallellnitialMarkEnabled:默認情況下初始標記是單線程的,這個參數可以讓他多線程執行,可以減少STW。
- -XX:+CMSParallelRemarkEnabled:使用多線程進行重新標記,目的也是為了減少STW。
CMS的出現是有著非常重要的意義,它為后面更加智能的垃圾回收器G1、ZGC的出現奠定了基礎,首次實現:用戶線程與垃圾回收線程并發執行,但是慢慢的CMS也退出了舞臺。
你會發現關于CMS的很多相關的JVM參數在jdk9已經廢棄,并且在jdk9,默認的垃圾回收器已經不再是PS+PO了,已經變成了G1,說明JVM設計團隊認為G1已經可以取代以前的垃圾回收器了(我還停留在jdk8,手動狗頭),我相信應該還有很多人在jdk8吧,哈哈哈。
G1
最后聊得就是G1了,G1因為它的優勢也成為了jdk9的默認垃圾回收器。它與其他的回收器不同的是,它將整個堆劃分為很多個Region,每個Region的大小大概在1M-32M之間。
它不在像以前的垃圾回收器一樣將整個堆劃分為年輕代和老年代,它的衡量標準是以哪塊Region回收的利益最大,這就是G1的MixedGC模式。
G1的階段過程和CMS有異曲同工之妙,也是分為四個階段:
- 初始標記
- 并發標記
- 最終標記
- 篩選回收
初始標記和CMS一樣,需要STW,只是標記GC Roots直接關聯的對象,時間會非常的短。
并發標記也是與用戶線程一起并發執行,需要從GC Root開始遍歷整個對象圖,也是消耗時間最長的階段。
最終標記階段用于處理并發階段結束后仍遺留下來的最后那少量的SATB記錄,也就是并發標記階段引用關系重新改變的對象。
最后就是回收階段,因為G1使用的是標記-整理算法,所以涉及到對象的移動,所以這個階段是需要STW的,必須暫停用戶線程,由多條線程來執行垃圾回收。
最后來聊一聊G1實現的一些小細節,一個是在并發標記階段,它是怎么解決新對象內存分配的問題?另外一個最重要的細節就是它是怎么建立起可預估的停頓時間模型?在G1和CMS之間如何做選擇呢?
先來看看第一個問題,在并發標記階段用戶線程也是在執行的,在執行就會產生新的對象,G1是為每一個Region設計了兩個名為TAMS(TopatMarkStart)的指針。
并且把Region中的一部分空間劃分出來用于新對象的內存分配,在并發回收時新分配的對象地址都必須要在這兩個指針位置以上。
然后第二個細節就是G1在垃圾回收的過程中會記錄每一個Region的回收耗時,花費的成本,并且根據多次計算出平均值,這樣能夠預估每一個Region的垃圾耗時,然后根據程序中設定的最短垃圾回收時間,估算回收哪一些Region是利益最大的。
那么在G1和CMS之間是如何進行選擇的呢?對于小內存的(1G-4G)CMS的可能會優于G1,而對于大內存的(6G-8G)的可能G1就會顯現出自己的優勢。
最后與G1有關的JVM參數如下:
- -XX:G1HeapRegionSize:設置每個Region的大小,取值范圍為1MB~32MB。
- -XX:MaxGCPauseMillis:設置垃圾收集器的停頓時間,默認值是200毫秒。
好了有關于垃圾器的就聊到這里,還有一個也是比較經典的就是ZGC,有興趣的可以自行去了解一下,限于篇幅原因,這一片關于JVM的垃圾,我們就聊到這里,下一篇繼續深入聊JVM,我是黎杜,我們下一期見。
本文轉載自微信公眾號「黎杜編程」,可以通過以下二維碼關注。轉載本文請聯系黎杜編程小熊公眾號。