V8 之旅: 垃圾回收器

注:本文轉自V8 之旅: 垃圾回收器

在之前的幾篇文章當中,我們深入了V8引擎的實現,討論了Full CompilerCrankshaft以及對象的內部表達。在這篇文章當中,我們來看看V8的 垃圾回收器 。

本文來自Jay Conrod的A tour of V8: Garbage Collection,其中的術語、代碼請以原文爲準。

垃圾回收器是一把十足的雙刃劍。其好處是可以大幅簡化程序的內存管理代碼,因爲內存管理無需程序員來操作,由此也減少了(但沒有根除)長時間運轉的程序的內存泄漏。對於某些程序員來說,它甚至能夠提升代碼的性能。

另一方面,選擇垃圾回收器也就意味着程序當中無法完全掌控內存,而這正是移動終端開發的癥結。對於JavaScript,程序中沒有任何內存管理的可能——ECMAScript標準中沒有暴露任何垃圾回收器的接口。網頁應用既沒有辦法管理內存,也沒辦法給垃圾回收器進行提示。

嚴格來講,使用垃圾回收器的語言在性能上並不一定比不使用垃圾回收器的語言好或者差。在C語言中,分配和釋放內存有可能是非常昂貴的操作,爲了使分配的內存能夠在將來釋放,堆的管理會趨於複雜。而在託管內存的語言中,分配內存往往只是增加一個指針。但隨後我們就會看到,當內存耗盡時,垃圾回收器介入回收所產生的巨大代價。一個未經琢磨的垃圾回收器,會致使程序在運行中出現長時間、無法預期的停頓,這直接影響到交互系統(特別是帶有動畫效果的)在使用上的體驗。引用計數系統時常被吹捧爲垃圾回收機制的替代品,但當大型子圖中的最後一個對象的引用解除後,同樣也會有無法預期的停頓。而且引用計數系統在頻繁執行讀取、改寫、存儲操作時,也會有可觀的性能負擔。

或好或壞,JavaScript需要一個垃圾回收器。V8的垃圾回收器實現現在已經成熟,其性能優異,停頓短暫,性能負擔也非常可控。

基本概念

垃圾回收器要解決的最基本問題就是,辨別需要回收的內存。一旦辨別完畢,這些內存區域即可在未來的分配中重用,或者是返還給操作系統。一個對象當它不是處於活躍狀態的時候它就死了(廢話)。一個對象處於活躍狀態,當且僅當它被一個根對象或另一個活躍對象指向。根對象被定義爲處於活躍狀態,是瀏覽器或V8所引用的對象。比如說,被局部變量所指向的對象屬於根對象,因爲它們的棧被視爲根對象;全局對象屬於根對象,因爲它們始終可被訪問;瀏覽器對象,如DOM元素,也屬於根對象,儘管在某些場合下它們只是弱引用。

從側面來說,上面的定義非常寬鬆。實際上我們可以說,當一個對象可被程序引用時,它就是活躍的。比如:

	function f() {
	  var obj = {x: 12};
	  g();   // 可能包含一個死循環
	  return obj.x;
	}

譯註:這裏的obj.xobj都是活躍的,儘管對其的再度引用是在死循環之後。

很遺憾,我們無法精確地解決這個問題,因爲這個問題實際等價於停機問題,無法確定。因此我們做一個等價約定:如果一個對象可經由某個被定義爲活躍對象的對象,通過某個指針鏈所訪問,則它就是活躍的。其他的都被視爲垃圾。

堆的構成

在我們深入研究垃圾回收器的內部工作原理之前,首先來看看堆是如何組織的。V8將堆分爲了幾個不同的區域:

  • 新生區:大多數對象被分配在這裏。新生區是一個很小的區域,垃圾回收在這個區域非常頻繁,與其他區域相獨立。
  • 老生指針區:這裏包含大多數可能存在指向其他對象的指針的對象。大多數在新生區存活一段時間之後的對象都會被挪到這裏。
  • 老生數據區:這裏存放只包含原始數據的對象(這些對象沒有指向其他對象的指針)。字符串、封箱的數字以及未封箱的雙精度數字數組,在新生區存活一段時間後會被移動到這裏。
  • 大對象區:這裏存放體積超越其他區大小的對象。每個對象有自己mmap產生的內存。垃圾回收器從不移動大對象。
  • 代碼區:代碼對象,也就是包含JIT之後指令的對象,會被分配到這裏。這是唯一擁有執行權限的內存區(不過如果代碼對象因過大而放在大對象區,則該大對象所對應的內存也是可執行的。譯註:但是大對象內存區本身不是可執行的內存區)。
  • Cell區、屬性Cell區、Map區:這些區域存放Cell、屬性Cell和Map,每個區域因爲都是存放相同大小的元素,因此內存結構很簡單。

每個區域都由一組內存頁構成。內存頁是一塊連續的內存,經mmap(或者Windows的什麼等價物)由操作系統分配而來。除大對象區的內存頁較大之外,每個區的內存頁都是1MB大小,且按1MB內存對齊。除了存儲對象,內存頁還含有一個頁頭(包含一些元數據和標識信息)以及一個位圖區(用以標記哪些對象是活躍的)。另外,每個內存頁還有一個單獨分配在另外內存區的槽緩衝區,裏面放着一組對象,這些對象可能指向其他存儲在該頁的對象。這就是一套經典配置方案,其他的方案我們稍後討論。

有了這些背景知識,我們可以來深入垃圾回收器了。

識別指針

垃圾回收器面臨的第一個問題是,如何才能在堆中區分指針和數據,因爲指針指向着活躍的對象。大多數垃圾回收算法會將對象在內存中挪動(以便減少內存碎片,使內存緊湊),因此即使不區分指針和數據,我們也常常需要對指針進行改寫。

目前主要有三種方法來識別指針:

  • 保守法:這種方法對於缺少編譯器支持的情況非常必要。大體上,我們將所有堆上對齊的字都認爲是指針,這就意味着有些數據也會被誤認爲是指針。於是某些實際是數字的假指針,會被誤認爲指向活躍的對象,則我們會時常出現一些奇異的內存泄漏。(譯註:因爲垃圾回收器會以爲死對象仍然還有指針指向,錯將死對象誤認爲活躍對象)而且我們也不能移動任何內存區域,因爲這很可能會導致數據遭到破壞。這樣,我們便無法通過緊湊內存來獲得任何好處(比如更容易的內存分配、更少的內存訪問、更有效的內存局部性緩存)。C/C++的垃圾回收器擴展會採用這種方式,比如Boehm-Demers-Weiser
    譯註:如果內存是緊湊的,則內存分配時可以更容易分配較大片的內存,而無需因內存碎片而不斷查找;同時,由於已分配的內存是連續或近似連續的,而Cahce所能緩存的內存有限,如果內存被Cache緩存起來,無需頻繁地迫使Cache更換緩存的內存。C/C++由於指針算術的存在,編譯器無法確定哪些內存是真正的垃圾,因而無法給垃圾回收器有效的提示,進而導致垃圾回收器不得不採取這樣的保守策略。
  • 編譯器提示法:如果我們和靜態語言打交道,則編譯器能夠準確地告訴我們每個類當中指針的具體位置。而一旦我們知道對象是哪個類實例化得到的,我們就能知道對象中所有的指針。JVM選擇了這樣的方法來進行垃圾回收。可惜,這種方法對於JS這樣的動態語言來說不太好使,因爲JS中對象的任何屬性既可能是指針,也可能是數據。
  • 標記指針法:這種方法需要在每個字的末位預留一位來標記這個字代表的是指針抑或數據。這種方法需要一定的編譯器支持,但實現簡單,而且性能不俗。V8採用的就是這種方法。某些靜態語言也採用了這樣的方法,如OCaml。

V8將所有屬於-230…230-1範圍內的小整數(V8內部稱其爲Smis)以32bit字寬來存儲,其中的最低一位保持爲0,而指針的最低兩位則爲01。由於對象以4字節對齊,因此這樣表達指針沒有任何問題。大多數對象所含有的只是一組標記後的字,因此垃圾回收可以進行的很快。而有些類型的對象,比如字符串,我們確定它只含有數據,因此無需標記。

分代回收

腳本中,絕大多數對象的生存期很短,只有某些對象的生存期較長。爲利用這一特點,V8將堆進行了分代。對象起初會被分配在新生區(通常很小,只有1-8 MB,具體根據行爲來進行啓發)。在新生區的內存分配非常容易:我們只需保有一個指向內存區的指針,不斷根據新對象的大小對其進行遞增即可。當該指針達到了新生區的末尾,就會有一次清理(小週期),清理掉新生區中不活躍的死對象。對於活躍超過2個小週期的對象,則需將其移動至老生區。老生區在標記-清除標記-緊縮(大週期)的過程中進行回收。大週期進行的並不頻繁。一次大週期通常是在移動足夠多的對象至老生區後纔會發生。至於足夠多到底是多少,則根據老生區自身的大小和程序的動向來定。

由於清理髮生的很頻繁,清理必須進行的非常快速。V8中的清理過程稱爲Scavenge算法,是按照Cheney的算法實現的。這個算法大致是,新生區被劃分爲兩個等大的子區:出區、入區。絕大多數內存的分配都會在出區發生(但某些特定類型的對象,如可執行的代碼對象是分配在老生區的),當出區耗盡時,我們交換出區和入區(這樣所有的對象都歸屬在入區當中),然後將入區中活躍的對象複製至出區或老生區當中。在這時我們會對活躍對象進行緊縮,以便提升Cache的內存局部性,保持內存分配的簡潔快速。

以下是這個算法的僞代碼描述:

	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
 

在這個算法的執行過程中,我們始終維護兩個出區中的指針:allocationPtr指向我們即將爲新對象分配內存的地方,scanPtr指向我們即將進行活躍檢查的下一個對象。scanPtr所指向地址之前的對象是處理過的對象,它們及其鄰接都在出區,其指針都是更新過的,位於scanPtrallocationPtr之間的對象,會被複制至出區,但這些對象內部所包含的指針如果指向入區中的對象,則這些入區中的對象不會被複制。邏輯上,你可以將scanPtrallocationPtr之間的對象想象爲一個廣度優先搜索用到的對象隊列。

譯註:廣度優先搜索中,通常會將節點從隊列頭部取出並展開,將展開得到的子節點存入隊列末端,周而復始進行。這一過程與更新兩個指針間對象的過程相似。

我們在算法的初始時,複製新區所有可從根對象達到的對象,之後進入一個大的循環。在循環的每一輪,我們都會從隊列中刪除一個對象,也就是對scanPtr增量,然後跟蹤訪問對象內部的指針。如果指針並不指向入區,則不管它,因爲它必然指向老生區,而這就不是我們的目標了。而如果指針指向入區中某個對象,但我們還沒有複製(未設置轉發地址),則將這個對象複製至出區,即增加到我們隊列的末端,同時也就是對allocationPtr增量。這時我們還會將一個轉發地址存至出區對象的首字,替換掉Map指針。這個轉發地址就是對象複製後所存放的地址。垃圾回收器可以輕易將轉發地址與Map指針分清,因爲Map指針經過了標記,而這個地址則未標記。如果我們發現一個指針,而其指向的對象已經複製過了(設置過轉發地址),我們就把這個指針更新爲轉發地址,然後打上標記。

算法在所有對象都處理完畢時終止(即scanPtrallocationPtr相遇)。這時入區的內容都可視爲垃圾,可能會在未來釋放或重用。

祕密武器:寫屏障

上面有一個細節被忽略了:如果新生區中某個對象,只有一個指向它的指針,而這個指針恰好是在老生區的對象當中,我們如何才能知道新生區中那個對象是活躍的呢?顯然我們並不希望將老生區再遍歷一次,因爲老生區中的對象很多,這樣做一次消耗太大。

爲了解決這個問題,實際上在寫緩衝區中有一個列表,列表中記錄了所有老生區對象指向新生區的情況。新對象誕生的時候,並不會有指向它的指針,而當有老生區中的對象出現指向新生區對象的指針時,我們便記錄下來這樣的跨區指向。由於這種記錄行爲總是發生在寫操作時,它被稱爲寫屏障——因爲每個寫操作都要經歷這樣一關。

你可能好奇,如果每次進行寫操作都要經過寫屏障,豈不是會多出大量的代碼麼?沒錯,這就是我們這種垃圾回收機制的代價之一。但情況沒你想象的那麼嚴重,寫操作畢竟比讀操作要少。某些垃圾回收算法(不是V8的)會採用讀屏障,而這需要硬件來輔助才能保證一個較低的消耗。V8也有一些優化來降低寫屏障帶來的消耗:

  • 大多數的腳本執行時間都是發生在Crankshaft當中的,而Crankshaft常常能靜態地判斷出某個對象是否處於新生區。對於指向這些對象的寫操作,可以無需寫屏障。
  • Crankshaft中新出現了一種優化,即在對象不存在指向它的非局部引用時,該對象會被分配在棧上。而一個棧上對象的相關寫操作顯然無需寫屏障。(譯註:新生區和老生區在堆上。
  • “老→新”這樣的情況相對較爲少見,因此通過將“新→新”和“老→老”兩種常見情況的代碼做優化,可以相對提升多數情形下的性能。每個頁都以1MB對齊,因此給定一個對象的內存地址,通過將低20bit濾除來快速定位其所在的頁;而頁頭有相關的標識來表明其屬於新生區還是老生區,因此通過判斷兩個對象所屬的區域,也可以快速確定是否是“老→新”。
  • 一旦我們找到“老→新”的指針,我們就可以將其記錄在寫緩衝區的末端。經過一定的時間(寫緩衝區滿的時候),我們將其排序,合併相同的項目,然後再除去已經不符合“老→新”這一情形的指針。(譯註:這樣指針的數目就會減少,寫屏障的時間相應也會縮短

“標記-清除”算法與“標記-緊縮”算法

Scavenge算法對於快速回收、緊縮小片內存效果很好,但對於大片內存則消耗過大。因爲Scavenge算法需要出區和入區兩個區域,這對於小片內存尚可,而對於超過數MB的內存就開始變得不切實際了。老生區包含有上百MB的數據,對於這麼大的區域,我們採取另外兩種相互較爲接近的算法:“標記-清除”算法與“標記-緊縮”算法。

這兩種算法都包括兩個階段:標記階段,清除或緊縮階段。

在標記階段,所有堆上的活躍對象都會被標記。每個頁都會包含一個用來標記的位圖,位圖中的每一位對應頁中的一字(譯註:一個指針就是一字大小)。這個標記非常有必要,因爲指針可能會在任何字對齊的地方出現。顯然,這樣的位圖要佔據一定的空間(32位系統上佔據3.1%,64位系統上佔據1.6%),但所有的內存管理機制都需要這樣佔用,因此這種做法並不過分。除此之外,另有2位來表示標記對象的狀態。由於對象至少有2字長,因此這些位不會重疊。狀態一共有三種:如果一個對象的狀態爲白,那麼它尚未被垃圾回收器發現;如果一個對象的狀態爲灰,那麼它已被垃圾回收器發現,但它的鄰接對象仍未全部處理完畢;如果一個對象的狀態爲黑,則它不僅被垃圾回收器發現,而且其所有鄰接對象也都處理完畢。

如果將堆中的對象看作由指針相互聯繫的有向圖,標記算法的核心實際是深度優先搜索。在標記的初期,位圖是空的,所有對象也都是白的。從根可達的對象會被染色爲灰色,並被放入標記用的一個單獨分配的雙端隊列。標記階段的每次循環,GC會將一個對象從雙端隊列中取出,染色爲黑,然後將它的鄰接對象染色爲灰,並把鄰接對象放入雙端隊列。這一過程在雙端隊列爲空且所有對象都變黑時結束。特別大的對象,如長數組,可能會在處理時分片,以防溢出雙端隊列。如果雙端隊列溢出了,則對象仍然會被染爲灰色,但不會再被放入隊列(這樣他們的鄰接對象就沒有機會再染色了)。因此當雙端隊列爲空時,GC仍然需要掃描一次,確保所有的灰對象都成爲了黑對象。對於未被染黑的灰對象,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

標記算法結束時,所有的活躍對象都被染爲了黑色,而所有的死對象則仍是白的。這一結果正是清理和緊縮兩個階段所期望的。

標記算法執行完畢後,我們可以選擇清理或是緊縮,這兩個算法都可以收回內存,而且兩者都作用於頁級(注意,V8的內存頁是1MB的連續內存塊,與虛擬內存頁不同)。

清理算法掃描連續存放的死對象,將其變爲空閒空間,並將其添加到空閒內存鏈表中。每一頁都包含數個空閒內存鏈表,其分別代表小內存區(<256字)、中內存區(<2048字)、大內存區(<16384字)和超大內存區(其它更大的內存)。清理算法非常簡單,只需遍歷頁的位圖,搜索連續的白對象。空閒內存鏈表大量被scavenge算法用於分配存活下來的活躍對象,但也被緊縮算法用於移動對象。有些類型的對象只能被分配在老生區,因此空閒內存鏈表也被它們使用。

緊縮算法會嘗試將對象從碎片頁(包含大量小空閒內存的頁)中遷移整合在一起,來釋放內存。這些對象會被遷移到另外的頁上,因此也可能會新分配一些頁。而遷出後的碎片頁就可以返還給操作系統了。遷移整合的過程非常複雜,因此我只提及一些細節而不全面講解。大概過程是這樣的。對目標碎片頁中的每個活躍對象,在空閒內存鏈表中分配一塊其它頁的區域,將該對象複製至新頁,並在碎片頁中的該對象上寫上轉發地址。遷出過程中,對象中的舊地址會被記錄下來,這樣在遷出結束後V8會遍歷它所記錄的地址,將其更新爲新的地址。由於標記過程中也記錄了不同頁之間的指針,此時也會更新這些指針的指向。注意,如果一個頁非常“活躍”,比如其中有過多需要記錄的指針,則地址記錄會跳過它,等到下一輪垃圾回收再進行處理。

增量標記與惰性清理

你應該想到了,當一個堆很大而且有很多活躍對象時,標記-清除和標記-緊縮算法會執行的很慢。起初我研究V8時,垃圾回收所引發的500-1000毫秒的停頓並不少見。這種情況顯然很難接受,即使是對於移動設備。

2012年年中,Google引入了兩項改進來減少垃圾回收所引起的停頓,並且效果顯著:增量標記和惰性清理。

增量標記允許堆的標記發生在幾次5-10毫秒(移動設備)的小停頓中。增量標記在堆的大小達到一定的閾值時啓用,啓用之後每當一定量的內存分配後,腳本的執行就會停頓並進行一次增量標記。就像普通的標記一樣,增量標記也是一個深度優先搜索,並同樣採用白灰黑機制來分類對象。

但增量標記和普通標記不同的是,對象的圖譜關係可能發生變化!我們需要特別注意的是,那些從黑對象指向白對象的新指針。回憶一下,黑對象表示其已完全被垃圾回收器掃描,並不會再進行二次掃描。因此如果有“黑→白”這樣的指針出現,我們就有可能將那個白對象漏掉,錯當死對象處理掉。(譯註:標記過程結束後剩餘的白對象都被認爲是死對象。)於是我們不得不再度啓用寫屏障。現在寫屏障不僅記錄“老→新”指針,同時還要記錄“黑→白”指針。一旦發現這樣的指針,黑對象會被重新染色爲灰對象,重新放回到雙端隊列中。當算法將該對象取出時,其包含的指針會被重新掃描,這樣活躍的白對象就不會漏掉。

增量標記完成後,惰性清理就開始了。所有的對象已被處理,因此非死即活,堆上多少空間可以變爲空閒已經成爲定局。此時我們可以不急着釋放那些空間,而將清理的過程延遲一下也並無大礙。因此無需一次清理所有的頁,垃圾回收器會視需要逐一進行清理,直到所有的頁都清理完畢。這時增量標記又蓄勢待發了。

Google近期還新增了並行清理支持。由於腳本的執行線程不會再觸及死對象,頁的清理任務可以放在另一個單獨的線程中進行並只需極少的同步工作。同樣的支持工作也正在並行標記上開展着,但目前還處於早期試驗階段。

總結

垃圾回收真的很複雜。我在文章中已經略過了大量的細節,而文章仍然變得很長。我一個同事說他覺得研究垃圾回收器比寄存器分配還要可怕,我表示確實如此。也就是說,我寧可將這些繁瑣的細節交給運行時來處理,也不想將其交給所有的應用開發者來做。儘管垃圾回收存在一些性能問題而且偶爾會出現靈異現象,它還是將我們從大量的細節中解放了出來,以便讓我們集中精力於更重要的事情上。

如果你還想了解更多垃圾回收上的東西,我建議你讀讀Richard Jones和Rafael Lins寫的《Garbage Collection》,這是一個絕好的參考,涵蓋了大量你需要了解的內容。你可能還對《Garbage First Garbage-Collection》感興趣,這是一篇描述JVM所使用的垃圾回收算法的論文。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章