文章目錄
JVM - 內功修煉之垃圾回收
1.垃圾回收概述
1.1 爲什麼要進行垃圾回收?
Java語言有一個顯著的特點就是引入了垃圾回收機制,瞭解C++的同學應該知道,內存管理的問題總是讓開發者頭痛不已。Java與C++之間存在着一堵高牆,一堵由
內存動態分配
和垃圾收集機制
所圍成的牆,牆外面的人想進來而裏面的人卻想出去。
當程序去申請新的內存空間但是空閒內存不夠,或者已分配內存達到一定比例時就會觸發垃圾回收機制。垃圾回收可以有效的防止內存的泄露以及更合理有效地使用空閒內存。正是因爲存在垃圾回收機制,所以使用Java開發者可以更自由、輕鬆地去發揮。
1.2 爲什麼要去了解垃圾回收機制?
在瞭解垃圾回收機制之前我們先思考一個問題,我們爲什麼要去了解JVM的
垃圾回收機制
?
Java語言在經歷了這麼長時間的發展,其實針對內存動態分配
和垃圾回收
的技術已經相當成熟了,在我們實際開發過程中幾乎都不需要我們去觸及到關於垃圾回收機制的層面。但接觸過墨菲定律
的同學應該聽過一句話:如果事情有變壞的可能,不管這種可能性有多小,它總會發生
。
常在河邊走,哪有不溼鞋。當我們某天真正需要排查各種內存溢出、內存泄漏等問題或者當垃圾收集成爲系統性能瓶頸時,我們就需要自己對其有充分的瞭解去實施必要的監控和調整。
1.3 垃圾回收的過程是怎樣的?
我們可以試想一下,假設我們自己去實現垃圾回收,應該考慮哪幾個方面?
- 哪些內存應該被回收?
- 什麼時候回收這些內存?
- 怎麼去回收這些內存?
垃圾回收的整個過程其實大部分情況下都是圍繞着上面三個問題展開的。我們大家都知道程序計數器、虛擬機棧、本地方法棧三塊內存區域的生命週期都是依附線程的,棧幀會隨着方法的執行和結束有條不紊地去進行入棧和出棧的操作。每個棧幀中所需分配多少內存在類結構確定後就是已知的,線程結束內存自然就會回收,所以這幾個內存空間中的內存分配和回收無需過多考慮。
但是Java堆以及方法區則不同,由於一個接口的多個實現類以及一個方法中條件分支不同都有可能造成所需內存不一。只有當服務在運行時才能夠確定哪些對象會被創建,也就是說這部分內存的分配和回收都是動態的,而我們所需要研究的垃圾回收也就是針對這一部分的。
2.對象存活算法
我們要進一步去了解GC,那就要能夠了解GC存在的意義。GC的目的是通過特定算法將不再被使用的對象內存空間進行回收。垃圾回收的算法有很多種,但是在這之前我們需要先學會如何判定一個對象是否存活。
2.1 引用計數算法(Reference Counting)
當一個對象被創建時都會初始化一個引用計數器,當該對象實例被引用時,該計數器的值就會+1(例:a = b,此時b實例的引用計數器就會+1),而當一個對象實例的引用失效(超過生命週期或者引用重定向)時,這個引用計數器就會-1。當垃圾回收機制執行時,所有引用計數器爲0的對象實例都會被標記爲不可用對象從而被當做垃圾回收。而當一個對象實例被回收後,它引用的其他對象實例的引用計數器也會-1(例:a = b,當a被回收時,b對應的引用計數器也會-1)。
2.1.1 引用計數算法可能面臨的問題
總的來說,引用計數算法的實現比較簡單,並且判定效率也很高。在大部分情況下都是一個不錯的算法,但是目前主流的Java虛擬機並沒有選用引用計數算法來管理內存,最主要的原因就是它很難解決對象循環引用的問題。
我們先看下面這段代碼:
package com.ithzk.gc;
/**
* @author hzk
* @date 2019/11/28
*/
public class ReferenceCounter {
public Object instance;
public static void main(String[] args){
ReferenceCounter referenceCounterA = new ReferenceCounter();
ReferenceCounter referenceCounterB = new ReferenceCounter();
referenceCounterA.instance = referenceCounterB;
referenceCounterB.instance = referenceCounterA;
referenceCounterA = null;
referenceCounterB = null;
System.gc();
}
}
上面這段代碼的運行情況也就是我們上圖中的情況,最初我們使用
referenceCounterA
和referenceCounterB
變量去接收兩個新創建的對象時,這兩個對象的引用計數器都會+1。然後我們使兩個對象實例進行循環引用,此時這兩個對象實例的引用計數器則會再次+1變爲2。然後我們將變量referenceCounterA
和referenceCounterB
的引用清除,這時兩個對象實例的引用計數器-1變爲1。此時因爲兩個對象實例的引用計數器都不爲0,所以使用引用計數算法則GC是無法回收這兩個對象實例的。
我們在運行程序時給其加上-verbose:gc -XX:+PrintGCDetails
虛擬機參數打印GC詳情日誌。
這裏可以看到,GC其實是有進行比較大比例內存回收的,這也驗證了上面我們所說的目前主流的Java虛擬機並沒有採用引用計數算法來管理內存。
2.1.2 引用計數算法優缺點
優點
:實現簡單,效率高。缺點
:很難解決對象之間相互循環引用時的對象存活判斷,例如對象A和B相互引用對方,即使A和B永遠都不會再被訪問,但是因爲AB彼此持有對方的引用導致,AB的計數器永遠不會爲0,也就不會死亡,引用計數器無法通知GC收集器回收它們,正是因爲這一點,主流的JVM都沒有選用引用計數法來管理內存。
2.2可達性分析算法(Reachability Analysis)
可達性分析算法又稱爲根搜索法,引用的是離散數學中圖論的思想。把所有引用關係看作一張圖,通過將一系列的
GC ROOT
對象作爲起點,從這些起點出發向下搜索經過的路線稱爲引用鏈
。當一個對象與GC ROOT
之間不存在任何引用鏈時,則說明該對象爲不可用。
如上圖所示,綠色的對象爲仍然存活的對象,紅色的爲可以回收的對象。以我們已致的兩個reference
作爲GC ROOT
可以通過1、2、3、4
四條路線作爲引用鏈搜索到A、B、C
三個對象。而D
和E
兩個節點雖然之間存在關聯,但是沒有任何可以到達GC ROOT
的引用鏈,即不可達,也就是說這兩個節點會被判定爲可回收的對象。
其實當一個對象是不可達時,也並非一定就被立即回收了,在整個回收的過程中其實還會根據一定的策略對其進行標記,最後整個篩選結束纔會確定哪些屬於要被回收的對象。具體詳細的包括方法區內的回收後面有機會可以作爲擴展給大家稍微講解下,這裏不多提。
2.2.1 GC ROOT大家庭
既然我們一直在以
GC ROOT
爲起點去搜索可達的對象,那麼我們就要弄清楚GC ROOT
到底包含哪些對象?
- 虛擬機棧中引用的對象(局部變量表)
- 本地方法棧中引用的對象
- 方法區中靜態屬性引用的對象
- 方法區中常量引用的對象
3.垃圾回收算法
上面我們提到了JVM虛擬機中如果判定對象的存活狀態,這一切都是爲了我們接下來的GC做準備的。因爲每個虛擬機管理內存的策略不同,所以垃圾算法其實有很多,例:引用計數、標記清除、標記整理、複製、分代等。這裏我們針對這幾種常用的垃圾回收算法做一個介紹。
3.1 引用計數算法
引用計數是一個比較古老的算法,它的核心思想也就是我們上面提到的引用計數:當對象被引用時計數器加1,引用失效時則減1。垃圾回收時,只會收集計數爲0的對象。此算法最致命的是無法處理循環引用的問題,並且每次進行計數器加減操作比較浪費系統性能。
3.2 標記-清除算法(Mark-Sweep)
標記-清除算法
也正如同其名一樣,算法分爲“標記”和“清除”兩個階段:
- 第一階段:標記所有需要回收的對象。
- 第二階段:統一回收所有被標記對象。
標記-清除算法所存在的問題
這種算法其實是存在一定缺陷的,正如上圖所示:
- 第一個問題就是空間問題,通過該算法會產生大量不連續的內存空間。在往後程序運行過程需要分配較大對象時,由於無法提供足夠的連續內存而導致被迫需要提前觸發另一次GC。
- 第二個問題就是效率問題,其實標記和清除這兩個過程效率都是不高的。而且由於上面所說會造成的內存碎片過多的問題,過多不連續的內存空間的工作效率其實是低於連續內存空間的。
3.3 複製算法
3.3.1 爲什麼會有複製算法?
複製算法
的出現主要是爲了接解決標記-清除算法
造成內存碎片過多所產生的效率問題。
其核心思想就是將可用內存空間劃分爲兩塊大小相等的區域,每次只使用其中一塊。當垃圾回收時,把正在使用這塊內存區域中仍然存活的對象複製到另一塊內存區域中,然後將已使用過的內存空間完全清除掉,整個過程兩塊內存區域反覆交替角色。複製算法使得每次垃圾回收都是針對半區的,內存分配時也就不用去考慮內存碎片等問題,只需按順序分配內存即可,十分簡單高效。
3.3.2 複製算法的缺點
從上圖我們可以看出,複製算法雖然已經不存在內存碎片的問題,使得所有可用內存空間都是連續的。但同時也引發了另一個很明顯的缺點,那就是我們真正使用的內存只佔到了總內存的一半,對於內存空間的使用率就會有點低。
3.3.3 複製算法的應用場景
我們思考一個問題,由於複製算法需要將存活對象複製到新的內存區域中,如果當一個內存塊中對象存活率很高的話,那效率其實就變得十分低了。所以這種算法主要用在
新生代
。
目前商業虛擬機都採用這種算法來回收新生代。有專門研究表明,新生代中的對象90%以上是“朝生夕死”的,所以並不需要按照1:1的比例去劃分內存空間,而是將內存劃分爲一塊較大的Eden
空間和兩塊較小的Survivor
空間,每次使用Eden
和其中一塊Survivor From
。Eden
和Survivor
空間內存比例8:1:1
。
當進行GC時,會將Eden
和Survivor From
區域中仍然存活的對象複製到Survivor To
上,然後將Eden
和Survivor From
清空。上面我們知道了Eden
和Survivor
空間內存比例8:1:1
,所以其實對於上面我們提出內存過度浪費的缺點可以忽略的。也就是說新生代中可用內存佔整個新生代的90%,只有10%被用來作爲保留內存,浪費其實已經十分小了。但其實還是需要依賴另外內存空間的支持,並不單純是我們表面看到10%內存的浪費。
對於98%對象都是可回收的這個研究,其實也僅僅侷限於一般場景,並不能夠保證每次回收都只有10%以內的對象存活。當Survivor To
區域不夠時,JVM會依賴老年代去進行分配擔保將存活的對象直接移至老年代中。
3.4 標記-整理算法(Mark-Compact)
3.4.1 爲什麼會有標記-整理算法?
上面我們知道了當對象存活率較高的情況下需要進行大量的複製操作,複製算法效率就會變得很低。更關鍵的是,如果不想浪費過多的保留空間,就需要有額外的空間進行分配擔保去應對使用內存對象存活率極高的極端情況,所以老年代中一般不能直接選用複製算法。
3.4.2 標記-整理算法核心思想
根據老年代的特點,
標記-整理算法
就出現了,該算法在標記-清除算法
和複製算法
的基礎上進行優點的結合。標記過程仍然和標記-清除算法
一樣,但是在標記後不會直接對需要回收的內存進行清理,而是將存活對象整理集中到一塊,然後將存活對象邊界以外的內存清理掉,整個過程如上圖所示。這種方法對於標記-清除算法來說,有效地解決了空間碎片的問題,而對於複製算法來說同樣也解決了內存浪費的問題。
3.5 分代收集算法
分代收集是一種基於對象生命週期分析得到的一種算法,由於JVM會將堆分爲新生代和老年代,這種算法的核心就是根據不同內存區域的特點會採用不同的算法。
對於新生代來說,每次GC都會有大量對象由於死亡而被回收,只有少量對象是存活的,這種情況採用複製算法最爲妥當,只需要對少量存活對象進行復制就可以完成收集。而老年代由於對象存活率較高,並且缺乏額外空間對其進行分配擔保,所以必須選擇標記-清除算法
或標記-整理算法
進行回收。
3.6 分區收集算法
這種算法大家可能聽得不是特別多,
分區收集算法
和分代收集算法
思路類似,只不過分區收集算法
是將整個內存分爲N個小的獨立空間,每個空間獨立使用。由於這種分區的是細粒度的,所以主要是控制一次回收某一些小空間,而不是整個空間都進行GC。從而提升性能,並且可以有效地減少GC所造成的停頓時間。