本文地址 http://www.cnblogs.com/jasonxuli/p/6041909.html
[A tour of V8: Garbage Collection]
基本是這篇文章的翻譯,但是對上半部分結構做了改動,去掉了關系不太緊密的部分,調整了結構,增加了相關知識介紹。
背景知識:
1, 一個對象如果被根對象引用或者被另一個活對象引用,那它就是活的。其他的都是垃圾。
根對象是由V8或者瀏覽器引用的。例如,被本地變量引用的對象是根 對象,因為當前的棧被視為根對象;全局對象是根對象,因為他們總是可訪問;瀏覽器對象例如DOM元素也是根對象,盡管某些情況下可能是被弱引用。
2, Heap是基於Page的。Page是連續的內存塊,由OS根據類似mmap的方式分配。Page總是1MB大並且是1MB對齊,大對象空間(large-object-space)例外。
為了存儲對象,Page包含一個含有各種tag和meta-data的header;一個標記位圖(marking bitmap),用來顯示存活的對象;以及一個分配在單獨內存中的slots buffer,包含指向本Page中對象的對象列表,也稱為rememberd set。

圖1 Page結構

3, 代際回收:
類似二八法則,大多數對象生存期很短,少數對象生存期很長,因此將最常用的那部分Heap區域分為:
新區:又分為From 和 To; 大多數新對象都被配置在To區域,當To區域滿了的時候,快速GC(scavenge)被觸發。
老區:經過兩次快速GC仍然存續的對象會被移動到老區。當老區達到閾值的時候,會觸發主GC(mark-sweep 或 mark-compact)

圖2 快速GC區域
4, Tagged Pointers:
需要編譯器少量的支持,也就是需要在Pointer上做標記以區分某個字(word)是數據還是指針,這樣GC掃描速度會加快。
快速GC:Scavenge
概要:
當To區域滿了的時候,交換To和From區域,然后從From區域復制存活對象到To區域或者到老區。這個過程同時也壓縮了To區域,因此改善了緩存局部性(cache locality),內存配置會保持快速簡單。
詳細:
在初始化算法時,復制新區中可從根到達的所有對象到一個隊列。隨后在主循環中保持兩個指針:配置指針(allocationPtr)和掃描指針(scanPtr)。
配置指針指向下次配置對象的地址,掃描指針指向下一次掃描的對象。
在掃描指針之前的對象都已經被處理過了-- 它們和它們的臨近對象都已經在To區域中,指針也已經被更新。
在兩個指針中間的對象已經被復制到To區域,但是它們的內部指針仍然指向From區域中的對象。
可以認為在兩個指針之間的是一個進行廣度優先搜索(breadth-first-search)的隊列。

圖3 快速GC隊列

每次從scanPtr處取一個對象obj,下移scanPtr,然后檢查obj的內部指針。
1,如果某個指針沒有指向From區域,那么肯定指向了老區。
2,如果發現某個指針指向了From區域的對象並且這個對象沒有被復制到過(沒有forwarding address),那么把它復制到To區域的最后(allocationPtr的位置),然后設置forwarding address到新的副本,並且下移allocationPtr。也因此是廣度優先搜索,因為這個對象以后還會經歷一次其內部指針的掃描。
3,如果發現某個老區的指針指向了這個對象,那么這顯然是個仍然存活的對象。不過這種情況也帶來下面一個問題。
算法的偽碼:
def scavenge(): swap(fromSpace, toSpace) allocationPtr = toSpace.bottom scanPtr = toSpace.bottom for i = 0..len(roots): root = roots[i] if inFromSpace(root): rootCopy = copyObject(&allocationPtr, root) setForwardingAddress(root, rootCopy) roots[i] = rootCopy while scanPtr < allocationPtr: obj = object at scanPtr scanPtr += size(obj) n = sizeInWords(obj) for i = 0..n: if isPointer(obj[i]) and not inOldSpace(obj[i]): fromNeighbor = obj[i] if hasForwardingAddress(fromNeighbor): toNeighbor = getForwardingAddress(fromNeighbor) else: toNeighbor = copyObject(&allocationPtr, fromNeighbor) setForwardingAddress(fromNeighbor, toNeighbor) obj[i] = toNeighbor def copyObject(*allocationPtr, object): copy = *allocationPtr *allocationPtr += size(object) memcpy(copy, object, size(object)) return copy
寫屏障,神秘因素(Write barriers: the secret ingredient)
掃描From區域對象的時候,怎么快速發現是否有老區對象的指針指向了該對象呢?顯然不能把老區掃描一遍。
為了解決這個問題,實際上在store buffer中維護了一份指針列表,包含老區指向新區對象的指針。(store buffer不知道是不是處理器架構中說的那個)
當一個對象剛被創建出來,並沒有指針指向它。當老區里的對象的屬性指向這個新區里的對象的時候,把這個字段的位置記錄在store buffer中。為了做到這一點,我們在多數存儲后執行一段探測和記錄這些指針的代碼,叫做write barriers。
寫屏障會帶來消耗,但並不太大,因為寫不如讀頻繁。其他有些GC算法使用讀屏障,需要硬件支持來減小代價。
還有一些優化措施來減小代價:
1,多數執行時間被用於Crankshaft生成的優化過的代碼。很多時候,Crankshaft會靜態的證明一個對象是在新區,寫屏障也因此會省略。
2,Crankshaft有一項新的優化,當沒有非本地(non-local)的引用關聯到對象時,把對象配置在棧(stack)中。配置在棧中的存儲顯然不需要寫屏障。
3,老區到新區的指針相對來說比較少,因此我們可以通過快速探測新區到新區和老區到老區的指針來針對這些常見的情況進行優化。每個Page都是1MB對其的,給定一個對象地址,我們可以通過mask掉低位20位來找到它所屬的Page。Page的頭包含了它們是在新區還是老區的信息,因此通過幾個指令就能查到兩個對象都是在新區或者老區。
4,一旦找到了老區到新區的指針,就把指針地址記錄到store buffer的末尾。當store buffer滿了的時候,進行整理,排序並去掉重復的,去掉已經被重寫並且不再指向新區的記錄。
標記-清除 標記-壓縮
Scavenge算法可以對數量小的內存進行快速的回收和壓縮,但空間占用太大,因此對動輒幾百兆大小的老區不適用。針對老區,我們使用標記-清除和標記-壓縮算法。
這兩個算法分為兩個階段,標記階段和清除/壓縮階段。
標記階段,所有堆中的存活對象都被發現並標記。每個Page都包含一個標記位圖(marking bitmap),一位(bit)標記一個可配置的字(word)。這是必須的,因為對象可以從任何字對齊的位置開始。顯然,這會產生內存占用(32位系統是3.1%, 1/32;64位系統是1.6%,1/64),但所有的內存管理系統都有一些消耗,這是很合理的。 對象至少有兩個字的長度,因此這些位(bit)肯定不會重疊。
有三種標記狀態:
白色表示對象還沒有被GC發現。
灰色表示對象已經被GC發現,但它的臨近並沒有被完全處理完。
黑色表示對象已經被發現,並且它所有的臨近都已經被處理完。
標記算法是深度優先搜索(depth-first-search),你可以把堆想成一個由指針連接起來的對象圖譜。
在標記輪的開始,標記位圖是空的,所有的對象都是白色的。從根對象可抵達的對象被標記為灰色,然后壓入標記隊列——一個單獨配置的緩沖區用來存儲正在處理的對象。隨后的每個步驟中,GC從隊列中彈出一個對象,標記為黑色,標記其臨近的白色對象為灰色,把它們壓入隊列。當隊列空了,並且所有被GC發現的對象都被標記為黑色時,算法終止。
非常大的對象,比如長的數組,會被分開進行處理以減少隊列溢出的幾率。如果隊列真的溢出了,對象仍然被標記為灰色,但是並不被壓入隊列。當隊列空了的時候,GC必須掃描堆中的灰色對象,把它們壓入隊列,然后再開始標記。
當標記算法結束的時候,所有的存活對象都是黑色的,死對象仍然是白色的。這些結果隨后被用於清除或者壓縮階段。
一旦標記階段完成,就可以通過清除或者壓縮來回收內存。這兩種算法都工作在Page級別。(記住,V8的Page是1MB大小連續的塊,這不同於虛擬內存的Page)。
清除算法掃描連續的死對象,轉化為可用空間,並添加到可用列表。每個Page都維護獨立的可用空間列表(Free lists),分為小區域(<256字),中區域(<2048字),大區域(<16384字)和極大區域(更大的)。
清除算法超級簡單:它只是迭代Page的標記位圖,尋找未標記的對象。可用空間列表(Free lists)主要被用於scavenge算法來提交存活對象到老區,但也用於壓縮算法來重新配置對象。某些類型的對象只能被配置到老區,因此可用空間列表這時候也有用。
壓縮算法通過把對象從碎片多的頁(包含一些小的可用空間)移動到其他頁的可用空間中來減少實際內存占用。如果需要,會配置到新的頁。一旦某個頁被騰空了,就可以把它釋放給OS。
移動過程實際上是相當復雜的,這里不講細節。基本上,每個待疏散頁面的活對象會被配置到另一個頁中。這些對象被復制到新分配的空間,原對象的第一個字中會被寫入轉移地址(forwarding address)。在疏散過程中,被疏散對象之間的指針會被記錄下來,一旦疏散完成,V8會迭代這個記錄指針位置的列表,用新的副本來更新指針。不同頁的指針地址在標記階段就被記錄下來了,因此其他頁的指針也在這時被更新。 注意,如果一個頁變得太受歡迎,如,有太多其他頁的指針指向該頁的對象,那么這個頁的指針位置記錄就不可用了,這個頁的疏散要等到下一次GC循環。
偽碼:
markingDeque = [] overflow = false def markHeap(): for root in roots: mark(root) do: if overflow: overflow = false refillMarkingDeque() while !markingDeque.isEmpty(): obj = markingDeque.pop() setMarkBits(obj, BLACK) for neighbor in neighbors(obj): mark(neighbor) while overflow def mark(obj): if markBits(obj) == WHITE: setMarkBits(obj, GREY) if markingDeque.isFull(): overflow = true else: markingDeque.push(obj) def refillMarkingDeque(): for each obj on heap: if markBits(obj) == GREY: markingDeque.push(obj) if markingDeque.isFull(): overflow = true return
增量標記和懶清除 (Incremental marking and lazy sweeping)
你可能已經想到了,標記清除和標記壓縮在處理活對象很多的很大的堆時是相當耗時的。當我開始工作於V8時,基本上很難見到因為GC導致的暫停落在500-1000ms之間的。這顯然很難接受,即使是移動設備。
在2012年中,Google引入了兩項改進,顯著減少了GC暫停:增量標記和懶清除。
增量標記使得堆的標記在一系列小的暫停中進行,差不多每次5-10ms(移動設備中)。
增量標記在堆達到某個閾值時開始。每當一定量的內存被配置后,執行(execution)就暫停以進行增量標記。增量標記和常規標記一樣,基本上是深度優先搜索,它使用同樣的白灰黑系統來區分對象。區別是,增量標記過程中,因為不能一次處理完成,對象圖譜是變化的!我們需要注意的主要問題是黑色對象創建的指向白色對象的指針。回想一下,黑色對象已經完全被GC掃描過了,不會被再次掃描,所以如果在兩個小的增量GC之間,創建了一個黑色到白色的指針,GC的最終結果是我們把活對象當做了死對象。
寫屏障會有一次拯救我們。它們不止記錄了老區到新區的指針,還探測黑色到白色的指針。當探測到這樣的指針時,黑色對象變為灰色對象並被壓入標記隊列。隨后標記算法彈出並處理這個對象,它的(內部)指針會被重新掃描,於是白色對象被發現了。世界秩序得到了維護。。。
增量標記完成后,懶清除就開始了。內存清除不必須一次完成,延遲清除並不會真的影響到什么。所以GC會在需要的基礎上進行清理知道最后完成全部工作。此時,GC循環完成了,增量標記也得到釋放,可以開始下一次工作了。
Google最近增加了並行清除的支持。由於主執行線程不會涉及死對象,頁的清除工作可以交給單獨的線程以最小的同步來進行。還有一些關於並行標記的活動,但目前還是早期實驗階段。
附:
store buffer 簡介, 參考:
http://www.tuicool.com/articles/a6Jnqm
SB 的作用是通過緩沖存儲操作,從而加快存儲操作。
其原理是這樣的:當執行存儲操作時,可能需要通過 WB_BIU 將要寫的數據寫入外部 Memory ,尤其是在通寫法模式下,每次執行存儲操作都要將數據寫入外部 Memory ,這樣會等待外部 Memory 完成存儲操作,在此期間, CPU 處於暫停狀態,降低了 CPU 的效率。
引入 SB后,如果是存儲操作,那么 SB 模塊將本次操作保存起來,同時立即向 DCache 返回一個存儲完成信號( dcsb_ack_o 為 1 ),使得 CPU 可以繼續執行,然后 SB 模塊會接着完成被其保存起來的存儲操作。在 SB 內部有一個 FIFO (先入先出隊列)作為緩沖,如果是連續的多個存儲操作,那么會將每個存儲操作都存放在 FIFO 中,並向 DCache 返回存儲完成信號,然后 SB 從 FIFO 中取出要保存的數據,完成存儲操作。