垃圾收集(Garbage Collection,GC),其實主要需要完成3件事情:哪些內存需要回收?什么時候回收?如何回收?
對于程序計數器、虛擬機棧以及本地方法棧,這三塊內存區域是線程私有的,伴線程生,隨線程死,并且每一個棧幀需要的內存在類結構確定后基本就確定了,因此對于這幾塊區域,內存的分配以及回收具備可確定性,當方法結束或者線程結束時,內存隨之回收
但是對于堆以及方法區,運行時才能確定需要創建哪些對象,因此內存分配具備動態性以及不確定性。GC主要考慮的也是這部分區域的內存回收
對象測活
所謂的“對象測活”,也就是要確定哪些對象已經“死亡”,需要被回收
1.堆
對于堆中對象的測活,主要有兩種方法:引用計數、可達性分析
#引用計數
主要思想是:給對象添加一個引用計數器,每當有一個地方引用該對象時,計數器+1;當引用失效時,計數器-1;當計數器為0的時候,說明該對象已經沒有被引用,可以被回收
大多數時候,該算法足夠簡單、高效,但是缺點也顯而易見:不能處理循環引用的情況。比如對象1引用對象2,對象2也引用對象1,但是1和2已經沒有被其他地方引用,因此這兩個對象實際上應該被回收。但是由于他們互相持有對方的引用,彼此的引用計數器都不為0,所以不能被回收
#可達性分析
核心思想是:有一系列被稱為“GC Roots”的對象,從這些對象開始向下搜索,走過的路徑稱為引用鏈。當一個對象沒有被任何鏈路所引用,則判定此對象是不可達的,可以被回收
Java中可以被當作GC Roots的對象包括:虛擬機棧中引用的對象、方法區中靜態屬性引用的對象、方法區中常量引用的對象、本地方法棧中JNI引用的對象
2.引用類型
Java提供了4種類型的引用:強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference)
之所以提供這么多的引用類型,主要目的是為了滿足這類需求:對于一類對象,在內存充足的時候,則可以保存在內存中;但是當GC后內存依然捉襟見肘的時候,則可以拋棄這類對象,釋放內存。比較常見的一種使用場景是緩存
#強引用
強引用是最常見,也是使用最頻繁的一種引用,通過“Object obj = new Object()”創建的就是強引用,只要引用還存在,對象就不會被回收
#軟引用
對于軟引用引用的對象,在系統將要OOM之前,會將這類對象納入回收范圍之內進行回收,如果這次回收后依然沒有足夠內存,才會OOM。Java提供SoftReference類來實現軟引用
#弱引用
弱引用的引用強度低于軟引用,被弱引用引用的對象,只能存活到下一次GC前。當GC發生時,無論當前內存是否充足,被弱引用引用的對象都會被回收。Java提供WeakReference類來實現弱引用
#虛引用
虛引用是強度最弱的一種引用,既不能通過虛引用來獲得對象實例,也不會對其生存時間構成影響。唯一的作用就是在這個對象被回收之前能通收到一個系統通知。Java提供PhantomReference類來實現虛引用
3.回收方法區
方法區(HotSpot虛擬機的永久代)的GC主要是回收兩部分內容:廢棄的常量和無用的類
#廢棄的常量
廢棄常量的判定與堆中對象的測活十分相似,只要沒有地方再引用到這個常量,這個常量就被判定為“廢棄”,是可被回收的
#無用的類
類是否無用,判定條件相對苛刻,需要同時滿足3個條件:
1)該類所有的實例已經被回收
2)加載該類的ClassLoader已經被回收
3)該類對應的java.lang.Class對象沒有在任何地方被引用,也就是不能通過反射訪問
對于大量使用反射、動態代理、CGLib、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景,需要虛擬機具備類卸載功能,以避免方法區(永久代)溢出
GC算法
1.標記清除算法
“標記-清除(Mark-Sweep)”算法是最基礎的GC算法,整個過程包括“標記”和“清除”兩個階段。主要不足有兩點:
#標記和清除,這兩個階段效率都不高
#產生大量內存碎片,導致為大對象分配內存時,可能會提前觸發一次GC
2.復制算法
復制算法可以解決標記清除算法的兩個缺點,核心思想是:把內存一分為二(容量相同),每次只使用其中的一塊。當一塊內存用完后,將仍然存活的對象復制到另一塊上,然后再將這塊內存空間完整的清理掉
優點:簡單高效、無內存碎片
缺點:每次只能使用一半內存,50%的內存被“浪費”
現代的虛擬機采用這種算法回收新生代。新生代的對象大多“朝生夕死”,因此并不需要按照1:1的比例劃分內存,而是將一塊較大的內存分配個Eden區,另外將兩塊較小的內存分配給Survivor區
GC時,將Eden和正在使用的一塊Survivor中仍然存活的對象復制到另一塊Survivor中,然后將Eden和剛才在使用的那塊Survivor空間一起回收掉。在復制過程中,如果這塊Survivor空間不足,那么這部分對象將通過“分配擔?!保苯舆M入老年代
HotSpot虛擬機默認將Eden和Survivor比例設置為8:1(兩塊Survivor各占10%),也就是新生代中每次可用內存為90%,只有10%會被“浪費”
3.標記整理算法
復制算法在對象存活率較高時,會進行大量的內存復制,效率會降低。另外還需要額外的空間進行“分配擔保”,以應對對象全部存活的極端情況。因此復制算法并不適用于老年代
標記整理算法與標記清除算法在標記階段一樣,但后續并不直接清理內存,而是將存活的對象向一側移動,之后再清理掉邊緣以外的內存
4.分代收集算法
分代收集算法嚴格來說并不是什么新算法,核心思想是將堆劃分為新生代和老年代,根據新生代和老年代不同的特點,采用不同的GC算法
新生代對象朝生夕死,存活率較低,采用復制算法,只需要少量的對象復制,就可以完成垃圾收集;老年代對象存活較久,并且沒有額外空間進行分配擔保,所以通常采用標記清除或者標記整理算法
HotSpot中GC算法實現
1.枚舉根節點
現代應用內存分配較多,如果完全對GC Roots進行枚舉往往需要很多時間。另外在此期間,為了保證分析結果的準確性,需要暫停所有用戶線程(Stop The World,STW)
目前主流的虛擬機都采用準確式GC,因此并不需要一個不漏的檢查完所有執行上下文和全局的引用位置,虛擬機應當是有辦法得知哪些地方存放著對象引用的。在HotSpot中,使用稱為OopMap的數據結構來達到上述目的。在類加載完成的時候,虛擬機會把對象內什么偏移量上是什么數據類型計算出來。這樣在GC時就可以得知這些信息了
2.安全點
通過OopMap,HotSpot虛擬機可以快速準確的完成根節點枚舉。但是如果為每條指令都生成OopMap,那么GC的空間成本將會大大增加
實際上,只會在稱為安全點(Safe Point)的特定位置記錄這些信息,也就是說GC并非隨時都可運行,只有到達安全點時才可以。安全點太少,會導致GC等待時間太長;過多,會過分增加系統負載。單條指令執行太快,所以不可能在任何指令后都設置安全點,而是要選擇“能夠讓程序長時間運行”的點,符合這種特征的點有:方法調用結束前、循環末尾、可能發生異常的位置等
有了安全點,下面面臨的一個問題是,如何在GC發生時讓所有線程都跑到安全點,主要有兩種方式:搶先式中斷和主動式中斷
#搶先式中斷
搶先式中斷,就是在GC發生時,搶先中斷所有線程,如果發現有線程不在安全點,那么恢復該線程,讓他跑到安全點。但是現在幾乎沒有虛擬機采用此方式
#主動式中斷
主動式中斷,就是在GC發生時,不直接對線程進行操作,而是僅僅設置一個標志位,讓各個線程主動去輪詢,當線程發現這個標志時,把自己中斷掛起。輪詢標志會被設置在和安全點重合的位置,或者創建對象時需要分配內存的位置,因此線程中斷掛起的位置正好在安全點上
3.安全區域
無論采用上述哪種中斷方式,都需要線程自己跑到安全點。但是對于例如處于Sleep或者Blocked狀態的線程,顯然是沒有辦法自己跑到安全點的,這時候就需要安全區域(Safe Region)
如果在一段代碼中,引用關系不會發生變化,也就是說在這個區域內是隨時可以開始GC的,那么這段區域就可以視為安全區域
線程在執行到安全區域時,首先會標記自己已經進入安全區域,當GC發生時,系統就不用管這些已經進入安全區域的線程了。當線程需要離開安全區域的時候,會檢查系統是否已經完成了根節點枚舉甚至完成了整個GC過程,如果沒有完成,那么線程就會等待,直到收到可以離開安全區域的信號為止
垃圾收集器
首先需要說明的一點是,沒有最好的或者萬能的收集器,只應當選擇最適合使用場景的收集器
1.Serial及Serial Old收集器
Serial收集器是最基本、最簡單的收集器,歷史也最為悠久,采用復制算法,用于新生代GC。Serial Old則是其老年代版本,采用標記整理算法
如其名,它是一款單線程收集器,在工作時需要暫停其他所有線程。如果需要掃描的內存很大,STW將是一場災難
2.ParNew收集器
ParNew收集器實際上就是Serial收集器的多線程版本,使用多線程進行GC。由于多線程切換的開銷,因此在單CPU的環境下,ParNew不會比Serial有更好的表現。但是對于多CPU(或者多核)環境來說,ParNew則可以更好的利用CPU資源進行GC。默認情況下,它的GC線程數與CPU數量相同,可通過啟動參數-XX:ParallelGCThreads調節
3.Parallel Scavenge及Parallel Old收集器
Parallel Scavenge收集器是一個新生代收集器,采用復制算法,用于新生代GC。Parallel Old則是其老年代版本,采用標記整理算法。
與其他更加關注用戶線程停頓時間的收集器(比如CMS收集器)不同,Parallel Scavenge收集器更加注重可控的吞吐量(換句話說就是GC耗費時間的占比)
Parallel Scavenge收集器提供兩個參數控制吞吐量:-XX:MaxGCPauseMillis和-XX:GCTimeRatio
#MaxGCPauseMillis
MaxGCPauseMillis參數使收集器盡可能保證GC在設定時間內完成。這個值并不是越小越好,因為這個值設置的小,并不代表GC速度會更快。相反,GC停頓時間縮短是以犧牲吞吐量和新生代空間換取來的。新生代小了當然單次回收會更快,但是GC的頻次卻會增加
#GCTimeRatio
GCTimeRatio參數設置的是GC時間占比,比如設置為19,那么允許的最大GC時間占比就是1/20,系統默認值是99
由于與吞吐量密切相關,所以Parallel Scavenge收集器也被稱為“吞吐量優先”收集器。除了上述兩個參數外,-XX:+UseAdaptiveSizePolicy也值得關注。UseAdaptiveSizePolicy是一個開關參數,當開啟時,就無需再手動設置新生代大小、Eden和Survivor的比例等細節參數,虛擬機會根據系統的實際運行監控數據,動態的調整這些參數以提供合適的GC提頓時間或者吞吐率
4.CMS收集器
在介紹Parallel Scavenge收集器的時候,我們提到CMS收集器是更加關注用戶線程停頓時間的收集器。這種特征更加適用于諸如互聯網服務這類尤其重視服務響應速度的需求
CMS全稱Concurrent Mark Sweep,可以看出其基于標記清除算法,整體來看分為4個步驟:初始標記、并發標記、重新標記以及并發清除。其中初始標記和重新標記階段仍然需要STW
#初始標記,僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快
#并發標記,就是進行根節點枚舉,此過程通常較長
#重新標記,是將并發標記過程中因用戶線程產生變動的對象進行重新標記記錄,比并發標記過程要短
#并發清除,執行清除,可與用戶線程并非執行
由于最耗時的并發標記和并發清除階段可以與用戶線程一起并發執行,因此該收集器的執行過程從整體上看是與用戶線程一起并發執行的
CMS收集器優點是并發收集、低停頓,但是缺點也很明顯:
#對CPU資源敏感。雖然在并發階段不會導致用戶線程停頓,但是會占用一部分線程資源,導致系統處理能力降低
#無法處理浮動垃圾。所謂“浮動垃圾”就是指在并發清除階段,由于用戶線程并發運行所產生的垃圾。這部分垃圾由于是出現在標記過程之后,因此在本次GC中是無法被收集掉的,只能等下次GC時處理。由此可以看出,CMS在運行時需要留給用戶線程足夠的內存空間正常運行,所以也就不能像其他收集器那樣等到內存幾乎快滿了才開始GC。當CMS運行時,如果預留的內存不能以滿足程序需求,將會觸發“Concurrent Mode Failure”,這時虛擬機將臨時啟用Serial Old收集器來進行GC,這個過程停頓就很長了,性能反而下降
#產生內存碎片。基于標記清除算法的收集器都有這個問題。碎片過多時,將給大對象分配帶來很大麻煩,有時候老年代還有很大剩余空間,但是由于一個大對象無法分配,不得不提前觸發一次GC。CMS提供了相關參數設置內存碎片合并,但合并過程是無法并發的,將導致停頓時間變長
5.G1收集器
G1收集器面向服務器應用,相比其他收集器,具備以下特征:
#并發與并行。G1能夠充分利用多CPU(或者多核)的硬件優勢來縮短STW時間,部分在其他收集器需要停頓用戶線程的操作,G1可通過并發的方式讓用戶線程繼續執行
#分代收集。與其他收集器中分代收集(通過對新生代和老年代使用不同的收集器)不同的是,G1能夠獨自完成整個堆的GC,它能夠對不同年齡的對象采用不同的方式處理
#空間整合。G1從整體上看采用標記整理算法,從局部來看(兩個Region之間)采用復制算法。這兩種算法可以保證不會產生內存碎片,GC后可提供規整的可用內存
#可預測的停頓。G1提供可預測的停頓時間模型,能夠明確指定在一段時間內GC時間不超過指定時間
在使用G1時,雖然仍然保留新生代、老年代的概念,但堆內存已經被劃分為大小相同的獨立Region。新生代與老年代不再物理隔離,而都是多個Region的集合(Region不要求連續)
G1跟蹤各個Region里面垃圾收集的價值(回收空間與回收時間的經驗值/性價比),在GC時優先回收收益最高的Region,從而避免對整個堆內存進行回收。這就是G1能夠提供可預測的停頓時間模型的原因
G1的原理似乎很簡單,但是細節卻十分復雜,比如不同Region之間對象的引用問題,如果進行全堆掃描,效率會降低很多。
G1的執行大致分為幾個步驟:初始標記、并發標記、最終標記、篩選回收
#初始標記,僅僅只是標記一下GC Roots能直接關聯到的對象。雖然需要停頓用戶線程,但是速度很快
#并發標記,就是進行根節點枚舉。此過程通常較長,但是可以與用戶線程并發執行
#最終標記,是將并發標記過程中因用戶線程產生變動的對象進行重新標記記錄。此過程需要停頓用戶線程,但是可以并行執行
#篩選回收,根據設定的期望停頓時間,對各Region回收收益進行權衡,然后回收內存。此過程需要停頓用戶線程,但是由于只需要回收一部分Region,時間可控,并且停頓用戶線程可以大幅提高回收效率
對象的內存分配
對象內存的分配,從整體上看,是在堆上分配。對象被分配在Eden區,如果啟用了TLAB,將分配在TLAB上,少數情況下還可能直接分配在老年代中。規則不是完全固定的,細節取決于使用的GC收集器組合以及JVM中內存相關的參數設置。接下來講解幾條基本的、普遍的分配規則
1.對象優先分配在Eden
大多數情況下,對象優先在Eden上分配,當Eden內存不足時,將觸發一次Minor GC
2.大對象直接進入老年代
大對象,指的是需要很大連續內存空間的對象。大量創建大對象,對虛擬機來說是個壞消息,因為很容易導致雖然還有很大內存,但是卻沒有連續內存存放大對象,從而提前觸發GC,另外也會導致新生代GC過程中在Eden和兩個Survivor區之間大量的內存復制操作
虛擬機提供-XX:PretenureSizeThreshold參數來設置需要內存超過一定閾值的大對象將直接分配在老年代
3.長期存活的對象將進入老年代
哪些對象該進入老年代?這就引入了一個age的概念,虛擬機會給每個對象定義一個對象年齡。如果對象在Eden中經過第一次Minor GC后仍然存活,并且能夠被Survivor容納,那么它將被轉移到Survivor中,并且age=1。之后每熬過一次Minor GC,age增加1歲。當age達到一定程度(默認15,可通過-XX:MaxTenuringThreshold設置)后,對象將會被晉升到老年代
但是,對象并非需要嚴格熬到MaxTenuringThreshold才能晉升到老年代。如果在Survivor空間中相同年齡所有對象占用內存總和大于Survivor空間的一半時,年齡大于等于該年齡的對象就會直接被晉升到老年代
4.空間分配擔保
在進行Minor GC之前,虛擬機會檢查老年代最大可用連續空間是否大于新生代所有對象的總空間。如果大于,說明這次Minor GC就是安全的。否則,會檢查是否允許擔保失敗。如果不允許,那么進行Full GC。否則,繼續檢查老年代最大可用連續空間是否大于歷次晉升到老年代對象的平均大小。如果小于,依然進行Full GC。否則將嘗試進行Minor GC,但這次Minor GC是有風險的
上面提到了風險,由于新生代采用復制算法進行Minor GC,每次只有一個Survivor區(默認10%)用于空間輪換,自然無法100%保證有足夠空間用于輪換,因此就需要老年代為此提供擔保
與現實中的擔保一樣,擔保人需要有足夠的能力保證被擔保人無力償還貸款時自己有能力代為償還。老年代也需要保證自己有足夠的空間容納這些對象,但是會有多少對象存活在GC前是無法知道的,因此會取歷次晉升到老年代對象的平均大小作為經驗值,與老年代最大可用連續空間做比較,再決定是否需要進行Full GC
這種手段是一種概率手段,既然是概率手段,那么終歸會有失敗的可能。如果失敗了,那么就將觸發一次Full GC。但是在大多數情況下,Full GC并不會發生。因此從概率上來看,這種方案有助于性能提升
思維導圖:
筆記2結束