引言
在內存管理中,垃圾是指那些不再被使用的對象。對于一個垃圾回收器(Garbage Collector),它需要完成三件事:
- 分配內存:垃圾回收算法的設計往往制約了內存分配的方式;
- 確保存活對象不會被回收
- 回收垃圾對象
很顯然的是,一個垃圾回收器并不提供回收全部垃圾對象的保證。這意味著一次垃圾回收結束之后,內存中依然可能存活著不會再被使用的對象。
對于HotSpot中的垃圾回收算法而言,其設計都是建立在兩個經驗法則之上的:
- 對于大部分對象來說,它會在“年輕”的時候死去;
- 很少引用存在于“年老的”和“年輕的”對象之間;
這里使用的“年輕的”和“年老的”的兩種說法,是指存活時間的長短。這兩條的經驗法則表明,大部分的對象存活時間都很短,無法活過一個回收周期。并且,存活時間長的對象傾向于引用存活時間長的對象,而存活時間短的對象傾向于引用存活時間短的對象。對于大部分的應用來說,這兩個經驗法則都是適用的。
設計因素
在垃圾算法回收的設計中,需要考慮的因素有:
- 串行(Serial)和并行(Parallel)
- 并發(Concurrent)和暫停式(Stop the world)
- 壓縮(Compacting)和非壓縮(Non-Compacting):這一組概念是指,在垃圾回收結束之后,是否需要把所有的存活對象挪到一起,占據一個連續空間。在Compacting之后,也意味著可用內存占據了一個連續的空間,這個時候就可以使用bump-the-pointer的分配內存技術。在這種技術中,只需要持有一個指針指向已分配內存的尾部。每次分配的時候只需要檢查剩余空閑空間能否容納新的對象,而后分配內存并且將指針指向新的尾部。這種Compacting的計數,在一些算法中需要付出額外的代價,這個代價要么是需要額外的內存空間,要么是額外的回收時間。
并發和并行是一對比較容易搞混的概念。并發是指,垃圾回收和應用可以在一段時間內同時運行,這個概念和操作系統上的概念是一致的。在HotSpot中,并發垃圾回收算法中大部分垃圾回收的工作都是在并發的情況下完成的,但是并不能完全免除Stop-the-world。并行是指利用多個CPU,多個線程同時進行垃圾回收。
度量
在衡量一個垃圾回收算法上,最為主要的兩個度量是:
- 暫停時間(pause time):是指在一次垃圾回收中,Stop-the-world狀態下占用的時間。暫停時間主要受到算法和堆大小的影響。相同條件下,堆越小,暫停時間就越短。但是堆越小,那么回收頻率就越高。
- 吞吐量(throughput):一般而言,堆越大,吞吐量越高,回收頻率越低。
可以看到一個有趣的地方:暫停時間和吞吐量對堆的大小要求是不一樣的。暫停時間要想短,那么應該有更小堆;而吞吐量要大,需要更大的堆。
分代
前面提到,大部分的對象都會在年輕的時候死去。因此將那些年輕對象放在一起,將那些年老的對象放在一起,在垃圾回收的時候區別對待,就是很有價值的。這就是分代(generation)的想法。在HotSpot中,所有的垃圾回收算法都是分代垃圾回收算法。年輕代垃圾回收更加頻繁,并且在一次回收中能夠存活下來的對象也是及其稀少的。在年輕代存活過一段時間(通常是活過了幾次年輕代的垃圾回收周期)后,對象會被提升(promoted or tenured)到老年代。如圖:
注:圖片引自Memory Managerment in the Java HotSpot Virtual Machine
除此以外,大對象也會直接分配在老年代。總體來說,老年代的對象存活時間更長,并且老年代分配出去的空間增長緩慢,由此導致了老年代垃圾回收不頻繁。
這種提升策略會有一個問題,就是老年代可能并沒有足夠的空間容納這些從年輕代提升上來的對象。在這種情況下,會觸發一次老年代的垃圾回收。在極端的情況下,整個年輕代的所有對象都會被提升到老年代。因此,老年代比年輕代要大是順理成章的事情。
關于老年代什么時候檢測剩余空閑空間,判斷能否容納被提升的對象,我閱讀的文檔文章中并沒有明確提出。它可能發生在每提升一個對象,就執行一次檢測;也可能是在年輕代回收之前,根據有多少待提升對象進行一次檢測;最后一種可能是,檢測發生在年輕代垃圾回收之前——也就是意味著每次檢測都是拿年輕代的大小和老年代空閑空間的大小進行比較。在Tuning Garbaga Collection with the 5.0 Java Virtual Machine中有一段話,“But for applications needing the largest possible heap, an eden bigger than half the virtually committed size of the heap is useless: only major collections would occur”。從這句話來看,老年代似乎并不管究竟有多少需要提升,也不管會回收多少垃圾,每一次比較都是用空閑空間和整個年輕代的大小進行比較。
在HotSpot中的分代為:
- 年輕代(young generation)分成Eden和Survivor。(注:在一些特殊的平臺上,HotSpot并沒有Eden和Survivor的概念)
- Eden:年輕代只有一個Eden。對象分配都是直接分配在Eden中的;
- Survivor:年輕代中會有兩個Survivor,拿來存放在一次垃圾回收算法中存活的對象。每次算法運行的時候,會把Eden和一個Survivor(標記為from)中的存活對象復制到另外一個Survivor(標記為to)中;
- 老年代(old generation):
- 持久代(perm generation):持久代幾乎不會出現垃圾回收(有些應用或者有些平臺會有這種需求),本文將不會涉及這個地方;
在HotSpot中,Eden和Survivor的比例可以通過使用++NewRatio來
Serial Collector
原理
Serial Collector(串行回收器)在年輕代是一個使用標記-復制算法的回收器。標記-復制垃圾回收算法可以分成兩個階段:
- 標記階段:從根(root)出發,沿著引用鏈標記存活對象;
- 復制:將存活對象復制到特定的區域;
如圖:
GC完成之后:
注:圖片引自Memory Managerment in the Java HotSpot Virtual Machine
在標記階段,Serial Collector將Eden中和這次被標記為from(恰好是上次標記為to)的Survivor中的存活對象標記出來,而后將存活對象復制到另外一個Survivor中。在這個過程中,可能有部分對象的存活時間達到了提升到老年代的標準,因此會有一些對象被復制到老年代。
Serial Collector也可以被應用在老年代,所不同的是,老年代并沒有什么Eden和Survivor的劃分。在老年代使用的是標記-清掃-整理算法。該算法流程為:
- 標記:比較存活的對象
- 清掃:在該階段,會識別出垃圾對象。“清掃”這個概念帶有一些誤導的色彩,在算法的實現中,并不需要真的對垃圾對象占據的內存進行清掃,僅僅標記一下就夠了。(注:在新對象被分配到該被清掃的區域的時候,會執行一次JVM層面上的初始化過程,該過程會把該內存區域重置,因而有了Java語言中的不同數據類型的默認值一說)
- 整理:將所有的存活對象都挪到一端
可以看到的是,在使用Serial Collector的情況下,無論是老年代還是年輕代,其內存都經過了Compacting,所有的存活對象占據了一塊連續的空間,而空閑空間也是占據了一個連續的空間。因此在分配內存空間的時候都可以使用bump-the-pointer的技術。
使用場景
Serial Collector主要適用于對pause time要求不高,可用內存和CPU數量都比較小的應用上。在新的HotSpot中,如果虛擬機運行的平臺是client-style類型的,那么就會采用Serial Collector。
影響因素和JVM選項
Serial Collector的性能和幾個因素有關:
- 堆大小。如前面所言,堆的大小直接影響了吞吐量和停頓時間(pause time)
- NewRatio:這個因素調節的是年輕代和老年代的比例。比如說值是3,那么意味著年輕代和老年代的比例是1:3。對于Serial Collector來說,年輕代過大,會造成相應老年代過小,這會導致更加頻繁地觸發老年代的垃圾回收(也即是major GC)。而如果年輕代過小,那么年輕代就會更加頻繁GC,并且相對而言,更加多的大對象會被直接分配到老年代。在這種情況下,觸發老年代GC的頻率要降低,但是pause time就要提升;
- SurvivorRatio:該選項調節的是年輕代中Suvivor部分占據的大小。舉例來說,如果值是8,那么意味著,每個Survivor占據的大小是1/(8+2)。Survivor的大小是比較關鍵的。如果Survivor比較小,那么Survivor很容易就裝不下存活對象,因此有更加多的存活對象被逼提升到老年代。這會帶來兩個影響:一個是老年代更加快裝滿,另外一個是老年代指向年輕代的引用會增加——這個因素會影響mark階段根的大小。而如果Survivor過大,那么每次裝完存活對象之后還會剩下一段沒有利用的空間,這段空間就會被浪費,影響年輕代的GC頻率和吞吐量;
還有其余的一些選項,如NewSize和MaxNewSize等,讀者可以自己去找相關的資料閱讀。
Parallel Collector
原理
Parallel Collector(并行垃圾回收器)和Serial Collector(串行回收器)比起來,要復雜微妙很多。總體的算法思想是一致的,不過Serial Collector在任何階段都是單線程在運行的,而Parallel Collector則是多線程運行的。額外要注意的是,即便在虛擬機中指定了Parallel Collector,但是老年代的回收,也就是major collection還是使用和Serail Collector一樣的單線程!換句話來說,只有對年輕代的回收(minor collection)將會采用多線程。如圖:
注:圖片來自Memory Managerment in the Java HotSpot Virtual Machine。
Parallel Collector與Serial Collector比起來,以下這些方面是要注意的:
- 工作分配。這是一個算法設計的難點。舉例來說,我們可以將整個標記階段看成是一個大的任務,由一系列的小的工作組成,那么在多線程的情況下,要考慮哪個線程負責標記哪一塊,還要考慮負載均衡——即任務的分配要公平,不能一些線程很快完成任務停下來,等待其余線程完成自己的工作;
在標記階段,還有一個很有意思的話題。就是如果多線程在進行標記的時候,可能會重復標記一個對象為存活對象,這并不會影響算法的正確性,只是會影響算法的性能。關于并行回收算法的更加細致的描述可以閱讀《垃圾回收算法手冊——自動內存管理的藝術》第14章。
- 在多線程的情況下,提升對象到老年代會遇到新的問題。按照原來的bump-the-point的算法,每一次分配都是指針的遞增。假設說現在有兩個線程同時在老年代分配空間,分配前的指針指向100,其中一個線程將指針增加到150,而另外一個線程將指針設置為200.那么最終的結果就可能是150,也可能是200。一種自然的想法是加鎖,但是和一般應用里面鎖爭用會帶來損耗一般,這也會導致老年代的分配損耗增加(這會極大影響pause time和吞吐量)。而Paraller Collector采用了另外一種被稱為TLABs(thread-local allocation buffers)技術:它將老年代劃分成一個個固定大小的Buffer,給每一個回收線程分配一個,回收線程就只在自己的Buffer內分配空間。在這種情況下,只有線程重新申請一個Buffer的時候,才會引入并發的問題。但是這會帶來另外一個問題,就是每一個線程并不能恰好用完一個Buffer,可能出現的情況是,一個線程檢測到Buffer里面的空閑空間已經不夠了,于是只能申請另外一個Buffer。而原來的Buffer的那部分不足的空間就會被浪費掉。這就是所謂的float garbage。
還有一個誤解要澄清。應用有多少個線程在運行并不決定有多少個線程回收。舉例說一個應用有200個線程正在運行,但是在垃圾回收的時候,回收的線程可能只有10個。
Parallel Collector有一個變種叫做Parallel Compacting Collector。從名字上很令人困惑,因為從前面對Serial Collector上的敘述中可以看出來,major collection使用的算法,本身就是compacting的。我簡單描述一下兩者的區別:對于Parallel Collector來說,每一次回收都會Compacting整個老年代;而對于Parallel Compacting Collector來說并不是。它會檢測一個區域的存活對象的密度,來決定是否進行compacting。更多相關信息可以閱讀《Memory Managerment in the Java HotSpot Virtual Machine》和《垃圾回收算法手冊——自動內存管理的藝術》。
使用場景
Parallel Collector又被叫做Throughput Collector。所以很顯然,它適用于要求吞吐量高的場景。
影響因素和JVM選項
前面在Serial Collector中談及的影響因素,對Parallel Collector同樣是適用的,而且還額外收到以下這些因素的影響:
- GC線程數量:線程數量和float garbage的數量是成正相關關系;
- Buffer大小
- CPU數量:在單個CPU的情況下,Parallel Collector的表現不如Serial Collector;在兩個CPU的情況下,Parallel Collector可以達到和Serial Collector相當的表現,甚至會超過一點。在CPU超過兩個的情況下, 表現都會比Serial Collector好。但是并不是CPU越多性能越好。在當前算法的實現中,CPU數量超過32(16?有點忘記了,閱讀的資料也忘了是哪個了)性能反而會下降;
CMS Collector
原理
CMS Collector(Concurrent Mark-Sweep Collector),是一個并發垃圾回收器,這意味著在垃圾回收的過程中間,大部分時間里,應用還是可以繼續運行的。所以,這里所謂的并發,更加多的是指應用和垃圾回收的并發。同時CMS Collector也是多線程的,也即它也是Parallel(并行)的。
在CMS Collector里,年輕代的回收是和Parallel Collector一樣的,也就是說,年輕代的回收是stop-the-world式的。只有在老年代,相應的major collection里面才會使用CMS算法。
CMS的過程如圖:
注:圖片來自Memory Managerment in the Java HotSpot Virtual Machine
- initial mark:initial mark是stop-the-world式的,也就是說在這個階段是需要暫停應用的執行。initial mark只是識別出來標記的根(準確說法是"identifies the initial set of live objects directly reachable from application code",另外在StackOverFlow上看到一段話"This includes: Reference from thread stacks, Reference from young space.")
- concurrent mark:并發標記階段。在該階段,應用可以繼續運行;
- remark:在concurrent mark階段,因為應用依舊在運行,所以可能原本標記為垃圾的對象又“復活”了,也可能分配了新的對象。所以會引入找一個remark階段。該階段也是stop-the-world的;
- concurrent sweep:并發清掃。該階段應用會繼續運行;
因為引入兩個并發階段,所以會造成很多問題:
- 如何快速的remark?顯然,如果要是在remark中還是需要掃描對象,那么該Collector就沒多大必要存在了。CMS Collector采用了一種稱為“card table”的技術。card table簡單理解是并發回收器的工作列表。CMS使用該技術會在concurrent mark階段,將改變了引用關系的對象標記為“dirty”,在remark階段中重新掃描;
- 還有一個問題是,在concurrent mark階段,可能觸發minor collection。CMS Collector會采用一種稱為Mod Union Table的技術來記錄GC前后card的信息。所以綜合這兩種情況,CMS Collector在remark階段會從Mod Union Table和Card Table出發;
- 因為在回收階段還有可能分配對象,所以垃圾回收不能等內存滿了才開始,必須要提前開始。但是CMS Collector并不提供在回收階段一定含有足夠的空閑給應用分配對象。這就會造成一個問題,就是在垃圾回收階段,空閑空間不足了。這個時候,應用會被停下來,也就是進入到stop-the-world狀態,直到回收完成;
- 該回收的垃圾沒有被回收。這也被稱為floating garbage。這主要是出現在,原本一個對象被標記為存活,但是在concurrent階段,應用修改了指向該對象的引用,使得它稱為了垃圾。但是CMS Collector無法將其檢測出來。因此它能夠躲過這一輪的垃圾回收,直到下一次的回收周期;
CMS Collector不是compacting的,這意味著垃圾回收之后得到的空閑空間并不是連續的。這會帶來一些問題:
- 分配方式改變:前面提到的最多的分配方式就是bump-the-pointer。但是該分配方式只使用于連續的空閑空間。CMS采用了新的分配方式:空閑鏈表分配方式,該概念和操作系統中內存管理中的空閑鏈表是一樣的;
- 空閑空間不連續會導致空間有效利用率下降。比如說空閑空間總和是足以容納分配對象的,但是因為不能容納,所以反而會觸發GC,或者會觸發擴容。所以對于CMS Collector來說,它會要求更大的堆;
- CMS Collector會合并相鄰的空閑空間:這是一個優化,但是因為合并這個空閑空間需要操作空閑鏈表,而分配對象又需要操作空閑鏈表,所以在concurrent sweep會出現空閑鏈表的爭用。CMS Collector使用了Mutual exclusion locks來保證JVM分配優先;
CMS Collector是允許整理空閑空間的,用戶可以通過命令行選項UseCMSCompactAtFullCollection來指定。
CMS Collector還有一種模式,增量模式(Incremental Mode)。在該模式下,CMS Collector會使用少量的線程來并發標記或者并發清掃,整個過程會持續多個minor collection周期。該模式的好處是,可以降低pause time,并且減少對應用的影響。所付出的代價,就是需要更加大的堆。該模式一般適用于CPU數量較少的情況。
使用場景
適用于要求pause time盡可能短,并且擁有多個CPU的應用。CMS Collector的別名是Latency Collector。
影響因素和選項
在Serial Collector中談及的因素對CMS Collector都會有影響,此外還受到:
- 線程數量
- CPU數量
- CMSInitiatingOccupancyFraction:該選項設置了當被分配內存占據了多大比例的時候,就會觸發major collection。若是設置的太小,那么會導致更加頻繁的GC;但是設置得太大,就更有可能出現回收過程中空閑空間不足的現象,而這會導致應用被停下來,直到GC完成;
- 是否使用增量模式
G1 Collector
原理
G1 Collector(Garbage-First Collector)可以被看做是CMS Collector的升級加強版。G1 Collector的算法流程和CMS類似,所不同的有:
- G1 Collector采用的是標記-整理算法。這意味著每次算法結束得到的都是連續空間;
- G1 Collector雖然還采用分代的方式,但是它的內存模型有了巨大的變化。它的內存基本結構被分成了一個個Region。G1 Collector維護了一個Region的列表,每次判斷哪個Region的回收價值最大,便回收該Region。也就是說,G1 Collector回收并不是回收整個區域,而是分區域收集的;
其具體流程是:
- initial marking phase:標記根,該階段是stop-the-world式的;
- root region scanning phase:該階段標記Survivor Region中的指向老年代的引用,及其引用對象;
- Concurrent marking phase:
- Remark phase:
- Cleanup phase:
所以CMS Collector因為并發引發的問題G1也同樣存在。但是G1 Collector能夠避開各種因為空閑空間不連續所導致的問題。
G1 Collector實現的算法是比較復雜的,詳細內容可以閱讀Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide和《垃圾回收算法手冊-自動內存管理的藝術》10.3.1,11.8.6等
G1 Collector有一個很重要的特性,就是“軟實時”。G1 Collector可以讓使用者指定在一個長度為M毫秒的時間段內,消耗在垃圾收集上的時間不得超過N毫秒。這已經是期望達到一種類似于實時垃圾回收的效果了。
所謂的實時垃圾回收,是指必須能夠精確地控制由垃圾回收所導致的賦值器的中斷。
結語
強烈推薦閱讀Oracle上關于HotSpot的相關文檔,以及《垃圾回收算法手冊-自動內存管理的藝術》。