Java記憶體運行時區域的各個部分,其中程式計數器、虛擬機堆疊、本地方法堆疊3個區域隨執行緒而生,隨執行緒而滅,堆疊中的堆疊幀隨著方法的進入和退出而有條不紊地執行著出堆疊和入堆疊操作,
每一個堆疊幀中分配多少記憶體基本上是在類結構確定下來時就已知的(盡管在運行期會由即時編譯器進行一些優化,但在基于概念模型的討論里,大體上可以認為是編譯期可知的),因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮如何回收的問題,當方法結束或者執行緒結束時,記憶體自然就跟隨著回收了,
而Java堆和方法區這兩個區域則有著很顯著的不確定性:一個介面的多個實作類需要的記憶體可能會不一樣,一個方法所執行的不同條件分支所需要的記憶體也可能不一樣
只有處于運行期間,我們才能知道程式究竟會創建哪些物件,創建多少個物件,這部分記憶體的分配和回收是動態的,
垃圾收集器所關注的正是這部分記憶體該如何管理,本文后續討論中的“記憶體”分配與回收也僅僅特指這一部分記憶體,
如何判斷物件已死?
計數演算法
很多教科書判斷物件是否存活的演算法是這樣的:在物件中添加一個參考計數器,每當有一個地方參考它時,計數器值就加一;當參考失效時,計數器值就減一;任何時刻計數器為零的物件就是不可能再被使用的,
...
客觀地說,參考計數演算法(Reference Counting)雖然占用了一些額外的記憶體空間來進行計數,但它的原理簡單,判定效率也很高,在大多數情況下它都是一個不錯的演算法,也有一些比較著名的應用案例,例如微軟COM(Component Object Model)技術、使用ActionScript 3的FlashPlayer、Python語言以及在游戲腳本領域得到許多應用的Squirrel中都使用了參考計數演算法進行記憶體管理,但是,在Java領域,至少主流的Java虛擬機里面都沒有選用參考計數演算法來管理記憶體,主要原因是,這個看似簡單的演算法有很多例外情況要考慮,必須要配合大量額外處理才能保證正確地作業,譬如單純的參考計數就很難解決物件之間相互回圈參考的問題,
一句話,計數演算法很不錯,但是Java不用
可達性分析演算法
當前主流的商用程式語言(Java、C#,上溯至前面提到的古老的Lisp)的記憶體管理子系統,都是通過可達性分析(Reachability Analysis)演算法來判定物件是否存活的,這個演算法的基本思路就是通過一系列稱為“GC Roots”的根物件作為起始節點集,從這些節點開始,根據參考關系向下搜索,搜索程序所走過的路徑稱為“參考鏈”(Reference Chain),如果某個物件到GC Roots間沒有任何參考鏈相連,或者用圖論的話來說就是從GC Roots到這個物件不可達時,則證明此物件是不可能再被使用的,
物件object 5、object 6、object 7雖然互有關聯,但是它們到GC Roots是不可達的,因此它們將會被判定為可回收的物件,
在Java技術體系里面,固定可作為GC Roots的物件包括以下幾種:
- 在虛擬機堆疊(堆疊幀中的本地變數表)中參考的物件,譬如各個執行緒被呼叫的方法堆疊中使用到的引數、區域變數、臨時變數等,
- 在方法區中類靜態屬性參考的物件,譬如Java類的參考型別靜態變數,
- 在方法區中常量參考的物件,譬如字串常量池(String Table)里的參考,
- 在本地方法堆疊中JNI(即通常所說的Native方法)參考的物件,
- Java虛擬機內部的參考,如基本資料型別對應的Class物件,一些常駐的例外物件(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器,
- 所有被同步鎖(synchronized關鍵字)持有的物件,
- 反映Java虛擬機內部情況的JMXBean、JVMTI中注冊的回呼、本地代碼快取等,
除了這些固定的GC Roots集合以外,根據用戶所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他物件“臨時性”地加入,共同構成完整GC Roots集合,
finalize(),死前最后的波紋
即使在可達性分析演算法中判定為不可達的物件,也不是“非死不可”的,這時候它們暫時還處于“緩刑”階段,要真正宣告一個物件死亡,至少要經歷兩次標記程序:
- 如果物件在進行可達性分析后發現沒有與GC Roots相連接的參考鏈,那它將會被第一次標記,
- 隨后進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法,假如物件:
- 沒有覆寫
finalize()方法, - 或者
finalize()方法已經被虛擬機呼叫過了一次,
- 沒有覆寫
那么虛擬機將這兩種情況都視為“沒有必要執行”,這時候這個物件就是必死無疑,
如果這個物件被判定為確有必要執行finalize()方法,那么該物件將會被放置在一個名為F-Queue的佇列之中,并在稍后由一條由虛擬機自動建立的、低調度優先級的Finalizer執行緒去執行它們的finalize()方法,這里所說的“執行”是指虛擬機會觸發這個方法開始運行,但并不承諾一定會等待它運行結束,這樣做的原因是,如果某個物件的finalize()方法執行緩慢,或者更極端地發生了死回圈,將很可能導致F-Queue佇列中的其他物件永久處于等待,甚至導致整個記憶體回收子系統的崩潰,
finalize()方法是物件逃脫死亡命運的最后一次機會,稍后收集器將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己——
- 只要重新與參考鏈上的任何一個物件建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,那在第二次標記時它將被移出“即將回收”的集合;
如果物件這時候還沒有逃脫,那基本上它就真的要被回收了,
演示代碼:
//finalize()方法
class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive(){
System.out.println("耶,我還活著!");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
FinalizeEscapeGC.SAVE_HOOK = this;
System.out.println("逃過一劫!");
}
}
public class JavaGcTest {
public static void main(String[] args) throws InterruptedException, Exception {
FinalizeEscapeGC.SAVE_HOOK = new FinalizeEscapeGC();
//第一次拯救自己
FinalizeEscapeGC.SAVE_HOOK = null;
System.gc();
// 因為Finalizer方法優先級很低,暫停0.5秒,以等待它
Thread.sleep(500);
if(FinalizeEscapeGC.SAVE_HOOK != null){
FinalizeEscapeGC.SAVE_HOOK.isAlive();
}
else{
System.out.println("日,我還是死了!");
}
//第二次拯救自己
FinalizeEscapeGC.SAVE_HOOK = null;
System.gc();
// 因為Finalizer方法優先級很低,暫停0.5秒,以等待它
Thread.sleep(500);
if(FinalizeEscapeGC.SAVE_HOOK != null){
FinalizeEscapeGC.SAVE_HOOK.isAlive();
}
else{
System.out.println("啊,我還是死了!");
}
}
}
運行結果:
逃過一劫!
耶,我還活著!
啊,我還是死了!
驗證了如果物件第一次要被gc殺死的時候,如果他有重寫finalize()方法,而且重寫之后讓他能產生與其他物件的參考,那么此時的finalize()就是他的免死金牌,但是第二次gc再來他還是會死就是了,
還有一點需要特別說明,上面關于物件死亡時finalize()方法的描述可能帶點悲情的藝術加工,筆者并不鼓勵大家使用這個方法來拯救物件,相反,筆者建議大家盡量避免使用它,因為它并不能等同于C和C++語言中的解構式,而是Java剛誕生時為了使傳統C、C++程式員更容易接受Java所做出的一項妥協,它的運行代價高昂,不確定性大,無法保證各個物件的呼叫順序,如今已被官方明確宣告為不推薦使用的語法,有些教材中描述它適合做“關閉外部資源”之類的清理性作業,這完全是對finalize()方法用途的一種自我安慰,finalize()能做的所有作業,使用try-finally或者其他方式都可以做得更好、更及時,所以筆者建議大家完全可以忘掉Java語言里面的這個方法,
回收方法區
在Java堆中,尤其是在新生代中,對常規應用進行一次垃圾收集通常可以回收70%至99%的記憶體空間,相比之下,方法區回收囿于苛刻的判定條件,其區域垃圾收集的回收成果往往遠低于此,
方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的型別,回收廢棄常量與回收Java堆中的物件非常類似,
舉個常量池中字面量回收的例子,假如一個字串“java”曾經進入常量池中,但是當前系統又沒有任何一個字串物件的值是“java”,換句話說,已經沒有任何字串物件參考常量池中的“java”常量,且虛擬機中也沒有其他地方參考這個字面量,
如果在這時發生記憶體回收,而且垃圾收集器判斷確有必要的話,這個“java”常量就將會被系統清理出常量池,常量池中其他類(介面)、方法、欄位的符號參考也與此類似,
判定一個常量是否“廢棄”還是相對簡單,而要判定一個型別是否屬于“不再被使用的類”的條件就比較苛刻了,需要同時滿足下面三個條件:
- 該類所有的實體都已經被回收,也就是Java堆中不存在該類及其任何派生子類的實體,
- 加載該類的類加載器已經被回收,這個條件除非是經過精心設計的可替換類加載器的場景,如OSGi、JSP的重加載等,否則通常是很難達成的,
- 該類對應的java.lang.Class物件沒有在任何地方被參考,無法在任何地方通過反射訪問該類的方法,
垃圾收集演算法
分代收集理論
人們在設計垃圾收集器(GC)的時候,提出了一個原則:收集器應該將Java堆劃分出不同的區域,然后將回收物件依據其年齡(年齡即物件熬過垃圾收集程序的次數)分配到不同的區域之中存盤,
因此JVM設計者往往吧Java堆劃分為新生代(Young Generation)和老年代(Old Gerneration)兩個主要的區域:

這么設計的初衷,《深入理解JVM》是這么解釋的:
當前商業虛擬機的垃圾收集器,大多數都遵循了“分代收集”(Generational Collection) 的理論進行設計,分代收集名為理論,實質是一套符合大多數程式運行實際情況的經驗法則,它建立在兩個分代假說之上:
1)弱分代假說(Weak Generational Hypothesis):絕大多數物件都是朝生夕滅的,
2)強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集程序的物件就越難以消亡,
這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然后將回收物件依據其年齡(年齡即物件熬過垃圾收集程序的次數)分配到不同的區域之中存盤,顯而易見,如果一個區域中大多數物件都是朝生夕滅,難以熬過垃圾收集程序的話,那么把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的物件,就能以較低代價回收到大量的空間;如果剩下的都是難以消亡的物件,那把它們集中放在一塊,虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和記憶體的空間有效利用,
在Java堆劃分出不同的區域之后,垃圾收集器才可以每次只回收其中某一個或者某些部分的區域——因而才有了“Minor GC”“Major GC”“Full GC”這樣的回收型別的劃分;也才能夠針對不同的區域安排與里面存盤物件存亡特征相匹配的垃圾收集演算法——因而發展出了“標記-復制演算法”“標記-清除演算法”“標記-整理演算法”等針對性的垃圾收集演算法,
一句話總結就是:大體上將Java堆分為存盤容易“殺死”的物件和不容易“殺死”的物件的兩塊區域,對前者我們可以高效的“殺死”,而后者因為不容易“殺死”,所以就少浪費時間,低頻地“殺”,
什么叫不容易“殺死”?就是說一個物件在gc多次開“殺戒”的時候都因為這個那個原因沒被清理掉,所以gc采取的策略就是算了,能不殺就不浪費時間殺,
這時候就要考慮一個問題:一個物件A他雖然可能在新生代區,但是卻有可能被老生代的物件B所參考,那如果要殺物件A,GC就要先去對B進行可達性分析,看看他是不是“孤立”的,這一切就使得物件A成了一個事實上的不容易“殺死”的物件,關于這個情況,《深入理解JVM》是這么解釋的:
假如要現在進行一次只局限于新生代區域內的收集(Minor GC),但新生代中的物件是完全有可能被老年代所參考的,為了找出該區域中的存活物件,不得不在固定的GC Roots之外,再額外遍歷整個老年代中所有物件來確保可達性分析結果的正確性,反過來也是一樣 [3] ,遍歷整個老年代所有物件的方案雖然理論上可行,但無疑會為記憶體回收帶來很大的性能負擔,為了解決這個問題,就需要對分代收集理論添加第三條經驗法則:
3)跨代參考假說(Intergenerational Reference Hypothesis):跨代參考相對于同代參考來說僅占極少數,
這其實是可根據前兩條假說邏輯推理得出的隱含推論:存在互相參考關系的兩個物件,是應該傾向于同時生存或者同時消亡的,舉個例子,如果某個新生代物件存在跨代參考,由于老年代物件難以消亡,該參考會使得新生代物件在收集時同樣得以存活,進而在年齡增長之后晉升到老年代中,這時跨代參考也隨即被消除了,
依據這條假說,我們就不應再為了少量的跨代參考去掃描整個老年代,也不必浪費空間專門記錄每一個物件是否存在及存在哪些跨代參考,只需在新生代上建立一個全域的資料結構(該結構被稱為“記憶集”,Remembered Set),這個結構把老年代劃分成若干小塊,標識出老年代的哪一塊記憶體會存在跨代參考,此后當發生Minor GC時,只有包含了跨代參考的小塊記憶體里的物件才會被加入到GC Roots進行掃描,雖然這種方法需要在物件改變參考關系(如將自己或者某個屬性賦值)時維護記錄資料的正確性,會增加一些運行時的開銷,但比起收集時掃描整個老年代來說仍然是劃算的,
也就是jvm在新生代上維護一個記憶集,對這種有“免死金牌”的新生代物件背后的老生代物件標記起來,每次要“殺”他們的時候就可以直接去(而不用大范圍掃描)把他們背后的老生代物件找出來,雖然有了一些開銷,但整體上是劃算的,
剛才我們已經提到了“Minor GC”,后續文中還會出現其他針對不同分代的類似名詞,為避免讀者產生混淆,在這里統一定義:
- 部分收集(Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分為:
- 新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集,
- 老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集,目前只有CMS收集器會有單獨收集老年代的行為,另外請注意“Major GC”這個說法現在有點混淆,在不同資料上常有不同所指,讀者需按背景關系區分到底是指老年代的收集還是整堆收集,
- 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集,目前只有G1收集器會有這種行為,
- 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集,
標記-清除演算法

如它的名字一樣,演算法分為“標記”和“清除”兩個階段:
- 首先標記出所有需要回收的物件
- 在標記完成后,統一回收掉所有被標記的物件,
也可以反過來,標記存活的物件,統一回收所有未被標記的物件,
之所以說它是最基礎的收集演算法,是因為后續的收集演算法大多都是以標記-清除演算法為基礎,對其缺點進行改進而得到的,它的主要缺點有兩個:第一個是執行效率不穩定,如果Java堆中包含大量物件,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個程序的執行效率都隨物件數量增長而降低;第二個是記憶體空間的碎片化問題,標記、清除之后會產生大量不連續的記憶體碎片,空間碎片太多可能會導致當以后在程式運行程序中需要分配較大物件時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作,
標記-復制演算法

將記憶體按容量分為大小相等的兩塊,每次只使用其中一塊(也就是只在其中一塊)分配記憶體,比如當前在使用的記憶體稱為A,保留的空閑記憶體稱為B,那么當A用完了,我們就把存活物件(不會被GC的)物件復制到B,然后把不要的回收了,讓A稱為新的空閑記憶體,回圈往復,
為什么要這么做?
- 雖然有記憶體空間復制的開銷,但如果多數都是可回收的記憶體,那么只需復制占少數的存活物件就行了
- 分配記憶體的時候比較方便,因為存活物件最后都會規整的儲存在記憶體中,此時只要移動堆頂指標,就可以按順序分配即可,
缺點是什么?
記憶體空間一下子沒了一半,事實上太浪費了
在1989年,Andrew Appel針對具備“朝生夕滅”特點的物件,提出了一種更優化的半區復制分代策略,現在稱為“Appel式回收”,HotSpot虛擬機的Serial、ParNew等新生代收集器均采用了這種策略來設計新生代的記憶體布局 [1] ,Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配記憶體只使用Eden和其中一塊Survivor,發生垃圾搜集時,將Eden和Survivor中仍然存活的物件一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間,HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用記憶體空間為整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%的新生代是會被“浪費”的,當然,98%的物件可被回收僅僅是“普通場景”下測得的資料,任何人都沒有辦法百分百保證每次回收都只有不多于10%的物件存活,因此Appel式回識訓有一個充當罕見情況的“逃生門”的安全設計,當Survivor空間不足以容納一次Minor GC之后存活的物件時,就需要依賴其他記憶體區域(實際上大多就是老年代)進行分配擔保(Handle Promotion),
記憶體的分配擔保好比我們去銀行借款,如果我們信譽很好,在98%的情況下都能按時償還,于是銀行可能會默認我們下一次也能按時按量地償還貸款,只需要有一個擔保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認為沒有什么風險了,記憶體的分配擔保也一樣,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活物件,這些物件便將通過分配擔保機制直接進入老年代,這對虛擬機來說就是安全的,
標記-整理演算法

非常類似于標記-清除演算法,可以理解為是進行了標記-清除演算法之后,又進行了“緊湊”,使得記憶體規整,
缺點是什么?
這種物件移動的操作是需要阻塞程式運行的(Stop the World),這就更加讓使用者不得不小心翼翼地權衡其弊端了
那為什么這么做?
因為事實上,記憶體訪問是非常頻繁的,一個規整的記憶體會更收作業系統的“歡迎”,而如果不進行“緊湊”,雖然GC效率提高了,但之后的記憶體訪問吞吐量就會變低,因此權衡利弊之下就這么做了,
另外,還有一種“和稀泥式”解決方案可以不在記憶體分配和訪問上增加太大額外負擔,做法是讓虛擬機平時多數時間都采用標記-清除演算法,暫時容忍記憶體碎片的存在,直到記憶體空間的碎片化程度已經大到影響物件分配時,再采用標記-整理演算法收集一次,以獲得規整的記憶體空間,前面提到的基于標記-清除演算法的CMS收集器面臨空間碎片過多時采用的就是這種處理辦法,
- 最新的ZGC和Shenandoah收集器使用讀屏障(Read Barrier)技術實作了整理程序與用戶執行緒的并發執行
垃圾收集器

圖中展示了七種作用于不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用,圖中收集器所處的區域,則表示它是屬于新生代收集器抑或是老年代收集器,
Serial 收集器
Serial(串行)收集器是最基本、歷史最悠久的垃圾收集器了,大家看名字就知道這個收集器是一個單執行緒收集器了,它的 “單執行緒” 的意義不僅僅意味著它只會使用一條垃圾收集執行緒去完成垃圾收集作業,更重要的是它在進行垃圾收集作業的時候必須暫停其他所有的作業執行緒( "Stop The World" ),直到它收集結束,
對這個特點,《深入理解JVM》有段有趣的描述:
對于“Stop The World”帶給用戶的惡劣體驗,早期HotSpot虛擬機的設計者們表示完全理解,但也同時表示非常委屈:“你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或者房間外待著,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完?”(筆者注:所以才會設計成stop the world,讓執行緒先停一停,不要再產生垃圾了)這確實是一個合情合理的矛盾,雖然垃圾收集這項作業聽起來和打掃房間屬于一個工種,但實際上肯定還要比打掃房間復雜得多!
- 新生代采用標記-復制演算法
- 老年代采用標記-整理演算法,

虛擬機的設計者們當然知道 Stop The World 帶來的不良用戶體驗,所以在后續的垃圾收集器設計中停頓時間在不斷縮短(仍然還有停頓,尋找最優秀的垃圾收集器的程序仍然在繼續),
但是 Serial 收集器有沒有優于其他垃圾收集器的地方呢?當然有,它簡單而高效(與其他收集器的單執行緒相比),Serial 收集器由于沒有執行緒互動的開銷,自然可以獲得很高的單執行緒收集效率,Serial 收集器對于運行在 客戶端模式下的虛擬機來說是個不錯的選擇,
在用戶桌面的應用場景以及近年來流行的部分微服務應用中,分配給虛擬機管理的記憶體一般來說并不會特別大,收集幾十兆甚至一兩百兆的新生代(僅僅是指新生代使用的記憶體,桌面應用甚少超過這個容量),垃圾收集的停頓時間完全可以控制在十幾、幾十毫秒,最多一百多毫秒以內,只要不是頻繁發生收集,這點停頓時間對許多用戶來說是完全可以接受的,
ParNew 收集器
ParNew 收集器其實就是 Serial 收集器的多執行緒版本,除了使用多執行緒進行垃圾收集外,其余行為(控制引數、收集演算法、回收策略等等)和 Serial 收集器完全一樣,
- 新生代采用標記-復制演算法(Stop the world)
- 老年代采用標記-整理演算法,(Stop the world)

它是許多運行在 服務端模式下的虛擬機的首要選擇,除了 Serial 收集器外,只有它能與 CMS 收集器(真正意義上的并發收集器,后面會介紹到)配合作業,
并行和并發概念補充:
- 并行(Parallel) :指多條垃圾收集執行緒并行作業,但此時用戶執行緒仍然處于等待狀態,
- 并發(Concurrent):指用戶執行緒與垃圾收集執行緒同時執行(但不一定是并行,可能會交替執行),用戶程式在繼續運行,而垃圾收集器運行在另一個 CPU 上,
CMS:
在JDK 5發布時,HotSpot推出了一款在強互動應用中幾乎可稱為具有劃時代意義的垃圾收集器——CMS收集器,這款收集器是HotSpot虛擬機中第一款真正意義上支持并發的垃圾收集器,它首次實作了讓垃圾收集執行緒與用戶執行緒(基本上)同時作業,
遺憾的是,CMS作為老年代的收集器,卻無法與JDK 1.4.0中已經存在的新生代收集器Parallel Scavenge配合作業 [1] ,所以在JDK 5中使用CMS來收集老年代的時候,新生代只能選擇ParNew或者Serial收集器中的一個,ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC選項)的默認新生代收集器,也可以使用-XX:+/-UseParNewGC選項來強制指定或者禁用它,
Parallel Scavenge 收集器
Parallel Scavenge收集器也是一款新生代收集器,它同樣是基于標記-復制演算法實作的收集器,也是能夠并行收集的多執行緒收集器……Parallel Scavenge的諸多特性從表面上看和ParNew非常相似,那它有什么特別之處呢?
Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是盡可能地縮短垃圾收集時用戶執行緒的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput),
如果虛擬機完成某個任務,用戶代碼加上垃圾收集總共耗費了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%,
停頓時間越短就越適合需要與用戶互動或需要保證服務回應質量的程式,良好的回應速度能提升用戶體驗;而高吞吐量則可以最高效率地利用處理器資源,盡快完成程式的運算任務,主要適合在后臺運算而不需要太多互動的分析任務,
需要注意的是,即使Parellel Scavenge收集器可以通過引數-XX:MaxGCPauseMillis人為設定停頓時間長度,但是不以為著設定越小吞吐量越大,因為他的底層是縮小新生代空間為代價的,新生代空間越小,會使得需要回收空間的次數變多,也就是收集的頻率變高,經常要出現stop the world,實質上會導致吞吐量下降,
- 新生代采用標記-復制演算法
- 老年代采用標記-整理演算法,

注意:這個收集器是jdk8默認的版本,可通過命令查看:
java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=510248320 -XX:MaxHeapSize=8163973120 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_202"
Java(TM) SE Runtime Environment (build 1.8.0_202-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)
其中的-XX:+UseParallelGC就指明了收集器,
Serial Old 收集器
Serial 收集器的老年代版本,它同樣是一個單執行緒收集器,它主要有兩大用途:一種用途是在 JDK1.5 以及以前的版本中與 Parallel Scavenge 收集器搭配使用,另一種用途是作為 CMS 收集器的后備方案,

Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本,使用多執行緒和“標記-整理”演算法,在注重吞吐量以及 CPU 資源的場合,都可以優先考慮 Parallel Scavenge 收集器和 Parallel Old 收集器,

CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器,目前很大一部分的Java應用集中在互聯網網站或者基于瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的回應速度,希望系統停頓時間盡可能短,以給用戶帶來良好的互動體驗,CMS收集器就非常符合這類應用的需求,
從名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于標記-清除演算法實作的,它的運作程序相對于前面幾種收集器來說要更復雜一些,
流程
整個程序分為四個步驟,包括:
- 初始標記(CMS initial mark)
:需要Stop the world,僅僅只是記錄下直接與GC Roots 相連的物件,速度很快 ; - 并發標記(CMS concurrent mark)
:從GC Roots直接關聯物件中開始遍歷整個物件圖,程序耗時,但是不需要stop the world,可以與GC執行緒并發運行; - 重新標記(CMS remark)
:修正并發標記時期之間,用戶執行緒繼續運行而導致標記發生變化的記錄(可以結合Serial收集器的例子,媽媽打掃衛生的時候,本來你說不要的東西,你又突然說要了,那GC就會把一開始的標記給去掉),停頓時間的長度介于初始標記與并發標記之間, - 并發清除(CMS concurrent sweep):清理洗掉掉標記階段判斷的已經死亡的物件,由于不需要移動存活物件,所以這個階段也是可以與用戶執行緒同時并發的,

優點
- 并發收集
- 低停頓
缺點
- 對處理器資源敏感,CMS默認的啟動GC執行緒數是(處理器核心數量+3)/4,我們定性的做個計算,假設處理器核心數量為x,處理器運算資源占用率計算按 執行緒數/處理器核心數量 來看:
,所以定性的看,x越大,占用率越小,和我們的直觀感受是匹配的;
- CMS收集器無法處理“浮動垃圾”(Floating Garbage),關于這一點,書中有一段可以說是非常深刻的描述:
在CMS的并發標記和并發清理階段,用戶執行緒是還在繼續運行的,程式在運行自然就還會伴隨有新的垃圾物件不斷產生,但這一部分垃圾物件是出現在標記程序結束以后,CMS無法在當次收集中處理掉它們,只好留待下一次垃圾收集時再清理掉,這一部分垃圾就稱為“浮動垃圾”,
...
同樣也是由于在垃圾收集階段用戶執行緒還需要持續運行,那就還需要預留足夠記憶體空間提供給用戶執行緒使用,因此CMS收集器不能像其他收集器那樣等待到老年代幾乎完全被填滿了再進行收集,必須預留一部分空間供并發收集時的程式運作使用,
在JDK 5的默認設定下,CMS收集器當老年代使用了68%的空間后就會被激活,這是一個偏保守的設定,如果在實際應用中老年代增長并不是太快,可以適當調高引數-XX:CMSInitiatingOccu-pancyFraction的值來提高CMS的觸發百分比,降低記憶體回收頻率,獲取更好的性能,
到了JDK 6時,CMS收集器的啟動閾值就已經默認提升至92%,但這又會更容易面臨另一種風險:要是CMS運行期間預留的記憶體無法滿足程式分配新物件的需要,就會出現一次“并發失敗”(Concurrent Mode Failure),這時候虛擬機將不得不啟動后備預案:凍結用戶執行緒的執行,臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,但這樣停頓時間就很長了,所以引數-XX:CMSInitiatingOccupancyFraction設定得太高將會很容易導致大量的并發失敗產生,性能反而降低,用戶應在生產環境中根據實際應用情況來權衡設定,
- CMS是一款基于“標記-清除”演算法實作的收集器,這意味著收集結束時會有大量空間碎片產生,空間碎片過多時,將會給大物件分配帶來很大麻煩,往往會出現老年代還有很多剩余空間,但就是無法找到足夠大的連續空間來分配當前物件,而不得不提前觸發一次Full GC的情況,JVM將收集整個Java堆和方法區的垃圾收集,時間開銷就很大了,
G1(Garbage First) 收集器
G1是一款主要面向服務端應用的垃圾收集器,HotSpot開發團隊最初賦予它的期望是(在比較長期的)未來可以替換掉JDK 5中發布的CMS收集器,現在這個期望目標已經實作過半了,JDK 9發布之日,G1宣告取代Parallel Scavenge加Parallel Old組合,成為服務端模式下的默認垃圾收集器,而CMS則淪落至被宣告為不推薦使用(Deprecate)的收集器,
可以看出來G1被賦予很高的期望,為什么這么說,因為從G1開始,GC的設計導向不再是單純追求一次性把Java堆清理干凈,以追求更少的Stop the world,而是追求能夠應付應用的記憶體分配速率(Allocation Rate),也就是GC的速度能跟上物件分配的速度,
特性
- 記憶體空間劃分思想上進行了轉變:垃圾收集的目標范圍要么是整個新生代(Minor GC),要么就是整個老年代(Major GC),再要么就是整個Java堆(Full GC),而G1跳出了這個樊籠,它可以面向堆記憶體任何部分來組成回收集(Collection Set,一般簡稱CSet)進行回收,衡量標準不再是它屬于哪個分代,而是哪塊記憶體中存放的垃圾數量最多,回收收益最大,這就是G1收集器的Mixed GC模式,
分代不再是最重要的,而是回收的價值:價值即回收所獲得的空間大小以及回收所需時間,也就是要看劃不劃得來
G1開創的基于Region的堆記憶體布局是它能夠實作這個目標的關鍵,雖然G1也仍是遵循分代收集理論設計的,但其堆記憶體的布局與其他收集器有非常明顯的差異:G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間,收集器能夠對扮演不同角色的Region采用不同的策略去處理,這樣無論是新創建的物件還是已經存活了一段時間、熬過多次收集的舊物件都能獲取很好的收集效果,
雖然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它們都是一系列區域(不需要連續)的動態集合,G1收集器之所以能建立可預測的停頓時間模型,是因為它將Region作為單次回收的最小單元,即每次收集到的記憶體空間都是Region大小的整數倍,這樣可以有計劃地避免在整個Java堆中進行全區域的垃圾收集,更具體的處理思路是讓G1收集器去跟蹤各個Region里面的垃圾堆積的“價值”大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然后在后臺維護一個優先級串列,每次根據用戶設定允許的收集停頓時間(使用引數-XX:MaxGCPauseMillis指定,默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是“Garbage First”名字的由來,
- 使用記憶集避免全堆作為GC Roots掃描,但在G1收集器上記憶集的應用其實要復雜很多,它的每個Region都維護有自己的記憶集,這些記憶集會記錄下別的Region指向自己的指標,并標記這些指標分別在哪些卡頁的范圍之內,這是回答:“將Java堆分成多個獨立Region后,Region里面存在的跨Region參考物件如何解決?”的答案,
G1的記憶集在存盤結構的本質上是一種哈希表,Key是別的Region的起始地址,Value是一個集合,里面存盤的元素是卡表的索引號,這種“雙向”的卡表結構(卡表是“我指向誰”,這種結構還記錄了“誰指向我”)比原來的卡表實作起來更復雜,同時由于Region數量比傳統收集器的分代數量明顯要多得多,因此G1收集器要比其他的傳統垃圾收集器有著更高的記憶體占用負擔,根據經驗,G1至少要耗費大約相當于Java堆容量10%至20%的額外記憶體來維持收集器作業,
- 通過原始快照(SATB)演算法來實作并發標記,
- G1為每一個Region設計了兩個名為TAMS(Top at Mark Start)的指標,把Region中的一部分空間劃分出來用于并發回收程序中的新物件分配,并發回收時新分配的物件地址都必須要在這兩個指標位置以上,
- 可預測停頓時間,甚至可讓用戶自己定義,
G1收集器的停頓預測模型是以衰減均值(Decaying Average)為理論基礎來實作的,在垃圾收集程序中,G1收集器會記錄每個Region的回收耗時、每個Region記憶集里的臟卡數量等各個可測量的步驟花費的成本,并分析得出平均值、標準偏差、置信度等統計資訊,這里強調的“衰減平均值”是指它會比普通的平均值更容易受到新資料的影響,平均值代表整體平均狀態,但衰減平均值更準確地代表“最近的”平均狀態,換句話說,Region的統計狀態越新越能決定其回收的價值,然后通過這些資訊預測現在開始回收的話,由哪些Region組成回收集才可以在不超過期望停頓時間的約束下獲得最高的收益,
流程
G1 收集器的運作大致分為以下幾個步驟:
- 初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的物件,并且修改TAMS指標的值,讓下一階段用戶執行緒并發運行時,能正確地在可用的Region中分配新物件,這個階段需要停頓執行緒,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際并沒有額外的停頓,
- 并發標記(Concurrent Marking):從GC Root開始對堆中物件進行可達性分析,遞回掃描整個堆里的物件圖,找出要回收的物件,這階段耗時較長,但可與用戶程式并發執行,當物件圖掃描完成以后,還要重新處理SATB記錄下的在并發時有參考變動的物件,****類似CMS的重新標記,
- 最終標記(Final Marking)(Final Marking):對用戶執行緒做另一個短暫的暫停,用于處理并發階段結束后仍遺留下來的最后那少量的SATB記錄,
- 篩選回收(Live Data Counting and Evacuation):
- 對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃
- 可以自由選擇任意多個Region構成回收集,然后把決定回收的那一部分Region的存活物件復制到空的Region中,再清理掉整個舊Region的全部空間,
- 這里的操作涉及存活物件的移動,是必須暫停用戶執行緒,由多條收集器執行緒并行完成的,
*注意:這里除了并發標記,其余階段都是要Stop the world的,體現了并非純粹追求低停頓(但是用戶可自定義),而追求盡可能高的吞吐量的設計思想,
優點
- 可指定最大停頓時間
- 分Region的記憶體布局
- 按收益動態確定回收集
- G1從整體來看是基于“標記-整理”演算法(進行“緊湊”)實作的收集器,但從區域(兩個Region之間)上看又是基于“標記-復制”演算法實作,G1運作期間不會產生記憶體空間碎片,垃圾收集完成之后能提供規整的可用記憶體,
Shenandoah 收集器
Shenandoah摒棄了在G1中耗費大量記憶體和計算資源去維護的記憶集,改用名為“連接矩陣”(Connection Matrix)的全域資料結構來記錄跨Region的參考關系,降低了處理跨代指標時的記憶集維護消耗,也降低了偽共享問題(見3.4.4節)的發生概率,連接矩陣可以簡單理解為一張二維表格,如果Region N有物件指向Region M,就在表格的N行M列中打上一個標記,如圖3-15所示,如果Region 5中的物件Baz參考了Region 3的Foo,Foo又參考了Region 1的Bar,那連接矩陣中的5行3列、3行1列就應該被打上標記,在回收時通過這張表格就可以得出哪些Region之間產生了跨代參考,
*筆者按:書上的圖好像畫錯了?
流程
- 初始標記 (Initial Marking):與G1一樣,首先標記與GC Roots直接關聯的物件,這個階段仍是“Stop The World”的,但停頓時間與堆大小無關,只與GC Roots的數量相關,
- 并發標記 (Concurrent Marking):與G1一樣,遍歷物件圖,標記出全部可達的物件,這個階段是與用戶執行緒一起并發的,時間長短取決于堆中存活物件的數量以及物件圖的結構復雜程度,
- 最終標記 (Final Marking):與G1一樣,處理剩余的SATB掃描,并在這個階段統計出回收價值最高的Region,將這些Region構成一組回收集(Collection Set),最終標記階段也會有一小段短暫的停頓,
- 并發清理 (Concurrent Cleanup):這個階段用于清理那些整個區域內連一個存活物件都沒有找到的Region(這類Region被稱為Immediate Garbage Region),
- 并發回收 (Concurrent Evacuation):Shenandoah要把回收集里面的存活物件先復制一份到其他未被使用的Region之中,并發回收階段運行的時間長短取決于回收集的大小,
復制物件這件事情如果將用戶執行緒凍結起來再做那是相當簡單的,但如果兩者必須要同時并發進行的話,就變得復雜起來了,其困難點是在移動物件的同時,用戶執行緒仍然可能不停對被移動的物件進行讀寫訪問,移動物件是一次性的行為,但移動之后整個記憶體中所有指向該物件的參考都還是舊物件的地址,這是很難一瞬間全部改變過來的,對于并發回收階段遇到的這些困難,Shenandoah將會通過讀屏障和被稱為“Brooks Pointers”的轉發指標來解決
- 初始參考更新 (Initial Update Reference):并發回收階段復制物件結束后,還需要把堆中所有指向舊物件的參考修正到復制后的新地址,這個操作稱為參考更新,初始參考更新時間很短,會產生一個非常短暫的停頓,
參考更新的初始化階段實際上并未做什么具體的處理,設立這個階段只是為了建立一個執行緒集合點,確保所有并發回收階段中進行的收集器執行緒都已完成分配給它們的物件移動任務而已,
- 并發參考更新 (Concurrent Update Reference):真正開始進行參考更新操作,這個階段是與用戶執行緒一起并發的,時間長短取決于記憶體中涉及的參考數量的多少,并發參考更新與并發標記不同,它不再需要沿著物件圖來搜索,只需要按照記憶體物理地址的順序,線性地搜索出參考型別,把舊值改為新值即可,
- 最終參考更新 (Final Update Reference):解決了堆中的參考更新后,還要修正存在于GC Roots中的參考,這個階段是Shenandoah的最后一次停頓,停頓時間只與GC Roots的數量相關,
- 并發清理 (Concurrent Cleanup):經過并發回收和參考更新之后,整個回收集中所有的Region已再無存活物件,這些Region都變成Immediate Garbage Regions了,最后再呼叫一次并發清理程序來回收這些Region的記憶體空間,供以后新物件分配使用,

- 藍色區域代表用戶執行緒可以用來分配物件的記憶體Region
- 黃色區域代表初始標記后會出現被選入回憶集物件的Region
- 綠色區域代表存活的物件
因此根據這幅圖:
-
在初始標記(Init Mark)、并發標記(Concurrent Mark)與最后標記(Final Mark)之后,將決定最終被選入回憶集物件的Region
-
在進行并發清除(Concurrent evacuation)之后,黃色區域里非回憶集物件將被復制到一個未被使用的Region中
-
初始化參考更新(Init Update Reference)和并發參考更新(concurrent update reference)做了這么一件事:并發回收階段復制物件結束后,堆中所有指向**舊物件的參考修正到復制后的新地址
,也就是說此時原本黃色區域內的綠色都已經到了橙色處,此時可以把原本的都標黃,他們也可以進回憶集了**
它不再需要沿著物件圖來搜索,只需要按照記憶體物理地址的順序,線性地搜索出參考型別,把舊值改為新值即可,
- 最終參考更新還需修改GC Roots
- 最后經過并發清理,將回憶集中的Region(即黃色區域)清理即可,
ZGC收集器
ZGC和Shenandoah的目標是高度相似的,都希望在盡可能對吞吐量影響不太大的前提下,實作在任意堆記憶體大小下都可以把垃圾收集的停頓時間限制在十毫秒以內的低延遲,
...
ZGC收集器是一款基于Region記憶體布局的,(暫時)不設分代的,使用了讀屏障、染色指標和記憶體多重映射等技術來實作可并發的標記-整理演算法的,以低延遲為首要目標的一款垃圾收集器,
特性:
- ZGC也采用基于Region的堆記憶體布局,但與它們不同的是,ZGC的Region具有動態性——動態創建和銷毀,以及動態的區域容量大小
ZGC的堆記憶體布局:
- ZGC收集器有一個標志性的設計是它采用的染色指標技術(Colored Pointer,其他類似的技術中可能將它稱為Tag Pointer或者Version Pointer),看起來有點像InnoDB用于MVCC的隱藏欄位的回滾指標,它直接把標記資訊記在參考物件的指標上,這時,與其說可達性分析是遍歷物件圖來標記物件,還不如說是遍歷“參考圖”來標記“參考”了,

染色指標可以使得一旦某個Region的存活物件被移走之后,這個Region立即就能夠被釋放和重用掉,而不必等待整個堆中所有指向該Region的參考都被修正后才能清理,這點相比起Shenandoah是一個頗大的優勢,使得理論上只要還有一個空閑Region,ZGC就能完成收集,而Shenandoah需要等到參考更新階段結束以后才能釋放回收集中的Region,這意味著堆中幾乎所有物件都存活的極端情況,需要1∶1復制物件到新Region的話,就必須要有一半的空閑Region來完成收集,
流程
- 并發標記 (Concurrent Mark):與G1、Shenandoah一樣,并發標記是遍歷物件圖做可達性分析的階段,前后也要經過類似于G1、Shenandoah的初始標記、最終標記(盡管ZGC中的名字不叫這些)的短暫停頓,而且這些停頓階段所做的事情在目標上也是相類似的,與G1、Shenandoah不同的是,ZGC的標記是在指標上而不是在物件上進行的,標記階段會更新染色指標中的Marked 0、Marked 1標志位,
- 并發預備重分配 (Concurrent Prepare for Relocate):這個階段需要根據特定的查詢條件統計得出本次收集程序要清理哪些Region,將這些Region組成重分配集(Relocation Set),ZGC每次回收都會掃描所有的Region,用范圍更大的掃描成本換取省去G1中記憶集的維護成本,因此,ZGC的重分配集只是決定了里面的存活物件會被重新復制到其他的Region中,里面的Region會被釋放,而并不能說回收行為就只是針對這個集合里面的Region進行,因為標記程序是針對全堆的,
- 并發重分配 (Concurrent Relocate):重分配是ZGC執行程序中的核心階段,這個程序要把重分配集中的存活物件復制到新的Region上,并為重分配集中的每個Region維護一個轉發表(Forward Table),記錄從舊物件到新物件的轉向關系,
益于染色指標的支持,ZGC收集器能僅從參考上就明確得知一個物件是否處于重分配集之中,如果用戶執行緒此時并發訪問了位于重分配集中的物件,這次訪問將會被預置的記憶體屏障所截獲,然后立即根據Region上的轉發表記錄將訪問轉發到新復制的物件上,并同時修正更新該參考的值,使其直接指向新物件,ZGC將這種行為稱為指標的“自愈”(Self-Healing)能力,
...
這樣做的好處是只有第一次訪問舊物件會陷入轉發,也就是只慢一次,對比Shenandoah的Brooks轉發指標,那是每次物件訪問都必須付出的固定開銷,簡單地說就是每次都慢,因此ZGC對用戶程式的運行時負載要比Shenandoah來得更低一些,還有另外一個直接的好處是由于染色指標的存在,一旦重分配集中某個Region的存活物件都復制完畢后,這個Region就可以立即釋放用于新物件的分配(但是轉發表還得留著不能釋放掉),哪怕堆中還有很多指向這個物件的未更新指標也沒有關系,這些舊指標一旦被使用,它們都是可以自愈的,
- 并發重映射 (Concurrent Remap):重映射所做的就是修正整個堆中指向重分配集中舊物件的所有參考,這一點從目標角度看是與Shenandoah并發參考更新階段一樣的,
但是ZGC的并發重映射并不是一個必須要“迫切”去完成的任務,因為前面說過,即使是舊參考,它也是可以自愈的,最多只是第一次使用時多一次轉發和修正操作,重映射清理這些舊參考的主要目的是為了不變慢(還有清理結束后可以釋放轉發表這樣的附帶收益),所以說這并不是很“迫切”,因此,ZGC很巧妙地把并發重映射階段要做的作業,合并到了下一次垃圾收集回圈中的并發標記階段里去完成,反正它們都是要遍歷所有物件的,這樣合并就節省了一次遍歷物件圖的開銷,一旦所有指標都被修正之后,原來記錄新舊物件關系的轉發表就可以釋放掉了,
參考
- 《深入理解Java虛擬機》
- JavaGuide
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/441930.html
標籤:其他



