1. 程式人生 > 其它 >垃圾回收器的基礎知識

垃圾回收器的基礎知識

在這裡插入圖片描述

垃圾回收器是一把十足的雙刃劍。

好處 是可以大幅簡化程式的記憶體管理程式碼,因為記憶體管理無需程式設計師來操作,由此也減少了(但沒有根除)長時間運轉的程式的記憶體洩漏。

對於某些程式設計師來說,它甚至能夠提升程式碼的效能

另一方面,選擇垃圾回收器也就意味著程式當中無法完全掌控記憶體,而這正是移動終端開發的癥結。

對於JavaScript,程式中沒有任何記憶體管理的可能——ECMAScript標準中沒有暴露任何垃圾回收器的介面。網頁應用既沒有辦法管理記憶體,也沒辦法給垃圾回收器進行提示。

嚴格來講,使用垃圾回收器的語言在效能上並不一定比不使用垃圾回收器的語言好或者差。

在C語言中,分配和釋放記憶體有可能是非常昂貴的操作,為了使分配的記憶體能夠在將來釋放,堆的管理會趨於複雜。而在託管記憶體的語言中,分配記憶體往往只是增加一個指標。但隨後我們就會看到,當記憶體耗盡時,垃圾回收器介入回收所產生的巨大代價。

一個未經琢磨的垃圾回收器,會致使程式在執行中出現長時間、無法預期的停頓,這直接影響到互動系統(特別是帶有動畫效果的)在使用上的體驗。引用計數系統時常被吹捧為垃圾回收機制的替代品,但當大型子圖中的最後一個物件的引用解除後,同樣也會有無法預期的停頓。而且引用計數系統在頻繁執行讀取、改寫、儲存操作時,也會有可觀的效能負擔。

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

基本概念

垃圾回收器要解決的最基本問題就是,辨別需要回收的記憶體。一旦辨別完畢,這些記憶體區域即可在未來的分配中重用,或者是返還給作業系統。

一個物件當它不是處於活躍狀態的時候它就死了(廢話)。一個物件處於活躍狀態,當且僅當它被一個根物件或另一個活躍物件指向。根物件被定義為處於活躍狀態,是瀏覽器或V8所引用的物件。

比如說,被區域性變數所指向的物件屬於根物件,因為它們的棧被視為根物件;全域性物件屬於根物件,因為它們始終可被訪問;瀏覽器物件,如DOM元素,也屬於根物件,儘管在某些場合下它們只是弱引用。

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

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

譯註:這裡的obj.x和obj都是活躍的,儘管對其的再度引用是在死迴圈之後。

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

堆的構成

在我們深入研究垃圾回收器的內部工作原理之前,首先來看看堆是如何組織的。

V8將堆分為了幾個不同的區域:

  • 新生區:大多數物件被分配在這裡。新生區是一個很小的區域,垃圾回收在這個區域非常頻繁,與其他區域相獨立。

  • 老生指標區:這裡包含大多數可能存在指向其他物件的指標的物件。大多數在新生區存活一段時間之後的物件都會被挪到這裡。

  • 老生資料區:這裡存放只包含原始資料的物件(這些物件沒有指向其他物件的指標)。字串、封箱的數字以及未封箱的雙精度數字陣列,在新生區存活一段時間後會被移動到這裡。

  • 大物件區:這裡存放體積超越其他區大小的物件。每個物件有自己mmap產生的記憶體。垃圾回收器從不移動大物件。

  • 程式碼區:程式碼物件,也就是包含JIT之後指令的物件,會被分配到這裡。這是唯一擁有執行許可權的記憶體區(不過如果程式碼物件因過大而放在大物件區,則該大物件所對應的記憶體也是可執行的。譯註:但是大物件記憶體區本身不是可執行的記憶體區)。

  • Cell區、屬性Cell區、Map區:這些區域存放Cell、屬性Cell和Map,每個區域因為都是存放相同大小的元素,因此記憶體結構很簡單。

每個區域都由一組記憶體頁構成。記憶體頁是一塊連續的記憶體,經mmap(或者Windows的什麼等價物)由作業系統分配而來。
除大物件區的記憶體頁較大之外,每個區的記憶體頁都是1MB大小,且按1MB記憶體對齊。
除了儲存物件,記憶體頁還含有一個頁頭(包含一些元資料和標識資訊)以及一個位圖區(用以標記哪些物件是活躍的)。
另外,每個記憶體頁還有一個單獨分配在另外記憶體區的槽緩衝區,裡面放著一組物件,這些物件可能指向其他儲存在該頁的物件。

這就是一套經典配置方案,其他的方案我們稍後討論。

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

識別指標

垃圾回收器面臨的第一個問題是,如何才能在堆中區分指標和資料,因為指標指向著活躍的物件。大多數垃圾回收演算法會將物件在記憶體中挪動(以便減少記憶體碎片,使記憶體緊湊),因此即使不區分指標和資料,我們也常常需要對指標進行改寫。

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

  • 保守法:這種方法對於缺少編譯器支援的情況非常必要。大體上,我們將所有堆上對齊的字都認為是指標,這就意味著有些資料也會被誤認為是指標。於是某些實際是數字的假指標,會被誤認為指向活躍的物件,則我們會時常出現一些奇異的記憶體洩漏。(譯註:因為垃圾回收器會以為死物件仍然還有指標指向,錯將死物件誤認為活躍物件)而且我們也不能移動任何記憶體區域,因為這很可能會導致資料遭到破壞。這樣,我們便無法通過緊湊記憶體來獲得任何好處(比如更容易的記憶體分配、更少的記憶體訪問、更有效的記憶體區域性性快取)。

譯註:如果記憶體是緊湊的,則記憶體分配時可以更容易分配較大片的記憶體,而無需因記憶體碎片而不斷查詢;同時,由於已分配的記憶體是連續或近似連續的,而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所指向地址之前的物件是處理過的物件,它們及其鄰接都在出區,其指標都是更新過的,位於scanPtr和allocationPtr之間的物件,會被複制至出區,但這些物件內部所包含的指標如果指向入區中的物件,則這些入區中的物件不會被複制。

邏輯上,你可以將scanPtr和allocationPtr之間的物件想象為一個廣度優先搜尋用到的物件佇列。

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

我們在演算法的初始時,複製新區所有可從根物件達到的物件,之後進入一個大的迴圈。在迴圈的每一輪,我們都會從佇列中刪除一個物件,也就是對scanPtr增量,然後跟蹤訪問物件內部的指標。

如果指標並不指向入區,則不管它,因為它必然指向老生區,而這就不是我們的目標了。而如果指標指向入區中某個物件,但我們還沒有複製(未設定轉發地址),則將這個物件複製至出區,即增加到我們佇列的末端,同時也就是對allocationPtr增量。

這時我們還會將一個轉發地址存至出區物件的首字,替換掉Map指標。這個轉發地址就是物件複製後所存放的地址。垃圾回收器可以輕易將轉發地址與Map指標分清,因為Map指標經過了標記,而這個地址則未標記。

如果我們發現一個指標,而其指向的物件已經複製過了(設定過轉發地址),我們就把這個指標更新為轉發地址,然後打上標記。

演算法在所有物件都處理完畢時終止(即scanPtr和allocationPtr相遇)。這時入區的內容都可視為垃圾,可能會在未來釋放或重用。

祕密武器:寫屏障

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

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

你可能好奇,如果每次進行寫操作都要經過寫屏障,豈不是會多出大量的程式碼麼?

沒錯,這就是我們這種垃圾回收機制的代價之一。但情況沒你想象的那麼嚴重,寫操作畢竟比讀操作要少。某些垃圾回收演算法(不是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近期還新增了並行清理支援。由於指令碼的執行執行緒不會再觸及死物件,頁的清理任務可以放在另一個單獨的執行緒中進行並只需極少的同步工作。同樣的支援工作也正在並行標記上開展著,但目前還處於早期試驗階段。

總結

垃圾回收真的很複雜。我在文章中已經略過了大量的細節,而文章仍然變得很長。我一個同事說他覺得研究垃圾回收器比暫存器分配還要可怕,我表示確實如此。也就是說,我寧可將這些繁瑣的細節交給執行時來處理,也不想將其交給所有的應用開發者來做。

儘管垃圾回收存在一些效能問題而且偶爾會出現靈異現象,它還是將我們從大量的細節中解放了出來,以便讓我們集中精力於更重要的事情上。

在這裡插入圖片描述