前言
第二篇介紹了Java
記憶體執行時區域,其中程式計數器、虛擬機器棧、本地方法棧 三個區域隨執行緒而生,隨執行緒而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的,因此這幾個區域的記憶體分配和回收都具備確定性。在這幾個區域內不需要過多考慮回收的問題,因為方法結束或執行緒結束時,記憶體自然就跟隨著回收了。
而Java
堆 和 方法區 則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣。我們只有在程式處於執行期間時才能知道會建立哪些物件,這部分記憶體的分配和回收都是動態的,垃圾收集器 所關注的是這部分記憶體。
正文
(一). 物件生死判定
如何判斷Java
中一個物件應該 “存活” 還是 “死去”,這是 垃圾回收器要做的第一件事。
1. 引用計數演算法
Java
堆 中每個具體物件(不是引用)都有一個引用計數器。當一個物件被建立並初始化賦值後,該變數計數設定為1
。每當有一個地方引用它時,計數器值就加1。當引用失效時,即一個物件的某個引用超過了生命週期(出作用域後)或者被設定為一個新值時,計數器值就減1。任何引用計數為0
的物件可以被當作垃圾收集。當一個物件被垃圾收集時,它引用的任何物件計數減1。
-
優點:
引用計數收集器執行簡單,判定效率高,交織在程式執行中。對程式不被長時間打斷的實時環境比較有利。
-
缺點:
難以檢測出物件之間的迴圈引用。同時,引用計數器增加了程式執行的開銷。所以Java語言並沒有選擇這種演算法進行垃圾回收。
2. 可達性分析演算法
可達性分析演算法也叫根搜尋演算法,通過一系列的稱為 GC Roots
的物件作為起點,然後向下搜尋。搜尋所走過的路徑稱為引用鏈 (Reference Chain
), 當一個物件到 GC Roots
沒有任何引用鏈相連時, 即該物件不可達,也就說明此物件是 不可用的。
如下圖所示: Object5
、Object6
、Object7
雖然互有關聯, 但它們到GC Roots
是不可達的, 因此也會被判定為可回收的物件。
GC根物件
在Java
中, 可作為GC Roots
的物件包括以下四種:
-
虛擬機器棧(棧幀中的本地變數表)中引用的物件
-
本地方法棧 中
JNI
(Native
方法)引用的變數 -
方法區 中類靜態屬性引用的變數
-
方法區 中常量引用的變數
JVM中用到的所有現代GC演算法在回收前都會先找出所有仍存活的物件。可達性分析演算法是從離散數學中的圖論引入的,程式把所有的引用關係看作一張圖。下圖展示的JVM中的記憶體佈局可以用來很好地闡釋這一概念:
(二). 物件引用分類
1. 強引用(Strong Reference)
在程式碼中普遍存在的,類似Object obj = new Object()
這類引用,只要強引用還在,垃圾收集器永遠不會回收掉被引用的物件。
2. 軟引用(Sofe Reference)
有用但並非必需 的物件,可用SoftReference
類來實現軟引用。在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。
3. 弱引用(Weak Reference)
非必需 的物件,但它的強度比軟引用更弱,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前,JDK
提供了WeakReference
類來實現弱引用。無論當前記憶體是否足夠,用軟引用相關聯的物件都會被回收掉。
4. 虛引用(Phantom Reference)
虛引用也稱為幽靈引用或幻影引用,是最弱的一種引用關係,JDK
提供了PhantomReference
類來實現虛引用。為一個物件設定虛引用的唯一目的是:能在這個物件在垃圾回收器回收時收到一個系統通知。
(三). finalize()二次標記
一個物件是否應該在垃圾回收器在GC
時回收,至少要經歷兩次標記過程。
第一次標記過程,通過可達性分析演算法分析物件是否與GC Roots
可達。經過第一次標記,並且被篩選為不可達的物件會進行第二次標記。
第二次標記過程,判斷不可達物件是否有必要執行finalize
方法。執行條件是當前物件的finalize
方法被重寫,並且還未被系統呼叫過。如果允許執行那麼這個物件將會被放到一個叫F-Query
的佇列中,等待被執行。
注意:由於
finalize
由一個優先順序比較低的Finalizer
執行緒執行,所以該物件的的finalize
方法不一定被執行,即使被執行了,也不保證finalize
方法一定會執行完。如果物件第二次小規模標記,即finalize
方法中拯救自己,只需要重新和引用鏈上的任一物件建立關聯即可。
(四). 垃圾回收演算法
本節具體介紹一下各種垃圾回收演算法的思想:
1. 標記-清除演算法
標記-清除演算法對根集合進行掃描,對存活的物件進行標記。標記完成後,再對整個空間內未被標記的物件掃描,進行回收。
-
優點:
實現簡單,不需要進行物件進行移動。
-
缺點:
標記、清除過程效率低,產生大量不連續的記憶體碎片,提高了垃圾回收的頻率。
2. 複製演算法
這種收集演算法解決了標記清除演算法存在的效率問題。它將記憶體區域劃分成相同的兩個記憶體塊。每次僅使用一半的空間,JVM
生成的新物件放在一半空間中。當一半空間用完時進行GC
,把可到達物件複製到另一半空間,然後把使用過的記憶體空間一次清理掉。
-
優點:
按順序分配記憶體即可,實現簡單、執行高效,不用考慮記憶體碎片。
-
缺點:
可用的記憶體大小縮小為原來的一半,物件存活率高時會頻繁進行復制。
3. 標記-整理演算法
標記-整理演算法 採用和 標記-清除演算法 一樣的方式進行物件的標記,但後續不直接對可回收物件進行清理,而是將所有的存活物件往一端空閒空間移動,然後清理掉端邊界以外的記憶體空間。
-
優點:
解決了標記-清理演算法存在的記憶體碎片問題。
-
缺點:
仍需要進行區域性物件移動,一定程度上降低了效率。
4. 分代收集演算法
當前商業虛擬機器都採用分代收集的垃圾收集演算法。分代收集演算法,顧名思義是根據物件的存活週期將記憶體劃分為幾塊。一般包括年輕代、老年代 和 永久代,如圖所示:
新生代(Young generation)
絕大多數最新被建立的物件會被分配到這裡,由於大部分物件在建立後會很快變得不可達,所以很多物件被建立在新生代,然後消失。物件從這個區域消失的過程我們稱之為 minor GC
。
新生代 中存在一個Eden
區和兩個Survivor
區。新物件會首先分配在Eden
中(如果新物件過大,會直接分配在老年代中)。在GC
中,Eden
中的物件會被移動到Survivor
中,直至物件滿足一定的年紀(定義為熬過GC
的次數),會被移動到老年代。
可以設定新生代和老年代的相對大小。這種方式的優點是新生代大小會隨著整個堆大小動態擴充套件。引數 -XX:NewRatio
設定老年代與新生代的比例。例如 -XX:NewRatio=8
指定 老年代/新生代 為8/1
. 老年代 佔堆大小的 7/8
,新生代 佔堆大小的 1/8
(預設即是 1/8
)。
例如:
-XX:NewSize=64m -XX:MaxNewSize=1024m -XX:NewRatio=8
複製程式碼
老年代(Old generation)
物件沒有變得不可達,並且從新生代中存活下來,會被拷貝到這裡。其所佔用的空間要比新生代多。也正由於其相對較大的空間,發生在老年代上的GC
要比新生代要少得多。物件從老年代中消失的過程,可以稱之為major GC
(或者full GC
)。
永久代(permanent generation)
像一些類的層級資訊,方法資料 和方法資訊(如位元組碼,棧 和 變數大小),執行時常量池(JDK7
之後移出永久代),已確定的符號引用和虛方法表等等。它們幾乎都是靜態的並且很少被解除安裝和回收,在JDK8
之前的HotSpot
虛擬機器中,類的這些**“永久的”** 資料存放在一個叫做永久代的區域。
永久代一段連續的記憶體空間,我們在JVM
啟動之前可以通過設定-XX:MaxPermSize
的值來控制永久代的大小。但是JDK8
之後取消了永久代,這些後設資料被移到了一個與堆不相連的稱為元空間 (Metaspace
) 的本地記憶體區域。
小結
JDK8
堆記憶體一般是劃分為年輕代和老年代,不同年代 根據自身特性採用不同的垃圾收集演算法。
對於新生代,每次GC
時都有大量的物件死亡,只有少量物件存活。考慮到複製成本低,適合採用複製演算法。因此有了From Survivor
和To Survivor
區域。
對於老年代,因為物件存活率高,沒有額外的記憶體空間對它進行擔保。因而適合採用標記-清理演算法和標記-整理演算法進行回收。
參考
周志明,深入理解Java虛擬機器:JVM高階特性與最佳實踐,機械工業出版社
歡迎關注技術公眾號:零壹技術棧
本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。