Java垃圾回收机制详解(万字总结!一篇入魂!)
Java垃圾回收機制詳解
之前在《Java內存區域詳解》這篇文章中,詳細介紹了JVM內存區域的劃分,以及創建對象時內存的分配情況。Java的自動內存管理機制,除了自動申請內存還會自動釋放內存,這篇文章就來說一說Java內存回收機制。
首先我們要明確幾點,問什么要回收內存?哪些內存是需要回收的?什么時候回收?應該怎樣回收?(Why?What?When?How?)
舉個例子,垃圾桶,你平時制造出來的垃圾都隨手扔到了垃圾桶里,這垃圾桶就好比堆空間,你扔進去一個垃圾,就好比在堆空間自動申請的一塊內存來存放這個垃圾,可以垃圾桶的容量畢竟有限,一旦垃圾桶堆滿了垃圾,你需要將垃圾桶里面的垃圾打包清理掉,然后才能繼續往垃圾桶里扔垃圾。一樣的道理,你如果不斷的創建對象,而不及時釋放掉那些沒有的內存的話,相同內存遲早會被消耗完,造成OOM進而拖垮整個程序,所以內存需要回收,將回收回來的內存整理重新分配給需要的對象使用。怎樣判定堆空間里面的哪些內存需要被回收?不可能閉著眼一下子把堆空間里面對象都給回收掉吧,當然不行,只有那些被JVM視為垃圾,才會被回收掉。那么何為垃圾?垃圾是指在運行程序中沒有任何指針指向的對象,這個對象就是需要被回收的垃圾,沒有任何指針指向的對象,也就是說不會再有任何其它對象使用它了,那么它還占用著內存干嘛,直接把它回收掉,騰出來內存給別的對象使用。
接下來就來說說Java垃圾回收機制,看看是怎么進行自動回收的,什么時候回收的。
因為幾乎所有的對象都在堆空間中分配內存,堆空間又劃分成了新生代和老年代,所以說堆空間是垃圾回收機制的重點關注對象。至于永久代/元空間,基本上很少發生垃圾回收,因為它觸發垃圾回收機制的條件比較苛刻。之前我們在講堆空間內存結構劃分的時候,已經簡單介紹過堆空間的垃圾回收,現在的垃圾回收機制大部分都是基于分代收集理論實現的。再來回顧下堆空間的內存結構劃分:
我們說垃圾回收機制回收的是那些被JVM視為垃圾的對象,那么JVM該如何判斷一個對象是不是垃圾呢?這就涉及到了垃圾回收的相關一些算法,主要可以分為垃圾標記階段的算法和垃圾清除階段的算法,只有先標記出來哪些是垃圾,才能進行后續的清除操作。
標記階段算法:
- 引用計數算法
- 可達性分析算法
清除階段算法:
- 標記-清除算法
- 復制算法
- 標記-壓縮算法
垃圾標記階段主要是來判斷對象是否存活,先來介紹一下引用計數算法
引用計數算法比較簡單,對每個對象保存一個整型的引用計數器屬性,用來記錄對象被引用的情況。如果當前對象被其它任何一個對象引用了,那么當前對象的引用計數器的值就加1,如果其它對象取消了對當前對象的引用,那么引用計數器的值就減1。如果當前對象的引用計數器的值為0,說明當前對象不再被任何其它對象所引用,JVM就會把這個對象標記為垃圾,可以進行回收。引用計數算法的優點可以出來,實現方式非常簡單,只需要維護一個引用計數器即可,但是它也存在很多缺點,維護一個單獨的引用計數器,需要額外的內存開銷,每次引用改變都需要更新計數器,同樣也有額外的時間開銷,最嚴重的一個問題就是,引用計數器無法處理循環引用的情況,當幾個對象的引用形成一個閉環的時候,無法將其標記為垃圾,從而導致內存泄漏。由于這個問題的存在,因此Java垃圾回收機制并沒有采用引用計數算法。
再來說一下Java選擇的標記階段的算法,可達性分析算法
可達性分析算法,有的地方也稱為根搜索算法,相對于引用計數算法,有效的解決了循環引用的問題,防止了內存泄漏的發生。這個根搜索算法維護了一個GC Roots根集合,這個集合就是一組活躍的引用。你想一下,如果幾個對象存在著引用關系,那么從一個根對象出發,沿著引用即可到達下一個對象。
可達性分析算法的基本思路就是:
-
以根對象GC Roots為起點,按照從上到下的方式搜索被根對象集合所連接的目標對象是否可達
-
使用可達性分析算法后,內存中的存活對象都會被根對象集合直接或者間接連接著,搜索所走過的路徑被稱為引用鏈
-
如果目標對象沒有任何引用鏈相連,則是不可達的,說明該對象可以被視為垃圾進行回收
-
在可達性分析算法中,只有能夠被根對象集合直接或者間接連接的對象才是存活對象
你可能會想這個GC Roots里面的對象是什么?哪些對象可以被當做根對象?
GC Roots一般包括以下幾類元素:
-
虛擬機棧中引用的對象
棧幀中的局部變量表中的變量
-
本地方法棧中引用的對象
-
方法區中類靜態屬性引用的對象
java類的引用類型靜態變量
-
方法區中常量引用的對象
字符串常量池里面的引用
-
所有被同步鎖synchronized持有的對象
-
JVM內部的引用
基本數據類型對應的Class對象,異常對象,系統類加載器等等運行時所必須的對象
除了上述的這些固定的GC Roots集合以外,根據不同的垃圾回收器以及當前回收的內存區域不同,還可以有其他對象臨時性加入進來,共同構成完整GC Roots集合。比如分代收集和布局回收(Partial GC),如果只針對堆空間中某一塊區域進行垃圾回收,像是Minor GC指對新生代區域進行垃圾回收,必須考慮到新生代區域的對象完全有可能被老年代等其他內存區域里面的對象所以用,此時就需要一并將關聯的其它區域里面的對象也加入到GC Roots集合中去考慮,才能保證可達性分析的準確性。要注意一點!如果要使用可達性分析算法來判斷內存是否可以回收,那么分析工作必須在一個能保證一致性的快照中進行,這點不滿足的話分析結果的準確性就無法保證。就比如當前時間點正在判斷哪些對象是垃圾,但是對象之間的引用是在不斷變化的,有可能沒有被引用的對象在這個時間點突然被引用了,而原來被引用的對象突然又被取消引用了。所以說我們必須在保證一致性快照中進行可達性分析,這點也是導致GC進行時必須STW的一個重要原因,即使是號稱幾乎不會發生停頓的CMS垃圾回收器,在枚舉根節點時也是必須要停頓的,至于STW是什么我們之后會提及到。
說一下對象的finalization機制
JVM垃圾回收機制可以說是給內存的管理帶來了極大的方便,但是某些時候,我們需要在對象被回收銷毀之前需要再做一些其它自定義的處理操作,JVM考慮到這一點,當回收垃圾對象之前,總會先調用這個對象的finalize()方法,這個方法是Object類中定義的空方法,目的就是允許在子類中被重寫,用來在對象銷毀之前自定義一些處理邏輯。一般主要用來在對象回收之前進行資源釋放操作,比如關閉文件、套接字和數據庫連接等等。
對于finalize()方法,不要主動去調用這個方法,交給垃圾回收機制就好了,因為如果你主動去調用這個方法,很有可能會導致對象復活,而且方法的執行是沒有保障的,它完全由GC線程決定,極端情況下如果沒有發生垃圾回收,這個方法就得不到執行的機會,并且執行這個方法還會影響垃圾回收的性能。
由于這個方法的存在,虛擬機中的對象一般處于三種可能的狀態:
- 可觸及的:從根節點出發,可以到達這個對象
- 可復活的:對象的所有引用都被釋放,但是對象有可能在finalize()中復活
- 不可觸及的:對象的finalize()方法被調用,并且沒有復活,那么就會進入不可觸及的狀態,不可觸及的對象無法被復活,因此finalize()方法只會被調用一次
如果說從所有的根對象都無法訪問到某個對象時,也就是說這個對象不可達,說明這個對象已經是個垃圾了,可以進行回收。但事實上,也并非非得被回收,可以理解為允許嘗試去回收這個對象,但不一定必須回收,很有可能這個對象在某個條件下會“復活”自己。
判定一個對象是否真正可被回收,需要至少進行兩次標記:
如果說GCRoots到某個對象沒有引用鏈,判定這個對象是不可達的,進行第一次標記。
判斷這個對象是否需要執行finalize()方法
(1)如果這個對象沒有重寫finalize()方法,或者finalize方法已經被JVM調用過了,則判定這個對象的finalize方法不需要執行,對象被判定為不可觸及狀態
(2)如果對象重寫了finalize方法并且還沒執行過,這個對象會被插入到隊列中,判定為可復活狀態,有一個JVM自動創建的、低優先級的Finalizer線程觸發其finalize方法
(3)finalize方法可以說是對象逃脫回收命運的最后機會,因為稍后垃圾回收機制會對隊列中的可復活狀態的對象進行第二次標記。如果隊列中的對象在finalize方法中與引用鏈上的任何一個對象重新建立了聯系,那么在進行第二次標記時,這個對象會被移除隊列,說明“復活”成功。之后如果這個對象再次出現沒有被任何對象引用的情況,也就是說再次被視為垃圾,那么finalize方法不會被再次調用,這個對象“必死無疑”,直接變成不可觸及狀態。
說完了垃圾標記階段,再來說一下垃圾清除階段的算法,標記-清除算法
垃圾標記階段,標記了那些被視為垃圾的對象,接下來垃圾回收機制就需要執行垃圾回收,回收那些被視為垃圾的對象,釋放出垃圾對象占用的內存。
當堆空間中的有效內存空間被耗盡時,比如新生代、老年代滿了,就會停止整個程序(stop the world,即STW),然后進行兩項工作,第一是標記,第二是清除。
- 標記:垃圾回收器從引用根節點開始遍歷,標記所有被引用的對象,一般是在對象的對象頭的運行時元數據中記錄為可達對象。
- 清除:垃圾回收器對堆空間進行全盤掃描,如果發現某個對象是不可達對象,就進行清除,將其回收。我們這里所說的清除并不是真的將其內存置空,而是把需要清除的對象地址保存在空閑列表中,下次有新對象申請內存時,可以直接申請這塊內存。
標記-清除算法也存在一些缺點,效率不是很高,在進行垃圾回收時,需要STW停止整個程序,目的是為了進行標記那些所有被引用的對象,而且從上圖中可以看出來,在清除后,清理出來的內存是不連續的,產出了內存碎片,并且還需要唯一一個空閑列表。
第二種垃圾清除階段的算法,復制算法
復制算法就是將內存空間分為兩塊,每次只使用其中的一塊,在垃圾回收時將正在使用的那塊內存中的還存活的對象復制到未被使用的另一塊內存中,之后清除正在使用的那塊內存中所有對象,然后這兩個內存塊的角色,最后完成垃圾回收。這讓你想起來了什么?沒錯,就是堆空間中新生代中的Survivor區,Survivor區劃分了from區和to區,垃圾回收時它們使用的就是復制算法。
復制算法,沒有標記和清除過程,只需要講那些還存活著的對象直接復制到另一塊內存中即可,復制過去以后是連續的,不會產生內存碎片,然后將原來內存中所有對象回收即可。但是這個復制算法最明顯的缺點就是需要2倍的內存空間,而且像對于G1這種拆分為大量region的垃圾回收器來說,復制意味著G1還需要維護region之間對象引用關系。復制算法在堆空間中垃圾對象多的情況下,才會發揮出優勢,試想一下,如果說在進行垃圾回收,存活的對象要遠大于垃圾對象,那么相當于把大部分的對象復制到了另一塊內存中,只清除了原來內存中很少的垃圾對象,內存釋放很少,導致垃圾回收效率低下。但是也不用擔心這種情況,大多數程序中的對象生命周期都很短,垃圾對象占比遠大于存活對象。
第三種垃圾清除階段算法,標記-壓縮算法
之前我們說過復制算法需要在存活對象少、垃圾對象多的新生代內存區域中才會發揮出優勢,但是對于老年代,大部分都是存活對象,如果在老年代使用復制算法效率就會不高。所以對于老年代,我們可以使用標記-壓縮算法,這個算法是基于標記-清除算法的優化改進。標記-壓縮算法第一階段和標記-清除算法一樣,從根節點開始標記所有被引用的對象,第二階段將所有的存活對象壓縮到內存的一端,按順序排放,之后,清除壓縮邊界以外的所有空間。
標記-壓縮算法相當于標記-清除算法+內存碎片整理。標記存活的對象會被集體移動到一端,按照內存地址一次排列,而未被標記的內存會被清理掉,這要只需要維護一個標識內存的起始地址的指針,方便新對象內存的申請,無需再維護一個空閑列表,這種分配方式稱為指針碰撞。標記-壓縮算法,清除了標記-清除算法產生的內存碎片,也消除了復制算法需要2倍內存空間的代價,但是缺點是僅從效率上來講,效率是要低于復制算法的,而且壓縮移動存活對象的同時,還需要調整對象之間引用的地址,并且移動過程中也會造成STW暫停。
分代收集算法
對比這三種清除階段的算法,從效率上來說,最高的是復制算法,相當于用空間換時間,統籌時間和空間的話,標記-壓縮算法會更有優勢。只能說根據具體的實際情況來選擇合適的回收算法。堆空間劃分是按照分代劃分的,垃圾回收也是按照分代收集的。不同的對象的生命周期也是不一樣的,因此不同生命周期的對象可以采取不同的收集方式,以便于提高回收效率,這就是分代收集算法的思想。
目前幾乎所有的垃圾回收器都是采用分代收集算法執行垃圾回收的。比如新生代區域,大多數對象生命周期都很短,垃圾回收頻繁,可以使用復制算法進行回收,同時Survivor區的設計使得復制算法內存利用率低的問題得到緩解。而老年代區域,大多數對象生命周期比較長,垃圾回收并不頻繁,比較適合使用標記-清除或者標記-壓縮算法。
增量收集算法
上述所說的所有基于分代收集的算法,在垃圾回收過程中,程序都會處于STW狀態,程序所有的線程都會掛起,暫停一切正常的工作,等待垃圾回收的完成。如果垃圾回收時間過長,程序會被掛起很久,甚至會出現卡頓。為了解決STW的問題,增量收集算法屬于一種實時垃圾收集算法。
其基本思想就是,如果一次性將所有的垃圾進行處理,會造成程序長時間停頓的話,那么就可以讓垃圾回收線程和程序線程交替執行。每次,垃圾回收線程只收集一小片區域的內存空間,接著卻換到應用程序線程,依次反復,直到垃圾回收完成。實際上增量收集算法的基礎還是標記-清除和復制算法,只不過采取了類似于CPU時間片輪詢的方式的,將垃圾回收分階段完成。這種算法雖然降低了STW停頓時間,但是由于垃圾回收線程和應用程序線程不斷切換,也造成了系統額外開銷。
分區算法
一般來說,堆空間越大,一次垃圾回收所需要的時間就越長,STW停頓也就越長。為了更好的控制垃圾回收產生的STW停頓時間,將一塊大的內存區域分割成多個小塊區域,根據目標的停頓時間,每次合理地回收若干個小塊區域,而不是整個堆空間,從而減少一次垃圾回收所產生的停頓。
分代算法是根據對象生命周期的長短劃分,增量收集算法相當于劃分垃圾回收的時間,而分區算法相當于劃分整個堆空間內存。每一個小塊區域都獨立使用,獨立回收,可以控制一次回收多少個小塊區域,從而控制STW的停頓時間。注意!分區算法,堆空間的分代劃分還在,只不過是將新生代、老年代區域給打散了。在回收完復用小塊內存區域的時候,該小塊內存區域的角色可以發生改變。
Stop The World(STW)
我們在將分代收集算法的時候,多次提及到了STW這個概念,指的是在GC過程中,會產生應用程序的停頓,停頓產生時整個應用程序線程都會被暫停,沒有任何響應,這個停頓被稱為STW。顧名思義,整個世界都停止了。
像是之前我們說的可達性分析算法,在枚舉GC Roots集合中所有根節點時,會導致應用程序所有線程停頓,之所以要停頓下來,是因為分析工作必須在一個能確保一致性的快照中進行,一致性指的是在整個分析期間,整個應用程序看起來像是被凍結住某個時間點上,如果說在分析過程中對象引用關系在不斷發生變化,那么分析結果的準確性將無法保證。被STW停止的應用程序線程會在完成垃圾回收之后恢復。STW的發生是不可避免的,只能說是盡可能縮短STW的停頓時間,STW是JVM在后臺自動發起和結束的,如果說我們調用System.gc()方法手動觸發垃圾回收,會導致STW的發生。
當STW發生時,應用程序正在執行的線程并不是說立即被暫停,并不是所有的地方都能夠停頓下來然后開始執行垃圾回收,只有在特定的位置才能停頓下來開始GC,這些特定的位置稱為**“安全點”。安全點的選擇非常重要,如果安全點太少可能導致GC等待的時間多長,一直等不到下一個安全點來進行垃圾回收,如果安全點太多,那么GC的太頻繁可能導致運行時性能問題。通常會選擇一些執行時間較長的指令作為安全點,比如方法調用、循環跳轉、異常跳轉等指令。那么如何在GC發生時,檢查所有線程都已經到達最近的安全點停頓下來了呢?有兩種方式可以檢查,一種是搶先式中斷**,首先中斷所有線程,如果還有線程不再安全點,就恢復這個線程,讓這個線程自己跑到安全點去;另一種是主動式中斷,設置一個中斷標志,各個線程運行到安全點的時候主動輪詢這個標志,如果中斷標志為真,就將自己中斷掛起。而對于那些無法響應JVM的中斷請求的線程,比如處于Sleep狀態或者Blocked阻塞狀態的線程,它們無法自己走到安全點去中斷掛起,JVM也不可能等待線程被喚醒,對于這種情況,就需要安全區域來解決。安全區域指的是在一段代碼片段中,對象的引用關系不會發生變化,在這個區域中的任何位置開始GC都是安全的??梢园寻踩珔^域看成是n多個安全點連成的線構成的區域。所以應用程序線程在實際執行時,當線程運行到安全區域的時候,首先標識已經進入了安全區域,如果這段時間內發生了GC,JVM會忽略標識為安全區域的線程;當線程即將離開安全區域,會檢查JVM是否已經完成GC,如果完成則繼續執行,否則線程必須等待直到收到可以離開安全區域的信號為止??梢岳斫鉃镚C可以等待線程任何時間到達安全區域,但是一旦到達可就沒有這么容易離開了,必須等待GC執行完之后,允許你離開的時候,這個線程才可以離開。
我們所說的垃圾回收指的是當對象不被任何其它對象所引用,即被視為垃圾,然后垃圾回收機制會自動回收這個對象。垃圾回收會銷毀額外的系統開銷,如果說此時內存空間非常充裕呢?反正內存夠用,我們是否能暫時不進行垃圾回收,等到內存緊張的時候,再統一進行垃圾回收呢?可以,既然垃圾回收回收的是那些被視為垃圾的對象,而判定某個對象是否是垃圾,是根據對象之間的引用來判定的。接下來就來講一下各類型的引用。
強引用
指的是程序中最常見的引用賦值,例如String s = new String()。無論任何情況下,只要強引用關系存在,垃圾回收器就永遠不會回收掉這個被引用的對象。
強引用是最常見的引用類型,通過強引用可以直接訪問目標對象,強引用的對象是可觸及的,即使拋出OOM異常,也不會回收這個對象。如果引超出了引用的作用域或者說被賦值為null,就是可以當做垃圾被回收了。強引用也是造成內存泄漏的主要原因之一。
軟引用
在系統將要發生OOM之前,就會把這些對象列入回收范圍之中進行第二次回收,如果這次回收之后還沒有足夠的內存空間,就直接OOM
軟引用是用來描述一些還有用,但并非必需的對象。通過用來實現內存敏感的緩存,比如高速緩存就用到了軟引用如果還有空閑內存,就可以暫時保留緩存,當內存不足時清理掉,這樣就保證了使用緩存的同時,不會耗盡內存。
弱引用
被弱引用關聯的對象只能生存到下一次垃圾回收之前,當垃圾回收器進行回收時,無論內存空間是否夠用,都會回收掉被弱引用關聯的對象。
由于垃圾回收器的線程優先級很低,因此弱引用對象可以存在較長的時間,也非常適合保存那些非必需的對象。
虛引用
一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,隨時都可能被垃圾回收器回收,也無法通過虛引用來獲得一個對象的實例,為一個對象設置虛引用關聯的唯一目的就是能在這個對象被垃圾回收器回收時收到一個系統通知,以此來追蹤垃圾回收的過程。
虛引用必須和引用隊列一起使用,虛引用在創建時必須要提供一個引用隊列作為參數,當垃圾回收器準備回收一個對象時,如果發生它還有虛引用,基本上可以無視虛引用的存在,但是在回收對象之后,需要將這個虛引用加入到引用隊列中,以此來通知系統這個對象被回收了。由于虛引用可以追蹤對象的回收時間,所以可以將一些資源釋放操作放置在虛引用中執行和記錄。
我們上述所說的所有垃圾回收機制、回收算法必須要有落地的實現,接下來就講一講各種垃圾回收器
從不同的角度分析垃圾回收器,可以將其分為不同的類型。
-
按照垃圾回收器回收時的線程數分,可以分為串行GC和并行GC。串行回收指的是在同一時間段內只允許有一個CPU用于執行垃圾回收操作,此時工作線程被暫停,直到垃圾回收結束。并行回收可以運用多個CPU同時執行垃圾回收,但是也是會暫停工作線程,直至垃圾回收結束。
-
按照GC工作模式分,可以分為并發式GC和獨占式GC。并發式垃圾回收器與應用程序線程交替執行,以此來盡可能減少STW的停頓時間。獨占式垃圾回收器一旦運行,就會停止應用程序中的所有線程,直到垃圾回收結束。
-
按照GC對內存碎片處理方式分,可以分為壓縮式GC和非壓縮式GC。壓縮式垃圾回收器會在回收完成后,對存活對象進行壓縮整理,消除內存碎片。非壓縮垃圾回收器不會進行內存碎片的整理。
-
按照工作的內存區域分,可以分為新生代GC和老年代GC。
這么多種類的垃圾回收器,我們如何評價一個GC的好壞?或者說衡量一個GC的性能高低看什么?主要看兩個性能指標,一個是吞吐量,一個是STW暫停時間。
吞吐量=運行用戶代碼時間 /(運行用戶代碼時間+垃圾回收時間),追求吞吐量高的GC一般應用程序能夠容忍較高的STW暫停時間,注重吞吐量的高低,不考慮應用程序的快速響應,也就是在一長段時間內,每次的STW暫停長點無所謂,只要能保證在這段時間內,到達預期的吞吐量就可以。
STW暫停時間指的是一個時間段內應用程序線程被暫停,讓垃圾回收器的線程執行垃圾回收操作。追求STW暫停時間的GC要盡可能減少每次STW的暫停時間,主要低延遲,每次暫停時間不能超過預期停頓時間。
這兩個性能指標是相互矛盾的,因為如果保證吞吐量,那么必然要降低內存回收的執行頻率,但是這樣就會導致GC需要更長的STW暫停時間來執行垃圾回收,相反,如果盡可能保證STW暫停時間短,那么只能提高執行垃圾回收的次數,但這樣又會導致降低了吞吐量。對此我們應該根據實際情況去衡量這兩個性能指標,一般來說,在保證最大吞吐量的情況下,盡可能降低STW停頓時間。
接下來將挨個來講一講以下7種最經典的垃圾回收器
- 串行GC:Serial、Serial Old
- 并行GC:ParNew、Parallel Scavenge、Parallel Old
- 并發GC:CMS、G1
這7種垃圾回收器按照工作的內存區域分,又可分為
- 新生代GC:Serial、ParNew、Parallel Scavenge
- 老年代GC:Serial Old、Parallel Old、CMS
- 整堆GC:G1
你可能會想問啥有這么多種GC,這其實JVM根據不同的應用場景來提供不同的垃圾回收器,以此來提高垃圾回收的性能,沒有最好的GC,只有最適合的GC。
Serial GC
serial垃圾回收器算是出現最早的GC了,Serial的工作內存區域是新生代,采用復制算法、串行回收、STW機制來執行垃圾回收。
Serial垃圾回收器是一個單線程的回收器,只會使用一個CPU或者一條回收線程去完成垃圾回收工作,在進行垃圾回收時必須暫停其它所有的工作線程,直到回收結束。適用于單核CPU,內存不大的應用場景。
Serial Old GC
Serial Old垃圾回收器主要是回收老年代的垃圾,采用了標記-壓縮算法、串行回收、STW機制來執行垃圾回收。
ParNew GC
ParNew垃圾回收器相當于Serial垃圾回收器的多線程版本,主要也是回收新生代的垃圾,采用了復制算法、并行回收、STW機制來執行垃圾回收。
對于新生代,回收垃圾的次數頻繁,使用并行方式高效,對于老年代,垃圾回收的次數比較少,所以使用串行方式更節省資源,因為CPU并行需要切換線程,串行可以省去切換線程的資源。所以可以選擇ParNew搭配Serial Old來回收新生代和老年代。
Parallel Scavenge GC
Parallel Scavenge垃圾回收器也是回收新生代的垃圾,并且和ParNew垃圾回收器一樣,同樣也采用了復制算法、并行回收、STW機制來進行垃圾回收。
那么既然已經有了ParNew垃圾回收器,那為什么還要Parallel Scavenge垃圾回收器呢?因為Parallel Scavenge和ParNew的目標不同,Parallel Scavenge垃圾回收器的目標是到達一個可控制的吞吐量,即吞吐量優先。高吞吐量則可以高效率的利用CPU時間,盡可能快的完成程序的運算任務,主要適合在后臺運算而不需要太多交互的任務,例如批量處理、訂單處理、科學計算等應用程序。在吞吐量優先的應用場景下,可以使用Parallel Scavenge搭配Parallel Old來回收新生代和老年代的垃圾。
Parallel Old GC
既然新生代都有了Parallel Scavenge垃圾回收器,那么老年代也要看齊,Parallel Old就是用來回收老年代的垃圾,采用了標記-壓縮算法、并行回收、STW機制來執行垃圾回收。
CMS GC
我們說過衡量一個垃圾回收器性能的指標主要有兩個,一個是吞吐量,一個是STW暫停時間。而CMS垃圾回收器主打的就是實現低延遲。CMS垃圾回收器可以算是第一個真正意義上的并發回收器,它第一次實現了讓GC線程和用戶工作線程同時工作。這里要強調一點,我們所說的并行回收器指的是GC線程之間并行執行,而所說的并發回收器指的是GC線程和工作線程并發執行。CMS垃圾回收器是用來回收老年代的垃圾,采用標記-清除算法、并發回收、STW機制來執行垃圾回收。由于高吞吐量和低延遲是一對相斥的指標,所以追求低延遲的CMS垃圾回收器不能和追求高吞吐量的Parallel Scavenge垃圾回收器搭配使用,只能和ParNew或者Serial搭配使用,來回收新生代和老年代的垃圾。
CMS回收垃圾的整個過程比之前的垃圾回收器都要復雜,主要分為4個階段:
-
初始標記階段:
在這個階段中,程序中所有的工作線程都會因為STW機制而出現短暫的暫停,這個階段的任務主要是**僅僅只是標記出GC Roots能直接關聯到的對象。**一旦標記完成之后就會回復之前被暫停的所有應用線程。由于直接關聯對象比較少,所以這個初始標記階段速度非常快,不會導致STW暫停時間過長。
-
并發標記階段:
從GC Roots的直接關聯對象開始沿著引用鏈遍歷所有對象,這個過程耗時比較長,但是不需要暫停用戶工作線程,GC線程和工作線程并發執行。這個并發標記階段相當于標記出來了所有存活著的對象。
-
重新標記階段:
由于在并發標記階段,GC線程和工作線程并發交替執行,有可能引用鏈上的對象之間的引用關系發生了變化,所以為了修正并發標記期間,因工作線程繼續運行而導致標記產生變動的那一部分對象,需要重新標記,這個階段為了保證標記的準確性,會暫停所有的工作線程,暫停時間比并發標記階段要短的多。
-
并發清除階段:
這個階段會清理刪除掉標記階段判斷已經死亡的對象,釋放內存空間。由于不需要移動存活下來的對象,所以這個階段GC線程和用戶工作線程也是并發執行的。
盡管CMS采用的是并發回收,但是在初始標記階段和重新標記階段,仍然需要執行STW機制暫停所有工作線程,不過暫停時間不會太長,所以說垃圾回收器不可能完全消除掉STW機制,只能盡可能減少STW暫停時間。而CMS低延遲體現在并發標記階段和并發清除階段不需要暫停工作線程,所以整體的回收效率還是很好的。由于CMS在垃圾回收階段工作線程并沒有中斷,所以在CMS回收垃圾時,還需要確保有足夠的內存給工作線程使用,所以CMS垃圾回收器不能像其他垃圾回收器那樣等到老年代滿了的時候才進行垃圾回收,而是**當堆空間內存使用率達到某一個閾值時,便要開始進行垃圾回收。**如果說CMS在執行垃圾回收期間,出現了OOM異常,CMS就會暫停GC的工作,臨時啟用Serial Old垃圾回收器重新對老年代進行垃圾回收,停頓時間也因此變長。
CMS具有并發回收低延遲的有點,但是也存在一些缺點。由于使用的時標記-清除算法,所以在垃圾回收之后會產生內存碎片;在并發階段雖然不會暫停工作線程,但是會因為占用了一部分線程而導致應用程序變慢,總吞吐量變低;CMS回收垃圾時可能會失敗,回臨時切換到Serial Old垃圾回收器重新進行回收。
需要注意一點,CMS在JDK 14已經被移除掉了。
G1 GC
G1垃圾回收器進一步降低了STW暫停時間,在延遲可控的情況下獲得盡可能高的吞吐量。
G1垃圾回收器是一個并行回收器,它把堆空間分割為很多不相關的區域(region),這些region物理上可以是不連續的,使用不同的region來表示Eden區、Survivor區、老年代等。G1垃圾回收器會跟蹤各個region里面的垃圾堆積的價值大小(回收空間大小、回收時間長短),在后臺維護一個優先列表,每次根據允許的回收時間,優先回收價值最大的region。
G1使用了分區算法,G1在回收垃圾時,可以有多個GC線程并行執行,此時由于STW機制會暫停所有工作線程。同時也兼顧了并發,G1的多個GC線程可以和工作線程并發的交替執行,一般來說,不會在整個回收階段發生完全阻塞工作線程的情況。所以說G1兼顧了并行性和并發性。從分代角度來看,G1仍然屬于分代垃圾回收器,他會區分新生代和老年代,新生代仍然進一步劃分為Eden區和Survivor區,但是與其他分代垃圾回收器不同的是,G1不要求分代在物理內存上是連續的,可以打散分布在堆空間的各個位置。將堆空間分為若干個小的region區域,邏輯上包含了新生代和老年代,所以G1可以兼顧回收新生代和老年代的垃圾。
G1垃圾回收器還增加了一種新的內存區域Humongous,主要是用來存儲大對象,如果對象大小超出1.5個region就會放到Humongous里面。之所以新增加了Humongous內存區域,是因為對于堆空間中的大對象,默認直接會被分配到老年代,但是如果它是一個短期存在的大對象,就會對垃圾回收造成影響,我們說一般生命周期短的應該存放在新生代,新生代GC頻繁能夠及時回收,但是由于這個生命周期短的對象太大,所以只能放在了老年代,老年代一般來說GC不頻繁,會導致這個大對象難以回收。為了解決這個問題,G1才劃分出了一個Humongous區域,用來專門存放大對象,如果一個Humongous區裝不下這個大對象,就會使用連續的Humongous區來存放這個大對象。為了能夠找到連續的Humongous區,有時候需要進行Full GC來回收整個堆空間的垃圾,來騰出足夠的內存空間,G1大多數行為都把Humongous區當做老年代來看待。
雖然G1把整個堆空間化整為零,分割成一個個region,但是單個region還是使用指針碰撞的方式來存放數據,region里面也是劃分了已經的內存空間和未使用的內存空間,但是由于各個region之間物理上不要求連續,所以region之間不能使用指針碰撞來存放數據。而且每個region依然有線程私有緩存區域TLAB,這樣可以保證多個線程并行操作。
G1將內存劃分為一個個的region,內存回收是以region作為基本單位,**region之間是復制算法,但是整體上實際上可以看作是標記-壓縮算法,**二者都能避免內存碎片的產生。
G1除了具有低延遲的優點以外,還可以建立可預測的停頓時間模型。即可能指定在一個長度為M毫秒的時間片段內,消耗在垃圾回收上的時間不得超過N毫秒。這是由于分區的原因,G1可以只選取一部分region進行垃圾回收,這樣就縮小了回收的范圍,進而對停頓時間做到可控。相比于CMS,G1未必能做到CMS在最好情況下的延遲停頓,進行高效率回收,但是最差的情況下,要比CMS好很多,G1在大內存應用上才能發揮其優勢。G1除了使用內置JVM線程執行GC的多線程操作,還可以使用應用線程承擔后臺運行的垃圾回收工作,幫助加速垃圾回收過程。
G1垃圾回收器的回收過程主要分為三個環節:
- 新生代GC
- 老年代并發標記過程
- 混合回收
當新生代的Eden區用盡時就會開始新生代回收過程,G1在新生代回收階段采取并行的獨占式的垃圾回收,多個GC線程回收垃圾時會暫停所有工作線程,然后從新生代區域移動存活對象到Survivor區或者老年代。當堆空間使用率達到閾值時(默認45%),就開始老年代的并發標記過程。標記完成后馬上開始混合回收,在混合回收期間,G1把老年代中存活的對象移動到空閑的region,這些空閑的region也就變成了老年代。與其他垃圾回收器不同,也不同于新生代,G1對老年代的回收不需要回收整個老年代,一次只需要回收一小部分老年代的region就可以了,老年代region和新生代region是一起被回收的。
在回收垃圾對象時,一個對象很可能被不同內存區域所引用,比如在回收新生代中垃圾時,新生代中的對象很有可能被老年代中的對象引用,這種跨內存區域引用的對象,我們該如何判定對象存活,難道在回收新生代垃圾時不得不同時也掃描老年代?這樣的話會降低Minor GC的效率。尤其是G1垃圾回收器,一個個region并不是孤立的,一個region中的對象很可能被其他region中的對象引用,難道每次判定一個對象是否存活,需要掃描整個堆空間嗎?實際上對于所有類型的垃圾回收器,JVM都是使用Remembered Set來避免全局掃描的。G1垃圾回收器的每一個region都有一個對應的Remembered Set,每次引用類型數據寫操作時,都會產生一個Write Barrier寫屏障來暫時中斷寫操作,然后檢查將要寫入的引用指向的對象是否和該引用類型數據在不同的region(如果是其它垃圾回收器,檢查的是老年代對象是否引用了新生代對象),如果不同,通過CardTable把相關引用信息記錄到引用指向對象的所在region對應的Remembered Set,當進行垃圾回收時,在GC根節點的枚舉范圍加入Remembered Set,這就是我們之前所說的“臨時性”加入的根節點,這樣就可以保證不進行全局掃描了。說白了Remembered Set就是用來記錄當前region中哪些對象被哪些外部對象所引用,并把哪些外部對象的相關信息存放到被引用的對象對應的region的Remembered Set中,當掃描這個region時也就不同掃描外部對象所在的region了,這樣就實現了region之間的隔離,避免了全局掃描。
-
新生代GC
JVM啟動時,G1先準備好Eden區,程序在運行過程中不斷創建對象到Eden區,當Eden區空間耗盡的時候,就會觸發新生代垃圾回收,新生代垃圾回收只會回收Eden區和Survivor區。首先G1執行STW機制停止所有工作線程,掃描所有的Eden區和Survivor區進行垃圾回收。
-
第一階段:掃描根節點
掃描GC Roots集合里面的所有根節點,聯通Remembered Set臨時加入的外部引用節點,一起作為掃描存活對象的入口。
-
第二階段:更新Remembered Set
更新Remembered Set,保證Remembered Set可以準確外部引用。Remembered Set的更新需要線程同步,不能在執行引用賦值語句時實時更新Remembered Set,這樣開銷會很大,所有我們需要把一個對象被其它對象引用的關系放在一個dirty card queue(臟卡表隊列)中,當新生代回收垃圾執行STW停頓時,我們正好可以利用這段時間,把臟卡表隊列中的值更新到Remembered Set中,這樣不僅沒有涉及到開銷的問題,還可以保證Remembered Set中數據的準確性。
-
第三階段:處理Remembered Set
識別哪些外部對象,也就是老年代中的指向的Eden中的對象,這些被外部引用的Eden區中對象也被認為是存活的對象。
-
第四階段:復制對象
新生代使用復制算法,沿著根節點出發的引用鏈遍歷所有可達的對象,Eden區存活的對象會被復制到Survivor區,Survivor區中存活的對象如果年齡未達到閾值,年齡就會加1,如果達到閾值就會被復制到老年代中。如果Survivor區空間不夠,從直接從Eden區復制到老年代。
-
第五階段:處理引用
垃圾回收操作回收的都是那些強引用對象,而這個處理引用階段,就是處理那些非強引用對象,包括軟引用、弱引用、虛引用等等。最終Eden區的數據為空,把這個region記錄到空閑列表匯總,GC停止工作,垃圾回收完成。因為使用的是復制算法,所以沒有產生內存碎片,無需進行壓縮整理。
-
-
并發標記過程
-
初始標記階段
新生代GC的第一階段已經掃描了所有根節點,初始標記階段會標記從根節點直接可達的對象,這個階段也是會產生STW停頓,并且會觸發一次新生代GC。
-
根區域掃描
G1會掃描Survivor區中可以直接可達老年代對象的那些對象,也就是掃描Survivor中那些引用老年代對象的的對象,并標記被引用的老年代對象,這一過程必須在新生代GC之前完成,因為新生代GC會移動Survivor區,移動之后就找不到哪些老年代對象是可達的了。
-
并發標記
在整個堆空間進行并發標記,這個并發標記過程可能會被新生代GC打斷,如果說在并發標記階段發現當前region中所有對象都是垃圾,那么這個region就會立即被回收。同時并發標記階段在標記的同時,會計算每個region的對象存活比例。
-
再次標記
和CMS的重新標記階段有點類似,由于并發標記階段工作線程并沒有被暫停,所以需要修正上一次并發標記的結果。這個再次標記為了保證標記的正確性,必須得是STW暫停所有工作線程,但是G1采用了比CMS更快地初始快照算法。
-
獨占清理統計
計算每個region存活對象和GC回收的比例,并進行排序,同時還識別可以混合回收的區域。這個階段時STW的,因為要計算每個region所以不允許再有變動。這個階段實際上只是一個統計計算過程,不會涉及到垃圾清理。
-
并發清理階段
這個階段的任務是如果發生region中的所有對象都是垃圾,那么直接將這個region立即回收。
我們可以看到,這個并發標記標記過程,最主要的是標記、識別、統計那些垃圾對象,意圖并不是去真正清理垃圾對象,而是為下一階段混合回收做準備。
-
-
混合回收
當越來越多的對象晉升到老年代時,為了避免堆內存被耗盡,JVM會觸發一個混合垃圾回收器Mixed GC,除了回收整個新生代,還會回收一部分老年代,是一部分老年代,而不是整個老年代!我們可以選擇哪些老年代region被回收,從而可以控制垃圾回收的時間。Mixed GC不等于Full GC。
并發標記過程結束以后,老年代中哪些全部為垃圾對象的region被立即回收掉了,而那些有部分垃圾的region也被計算統計了出來,默認情況下會分8次將這些region回收。每次混合回收回收的是Eden區、Survivor區以及1/8老年代,回收算法和新生代回收算法完全一樣。由于老年代的region默認分為8次回收,所有G1會優先回收那些垃圾比較多的region,也可以設置一個閾值,只有當這個老年代region中垃圾對象比例達到閾值時,才會回收這個region。
-
Full GC階段(可選)
G1垃圾回收器目標是為了避免觸發Full GC,但是如何上述所說的垃圾回收機制不能正常工作,為了保底,G1還是會觸發Full GC,停止所有工作線程,使用單線程的內存回收算法進行垃圾回收,此時回收效率非常差。
那么導致G1觸發Full GC的原因是什么?主要有3個原因:
- 堆內存太小,在復制存活對象的時候沒有足夠多的空閑region存放??梢约哟蠖芽臻g內存來解決
- 并發處理過程完成之前堆空間耗盡,因為工作線程和GC線程并發執行,很有可能工作線程產生垃圾的速度大于GC回收的速度。此時可以加大堆內存,或者調小觸發GC的堆占用閾值,使得GC能夠盡早執行。
- 最大GC停頓時間設置的太短,雖然我們可以設置停頓時間,但是如果太短的話,G1根本來不及回收垃圾,導致在規定的時間內無法完成垃圾回收,也就是回收時間到了,但是堆空間垃圾還是很多,沒有清理出足夠的內存,也會導致Full GC。我們應該調整加大停頓時間。
G1垃圾回收器無論回收新生代還是老年代的垃圾,都是選擇完全暫停工作線程,這是為了保證吞吐量,但是延遲停頓時間是可控的!
至此,詳細講述了Java垃圾回收機制以及各種垃圾回收器的原理與實現。
總結
以上是生活随笔為你收集整理的Java垃圾回收机制详解(万字总结!一篇入魂!)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: ASP.NET多线程编程(一) 收藏
- 下一篇: hive sql 报错后继续执行_Hiv