簡述
在上篇文章中簡單介紹了JVM內部結構,執行緒隔離區域隨著執行緒而生,隨著執行緒而忘。執行緒共享區域因為是共享,所以可能多個執行緒都用到,不能輕易回收,與C語言不同,在Java虛擬機器自動記憶體管理機制的幫助下,不再需要為每個new操作去寫配對的delte/free程式碼,能夠幫助程式設計師更好的編寫程式碼。那麼JVM是如何進行物件記憶體分配以及回收分配給物件記憶體呢?
記憶體分配
幾乎所有的物件例項都分配在堆中,為了進行高效的垃圾回收,虛擬機器把堆劃分成新生代(Young Generation)、老年代(Old Generation)。
新生代
新生代又分為1個Eden區和2個survivor區(S0,S1),Eden區與Survivor區的記憶體大小比例預設為8:1。
Eden伊甸園,在大多數情況下物件優先在Eden區中分配
Survivor倖存者,當Eden區沒有足夠記憶體進行分配,會觸發一次Minor GC,會將倖存的物件移動到記憶體區域S0區域,並清空Eden區域。當再次發生Minor GC時,將Eden和S0中倖存的物件移動到S1記憶體區域。
倖存物件會反覆在S0和S1之間移動,當物件從Eden移動到Survivor或者在Survivor之間移動時,物件的GC年齡自動累加,當GC年齡超過預設閾值15時,會將該物件移動到老年代,可以通過引數-XX:MaxTenuringThreshold 對GC年齡的閾值進行設定。
老年代
除了長期存活的物件會分配到老年代,還有以下情況物件會分配到老年代:
①大物件(需要大量連續記憶體空間的Java物件)直接進入老年代,可以通過引數
-XX:PretenureSizeThreshold設定物件大小閾值,超過其值進入老年代
②若Survivor區域中所有相同GC年齡的物件大小超過Survivor空間的一半,年齡不小於該年齡的物件就直接進入老年代
分配方法
假設Java堆中記憶體是完整的,已分配的記憶體和空閒記憶體分別在不同的一側,通過一個指標作為分界點,需要分配記憶體時,僅僅需要把指標往空閒的一端移動與物件大小相等的距離。
事實上,Java堆的記憶體並不是完整的,已分配的記憶體和空閒記憶體相互交錯,JVM通過維護一個列表,記錄可用的記憶體塊資訊,當分配操作發生時,從列表中找到一個足夠大的記憶體塊分配給物件例項,並更新列表上的記錄。
物件建立是一個非常頻繁的行為,進行堆記憶體分配時還需要考慮多執行緒併發問題,可能出現正在給物件A分配記憶體,指標或記錄還未更新,物件B又同時分配到原來的記憶體,解決這個問題有兩種方案:
1、採用CAS保證資料更新操作的原子性;
2、把記憶體分配的行為按照執行緒進行劃分,在不同的空間中進行,每個執行緒在Java堆中預先分配一個記憶體塊,稱為本地執行緒分配緩衝(Thread Local Allocation Buffer, TLAB);
記憶體回收
如何判斷哪些物件佔用的記憶體需要回收?虛擬機器有如下方法:
給物件新增一個引用計數器,每當有一個地方引用它時,計數器就加1;當引用失效時,計數器就減1;當計數器為0時就代表此物件已死,需要回收。
此方法無法解決物件之間相互引用的問題
public class ReferenceCountingGC {
public Object instance = null; private static final int _1MB = 1024 * 1024; /** * 方便GC能看清楚是否被回收 */ private byte[] bigSize = new byte[2 * _1MB]; public static void testGC(){ ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; System.gc(); } 複製程式碼
複製程式碼
}
複製程式碼結果:
[GC 6758K->632K(124416K), 0.0016573 secs]
[Full GC 632K->530K(124416K), 0.0148864 secs]
從結果可以看出這兩個物件依然被回收
通過一些節點開始搜尋,當一個物件到 GC Roots 沒有任何引用鏈(通過路徑)時,代表該物件可以被回收
GC Roots的物件包括:
①本地變數表中引用的物件
②方法區中類靜態屬性引用的物件
③方法區中常量引用的物件
④Native方法引用的物件
判定一個物件是否可回收,至少要經歷兩次標記過程:
①若物件與GC Roots沒有引用鏈,則進行第一次標記
②若此物件重寫了finalize()方法,且還未執行過,那麼它會被放到F-Queue佇列中,並由一個虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行此方法(並非一定會執行)。finalize方法是物件逃脫死亡的最後機會,GC對佇列中的物件進行第二次標記,若該物件在finalize方法中與引用鏈上的任何一個物件建立聯絡,那麼在第二次標記時,該物件會被移出”即將回收”集合。
自我救贖示例:
public class FinalizeGC {
public static FinalizeGC obj;
public void isAlive() {
System.out.println("yes, i am still alive");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("method finalize executed");
obj = this;
}
public static void main(String[] args) throws Exception {
obj = new FinalizeGC();
// 第一次執行,finalize方法會自救
obj = null;
System.gc();
Thread.sleep(500);
if (obj != null) {
obj.isAlive();
} else {
System.out.println("I`m dead");
}
// 第二次執行,finalize方法已經執行過
obj = null;
System.gc();
Thread.sleep(500);
if (obj != null) {
obj.isAlive();
} else {
System.out.println("I`m dead");
}
}
}
複製程式碼
結果:
method finalize executed
yes, i am still alive
I`m dead
從結果來看,第一次GC時,finalize方法執行,在回收之前成功自我救贖
第二次GC時,finalize方法已經被JVM呼叫過,所以無法再次逃脫
垃圾回收演算法
知道了如何判斷物件為”垃圾”,接下來就是如何清理這些物件
對”垃圾”物件進行標記並刪除
演算法缺點:
效率問題,標記和清除這個兩個過程的效率都不高
空間問題,標記清除後會產生大量不連續的記憶體碎片,不利於大物件分配
將可用記憶體一分為二,每次只用其中一塊,當一塊記憶體用完了,就把存活的物件複製到另一塊去,並清空已使用過的記憶體空間。相對於複製演算法不需要考慮記憶體碎片等複雜問題,只要移動指標,按順序分配記憶體即可。
缺陷:總有一塊空閒區域,空間浪費
在老年代中,物件存活率較高,複製演算法效率較低。基於標記-清除,讓所有存活物件都移動到一端,然後直接清理邊界以外的記憶體。
垃圾收集器
垃圾收集器組合:
Serial是一個單執行緒,基於複製演算法,序列GC的新生代收集器。在GC時必須停掉所有其他工作執行緒直到它收集完成。對於單CPU環境來說,Serial由於沒有執行緒互動的開銷,可以很高效的進行垃圾收集,是Clinet模式下新生代預設的收集器
ParNew是Serial收集器的多執行緒版本(並行GC),除了使用多條執行緒進行GC以外,其餘行為與Serial一樣
Parallel Scavenge是一個多執行緒,基於複製演算法,並行GC的新生代收集器。其關注點在於達到一個可控的吞吐量。
吞吐量 = 執行使用者程式碼時間/(執行使用者程式碼時間 + 垃圾收集時間)
Parallel Scavenge提供兩個引數用於精確控制吞吐量:
① -XX:MaxGCPauseMillis 控制垃圾收集的最大停頓時間
② -XX:GCTimeRatio 設定吞吐量大小
Serial Old是基於標記-整理演算法的Serial收集器的老年代版本,是Client模式下老年代預設收集器
Parallel Old是基於標記-整理演算法的Parallel收集器的老年代版本,在注重吞吐量以及CPU資源敏感的場合,可以優先考慮Parallel Scavenge加Parallel Old收集器
CMS收集器是一種以獲取最短回收停頓時間為目標的老年代收集器(併發GC),基於標記-清除演算法,整個過程分為以下4步:
①初始標記:只標記與GC Roots直接關聯到的物件,仍然會Stop The World
②併發標記:進行GC Roots Tracing的過程,可以和使用者執行緒一起工作
③重新標記:用於修正併發標記期間因使用者程式繼續執行而導致標記產生變動的那部分物件記錄,此過程會暫停所有執行緒,但停頓時間,比初始標記階段稍長,遠比並發標記的時間短
④併發清理:清理”垃圾物件”,可以與使用者執行緒一起工作
CMS收集器缺點:
①對CPY資源非常敏感, 在併發階段,雖然不會導致使用者停頓,但是會佔用一部分執行緒資源(或者說CPU資源)而導致應用程式變慢,總吞吐量降低
②無法處理浮動垃圾,在併發清理階段使用者執行緒還在執行依然會產生新的垃圾,這部分垃圾出現在標記過程之後,只能在下一次GC時回收
③CMS基於標記-清除演算法實現,即可能收集結束會產生大量空間碎片,導致出現老年代還有很大空間剩餘,不得不提前觸發一次Full GC
G1垃圾收集器被視為JDK1.7中HotSpot虛擬機器的一個重要進化特徵(JDK9預設垃圾收集器),基於”標記-整理”演算法實現。
G1收集器優點:
①並行與併發:充分利用多CPU來縮短Stop-The-World(停使用者執行緒)停頓時間
②分代收集:不需要其他收集器配合,採用不同的方式處理新建的物件和已經存活一段時間、熬過多次GC的舊物件來獲取更好的收集效果
③空間整合:因為基於”標記-整理”演算法實現,避免了記憶體空間碎片問題,有利於程式長時間執行,分配大物件時不會因為無法找到連續記憶體空間而提前觸發一次GC
④可預測停頓:G1建立了可預測的停頓時間模型,能讓使用者明確指定在M毫秒時間片段內,消耗在垃圾收集上的時間不得超過N毫秒
G1執行步驟:
①初始標記:只標記與GC Roots直接關聯到的物件,仍然會Stop The World
②併發標記:從GC Root開始對堆中物件進行可達性分析,找出存活物件,可與使用者執行緒併發執行
③最終標記:修正在併發標記期間因使用者程式繼續運作而導致標記產生變動的那部分標記記錄,虛擬機器將物件變化記錄線上程Remembered Set Logs裡,併合併到Remembered Set中,此過程會暫停所有執行緒。
④篩選回收:對各個Region的回收價值與成本進行排序,根據使用者所期望的GC停頓時間來指定回收計劃
注:
並行:多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態
併發:使用者執行緒與垃圾收集執行緒同時執行(不一定是並行,可能交替執行),使用者程式在繼續執行,而垃圾收集程式執行於另一個CPU上
感謝
《深入理解JAVA虛擬機器》
https://www.jianshu.com/p/eaef248b5a2c