JVM垃圾回收
看到垃圾回收,首先你會想到什么?
1、什么是垃圾?
2、哪些地方的垃圾需要被回收?
3、如何定位垃圾?
4、如何回收垃圾?
5、什么時候回收垃圾?
下面,我們將帶著這5個問題來進行分析。
1、什么是垃圾?
JVM中的垃圾指的是無用的內存,這些內存中的數據若在后續(xù)處理過程中不再被使用,那么我們將是為垃圾進行回收,以保證有足夠的內存可用。
程序在運行過程中會存在兩個問題:
內存溢出:是指內存空間分配不足
內存泄露:是指內存使用完后無法被回收
2、哪些地方的垃圾需要被回收?
線程私有內存區(qū)域(程序計數器、虛擬機棧、本地方法棧)會隨著線程的結束而釋放,棧中的棧幀隨著方法的進入和退出有條不紊的執(zhí)行者進棧和出棧操作,每個棧幀中的分配多少內存在編譯期便已確定,因此無需對該區(qū)域進行垃圾回收。
線程共享內存區(qū)域(方法區(qū)和Java堆)需要分配的內存大小只有在運行期才知道,因此這部分的內存是動態(tài)分配和回收的,也是我們接下來需要對垃圾內存回收的區(qū)域。
3、如何定位垃圾?
因為我們垃圾回收的主要區(qū)域是針對于Java堆(又稱為GC堆),這部分區(qū)域主要存放的是Java對象的實例,因此判斷內存是否可以被回收只需要確認實例對象已經不被使用,即對象是否存活。
判斷對象是否的方法有兩種,一種是引用計數算法,另一種是可達性分析算法。下面我們來介紹一下這兩中算法的優(yōu)缺點。
1)引用計數算法
首先我們來說一下這個算法的實現(xiàn)思想:為每個對象添加一個引用計數器,每當有一個地方引用它時,計數器的值加1,當引用失效時,計數器的值減1。當計數器的值為0時表示該對象沒有被引用。
上述實現(xiàn)思想看起了一點毛病都沒有,實現(xiàn)起來也是非常的簡單,而且效率也比較高,但是既然會有其他的算法被使用,那么該算法也就一定有他的缺點。
優(yōu)點:實現(xiàn)簡單且效率高
缺點:無法處理對象互相循環(huán)引用的情況。
對象的互相循環(huán)引用?聽起來不太容易理解,那我們就來舉個例子吧
public class ReferenceCountingGC {
public Object instance = null;
public static void testGC () {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}上述代碼中,對象A和對象B彼此引用,雖然對其句柄賦值為null,但是兩個對象的引用計數器的值均為1,因此使用引用計數算法無法對上述兩個對象進行回收。
2)可達性分析算法
目前主流的程序語言的實現(xiàn)中都稱是通過可達性分析算法來判斷對象是否存活。其算法的基本思想是:通過一系列被稱為”GC Roots“的對象作為起始點,開始向下搜索,搜索所走過的路徑成為引用鏈,當一個對象到GC Roots沒有任何的引用鏈時,則證明此對象是不可用的。

上面提到了一個“GC Roots”,那么哪些對方的對象是可以作為GC Roots呢?
在Java語言中,可作為GC Roots的對象包括:
a. 虛擬機棧(棧幀中的本地變量表)中引用的對象
b. 本地方法棧中JNI(native方法)引用的對象
c. 方法區(qū)中類靜態(tài)屬性引用的對象
d. 方法區(qū)中常量引用的對象
4、如何回收垃圾?
關于如何回收垃圾這一部分我們要分為兩部分進行講解:垃圾收集算法和垃圾收集器
第一部分 垃圾收集算法
垃圾收集算法主要包括三種算法:標記-清除算法、復制算法、標記-整理算法
另外還有分代收集算法,這種算法沒有特別的思想,而是根據對象存活周期的不同將內存劃分為幾塊,然后根據對應內存區(qū)域的特點采用適當的收集算法。
下面我們分別對上述三種算法進行分析說明。
1)標記-清除算法
實現(xiàn)思路:分為兩個階段,第一個階段是標記,如何標記(定位)垃圾已經在第3點中介紹過了。第二個階段是直接清除。
標記-清除算法的執(zhí)行過程如圖所示。它存在兩個不足之處:
a. 效率問題,標記和清除兩個過程的效率都不高。
b. 空間問題,標記清除之后會產生大量不連續(xù)的內存碎片,可能會導致在后續(xù)分配較大對象是沒有足夠的空間導致出發(fā)一次內存收集的動作。


2)復制算法
實現(xiàn)思路:將內存按照大小劃分為兩塊,每次只使用其中的一塊,當另一塊的內存用完了,就將還存活的對象復制到另一塊的上面,然后再把當前內存區(qū)域一次清理掉。
優(yōu)點:內存分配時只需要按照順序分配即可,實現(xiàn)簡單,運行效率高。
缺點:犧牲了一半的內存空間
目前商業(yè)虛擬機并非將內存空間按照1:1的比例來劃分內存,而是將內存分為一塊較大空間的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。回收時將Eden和Survivor中還存活的對象一次性復制到另一塊Survivor空間。
HotSpot虛擬機默認Eden和Survivor的大小比例是8:1,每次新生代中可用的內存空間為整個新生代容量的90%。這里面臨著兩個問題:一是什么場景適合使用該方式,二是如果Eden和Survivor存活的對象超過10%該怎么處理?
適合使用的場景:在絕大數情況下,Eden和Survivor在回收后存活的對象小于10%
超過10%的處理方式:內存分配擔保。我們將內存空間分為新生代(Eden和Survivor)和老年代,如果Eden和Survivor回收后的存活對象超過10%,則直接進入老年代。


3)標記-整理算法
實現(xiàn)思路:類似于標記-清除算法,不同的是不直接對可回收的對象進行清理,而是讓所有存活的對象都想前一端移動,然后清理掉端邊界以外的內存。
優(yōu)點:不會產生大量不連續(xù)的內存空間,適用于老年代


4)分代收集算法
將Java堆劃分為新生代和老年代。
新生代在每次垃圾收集時會有大批對象死去,適合采用復制算法。
老年代對象存活率高且沒有額外空間進行分配擔保,因此適合采用標記-整理算法。
第二部分 垃圾收集器
接下來,我們將對HotSpot虛擬機中不同jdk版本中使用過的垃圾收集器的實現(xiàn)進行分析,下圖對可以組合使用的垃圾收集器通過聯(lián)系進行了關聯(lián)。
新生代的垃圾收集器
Serial
ParNew
Parallel Scavenge
老年代的垃圾收集器
Serial Old
CMS(Concurrent Mark Sweep)
Parallel Old
新生代和老年代均可用:G1垃圾收集器
以及在JDK11中的ZGC垃圾收集器
關于垃圾收集過程的個人理解:
無論是何種收集算法何種收集器,我們在標記、清除、復制、整理這幾個過程需要保證一點,不能清理掉正在被引用的對象。在這個基礎之上,我們接下來要做的就是兩點,一是提升收集效率,二是提升用戶體驗。
提到對象的標記,我產生了一個疑問:難道當我們需要進行垃圾標記時真的要(從虛擬機棧棧幀中的本地變量表中引用的對象、方法區(qū)中類的靜態(tài)屬性引用的對象、方法區(qū)中常量引用的對象、本地方法棧中引用的對象)一個一個的去遍歷嗎?想想都覺得這個過程真的好耗時啊.....這一點當前主流的虛擬機已經考慮到了,在 HotSpot 虛擬機中使用了 OopMap 的數組結構來幫我們存放了對象引用。
在類加載時,HotSpot 會將對象內什么偏移量上是什么類型的數據計算出來
在JIT編譯時,會在特定未知記錄下棧和寄存器中哪些位置是引用
所以GC時直接掃描這里就可以了。
問題又來了....
JIT編譯時,我們該不會在執(zhí)行過程中每一步都維護這個 OopMap 吧?這樣的話得又多少個 OopMap .... 我們看到上面提到了在“特定位置”,那接下來我們來看看這個“特定位置”應該是哪里。
現(xiàn)在給你個機會,讓你來說說你怎么來設定這個“特定位置” ?
emm...要是能有一個地方,可以讓我在更長的時間里使用同一個OopMap那就好了,這個想法不錯,哪到底哪里能夠長時間或者重復使用一個 OopMap 呢?哈,循環(huán)調用的時候!!!沒錯了,像方法調用、循環(huán)跳轉、異常跳轉這些地方指令是可以重用的。教科書上稱這個地方為安全點(saftpoint)。那我們只需要記錄這些安全點上對應的 OopMap 就可以咯。
不要太開心,問題是解決不完滴.....接著看
現(xiàn)在你在安全點上挖了坑等著線程往里面跳,但是我該用什么姿勢會更帥一點呢?
換句話說:我就問你你喜歡我主動點還是被動點?

好吧,老爺們怎么能這么被動,還得老司機扶你一把。那我們來看一下怎么扶你到點上吧。
搶先式中斷:在GC時,首先把所有線程中斷,如果發(fā)現(xiàn)哪些線程中斷的地方不再安全點上,就恢復線程,讓它“跑”到安全點上。當前幾乎沒有虛擬機采用搶先式中斷來暫停線程去響應GC了。
不過,單生狗們,下面才是我們的標準動作,看好咯。

主動式中斷:當GC需要中斷線程的時候,我們不直接中斷線程,而是設置一個標志,各個線程執(zhí)行時主動去輪詢這個標志,發(fā)現(xiàn)是中斷標志時就自己中斷掛起。輪詢標志的地方和安全點是重合的。
然而我們 GC 妹子還是很擔心總有那么一些渣男線程偏偏搗亂,你說該咋整呢?
這個好辦,我把哪些賢良淑德的帥哥們給你們安排個專場,你來這里找就行了。
安全區(qū)域:安全點貌似已經解決了 GC 的問題,然后有一些線程可能沒有分配到 CPU 而無法執(zhí)行(如線程處于 Sleep 狀態(tài)或 Blocked 狀態(tài)),導致這些線程進入安全點需要等待較長的時間。所以,為了能夠排除這些無法執(zhí)行的線程,我們給那些可以執(zhí)行的線程一個安全區(qū)域,在這個區(qū)域中任意地方開始 GC都是安全的。
回歸主題......
我們通過引用計數算法或可達性分析算法定位到當前這個時刻哪些對象是可以被清理的,但是我們需要保證被標記的對象在被清理之前不會被再次引用,所以這里出現(xiàn)了一個動作stop the world,一個很霸氣的名字,意思就是停止所有用戶線程的執(zhí)行。什么時候發(fā)生 stop the world ? 當然是要執(zhí)行 GC 操作的時候咯。線程停在哪里?上面那個專場(安全區(qū)域),然后現(xiàn)在就可以安心的清理垃圾了。
在保證了不能清理掉正在被引用的對象這個基礎之上,我們接下來再看看我們該提升收集效率和提升用戶體驗。
提升收集效率,具體化就是提高吞吐量。
提升用戶體驗,具體化就是縮短回收停頓的時間
吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)
如何提高吞吐量?
將單線程處理改為多線程處理,充分利用CPU的處理能力
如何縮短回收停頓時間?
在標記期間,可以讓用戶線程和GC線程同時執(zhí)行,然后在執(zhí)行清除前在對標記的內存進行一次確認,盡量縮短回收停頓的時間。
那接下來,我們來看看 HotSpot 在收集器迭代過程中是如何提高收集效率和提升用戶體驗的。

1)Serial 收集器
jdk1.3.1之前版本
新生代的收集器
采用復制算法
單線程收集器,進行垃圾收集時暫停所有用戶線程
優(yōu)點:在單個CPU環(huán)境下運行簡單高效
缺點:無法充分利用多個CPU的處理能力

2)ParNew 收集器
可以看做是Serial收集器的多線程版本
新生代收集器
采用復制算法
多線程收集器,進行垃圾收集時暫停所有用戶線程
優(yōu)點:在多核CPU環(huán)境下要比Serial收集器效率高。
缺點:CPU個數較少情況下不明顯,甚至可能比Serial收集器效率還要低

3)Parallel Scavenge 收集器
新生代收集器
采用復制算法
多線程收集器,進行垃圾收集時暫停所有用戶線程
和ParNew收集器的區(qū)別:
Paralle Scavenge 收集器的目標是達到一個可控制的吞吐量(CPU用于用戶代碼的時間與CPU總消耗時間的比值)
吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)
兩個重要參數設置:
-XX:MaxGCPauseMills ? 參數說明:控制垃圾收集停頓時間,參數值的設定并非越小越好,參數值較小,垃圾收集速度較快,則會犧牲吞吐量和新生代空間
-XX:GCTimeRatio ?參數說明:設置垃圾收集時間占總時間的比率,即吞吐量=1-垃圾收集時間占總時間的比率
優(yōu)點:可以提供良好的響應速度,從而提升用戶體驗
缺點:需要減少新生代空間來以及降低吞吐量來縮短停頓時間

4)Serial Old收集器
老年代收集器
采用標記-整理算法
單線程收集器,進行垃圾收集時暫停所有用戶線程
用于Client模式下的虛擬機使用
兩大用途:
JDK1.5 以及之前的版本中與Parallel Scavenge 收集器搭配使用
作為 CMS 收集器的后備預案,在并發(fā)收集發(fā)生Concurrent Mode Failure 時使用

5)Parallel Old 收集器
JDK 1.6版本提供使用
老年代收集器
采用標記-整理算法
多線程收集器,進行垃圾收集時暫停所有用戶線程
優(yōu)點:可以充分利用服務器多CPU的處理能力

6)CMS 收集器
老年代收集器
采用標記-清除算法
多線程收集器
與 Parallel Old 收集器的區(qū)別:
CMS 收集器目標是獲取最短回收停頓時間。
處理過程:
初始標記(CMS initial mark)
并發(fā)標記(CMS concurrent mark)
重新標記(CMS remark)
并發(fā)清除(CMS concurrent sweep)
在這里我需要簡單說明一下:所有在堆中的對象都是記錄的,我們可以暫且看作一個記錄表,那么在這個記錄表中,如果標記了所有被引用的對象,那么反過來我們就自然知道那些沒有被標記的對象就是可回收對象,有時候我們說標記了對象是指標記了可回收對象。
從下面示意圖中,有沒有發(fā)現(xiàn)這樣一個問題,就是在標記和清除的過程中,并不是一直都處于 stop the world,當時我產生了一個疑問,那在這個過程中會不會存在錯誤的把那些被引用的對象當作垃圾對象的情況呢?我們看到標記階段被分為了三次執(zhí)行:
第一次標記,僅僅是標記了與 GC Roots 直接關聯(lián)的對象。且會 stop the world
第二次標記,將第一次標記的對象進行追溯。這個過程不會 stop the world,而是與用戶線程并發(fā)執(zhí)行的,所以可能會產生這樣兩個問題,一是可能有些被標記的對象是被引用了的,二是可能又產生了一些新的垃圾。針對于“可能有些被標記的對象是被引用了的”情況我們可以通過第三次標記來處理。而對于“可能又產生了一些新的垃圾”的情況我們可以在下次在對其清理,并不影響我本次 GC 的準確性,這個階段產生的垃圾在教科書中被成為“浮動垃圾”。
第三次標記,會修正第二次并發(fā)標記過程中因為用戶線程執(zhí)行而導致標記變動的對象。這個階段耗時比第一次標記要長,但是遠比第二次標記耗時要短。
你可能你存在一個疑問:第二次標記為什么會產生“可能有些被標記的對象是被引用了的”?
因為我們通過可達性分析算法追溯那些對象被引用時,只是根據第一次標記時與 GC Roots 直接關聯(lián)的對象進行追溯的,但是在第二個階段用戶線程也在執(zhí)行,這個時候 GC Roots中會產生新的對象,也就會產生新的鏈路,那么我們并沒有對這些鏈路去追溯對象,所以那些對象也就被我們標記而被是為垃圾對象,所以這個時候,需要第三次標記,把這段時間新產生的 GC Roots 在進行一次追溯,修正一下被誤認為是垃圾的對象。由于第二次并發(fā)標記執(zhí)行的時間產生新的 GC Roots 是要比第一次少的多的,所以第三次重新標記的時間要短的多。
那么在上面我們已經標記了哪些對象為垃圾對象了,那么回收階段我們就只管回收就好了,是否與用戶線程并發(fā)執(zhí)行并沒有關系。好吧,也會你會問為什么會沒有關系呢?因為當某一個時刻,這個對象在全局范圍內沒有被使用,那么后續(xù)過程怎么會使用呢?相當于這個引用的傳遞關系已經就此斷開了呀。
通過上面的分析我們也能夠知道 CMS 收集器存在哪些缺點了吧。主要有兩個缺點:
無法處理“浮動垃圾”,只能等到第二次觸發(fā)GC時進行清理
CMS既然采用的是標記-清除算法,那么必然會產生大量的空間碎片

7)G1 收集器
面向服務端應用的垃圾收集器。
處理過程:
初始標記
并發(fā)標記
最終標記
篩選回收
特點:
并行與并發(fā)
分代收集
空間整合
可預測的停頓
接下來,我們來看兩個問題,一是 G1 收集器和 CMS 收集器的區(qū)別,二是 G1 收集器的特點具體是什么。
問題1:G1 收集器和 CMS 收集器的區(qū)別
在處理過程中,G1 收集器和 CMS 收集器的差別并不是很大,僅僅是在回收時,CMS 收集器是并發(fā)執(zhí)行,而 G1 收集器是篩選回收。那我們來看一下它們在回收階段到底有哪些區(qū)別。
CMS 收集器通過并發(fā)執(zhí)行,將標記階段標記的所有垃圾對象進行了回收。
G1 收集器通過篩選回收,可以選擇部分垃圾對象進行回收。
兩者的差別就是一個是只能全部垃圾回收,一個可以部分垃圾回收。
那么部分垃圾回收有哪些好處呢?或者我們可以先考慮一下 G1 是如何做到部分垃圾回收的。
G1 收集器和 CMS 收集器的關注點都是追求低停頓,但是 G1 收集器通過將 Java 堆劃分為多個大小相等的獨立區(qū)域,然后有計劃的去對各個區(qū)域進行收集,從而達到對垃圾收集時間的一個控制。
接踵而來的問題......
劃分成大小相等的獨立區(qū)域,難道區(qū)域之間是真的獨立而沒有任何交集嗎?
我們知道 HotSpot 虛擬機將 Java 堆分為新生代和老年代,上面提到的收集器也是分別針對于新生代和老年代進行的處理。但是 G1 收集器可以同時處理新生代和老年代,所以對于 G1 收集器,Java 堆的布局也是發(fā)生了變化的。Java 堆被劃分為多個大小相等的獨立區(qū)域,新生代和老年代也變成了只是邏輯上的區(qū)分,在物理上不再隔離了。
在回答區(qū)域之間是否是真的獨立而沒有交集這個問題前,我想問一下,新生代和老年代之間交集嗎?毋庸置疑,肯定是有交集啊,我只不過是把對象從新生代挪到老年代而已嘛。那好,我再問一個問題,那我們在對新生代進行 GC 時,也就是我們說的 Minor GC 時,我們還要把老年代也給遍歷一遍嗎?如果真的需要遍歷一遍,那估計能把人等死....
其實,新生代和老年代之間的對象引用,虛擬機通過使用 Remembered Set 記錄下來了,從而避免了全堆掃描。那現(xiàn)在回到上面那個問題上來,你說 G1 收集器劃分的各個區(qū)域之間有沒有交集呢?如果有它們之間是怎么處理的呢?現(xiàn)在應該不用我再來解釋了吧。
問題2: G1 收集器的特點具體是什么?
1)并行與并發(fā)
充分利用多CPU、多核的處理能力來縮短 Stop-The-World 停頓時間。
2)分代收集
與其他收集器的概念相同。
3)空間整合
從整體來看是基于標記-整理算法實現(xiàn)的垃圾收集。
從局部(兩個Region之間)上來看是基于復制算法實現(xiàn)的垃圾收集。
因此,G1 收集器在垃圾收集期間不會產生內存空間碎片。
4)可預測的停頓
G1會跟蹤各個區(qū)域里面的垃圾堆積的價值大小(回收所獲得空間大小記憶回收所需時間的經驗值),在后臺維護一個優(yōu)先列表,根據設定的允許收集時間,優(yōu)先回收價值最大的區(qū)域。

5、什么時候回收垃圾?
什么時候回收垃圾?
換種說法就是什么時候回收內存?
好了,請問你什么時候找你朋友借錢或者還錢?----錢不夠用的時候
同樣道理,當內存不夠用的時候就要進行回收了。
既然提到內存不夠用了,那你得直到內存是怎么用了吧,就像是你錢沒了你得知道錢怎么花了吧。不過話說回來,這方面得學學人家內存,大部分都花在對象身上了。那我們就說說對象是怎么“花錢”的吧。
內存分配
對象的內存分配主要是在堆上分配。對象優(yōu)先在新生代的 Eden 區(qū)上,少數情況也會直接分配到老年代中,分配規(guī)則是可以通過配置參數進行調整。我們接下來看一下通常情況下內存的分配規(guī)則。
1)對象優(yōu)先在 Eden 分配
大多數情況下,對象在新生代 Eden 區(qū)中分配。當 Eden 區(qū)空間不足時觸發(fā) Minor GC。
2)大對象直接進入老年代
所謂大對象是指需要大量連續(xù)內存空間的對象,比如很長的字符串或數組。
虛擬機提供了 -XX:PretenureSizeThreshold 參數,令大于該參數值的對象直接進入老年代。避免在 Eden 區(qū)和兩個 Survivor 區(qū)之間發(fā)生大量的內存復制。
3)長期存活的對象將進入老年代
在內存回收時,虛擬機需要識別哪些對象應該放在新生代,哪些對象應該放在老年代。虛擬機給每個對象定義了一個對象年齡計數器。對象在 Eden 出生并經過第一次 Minor GC 后仍然存活,并且能夠被 Survivor 容納,則被移動到 Survivor 區(qū),且對象年齡設為1。對象在 Survivor 區(qū)每經過一次 Minor GC, 年齡加1。當年齡增長到一定程度(默認為15歲),將會被放到老年代。
虛擬機提供了 -XX:MaxTenuringThreshold 參數,設置對象進入老年代的年齡閾值。
4)動態(tài)對象年齡判定
如果在 Survivor 區(qū)中相同年齡所有對象大小的總和大于 Survivor 空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代。
5)空間分配擔保
在前面我們講到,新生代采用的是復制算法。如 HotSpot 虛擬機中,將新生代分為 Eden 區(qū)和兩個 Survivor 區(qū),默認比例是8:1。每次 Minor GC 時會講 Eden 區(qū)和 一個 Survivor 區(qū)中存活的對象拷貝到另一個 Survivor 區(qū)。那么可能存在這么一種情況,就是另一Survivor 區(qū)的空間不足一容納 Eden 區(qū)和 Survivor 區(qū)中存活的對象,這個時候該怎么辦?這里就是我們要說的空間分配擔保,如果上面發(fā)生了上面說的那種情況,那么在 Eden 區(qū)和 Survivor 區(qū)存活的對象將直接進入老年代,即老年代為 Survivor 區(qū)進行擔保。
而老年代采用的是標記-整理算法,無需其他空間擔保。當老年代空間不足時,觸發(fā)一次 Full GC。
現(xiàn)在來看什么時候進行垃圾回收就比較清晰了,當新生代空間不足以為對象分配空間時,觸發(fā)一次 Minor GC,當老年代空間不足以為對象分配空間時,觸發(fā)一次 Full GC。
作者:雨陽
鏈接:https://zhuanlan.zhihu.com/p/82936943
來源:知乎
end
*版權聲明:轉載文章和圖片均來自公開網絡,版權歸作者本人所有,推送文章除非無法確認,我們都會注明作者和來源。如果出處有誤或侵犯到原作者權益,請與我們聯(lián)系刪除或授權事宜。
長按識別圖中二維碼
關注獲取更多資訊
不點關注,我們哪來故事?

點個再看,你最好看
