(二)垃圾收集器與記憶體分配策略
上一篇主要講解的是JVM記憶體管理,記憶體分割槽,在本篇部落格中主要講解的是垃圾收集器以及記憶體分配策略。
1、概述
JAVA語言中,JVM記憶體管理都是“自動化”的,為啥還需要繼續關注JVM記憶體管理呢?原因很簡單,JVM記憶體管理不是萬能的,也會出現記憶體洩漏以及記憶體溢位等問題,當垃圾收整合為系統達到更高併發量的瓶頸時,我們就需要對JVM記憶體管理進行監控、干預。
由上一篇部落格知道,JVM記憶體分割槽主要分為5部分,它們分別是:1、程式計數器;2、虛擬機器棧;3、本地方法棧;4、JAVA堆;5、方法區。其中1、程式計數器;2、虛擬機器棧;3、本地方法棧,這三個區域隨執行緒的建立和滅亡,棧中的棧幀隨著方法的進入和退出有條不紊的執行著出棧和入棧操作,每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就知道的,因此這幾個區域的記憶體分配和回收都具有確定性,在這幾個區域就不需要過多考慮記憶體回收的問題,因為方法結束或者執行緒結束,記憶體就自然釋放了。
但是JAVA堆和方法區則不一樣,這兩塊區域是所有執行緒共享的,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間時才能知道會建立哪些物件,這部分記憶體的分配和回收是動態的,垃圾回收器所關心的也正是這部分內容。
2、物件的存活判斷
垃圾收集器在對堆裡面的物件進行垃圾回收前,需要確定哪些物件是存活的,哪些物件是死亡的,只能回收死亡的物件。
2.1、引用計數法
引用計數法原理:
給物件中新增一個引用計數器,每次有一個地方引用它時,計數器就+1,當引用失效時,計數器就-1,任何時刻計數器為0的物件就是不可能被再次使用。
客觀的說引用計數法實現比較簡單,判斷效率也很高,但是在主流的JAVA虛擬機器裡面沒有采用引用計數法來管理記憶體,其主要原因就是無法解決物件之間的互相迴圈引用的問題。
2.2、可達性分析法
可達性分析法的原理:
通過一系列稱為“GC Roots”的物件作為起點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為“引用鏈”,當一個物件到GC Roots沒有任何引用鏈相連時,則說明該物件是不可用的。
在JAVA語言中,可作為GC Roots的物件包括以下幾種:
- 1、虛擬機器棧(棧幀中的本地變量表)中引用的物件
- 2、方法區中類靜態屬性引用的物件
- 3、方法區中常量引用的物件
- 4、本隊方法棧中JNI(即一般所說的Native方法)引用的物件
2.3、再談引用
以上的引用計數法和可達性分析法來判斷物件的存活時,都會用到“引用”有關.
在JDK 1.2之前,JAVA中的引用定義:如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱這塊記憶體代表一個引用。這種定義很狹隘,一種物件在這種定義下只有被引用和沒有被引用兩種狀態,而對於一些“食之無味,棄之可惜”的物件就顯得無能為力。
我們希望能描述這樣一些物件:當記憶體空間還足夠時,則能保留在記憶體之中,如果記憶體空間在進行垃圾收集後還比較緊張,則可以拋棄這些物件。
在JDK 1.2之後,JAVA對引用的概念進行了擴充,將引用分為4種,1、強引用;2、軟引用;3、弱引用;4、虛引用。這4種引用強度逐次遞減。
1、強引用
類似於Object obj = new Object(),這類引用只要還在,垃圾回收器就不會回收
2、軟引用
用來描述一些還有用但非必需的物件
3、弱引用
用來描述一些還有用但非必需的物件
4、虛引用
最弱的一種引用,,不能通過改引用獲取一個物件的例項,也不會對其生存空間造成影響,它的唯一作用就是在這個物件被收集器回收時,收到一個系統通知。
2.4、判斷物件存活的流程
即使在可達分析法中不可達的物件,也並非是“非死不可”的,這時候這些物件處於“緩刑”階段,要真正宣告一個物件死亡,其至少要經歷兩次標記過程,具體過程如下:
如果物件在經過可達分析法後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。
如果這個物件被判定為有必要執行finalize()方,那麼這個物件將會放置到一個F-Queue的佇列之中,並在稍後由一個虛擬機器自動建立的、低優先順序的Finalize執行緒去執行它。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它執行結束,這樣做的原因是,如果一個物件在finalize()方法中執行緩慢,或者發生了死迴圈(更極端的情況),將很可能會導致F-Queue佇列中其他物件永遠處於等待,甚至導致整個記憶體回收系統崩潰。finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC會對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己(重新與引用鏈中的任何一個物件建立關聯即可),那麼第二次標記時,該物件將被移除“即將回收”的集合,如果該物件這時候還沒有逃脫,那該物件基本就被回收了。
2.5、回收方法區
方法區(在HotSpot虛擬機器中叫做永久代)也是有垃圾回收的,只不過價效比比較低,在堆中,尤其是新生代,常規進行一次垃圾回收,一般回收70%--95%的空間,而在方法區的垃圾回收效率遠低於此。
方法區中垃圾回收主要是兩部分內容:1、廢棄常量;2、無用的類。
判斷一個常量是否是“廢棄常量”比較簡單,而要判斷一個類是否是無用的類的條件比較複雜,需同時滿足以下三點:
1)該類所有的例項都被回收
2)載入該類的ClassLoader已經被回收
3)該類對應的java.lang.Class物件沒有任何地方被引用。
3、垃圾回收演算法
3.1、標記-清除演算法
最基礎的收集演算法是“標記-清除”演算法,該演算法主要分為“標記”和“清除”兩個階段:首先標記所有需要回收的物件,在標記完成後統一回收所有被標記的物件。
問題:
1)效率問題
標記和清除兩個過程的效率都不高
2)空間問題
標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得已提前觸發另一次垃圾收集動作。
3.2、複製演算法
為了解決效率問題,出現了複製演算法,該演算法將記憶體容量分為大小相等的兩塊,每次使用其中的一塊,當這一塊的記憶體用完了,就將還存活的物件複製到另一塊上面,然後再把已使用過的記憶體空間一次清理掉。
現在JAVA堆中新生代記憶體區域採用複製演算法,但是並不是按照1:1的比例來劃分記憶體的,而是將記憶體劃分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor,當回收時,將Eden和Survivor中還存活的物件一次性的複製到另一塊Survivor空間上,最後清理掉Eden和Survivor記憶體空間。HotSpot預設的Eden和Survivor的比例是8:1.。
這樣做的好處就是隻有10%的記憶體會被浪費,但是如果當90%以上的物件可回收時,這時候Survivor的記憶體空間會不夠用,需要依賴其他記憶體*(老年代)進行分配擔保。
3.3、標記-整理演算法
複製演算法在物件存活率較多的時候就要進行較多的複製操作,效率將會變得低下,更關鍵的是,如果不想浪費50%的空間,就需要額外的空間進行分配擔保,所以老年代一般不能直接選用這種演算法。
由老年代的特點,提出了另一種演算法----“標記--整理”演算法,標記過程和“標記--清除”演算法一致,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。
3.4、分代收集演算法
當前商業虛擬機器都採用的是“分代收集”,該演算法沒有新的思想,只是根據物件的存活週期採用上述的幾種演算法。
4、HotSpot演算法實現
上面講述的都是從理論層面來說明垃圾回收的相關問題,而在實際的虛擬機器中如何實現這些演算法時,需要有更加嚴格、完善的考量,下面拿HotSpot的演算法來說明。
4.1、列舉根節點
由可達分析法可知,判斷物件是否存活時,需要從GC Roots節點找,可作為GC Roots的節點有1、全域性性引用(例如常量或者類靜態屬性);2、執行上下文(例如棧幀中的本地變量表),因此GC Roots容量非常大,如果逐個檢查這裡面的引用,那麼會消耗很長時間,將會導致上時間的GC停頓,這是絕對不能允許的。
由於目前的主流JAVA虛擬機器使用的都是準確式的GC,虛擬機器應當知道哪些地方存放著物件的引用,在HotSpot的實現中,是使用一組成為OopMap的資料結構來達到這個目的的 。
4.2、安全點
在OopMap的協助下,HotSpot可以快速且準確的完成GC Roots列舉,但一個很現實的問題是:引用關係變化,或者OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那麼將會產生大量的額外空間,這樣GC的空間成本會很大。
實際上,HotSpot也的確沒有為每條指令都生成OopMap,只是在“特殊位置”記錄了這些資訊,這些位置稱為安全點,及程式執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。
安全點的選定標準:“是否具有讓程式長時間執行的特徵”為標準選定的。如方法呼叫、迴圈跳轉等-
4.3、安全區域
未完,待續