自動的記憶體管理系統實操手冊——Java垃圾回收篇

導語 | 現代高階程式語言管理記憶體的方式分自動和手動兩種。手動管理記憶體的典型代表是C和C++,編寫程式碼過程中需要主動申請或者釋放記憶體;而PHP、Java 和Go等語言使用自動的記憶體管理系統,由記憶體分配器和垃圾收集器來代為分配和回收記憶體,其中垃圾收集器就是我們常說的GC。本文中,騰訊後臺開發工程師汪匯從原理出發,介紹 Java 和Golang垃圾回收演算法,並從原理上對他們做一個對比。今天先向大家分享 Java 垃圾回收演算法。

一、 垃圾回收區域及劃分

在介紹 Java 垃圾回收之前,我們需要了解 Java 的垃圾主要存在於哪個區域。JVM記憶體執行時區域劃分如下圖所示:

自動的記憶體管理系統實操手冊——Java垃圾回收篇

圖源:深入理解Java虛擬機器:JVM高階特性與最佳實踐(第3版) —機械工業出版社

程式計數器

:是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器,各條執行緒之間計數器互不影響,獨立儲存。

虛擬機器棧

:它描述的是 Java 方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame,是方法執行時的基礎資料結構)用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。

本地方法棧

:它與虛擬機器棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機器棧為虛擬機器執行 Java 方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的Native方法服務。

Java堆

:它是 Java 虛擬機器所管理的記憶體中最大的一塊。Java 堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。

方法區

:它與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

Java 記憶體執行時區域的各個部分,其中程式計數器、虛擬機器棧、本地方法棧3個區域隨著執行緒而生,隨著執行緒而滅;棧中的棧幀隨著方法的進入退出而進棧出棧,在類結構確定下來時就已知每個棧幀中的分配記憶體。而 Java 堆和方法區則不同,一個介面中的多個實現類需要的記憶體可能不同,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間時才能知道會建立哪些物件,這部分記憶體的分配和回收都是動態的,而在java8中,方法區存放於元空間中,元空間與堆共享物理記憶體,因此,

Java 堆和方法區是垃圾收集器管理的主要區域

從垃圾回收的角度

,由於JVM垃圾收集器基本都採用分代垃圾收集理論,所以 Java 堆還可以細分為如下幾個區域(以HotSpot虛擬機器預設情況為例):

其中,Eden區、From Survivor0(“From”)區、To Survivor1(“To”)區都屬於新生代,Old Memory區屬於老年代。

大部分情況,物件都會首先在Eden區域分配;在一次新生代垃圾回收後,如果物件還存活,則會進入To區,並且物件的年齡還會加1(Eden 區->Survivor區後物件的初始年齡變為1),當它的年齡增加到一定程度(超過了survivor區的一半時,取這個值和

MaxTenuringThreshold

中更小的一個值,作為新的晉升年齡閾值),就會晉升到老年代中。經過這次GC後,Eden區和From區已經被清空。這個時候,From和To會交換他們的角色,保證名為To的Survivor區域是空的。Minor GC會一直重複這樣的過程。在這個過程中,有可能當次Minor GC後,Survivor 的“From”區域空間不夠用,有一些還達不到進入老年代條件的例項放不下,則放不下的部分會提前進入老年代。

針對HotSpot VM的實現,它裡面的GC其實準確分類只有兩大種:

1.部分收集 (Partial GC)

新生代收集(Minor GC/Young GC):只對新生代進行垃圾收集;

老年代收集(Major GC/Old GC):只對老年代進行垃圾收集。需要注意的是Major GC在有的語境中也用於指代整堆收集;

混合收集(Mixed GC):對整個新生代和部分老年代進行垃圾收集。

2.整堆收集 (Full GC):收集整個Java堆和方法區

Java 堆記憶體常見分配策略

1。物件優先在eden區分配。大部分物件朝生夕滅。

2。大物件直接進入老年代。大物件就是需要大量連續記憶體空間的物件(比如:字串、陣列),容易導致記憶體還有不少空間就提前觸發垃圾收集獲取足夠的連續空間來安置它們。為了避免為大物件分配記憶體時,由於分配擔保機制帶來的複製而降低效率,建議大物件直接進入空間較大的老年代。

3。長期存活的物件將進入老年代,動態物件年齡判定:在一次新生代垃圾回收後,如果物件還存活,則會進入s0或者s1,並且物件的年齡還會加1(Eden 區->Survivor區後物件的初始年齡變為1),當它的年齡增加到一定程度(超過了survivor區的一半時,取這個值和

MaxTenuring

Thres

hold

中更小的一個值,作為新的晉升年齡閾值),就會被晉升到老年代中。物件晉升到老年代的年齡閾值,可以透過引數

-XX:MaxTenuring

Th

r

es

hold

來設定。

4。空間分配擔保。在發生Minor GC之前,虛擬機器會先檢查老年代最大可用連續記憶體空間是否大於新生代所有物件總空間。如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機器會檢視HandlePromotionFailure設定值是否允許【擔保失敗】:

如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小。

如果大於,將嘗試著進行一次Minor GC,儘管這次Minor GC是有風險的。

如果小於,或者HandlePromotionFailure設定不允許冒險,那這時也要改為進行一次Full GC。

二、 判斷物件死亡

堆中幾乎放著所有的物件例項,對堆垃圾回收前的第一步就是要判斷哪些物件已經死亡(即不能再被任何途徑使用的物件)。判斷一個物件是否存活有引用計數、可達性分析這兩種演算法,兩種演算法各有優缺點。Java 和Go都使用可達性分析演算法,一些動態指令碼語言(如:ActionScript)一般使用引用計數演算法。

(一)引用計數法

引用計數法給每個物件的物件頭新增一個引用計數器,每當其他地方引用一次該物件,計數器就加1;當引用失效,計數器就減1;任何時候計數器為0的物件就是不可能再被使用的。

這個方法實現簡單,效率高,但是主流的Java虛擬機器中並沒有選擇這個演算法來管理記憶體,其最主要的原因是它很難解決物件之間相互迴圈引用的問題。即如下程式碼所示:除了物件objA和objB相互引用著對方之外,這兩個物件之間再無任何引用。但是他們因為互相引用對方,導致它們的引用計數器都不為0,於是引用計數演算法無法通知GC回收器回收他們。

目前Python語言使用的是引用計數法,它採用了“標記-清除”演算法,解決容器物件可能產生的迴圈引用問題。關於詳細原理可以參考《Python垃圾回收機制詳解]。

(二)可達性分析演算法

這個演算法的基本思想就是透過一系列的稱為“GC Roots”的物件作為起點,從這些節點開始向下搜尋,節點所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連的話,則證明此物件是不可用的。演算法優點是能準確標識所有的無用物件,包括相互迴圈引用的物件;缺點是演算法的實現相比引用計數法複雜。比如如下圖所示Root1和Root2都為“GC Roots”,白色節點為應被垃圾回收的。

關於Java檢視可達性分析、記憶體洩露的工具,強烈推薦“Memory Analyzer Tool”,可以檢視記憶體分佈、物件間依賴、物件狀態。

自動的記憶體管理系統實操手冊——Java垃圾回收篇

在Java中,可以作為“

GC Roots

”的物件有很多,比如:

在虛擬機器棧(棧幀中的本地變量表)中引用的物件,譬如各個執行緒被呼叫的方法堆疊中使用到的引數、區域性變數、臨時變數等。

在方法區中類靜態屬性引用的物件,譬如Java類的應用型別靜態變數。

在方法區中常量應用的物件,譬如字串池中的引用。

在本地方法棧中JNI引用的物件。

Java虛擬機器內部的引用,如基本資料型別對應的Class物件,一些常駐異常物件(如NPE),還有系統類載入器。

所有被同步鎖(synchronized)持有的物件。

反映Java虛擬機器內部情況的JMXBean、JVMTI中註冊的回撥、原生代碼快取等。

不可達的物件並非“非死不可”

即使在可達性分析法中不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑階段”,要真正宣告一個物件死亡,至少要經歷兩次標記過程;可達性分析法中不可達的物件被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize方法。當物件沒有覆蓋finalize方法,或 finalize 方法已經被虛擬機器呼叫過時,虛擬機器將這兩種情況視為沒有必要執行。被判定為需要執行的物件將會被放在一個佇列中進行第二次標記,除非這個物件與引用鏈上的任何一個物件建立關聯,否則就會被真的回收。

判斷一個執行時常量池中的常量是廢棄常量

1。JDK1。7 之前執行時常量池邏輯包含字串常量池存放在方法區, 此時 hotspot 虛擬機器對方法區的實現為永久代。

2。JDK1。7 字串常量池被從方法區拿到了堆中, 這裡沒有提到執行時常量池,也就是說字串常量池被單獨拿到堆,執行時常量池剩下的東西還在方法區, 也就是 hotspot 中的永久代。

3。JDK1。8 hotspot 移除了永久代用元空間(Metaspace)取而代之, 這時候字串常量池還在堆, 執行時常量池還在方法區, 只不過方法區的實現從永久代變成了元空間(Metaspace)。

假如在字串常量池中存在字串“abc”,如果當前沒有任何String物件引用該字串常量的話,就說明常量“abc”就是廢棄常量,如果這時發生記憶體回收的話而且有必要的話,“abc”就會被系統清理出常量池了。

如何判斷一個方法區的類是無用的類

類需要同時滿足下面3個條件才能算是“無用的類”,虛擬機器可以對無用類進行回收。

1。該類所有的例項都已經被回收,也就是 Java 堆中不存在該類的任何例項。

2。載入該類的ClassLoader已經被回收。

3。該類對應的 java。lang。Class物件沒有在任何地方被引用,無法在任何地方透過反射訪問該類的方法。

三、垃圾收集演算法

當確定了哪些物件可以回收後,就要需要考慮如何對這些物件進行回收,目前垃圾回收演算法主要有以下幾種。

(一)標記清除演算法

該演算法分為“標記”和“清除”階段:首先標記出所有不需要回收的物件,在標記完成後統一回收掉所有沒有被標記的物件。

適用場合

:存活物件較多的情況、適用於年老代(即舊生代)。

缺點

1.空間問題

,易產生記憶體碎片,當為一個大物件分配空間時可能會提前觸發垃圾回收(例如,物件的大小大於空閒表中的每一塊兒大小但是小於其中兩塊兒的和)。

2.效率問題

,掃描了整個空間兩次(第一次:標記存活物件;第二次:清除沒有標記的物件)。

自動的記憶體管理系統實操手冊——Java垃圾回收篇

(二)標記複製演算法

為了解決效率問題,出現了“標記-複製”收集演算法。它可以將記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就將還存活的物件複製到另一塊去,然後再把使用的空間一次清理掉。使用複製演算法,回收過程中就不會出現記憶體碎片,也提高了記憶體分配和釋放的效率。

適用場合

:存活物件較少的情況下比較高效、用於年輕代(即新生代)。

缺點

:需要一塊兒空的記憶體空間,整理階段,由於移動了可用物件,需要去更新引用。

自動的記憶體管理系統實操手冊——Java垃圾回收篇

(三)標記整理演算法

對於物件存活率較高的場景,複製演算法要進行較多複製操作,使得效率會變低,這種場景更適合標記-整理演算法,與標記-清理一樣,標記整理演算法先標記出物件的存活狀態,但在清理時,是先把所有存活物件往一端移動,然後直接清掉邊界以外的記憶體。

自動的記憶體管理系統實操手冊——Java垃圾回收篇

適用場合

:物件存活率較高(即老年代)

缺點

:整理階段,由於移動了可用物件,需要去更新引用。

(四)分代收集演算法

當前 Java 虛擬機器的垃圾收集採用分代收集演算法,一般根據物件存活週期的不同將記憶體分為新生代和老年代。在新生代中,每次收集都會有大量物件死去,可以選擇“標記-複製”演算法,只需要付出少量物件的複製成本就可以完成每次垃圾收集。而老年代的物件存活機率是比較高,而且沒有額外的空間對它進行分配擔保,所以我們選擇“標記-清除”或“標記-整理”演算法進行垃圾收集。

四、垃圾收集器

自動的記憶體管理系統實操手冊——Java垃圾回收篇

圖源:深入理解Java虛擬機器:JVM高階特性與最佳實踐(第3版) —機械工業出版社

雖然我們對各個收集器進行比較,但並非要挑選出一個最好的收集器。因為直到現在為止還沒有最好的垃圾收集器出現,更加沒有萬能的垃圾收集器,我們能做的就是根據具體應用場景選擇適合自己的垃圾收集器。

參考文獻

1。[CMS垃圾收集器]

2。[一個專家眼中的Go與Java垃圾回收演算法大對比]

3。《深入理解Java虛擬機器:JVM高階特性與最佳實踐(第3版)》—機械工業出版社

作者簡介

汪匯

騰訊後臺開發工程師

騰訊後臺開發工程師,負責騰訊看點相關後端業務,畢業於南京大學軟體學院。