JS垃圾回收機制筆記

Bjkb發表於2018-10-19

直到不久之前,對於JS的垃圾回收機制,還停留在‘所分配的記憶體不再需要’的階段。問題來了,瀏覽器是怎麼確定‘所分配的記憶體不再需要’了呢?

  • 記憶體簡介
  • 垃圾回收簡介

記憶體簡介

MDN:像C語言這樣的高階語言一般都有底層的記憶體管理介面,比如 malloc()和free()。另一方面,JavaScript建立變數(物件,字串等)時分配記憶體,並且在不再使用它們時“自動”釋放。 後一個過程稱為垃圾回收。這個“自動”是混亂的根源,並讓JavaScript(和其他高階語言)開發者感覺他們可以不關心記憶體管理。 這是錯誤的。

記憶體生命週期

  1. 分配你所需要的記憶體
  2. 使用分配到的記憶體(讀、寫)
  3. 不需要時將其釋放\歸還

JavaScript記憶體分配

為了不讓程式設計師費心分配記憶體,JavaScript 在定義變數時就完成了記憶體分配。

值的初始化

var n = 123; // 給數值變數分配記憶體
var s = "azerty"; // 給字串分配記憶體

var o = {
  a: 1,
  b: null
}; // 給物件及其包含的值分配記憶體

// 給陣列及其包含的值分配記憶體(就像物件一樣)
var a = [1, null, "abra"]; 

function f(a){
  return a + 2;
} // 給函式(可呼叫的物件)分配記憶體

// 函式表示式也能分配一個物件
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);
複製程式碼

通過函式呼叫分配記憶體

有些函式呼叫結果是分配物件記憶體:

var d = new Date(); // 分配一個 Date 物件

var e = document.createElement('div'); // 分配一個 DOM 元素
複製程式碼

有些方法分配新變數或者新物件

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一個新的字串
// 因為字串是不變數,
// JavaScript 可能決定不分配記憶體,
// 只是儲存了 [0-3] 的範圍。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新陣列有四個元素,是 a 連線 a2 的結果
複製程式碼

使用值

使用值的過程實際上是對分配記憶體進行讀取與寫入的操作。讀取與寫入可能是寫入一個變數或者一個物件的屬性值,甚至傳遞函式的引數。

當記憶體不再需要時釋放

MDN:大多數記憶體管理的問題都在這個階段。在這裡最艱難的任務是找到“所分配的記憶體確實已經不再需要了”。它往往要求開發人員來確定在程式中哪一塊記憶體不再需要並且釋放它。

高階語言直譯器嵌入了“垃圾回收器”,它的主要工作是跟蹤記憶體的分配和使用,以便當分配的記憶體不再使用時,自動釋放它。這隻能是一個近似的過程,因為要知道是否仍然需要某塊記憶體是無法判定的(無法通過某種演算法解決)


垃圾回收機制策略簡介

引用概念

垃圾回收演算法主要依賴於引用的概念。

在記憶體管理的環境中,一個物件如果有訪問另一個物件的許可權(隱式或者顯式),叫做一個物件引用另一個物件。例如,一個Javascript物件具有對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。

“物件”的概念不僅特指 JavaScript 物件,還包括函式作用域(或者全域性詞法作用域)。

引用計數垃圾收集

這是最初級的垃圾收集演算法。此演算法把“物件是否不再需要”簡化定義為“物件有沒有其他物件引用到它”。如果沒有引用指向該物件(零引用),物件將被垃圾回收機制回收。

var o = { 
  a: {
    b:2
  }
}; 
// 兩個物件被建立,一個作為另一個的屬性被引用,另一個被分配給變數o
// 很顯然,沒有一個可以被垃圾收集


var o2 = o; // o2變數是第二個對“這個物件”的引用

o = 1;      // 現在,“這個物件”的原始引用o被o2替換了

var oa = o2.a; // 引用“這個物件”的a屬性
// 現在,“這個物件”有兩個引用了,一個是o2,一個是oa

o2 = "yo"; // 最初的物件現在已經是零引用了
           // 他可以被垃圾回收了
           // 然而它的屬性a的物件還在被oa引用,所以還不能回收

oa = null; // a屬性的那個物件現在也是零引用了
           // 它可以被垃圾回收了
複製程式碼

引用計數缺陷

該演算法有個限制:無法處理迴圈引用。在下面的例子中,兩個物件被建立,並互相引用,形成了一個迴圈。它們被呼叫之後會離開函式作用域,所以它們已經沒有用了,可以被回收了。然而,引用計數演算法考慮到它們互相都有至少一次引用,所以它們不會被回收。

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();
複製程式碼

標記-清除演算法

這個演算法把“物件是否不再需要”簡化定義為“物件是否可以獲得”。

此演算法可以分為兩個階段,一個是標記階段(mark),一個是清除階段(sweep)。

  1. 標記階段,垃圾回收器會從根物件開始遍歷。每一個可以從根物件訪問到的物件都會被新增一個標識,於是這個物件就被標識為可到達物件。
  2. 清除階段,垃圾回收器會對堆記憶體從頭到尾進行線性遍歷,如果發現有物件沒有被標識為可到達物件,那麼就將此物件佔用的記憶體回收,並且將原來標記為可到達物件的標識清除,以便進行下一次垃圾回收操作。

簡單看看下面兩張圖片

JS垃圾回收機制筆記

  • 在標記階段,從根物件1可以訪問到B,從B又可以訪問到E,那麼B和E都是可到達物件,同樣的道理,F、G、J和K都是可到達物件。
  • 在回收階段,所有未標記為可到達的物件都會被垃圾回收器回收。

這個演算法比前一個要好,因為“有零引用的物件”總是不可獲得的,但是相反卻不一定,參考“迴圈引用”。

從2012年起,所有現代瀏覽器都使用了標記-清除垃圾回收演算法。所有對JavaScript垃圾回收演算法的改進都是基於標記-清除演算法的改進,並沒有改進標記-清除演算法本身和它對“物件是否不再需要”的簡化定義。

何時開始垃圾回收

通常來說,在使用標記清除演算法時,未引用物件並不會被立即回收。取而代之的做法是,垃圾物件將一直累計到記憶體耗盡為止。當記憶體耗盡時,程式將會被掛起,垃圾回收開始執行。

標記-清楚演算法缺陷

  • 那些無法從根物件查詢到的物件都將被清除
  • 垃圾收集後有可能會造成大量的記憶體碎片,像上面的圖片所示,垃圾收集後記憶體中存在三個記憶體碎片,假設一個方格代表1個單位的記憶體,如果有一個物件需要佔用3個記憶體單位的話,那麼就會導致Mutator一直處於暫停狀態,而Collector一直在嘗試進行垃圾收集,直到Out of Memory。

ChromeV8垃圾回收演算法分代回收(Generation GC)

這個和 Java 回收策略思想是一致的。目的是通過區分「臨時」與「持久」物件;多回收「臨時物件區」(young generation),少回收「持久物件區」(tenured generation),減少每次需遍歷的物件,從而減少每次GC的耗時。Chrome 瀏覽器所使用的 V8 引擎就是採用的分代回收策略。

「臨時」與「持久」物件也被叫做作「新生代」與「老生代」物件

JS垃圾回收機制筆記

V8分代回收

JS垃圾回收機制筆記

V8記憶體限制

在node中javascript能使用的記憶體是有限制的.

  1. 64位系統下約為1.4GB。
  2. 32位系統下約為0.7GB。

對應到分代記憶體中,預設情況下。

  1. 32位系統新生代記憶體大小為16MB,老生代記憶體大小為700MB。
  2. 64位系統下,新生代記憶體大小為32MB,老生代記憶體大小為1.4GB。

新生代平均分成兩塊相等的記憶體空間,叫做semispace,每塊記憶體大小8MB(32位)或16MB(64位)。

這個限制在node啟動的時候可以通過傳遞--max-old-space-size 和 --max-new-space-size來調整,如:

node --max-old-space-size=1700 app.js //單位為MB
node --max-new-space-size=1024 app.js //單位為MB
複製程式碼

上述引數在V8初始化時生效,一旦生效就不能再動態改變。

V8為什麼會有記憶體限制

  • 表面上的原因是V8最初是作為瀏覽器的JavaScript引擎而設計,不太可能遇到大量記憶體的場景。
  • 而深層次的原因則是由於V8的垃圾回收機制的限制。由於V8需要保證JavaScript應用邏輯與垃圾回收器所看到的不一樣,V8在執行垃圾回收時會阻塞JavaScript應用邏輯,直到垃圾回收結束再重新執行JavaScript應用邏輯,這種行為被稱為“全停頓”(stop-the-world)。
  • 若V8的堆記憶體為1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。
  • 這樣瀏覽器將在1s內失去對使用者的響應,造成假死現象。如果有動畫效果的話,動畫的展現也將顯著受到影響。

V8新生代演算法(Scavenge)

新生代中的物件主要通過Scavenge演算法進行垃圾回收。在Scavenge的具體實現中,主要採用Cheney演算法。

JS垃圾回收機制筆記

  • Cheney演算法是一種採用複製的方式實現的垃圾回收演算法,它將堆記憶體一分為二,這兩個空間中只有一個處於使用中,一個處於閒置狀態。
  • 處於使用狀態的空間稱為From空間,處於閒置的空間稱為To空間。
  • 分配物件時,先是在From空間中進行分配,當開始垃圾回收時,會檢查From空間中的存活物件,並將這些存活物件複製到To空間中,而非存活物件佔用的空間被釋放。
  • 完成複製後,From空間和To空間的角色互換。
  • 簡而言之,垃圾回收過程中,就是通過將存活物件在兩個空間中進行復制。
    JS垃圾回收機制筆記
    Scavenge演算法的缺點是隻能使用堆記憶體中的一半,但由於它只複製存活的物件,對於生命週期短的場景存活物件只佔少部分,所以在時間效率上有著優異的表現。

晉升

以上所說的是在純Scavenge演算法中,但是在分代式垃圾回收的前提下,From空間中存活的物件在複製到To空間之前需要進行檢查,在一定條件下,需要將存活週期較長的物件移動到老生代中,這個過程稱為物件晉升。

物件晉升的條件有兩個,一種是物件是否經歷過Scacenge回收:

JS垃圾回收機制筆記

另外一種情況是當To空間的使用應超過25%時,則這個物件直接晉升到老生代空間中。

JS垃圾回收機制筆記

V8老生代演算法(Mark-Sweep,Mark-Compact)

在老生代中的物件,由於存活物件佔比較大,再採用Scavenge方式會有兩個問題:

  • 一個是存活物件就較多,複製存活物件的效率將會降低;
  • 另一個依然是浪費一半空間的問題。為此,V8在老生代中主要採用Mark-Sweep和Mark-Compact相結合的方式進行垃圾回收。

Mark-Sweep(標記- 清除演算法)

這個演算法上文有提到過,這裡再說一下。

  • 與Scavenge不同,Mark-Sweep並不會將記憶體分為兩份,所以不存在浪費一半空間的行為。Mark-Sweep在標記階段遍歷堆記憶體中的所有物件,並標記活著的物件,在隨後的清除階段,只清除沒有被標記的物件。
  • 也就是說,Scavenge只複製活著的物件,而Mark-Sweep只清除死了的物件。活物件在新生代中只佔較少部分,死物件在老生代中只佔較少部分,這就是兩種回收方式都能高效處理的原因。
  • 但是這個演算法有個比較大的問題是,記憶體碎片太多。如果出現需要分配一個大記憶體的情況,由於剩餘的碎片空間不足以完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。
  • 所以在此基礎上提出Mark-Compact演算法。

Mark-Compact(標記-整理演算法)

Mark-Compact在標記完存活物件以後,會將活著的物件向記憶體空間的一端移動,移動完成後,直接清理掉邊界外的所有記憶體。


記憶體問題

  1. 現在的chrome瀏覽器是否還會存在記憶體洩漏?
  2. 記憶體洩漏跟記憶體溢位的區別是什麼?
  3. chrome何時開始記憶體回收?
  4. 回收分配的記憶體一定比不回收要好嗎?

ps: 請勿轉載,只學習交流使用。

相關文章