1. 程式人生 > >《Java之JVM》---深入探究之GC垃圾回收演算法

《Java之JVM》---深入探究之GC垃圾回收演算法

預計要閱讀十多分鐘!

入坑
首先,談一下什麼是GC(Garbage Collection)。說起GC,大部分人都把這項技術當做Java語言的伴生產物。事實上,GC的歷史比Java久遠,早在1960年Lisp這門語言中就使用了記憶體動態分配和垃圾回收技術。在Java中,程式設計師不需要去關心記憶體動態分配和垃圾回收的問題,這一切都交給了JVM來處理。顧名思義,垃圾回收就是釋放垃圾佔用的空間,那麼在Java中那些記憶體需要回收呢?我們接著往下看。

哪些記憶體需要回收?

JVM的記憶體結構包括五大區域:程式計數器、虛擬機器棧、本地方法棧、堆區、方法區。其中程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生、隨執行緒而滅,因此這幾個區域的記憶體分配和回收都具備確定性,就不需要過多考慮回收的問題,因為方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。而Java堆區和方法區則不一樣,這部分記憶體的分配和回收是動態的,正是垃圾收集器所需關注的部分。

需要注意的是:垃圾收集器在對堆區和方法區進行回收前,首先要確定這些區域的物件哪些可以被回收,哪些暫時還不能回收,這就要用到判斷物件是否存活的演算法!

注意!

在介紹垃圾回收演算法之前需要先了解一個詞“stop the world”,JVM為了執行垃圾回收,會暫時java應用程式的執行,等垃圾回收完成後,再繼續執行。如果你使用JMeter測試過java程式,你可能會發現在測試過程中,java程式有不規則的停頓現象,其實這就是“stop the world”,停頓的時候JVM是在做垃圾回收。所以儘可能減少stop the world的時間,就是我們優化JVM的主要目標。接下來我們看一下目前有哪些常見垃圾回收的演算法。

常用的垃圾回收演算法:

引用計數演算法: 引用計數是垃圾收集器中的早期策略。顧名思義,就是對一個物件被引用的次數進行計數,當增加一個引用計數就加1,減少一個引用計數就減1。 圖示如下: 在這裡插入圖片描述 上圖表示3個Teacher的引用指向堆中的Teacher物件,那麼Teacher物件的引用計數就是3,以此類推Student物件的引用計數就是2。 在這裡插入圖片描述 上圖表示Teacher物件的引用減少為2,Student物件的引用減少為0(減少的原因是該引用指向了null,例如teacher3=null),按照引用計數演算法,Student物件的記憶體空間將被回收掉。

引用計數演算法原理非常簡單,但是java中沒有使用這種演算法,其中它的優缺點如下:

優點:引用計數收集器可以很快的執行,交織在程式執行中。對程式需要不被長時間打斷的實時環境比較有利。

缺點:無法檢測出迴圈引用。如父物件有一個對子物件的引用,子物件反過來引用父物件。這樣,他們的引用計數永遠不可能為0。

為了解決無法檢測出迴圈引用這個問題,在Java中採取了 可達性分析法。可達性分析演算法是從離散數學中的圖論引入的,該方法的基本思想是程式通過一系列的“GC Roots”物件作為起點進行搜尋,如果在“GC Roots”和一個物件之間沒有可達路徑,則稱該物件是不可達的,不過要注意的是被判定為不可達的物件不一定就會成為可回收物件。被判定為不可達的物件要成為可回收物件必須至少經歷兩次標記過程,如果在這兩次標記過程中仍然沒有逃脫成為可回收物件的可能性,則基本上就真的成為可回收物件了。 圖示如下: 在這裡插入圖片描述 小提示:在java中以下幾種物件可以作為GCRoots:

  1. 虛擬機器棧(棧幀中的本地變量表)中引用的物件
  2. 方法區中的類靜態屬性引用的物件。
  3. 方法區中的常量引用的物件
  4. 本地方法棧中JNI(通常說的Native方法)引用的物件

標記-清除演算法(Mark-Sweep): 標記-清除演算法,它是很多垃圾回收演算法的基礎,簡單來說有兩個步驟:標記、清除。 標記: 遍歷所有的GC Roots,並將從GC Roots可達的物件設定為存活物件; 清除: 遍歷堆中的所有物件,將沒有被標記可達的物件清除;

具體流程見下圖: 在這裡插入圖片描述 總結一下標記清除演算法:

  1. 標記-清除演算法不需要進行物件的移動,只需對不存活的物件進行處理,在存活物件比較多的情況下極為高效;
  2. 標記-清除演算法涉及大量的記憶體遍歷工作,所以執行效能較低,這也會導致“stop the world”時間較長,java程式吞吐量降低;
  3. 標記-清除演算法直接回收不存活的物件,因此會造成記憶體碎片。
接下來我們看一下其他演算法能不能改善這些問題?

標記-整理演算法(Mark-compact): 標記-整理演算法採用標記-清除演算法一樣的方式進行物件的標記,但在清除時不同,在回收不存活的物件佔用的空間後,會將所有的存活物件往一端端空閒空間移動。標記-整理演算法是在標記-清除演算法的基礎上,又進行了物件的移動,因此成本更高,但是卻解決了記憶體碎片的問題。

具體流程見下圖: 在這裡插入圖片描述 總結一下標記整理演算法:

  1. 標記-整理演算法在進行完標記清除之後,對記憶體空間進行整理,節省記憶體空間,解決了標記清除演算法記憶體不連續的問題;
  2. 標記-整理演算法也會產生“stop the world”,不能和java程式併發執行。在整理過程中一些物件記憶體地址會發生改變,java程式只能等待壓縮完成後才能繼續。

複製演算法(Copying): 標記——複製儲存演算法通過採用雙區域交替使用這種方式解決了標記——清除演算法中效率低下的問題。它將可可用記憶體劃分為兩個等量的區域(使用區和空閒區),每次只使用一塊。當正在使用的區域需要進行垃圾回收時,存活的物件將被複制到另外一塊區域。原先被使用的區域被重置,轉為空閒區。

具體流程見下圖:在這裡插入圖片描述 總結一下複製演算法:

  1. 複製演算法相對標記-整理演算法來說更簡潔高效;
  2. 複製演算法不適合用於存活物件多的情況,因為那樣需要複製的物件很多,複製效能較差,所以複製演算法往往用於記憶體空間中新生代的垃圾回收,因為新生代中存活物件較少,複製成本較低;
  3. 複製演算法記憶體空間佔用成本高,因為它基於兩份記憶體空間做物件複製,在非垃圾回收的週期內只用到了一份記憶體空間,記憶體利用率較低。

分代收集演算法: 分代收集演算法理論來源於統計學。IBM公司的專門研究發現,物件的生存週期總體可分為三種:新生代、老年代和永久代。因此可以根據各個年代的特點採用適當的垃圾回收演算法。比如新生代的物件在每次垃圾時都會有大量的物件死去,只有很少一部分存活,那就可以選擇標記-複製演算法。另外I,在新生代中每次死亡物件約佔98%,那麼在標記-複製演算法中就不需要按照1:1的比例來劃分記憶體區域,而是將新生代細分為了一塊較大的Eden和兩塊較小的Survivor區域,HotSpot中預設這兩塊區域的大小比例為8:2。每次新生代可用區域為Eden加上其中一塊Survivor區域,共90%的記憶體空間,這樣就只有10%的記憶體空間處在被閒置狀態。在進行垃圾回收時,存活的物件被轉移到原本處在“空閒的”Eden區域。如果某次垃圾回收後,存活物件所佔空間遠大於這10%的記憶體空間時,也就是Survivor空間不夠用時,需要額外的空間來擔保,通常是將這些物件轉移到老年代。對於老年代來說,大部分物件都處在存活狀態。同時,如果一個大物件要在該區域進行分配,而記憶體空間又不足,那麼在沒有外部記憶體空間擔保的情況下,就必須選用標記-清除或者標記-整理演算法來進行垃圾回收了。

總而言之,分代收集只是根據物件生存週期的不同來選擇不同的演算法,其本身並沒有任何新思想。

增量收集演算法: 以上所述的演算法,都存在一個缺點:在進行垃圾回首時需要暫停當前應用的執行,也就是這時候的垃圾回收執行緒不能和應用執行緒同時執行。如果我們想做到“在不打斷同學們看書的情況下,圖書管理員就可以收集沒有被看的書”,這也是增量收集演算法的目標,即在不中斷應用執行緒的狀態下垃圾回收執行緒也能進行垃圾回收。但是這裡需要面對的問題是:垃圾回收執行緒在標記階段標記好了,還沒來的及清除時,當前應用執行緒進行記憶體操作,以至於清除階段無法正確開展,類似的情況是:圖書管理員剛標記了《JAVA程式設計思想》這本書已經沒有人看了,等標記完後,卻發現這本書已經有人在看了。

如果有疑問,請大家留言。