1. 垃圾回收基本操作
1.1 標記可達對象(Marking Reachable Objects)
目前幾乎所有的GC算法都是從標記存活對象開始的。如下圖所示,GC算法會從GC Root開始,標記所有目前所有可達的對象。
1.1.1 GCRoots
GCRoots大致有如下幾種:
當前執行函數的局部變量和輸入參數
正在工作的線程
被加載類的靜態塊
JNI(Java Native Interface)引用
1.1.2 標記算法實現
標記算法的算法實現,可參考如下偽碼:
Marking stack (linked list) = empty
Root.mark = 1
Push root onto marking stack (i.e., insert root at head of marking stack linked list using extra header word to hold the link)
While marking stack is not empty
Pop a block P off the marking stack
For every pointer Q in P’s data area
If Q.mark == 0
Q.mark = 1
Push Q onto marking stack
1.1.3 Card Marking
上述的樸素的標記算法并不適用于并發GC的情況。
并發標記操作在進行的過程中,如果一個對象及其子對象已經被標記,而此時用戶線程操作這一對象,為其新引用了一個將要被回收的對象時,普通的標記算法可能無法標記到這一對象。但是Card Marking算法則可以利用將這一個塊(Card)標記為為“臟塊”(Dirty Card)的方式記錄下這個塊,等待GC Collector后續處理。
本節介紹的是最簡單的Card Marking算法,更多的實現請查看Card Marking算法。
1.1.4 三色標記算法(Tri-color Marking Algorithm)
三色標記算法的數據結構中包含有三個集合:White Set, Black Set和Gray Set。
- 白色集合對象:需要被回收的對象。
- 黑色集合對象:沒有對白色集合對象的外部引用,并且是GC Root可達的對象。這些對象將不會被回收。
- 灰色集合對象:集合中的對象全都是GC Root可達的對象,但是正在掃描或正在等待掃描其對“白色集合對象”的引用,這些對象也不會被回收,并且會在掃描結束之后被移入黑色集合。
在大多數算法實現中,黑色集合初始是空,灰色集合中保存有與GC Roots對象直連的所有老年代對象,白色集合中包含有其他對象。內存中的任意對象在任意時間都僅存在于這三個集合當中的一個。
算法步驟:
- 從灰色集合中取出一個對象放入黑色集合
- 遍歷第1步取出的對象的所有白色集合對象引用,并將它們移入灰色集合。這保證了這個對象和它的引用對象都不會被GC
- 重復上述兩步,直到灰色集合為空
由于非GC Root直接可達的節點都被加入到了White Set,并且對象只能從白色集合移動到灰色集合,從灰色集合移動到黑色集合,所以算法體現了一個重要特性-黑色集合中的對象不會引用到白色集合中的對象。這就保證了在灰色集合為空時,我們可以放心地釋放白色空間中的對象。這被稱作三色不變式(The Tri-color Invariant)。
1.2 移除不可達對象 (Removing Unused Objects)
1.2.1 清理(Sweep)
清理算法會遍歷整個內存,釋放(Free)掉內存區域中所有未被標記的對象,同時重置標記過的對象的標記位。標記-清理算法使用最簡單
釋放內存是由一個叫做free-list的數據結構來實現的,它會記錄每一個空閑的空間地址和他的大小。維護這些free-lists會給創建對象的內存分配帶來額外的開銷。除此之外,這種方法還有另外一個缺點--可能存在有大量的空閑空間,但是卻沒有一個比較大的連續空閑空間(過多內存碎片),當內存中需要放入一個大對象時,系統將會分配內存失敗。(OutOfMemoryError)
清理算法的代碼實現可參考如下偽碼:
Free list = empty
For every block P in the heap
If P.mark == 1
(P is an in-use block)
P.mark = 0
Else if block Q, the block immediately before block P, is a free block
(Coalesce P into Q)
Q.nWords = Q.nWords + P.nWords
Else
(P is a free block)
Append P to free list
1.2.2 整理(Compact)
整理步驟是將所有被標記的存活對象按順序移動到內存的前部,它一般配合標記算法和清除算法一起使用(標記-清除-整理算法)。由于壓縮的過程中清理了內存碎片,所以這個算法可以彌補標記-清除算法的短處。但是這個整理的步驟也會影響到算法的性能,因為算法需要把所有的對象拷貝到一塊新的內存空間,同時還要改變他們的引用。
目前常用的整理算法分為兩種,Table-based compaction和LISP2:
1.2.2.1 Table-based compaction
Table-based compaction算法是由Haddon和Waite在1967年提出來的。算法的特點是不需要額外的空間。
算法步驟:
- 第一步是用mark算法先將所有的存活對象標記出來。
- 第二步是遍歷整個堆,將對象移動到最初的free空間中(向前移動),并且將對象的原始起始地址和移動的字節數記錄在一個叫break table的數據結構中。(break table會不斷的移動,使用的是unused空間)
- 第三步是對break table按照對象初始地址排序。排序時間復雜度O(n logn),n是存活對象數。
- 第四步是修改移動過后的對象中的指針引用,如果引用指針個數總數為m,一次指針查找的復雜度是logn(二分查找),則算法復雜度為O(m logn)
詳細信息可查看A compaction procedure for variable-length storage elements。(論文介紹了break table是如何通過移動來找到一塊合適的unused空間)
1.2.2.2 LISP2算法
LIST2算法是一種時間復雜度與堆大小成正比的compact算法,目前被應用于ParallelScavenge算法中。
LISP2算法需要每一個對象都存在一個叫“forwarding pointer”的指針,它被用來臨時保存對象在compact之后被移動到的位置。同時,LISP2算法在進行的過程中還需要用到兩個全局的指針–free指針和live指針。其中,free指針用來指向當前空閑的區域,live指針用來指向當前操作的存活對象。
算法步驟:
- 計算對象移動后的位置:先讓free指針和live指針都指向堆的頭部,如果當前live指針指向了一個存活的(被標記的)對象,那么將free指針指向的值賦予live指針指向對象的forwarding pointer區域,然后將live指針和free指針的值都加上sizeof(current_obj)。如果live指針當前指向的不是一個存活對象,那么就逐步移動live指針,直到它指向了一個存活的對象為止。整個步驟結束于live指針指向了堆的結尾。
- 更新所有指針:與第一步一樣,找出每一個存活對象,將這些對象里面的對象對應引用變量值修正為被引用對象的forwarding pointer值。
- 移動對象:還是需要找出所有存活對象,將對象的數據移動到forwarding pointer所指向的區域。
算法的偽代碼如下:
Pass 1: Compute forwarding addresses
free = start of heap + size(header)
For each block P in the heap
If P.mark == 1
P.fwdAddr = free
free = free + P.nWords
Else
Q = block before P
If Q.mark == 0
(Coalesce P into Q)
Q.nWords = Q.nWords + P.nWords
Pass 2: Update pointers
root = (*root).fwdAddr
For each block P in the heap
If P.mark == 1
For each pointer Q in P’s data area
Q = (*Q).fwdAddr
Pass 3: Relocate blocks
For each block P in the heap
If P.mark == 1
P.mark = 0
Copy contents of P backwards so first word of P’s data area ends up at P.fwdAddr
(*free).nWords = Number of free words in the heap
這個算法一共要遍歷三次堆,所以時間復雜度和堆空間的大小成正比。為O(m),m為堆空間大小。
1.2.3 復制(Copy)
復制算法很像壓縮算法,他們都會移動所有的存活對象并且修改對象內部引用的指針地址。而復制算法的不同之處則在于它會將這些對象移動到一個新的區域內。標記復制算法有一個非常大的優點--復制操作可以和標記操作在同一時間進行。它的缺點也很明顯,他需要一個足夠容納一次GC后存活對象大小的內存區域。
復制算法的一種實現是Cheney算法,它是由C.J. Cheney在1970年在論文A nonrecursive list compacting algorithm提出的。
算法步驟:
主函數:
- 將roots對象加入tospace(調用Copy函數)。
- 將tospace看做一個Queue,從頭至尾遍歷存在的數據節點,針對數據節點中的每一個指針,調用Copy函數。
Copy函數:
- 如果該對象已經被移動到了tospace(對象中的forworded字段為1),那么直接返回對象在tospace中的首地址。
- 如果對象尚未移動,則將對象的數據移動到tospace,free指針后移對象的長度個單位,并且把對象的forwarded域置為1,最后返回移動后的地址。
算法實現(Cheney's algorithm):
scan = fromspace
free = tospace
root = Copy (root)
While scan < free
Block P = *scan
For every pointer Q in P’s data area
Q = Copy (Q)
scan = scan + size(P)
Interchange roles of fromspace and tospace
function Copy
# To copy a block B located in the fromspace, returning its new address in the tospace:
If B.forwarded == 1
Return forwarding address from first word of B’s data area
Else
fwdaddr = free
Copy B’s contents to fwdaddr
free = free + size(B)
Store fwdaddr in first word of B’s data area
B.forwarded = 1
Return fwdaddr
注:
- 算法中forwarded字段位于Block Header中,值為1表示塊已經被移動to space。
- forwarded=1標示塊中數據區存放了fwdaddr,位于塊數據區的第一個word中。
- 本算法遵循三色不變式
1.3 三種基本算法比較
上述的幾種垃圾回收基本操作可以組成的三種算法:標記-清除,標記-整理和復制算法。它們的基本信息如下表所示。
mark-sweep | mark-compact | copying | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空間開銷 | 少(但會堆積碎片) | 少(不堆積碎片) | 通常需要活對象的2倍大小(不堆積碎片) |
移動對象? | 否 | 是 | 是 |
關于時間開銷:
- mark-sweep:mark階段與活對象的數量成正比,sweep階段與整堆大小成正比
- mark-compact:mark階段與活對象的數量成正比,compact階段與活對象的大小成正比
- copying:與活對象大小成正比
如果把mark、sweep、compact、copying這幾種動作的耗時放在一起看,大致有這樣的關系:
- compaction >= copying > marking > sweeping
- marking + sweeping > copying
雖然compactiont與copying都涉及移動對象,但取決于具體算法,compact可能要先計算一次對象的目標地址,然后修正指針,然后再移動對象;copying則可以把這幾件事情合為一體來做,所以可以快一些。
另外還需要留意GC帶來的開銷不能只看collector的耗時,還得看allocator一側的。如果能保證內存沒碎片,分配就可以用pointer bumping方式,只有挪一個指針就完成了分配,非常快;而如果內存有碎片就得用freelist之類的方式管理,分配速度通常會慢一些。
在分代式假設中,年輕代中的對象在minor GC時的存活率應該很低,這樣用copying算法就是最合算的,因為其時間開銷與活對象的大小成正比,如果沒多少活對象,它就非常快;而且young gen本身應該比較小,就算需要2倍空間也只會浪費不太多的空間。
而年老代被GC時對象存活率可能會很高,而且假定可用剩余空間不太多,這樣copying算法就不太合適,于是更可能選用另兩種算法,特別是不用移動對象的mark-sweep算法。
2. 對象分配算法
2.1 內存塊存儲結構
內存塊的數據結構如下圖所示,其中每一個區域的作用如下:
- Block Data Area:1. 這一塊是用戶程序所使用的內存;2. 大小可變,取決于用戶程序一開始申請了多少空間;3. 塊分配時,返回的指針指向的是- - Block Data Area的頭部,用戶程序也只能感受到Block Data Area中的內容。
- Block Header:1. 這一塊內容是堆的內部程序鎖使用的空間;2. 這一塊大小是固定的;3. 用戶程序無法感知到這一塊內存。
- nWords:1. 內容是Block的大小(data area + header);2. 塊的最大大小被nWords的位(bit)數所約束,比如說24位的nWords可以支持的最大Block是16,777,215個Words(word大小自定義)
- Control bits:包含了垃圾回收時所需要用到的一些信息(比如標記信息)
2.2 單個空閑空間組織結構(One Big Free Space Organization)
2.2.1 堆結構
單個大空閑塊的組織結構如下圖所示,所有被使用的塊都存放于堆的頂部,而未被使用的快則是被某一個塊所控制。使用移動對象類型的垃圾回收算法(如mark-compact,copying)可以保證堆空間中的空閑空間總是以該結構的形式組織。
這種組織結構的優點:1. 對象分配速度快;2. 堆中沒有內存碎片,可以分配更大的對象。
缺點:需要可以移動對象的垃圾回收算法(速度慢,對象需要移動,指針需要調整)
2.2.2 分配算法
這種單個空閑空間的堆空間分配內存非常簡單,可以直接使用bump-the-pointer的算法,如下圖,移動一下指針就完成了內存分配。
算法實現的偽代碼如下,算法的時間復雜度是O(1):
size(block) = n + size(header)
If free.nWords < size(block)
Failure (time for garbage collection!)
p = free
free = free + size(block)
free.nWords = p.nWords - size(block)
p.nWords = size(block)
return p
2.3 Free List組織結構
2.3.1 堆結構
Free List組織結構的對結構如下圖所示,被使用的塊和空閑的塊在堆中是相互交錯著排列的,另外一個要點就是空閑的塊會被連接成一個叫做Free List的鏈表。
這種組織結構的優點:適用不需要移動對象的垃圾回收算法(對象回收速度更快,因為不需要移動被使用的對象,也不需要調整指針)
缺點:1. 分配算法更加復雜,耗時也更長;2. 存在有內存碎片,會限制分配對象的最大值
2.3.2 分配算法
Free List組織結構下存在有兩種分配算法:First-Fit分配算法和Best-Fit分配算法。顧名思義,First-Fit分配算法就是在Free List中找出第一個適合分配的空間,而Best-Fit分配算法則是在Free List中找出最接近被分配對象大小的Free塊,并將這個塊分配給這個對象。這兩種算法的偽代碼如下所示:
First-Fit Allocation Algorithm
size(block) = n + size(header)
Scan free list for first block with nWords >= size(block)
If block not found
Failure (time for garbage collection!)
Else if free block nWords >= size(block) + threshold*
Split into a free block and an in-use block
Free block nWords = Free block nWords - size(block)
In-use block nWords = size(block)
Return pointer to in-use block
Else
Unlink block from free list
Return pointer to block
*Threshold must be at least size(header) + 1 to leave room for header and link
Threshold can be set higher to combat fragmentation
Best-Fit Allocation Algorithm
size(block) = n + size(header)
Scan free list for smallest block with nWords >= size(block)
If block not found
Failure (time for garbage collection!)
Else if free block nWords >= size(block) + threshold*
Split into a free block and an in-use block
Free block nWords = Free block nWords - size(block)
In-use block nWords = size(block)
Return pointer to in-use block
Else
Unlink block from free list
Return pointer to block
*Threshold must be at least size(header) + 1 to leave room for header and link
Threshold can be set higher to combat fragmentation
2.4 分配邏輯
- 當堆中的空閑塊不夠新申請空間的大小時,將會進行一次垃圾回收,然后在嘗試分配
- 如果空間還是不夠,則會嘗試增大堆的大小,然后再嘗試分配
- 最后如果空間還是不夠,則會拋出OutOfMemoryError
3. CMS算法
CMS算法是JVM中老年代常用的垃圾回收算法,全稱是Concurrent Mark Sweep算法,即并發標記-清除算法。算法的執行步驟如下圖所示,共有六個步驟。
3.1 初始標記(Initial Mark):
CMS算法中兩個會觸發Stop the World事件中的一個,這個階段會標記所有與GC Roots直接相關聯的對象,以及被存活的青年代對象所直接引用的對象。
3.2 并發標記(Concurrent Mark):
并發標記,顧名思義,它是并發的執行標記任務的,這也就意味著GC在運行的過程中用戶的應用線程并不會停止工作。該階段GC收集器會從第一步“初始標記”中所標記出來的對象開始逐步遍歷這些對象(與GCRoot直接相連或與存活的青年代對象直接相關聯的對象)的所引用的對象,并將這些被引用的對象加上標記。
需要注意的是,這一步中,會漏掉一下老年代的存活對象,這是因為在并發的過程中,用戶應用線程可能會對老年代的對象產生引用上的改變。某一些被改變的標記可能會被遺漏。
3.3 并發預清理(Concurrent Preclean):
并發預清理是Java1.5被加入進來的。主要目的是減少重標記(Remark)步驟Stop-the-World的時間。這一步同樣也是并發的,不會停止用戶應用線程。在前面的并發標記中,一些引用被改變了。當某一塊塊(Card)中的對象引用發生改變時,JVM會標記這個空間為“臟塊”(Dirty Card)。
在預清理階段,JVM根據之前記錄的這些“臟對象”重新標記了他們新的可達對象。這一步結束后空間重新進入clean狀態。另外,一些必要的最終重標記之前的準備步驟也會在這一步做好。
預清理步驟將會不斷重復一直到Eden區的占用量達到某個指定的閾值。設定這個閾值作為結束條件的原因主要是為了防止YoungGC產生的Stop-the-World和下一階段的Remark同時產生,導致系統產生一個更長的停滯。設定了這個閾值之后基本可以保證Remark階段可以在兩次YoungGC之間進行。
3.4 重標記(Remark):
這是CMS算法中第二個會觸發Stop-the-World事件的步驟,由于前一步是一個并發的步驟,預清理的速度可能會趕不上用戶應用對對象改變的速度,所以需要一個Stop-the-World的暫停來完整的標記所有對象結束整個標記階段。
通常CMS會在年輕代為空時來運行重標記階段,以此避免一個接一個的Stop-the-World階段。
3.5 并發清理(Concurrent Sweep):
這一階段程序并發地工作,目的是移除所有不用的對象,并且重新聲明內存空間的歸屬等候將來使用。
3.6 并發清理(Concurrent Reset):
并發地重置所有算法需要的內部數據結構,為下一次GC做準備。
3.7 CMS算法注意事項:
3.7.1 Concurrent Mode Failures
當CMS算法在并發的過程中堆空間無法滿足用戶程序對新空間的需求時,Stop-the-World的Full GC就會被觸發,這就是Concurrent Mode Failures,這通常會造成一個長時間停頓。這種情況通常是因為老年代沒有足夠的空間供青年代對象promote。(包括沒有足夠的連續空間)
3.7.2 CMS相關JVM參數
- -XX:+UseConcMarkSweepGC:激活CMS收集器,默認情況下使用ParNew + CMS + Serial Old的收集器組合進行內存回收,Serial Old作為CMS出現“Concurrent Mode Failure”失敗后的后備收集器使用。
- -XX:CMSInitiatingOccupancyFraction={x}:在老年代的空間被占用{x}%時,調用CMS算法對老年代進行垃圾回收。
- -XX:CMSFullGCsBeforeCompaction={x}:在進行了{x}次CMS算法之后,對老年代進行一次compaction
- -XX:+CMSPermGenSweepingEnabled & -XX:+CMSClassUnloadingEnabled:讓CMS默認遍歷永久代(Perm區)
- -XX:ParallelCMSThreads={x}:設置CMS算法中并行線程的數量為{x}。(默認啟動(CPU數量+3) / 4個線程。)
- -XX:+ExplicitGCInvokesConcurrent:用戶程序中可能出現利用System.gc()觸發系統Full GC(將會stop-the-world),利用這個參數可以指定System.gc()直接調用CMS算法做GC。
- -XX:+DisableExplicitGC:該參數直接讓JVM忽略用戶程序中的System.gc()
4. 參考資料
- GC算法基礎及實現:https://plumbr.eu/handbook/what-is-garbage-collection
- Oracle文檔:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html
- CMS算法:http://insightfullogic.com/2013/May/07/garbage-collection-java-3/
- GC Tuning: https://plumbr.eu/handbook/gc-tuning-measuring
- 三色標記: https://en.wikipedia.org/wiki/Tracing_garbage_collection#Tri-color_marking
- 內存管理wiki: http://www.memorymanagement.org/glossary/
- Rednaxelafx’s Blog: http://rednaxelafx.iteye.com/blog/362738
- rit GC資料:https://www.cs.rit.edu/~ark/lectures/gc/index.html
- memory barrier: https://www.kernel.org/doc/Documentation/memory-barriers.txt