深入理解JVM(三)——垃圾收集策略詳解

喝水會長肉發表於2021-12-30

Java虛擬機器的記憶體模型分為五個部分,分別是:程式計數器、Java虛擬機器棧、本地方法棧、堆、方法區。

這五個區域既然是儲存空間,那麼為了避免Java虛擬機器在執行期間記憶體存滿的情況,就必須得有一個垃圾收集者的角色,不定期地回收一些無效記憶體,以保障Java虛擬機器能夠健康地持續執行。

這個垃圾收集者就是平常我們所說的“垃圾收集器”,那麼垃圾收集器在何時清掃記憶體?清掃哪些資料?這就是接下來我們要解決的問題。 


程式計數器、Java虛擬機器棧、本地方法棧都是執行緒私有的,也就是每條執行緒都擁有這三塊區域,而且會隨著執行緒的建立而建立,執行緒的結束而銷燬。那麼,垃圾收集器在何時清掃這三塊區域的問題就解決了。

此外,Java虛擬機器棧、本地方法棧中的棧幀會隨著方法的開始而入棧,方法的結束而出棧,並且每個棧幀中的本地變數表都是在類被載入的時候就確定的。因此以上三個區域的垃圾收集工作具有確定性,垃圾收集器能夠清楚地知道何時清掃這三塊區域中的哪些資料。

然而,堆和方法區中的記憶體清理工作就沒那麼容易了。 
堆和方法區所有執行緒共享,並且都在JVM啟動時建立,一直得執行到JVM停止時。因此它們沒辦法根據執行緒的建立而建立、執行緒的結束而釋放。

堆中存放JVM執行期間的所有物件,雖然每個物件的記憶體大小在載入該物件所屬類的時候就確定了,但究竟建立多少個物件只有在程式執行期間才能確定。 
方法區中存放類資訊、靜態成員變數、常量。類的載入是在程式執行過程中,當需要建立這個類的物件時才會載入這個類。因此,JVM究竟要載入多少個類也需要在程式執行期間確定。 
因此,堆和方法區的記憶體回收具有不確定性,因此垃圾收集器在回收堆和方法區記憶體的時候花了一些心思。 


堆記憶體的回收

1. 如何判定哪些物件需要回收?
在對堆進行物件回收之前,首先要判斷哪些是無效物件。我們知道,一個物件不被任何物件或變數引用,那麼就是無效物件,需要被回收。一般有兩種判別方式:

引用計數法 
每個物件都有一個計數器,當這個物件被一個變數或另一個物件引用一次,該計數器加一;若該引用失效則計數器減一。當計數器為0時,就認為該物件是無效物件。

可達性分析法 
所有和GC Roots直接或間接關聯的物件都是有效物件,和GC Roots沒有關聯的物件就是無效物件。 
GC Roots是指:

Java虛擬機器棧所引用的物件(棧幀中區域性變數表中引用型別的變數所引用的物件)
方法區中靜態屬性引用的物件
方法區中常量所引用的物件
本地方法棧所引用的物件 
PS:注意!GC Roots並不包括堆中物件所引用的物件!這樣就不會出現迴圈引用。
兩者對比: 
引用計數法雖然簡單,但存在一個嚴重的問題,它無法解決迴圈引用的問題。 
因此,目前主流語言均使用可達性分析方法來判斷物件是否有效。

2. 回收無效物件的過程
當JVM篩選出失效的物件之後,並不是立即清除,而是再給物件一次重生的機會,具體過程如下:

判斷該物件是否覆蓋了finalize()方法

若已覆蓋該方法,並該物件的finalize()方法還沒有被執行過,那麼就會將finalize()扔到F-Queue佇列中;
若未覆蓋該方法,則直接釋放物件記憶體。
執行F-Queue佇列中的finalize()方法 
虛擬機器會以較低的優先順序執行這些finalize()方法們,也不會確保所有的finalize()方法都會執行結束。如果finalize()方法中出現耗時操作,虛擬機器就直接停止執行,將該物件清除。

物件重生或死亡 
如果在執行finalize()方法時,將this賦給了某一個引用,那麼該物件就重生了。如果沒有,那麼就會被垃圾收集器清除。

注意: 
強烈不建議使用finalize()函式進行任何操作!如果需要釋放資源,請使用try-finally。 
因為finalize()不確定性大,開銷大,無法保證順利執行。

方法區的記憶體回收

我們知道,如果使用複製演算法實現堆的記憶體回收,堆就會被分為新生代和老年代,新生代中的物件“朝生夕死”,每次垃圾回收都會清除掉大量的物件;而老年代中的物件生命較長,每次垃圾回收只有少量的物件被清除掉。

由於方法區中存放生命週期較長的類資訊、常量、靜態變數,因此方法區就像是堆的老年代,每次垃圾收集的只有少量的垃圾被清除掉。

方法區中主要清除兩種垃圾: 
1. 廢棄常量 
2. 廢棄的類

1. 如何判定廢棄常量?
清除廢棄的常量和清除物件類似,只要常量池中的常量不被任何變數或物件引用,那麼這些常量就會被清除掉。

2. 如何廢棄廢棄的類?
清除廢棄類的條件較為苛刻: 
1. 該類的所有物件都已被清除 
2. 該類的java.lang.Class物件沒有被任何物件或變數引用 
只要一個類被虛擬機器載入進方法區,那麼在堆中就會有一個代表該類的物件:java.lang.Class。這個物件在類被載入進方法區的時候建立,在方法區中該類被刪除時清除。 
3. 載入該類的ClassLoader已經被回收

垃圾收集演算法
現在我們知道了判定一個物件是無效物件、判定一個類是廢棄類、判定一個常量是廢棄常量的方法,也就是知道了垃圾收集器會清除哪些資料,那麼接下來介紹如何清除這些資料。

1. 標記-清除演算法

首先利用剛才介紹的方法判斷需要清除哪些資料,並給它們做上標記;然後清除被標記的資料。

分析: 
這種演算法標記和清除過程效率都很低,而且清除完後存在大量碎片空間,導致無法儲存大物件,降低了空間利用率。

2. 複製演算法

將記憶體分成兩份,只將資料儲存在其中一塊上。當需要回收垃圾時,也是首先標記出廢棄的資料,然後將有用的資料複製到另一塊記憶體上,最後將第一塊記憶體全部清除。

分析: 
這種演算法避免了碎片空間,但記憶體被縮小了一半。 
而且每次都需要將有用的資料全部複製到另一片記憶體上去,效率不高。

解決空間利用率問題: 
在新生代中,由於大量的物件都是“朝生夕死”,也就是一次垃圾收集後只有少量物件存活,因此我們可以將記憶體劃分成三塊:Eden、Survior1、Survior2,記憶體大小分別是8:1:1。分配記憶體時,只使用Eden和一塊Survior1。當發現Eden+Survior1的記憶體即將滿時,JVM會發起一次MinorGC,清除掉廢棄的物件,並將所有存活下來的物件複製到另一塊Survior2中。那麼,接下來就使用Survior2+Eden進行記憶體分配。

通過這種方式,只需要浪費10%的記憶體空間即可實現帶有壓縮功能的垃圾收集方法,避免了記憶體碎片的問題。

但是,當一個物件要申請記憶體空間時,發現Eden+Survior中剩下的空間無法放置該物件,此時需要進行Minor GC,如果MinorGC過後空閒出來的記憶體空間仍然無法放置該物件,那麼此時就需要將物件轉移到老年代中,這種方式叫做“分配擔保”。


什麼是分配擔保? 

當JVM準備為一個物件分配記憶體空間時,發現此時Eden+Survior中空閒的區域無法裝下該物件,那麼就會觸發MinorGC,對該區域的廢棄物件進行回收。但如果MinorGC過後只有少量物件被回收,仍然無法裝下新物件,那麼此時需要將Eden+Survior中的所有物件都轉移到老年代中,然後再將新物件存入Eden區。這個過程就是“分配擔保”。

3. 標記-整理演算法

在回收垃圾前,首先將所有廢棄的物件做上標記,然後將所有未被標記的物件移到一邊,最後清空另一邊區域即可。

分析: 
它是一種老年代的垃圾收集演算法。老年代中的物件一般壽命比較長,因此每次垃圾回收會有大量物件存活,因此如果選用“複製”演算法,每次需要複製大量存活的物件,會導致效率很低。 //java學習交流:603835449  進入可領取學習資源及對十年開發經驗大佬提問免費解答! 而且,在新生代中使用“複製”演算法,當Eden+Survior中都裝不下某個物件時,可以使用老年代的記憶體進行“分配擔保”,而如果在老年代使用該演算法,那麼在老年代中如果出現Eden+Survior裝不下某個物件時,沒有其他區域給他作分配擔保。因此,老年代中一般使用“標記-整理”演算法。

4. 分代收集演算法

將記憶體劃分為老年代和新生代。老年代中存放壽命較長的物件,新生代中存放“朝生夕死”的物件。然後在不同的區域使用不同的垃圾收集演算法。

Java中引用的種類
Java中根據生命週期的長短,將引用分為4類。

1. 強引用
我們平時所使用的引用就是強引用。 
A a = new A(); 
也就是通過關鍵字new建立的物件所關聯的引用就是強引用。 
只要強引用存在,該物件永遠也不會被回收。 


2. 軟引用
只有當堆即將發生OOM異常時,JVM才會回收軟引用所指向的物件。 
軟引用通過SoftReference類實現。 
軟引用的生命週期比強引用短一些。 


3. 弱引用
只要垃圾收集器執行,軟引用所指向的物件就會被回收。 
弱引用通過WeakReference類實現。 
弱引用的生命週期比軟引用短。 


4. 虛引用
虛引用也叫幽靈引用,它和沒有引用沒有區別,無法通過虛引用訪問物件的任何屬性或函式。 
一個物件關聯虛引用唯一的作用就是在該物件被垃圾收集器回收之前會受到一條系統通知。 
虛引用通過PhantomReference類來實現。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70010294/viewspace-2850127/,如需轉載,請註明出處,否則將追究法律責任。

相關文章