Chrome V8系列--淺析Chrome V8引擎中的垃圾回收機制和記憶體洩露優化策略

saucxs發表於2019-05-24

V8 實現了準確式 GC,GC 演算法採用了分代式垃圾回收機制。因此,V8 將記憶體(堆)分為新生代和老生代兩部分。

 

一、前言

V8的垃圾回收機制:JavaScript使用垃圾回收機制來自動管理記憶體。垃圾回收是一把雙刃劍,其好處是可以大幅簡化程式的記憶體管理程式碼,降低程式設計師的負擔,減少因 長時間運轉而帶來的記憶體洩露問題。

但使用了垃圾回收即意味著程式設計師將無法掌控記憶體。ECMAScript沒有暴露任何垃圾回收器的介面。我們無法強迫其進 行垃圾回收,更無法干預記憶體管理

記憶體管理問題:在瀏覽器中,Chrome V8引擎例項的生命週期不會很長(誰沒事一個頁面開著幾天幾個月不關),而且執行在使用者的機器上。如果不幸發生記憶體洩露等問題,僅僅會 影響到一個終端使用者。且無論這個V8例項佔用了多少記憶體,最終在關閉頁面時記憶體都會被釋放,幾乎沒有太多管理的必要(當然並不代表一些大型Web應用不需 要管理記憶體)。但如果使用Node作為伺服器,就需要關注記憶體問題了,一旦記憶體發生洩漏,久而久之整個服務將會癱瘓(伺服器不會頻繁的重啟)。

 

二、chrome記憶體限制

2.1存在限制

Chrome限制了所能使用的記憶體極限(64位為1.4GB,32位為1.0GB),這也就意味著將無法直接操作一些大記憶體物件。

2.2為何限制

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

 

三、chrome V8的堆構成

V8的堆其實並不只是由老生代和新生代兩部分構成,可以將堆分為幾個不同的區域:

1、新生代記憶體區:大多數的物件被分配在這裡,這個區域很小但是垃圾回特別頻繁;

2、老生代指標區:屬於老生代,這裡包含了大多數可能存在指向其他物件的指標的物件,大多數從新生代晉升的物件會被移動到這裡;

3、老生代資料區:屬於老生代,這裡只儲存原始資料物件,這些物件沒有指向其他物件的指標;

4、大物件區:這裡存放體積超越其他區大小的物件,每個物件有自己的記憶體,垃圾回收其不會移動大物件;

5、程式碼區:程式碼物件,也就是包含JIT之後指令的物件,會被分配在這裡。唯一擁有執行許可權的記憶體區;

6、Cell區、屬性Cell區、Map區:存放Cell、屬性Cell和Map,每個區域都是存放相同大小的元素,結構簡單。

每個區域都是由一組記憶體頁構成,記憶體頁是V8申請記憶體的最小單位,除了大物件區的記憶體頁較大以外,其他區的記憶體頁都是1MB大小,而且按照1MB對 齊。記憶體頁除了儲存的物件,還有一個包含後設資料和標識資訊的頁頭,以及一個用於標記哪些物件是活躍物件的點陣圖區。另外每個記憶體頁還有一個單獨分配在另外內 存區的槽緩衝區,裡面放著一組物件,這些物件可能指向其他儲存在該頁的物件。垃圾回收器只會針對新生代記憶體區、老生代指標區以及老生代資料區進行垃圾回收。

 

四、chrome V8的垃圾回收機制

4.1如何判斷回收內容

如何確定哪些記憶體需要回收,哪些記憶體不需要回收,這是垃圾回收期需要解決的最基本問題。我們可以這樣假定,一個物件為活物件當且僅當它被一個根物件 或另一個活物件指向。根物件永遠是活物件,它是被瀏覽器或V8所引用的物件。被區域性變數所指向的物件也屬於根物件,因為它們所在的作用域物件被視為根對 象。全域性物件(Node中為global,瀏覽器中為window)自然是根物件。瀏覽器中的DOM元素也屬於根物件。

 

4.2如何識別指標和資料

垃圾回收器需要面臨一個問題,它需要判斷哪些是資料,哪些是指標。由於很多垃圾回收演算法會將物件在記憶體中移動(緊湊,減少記憶體碎片),所以經常需要進行指標的改寫:

目前主要有三種方法來識別指標:
1. 保守法:將所有堆上對齊的字都認為是指標,那麼有些資料就會被誤認為是指標。於是某些實際是數字的假指標,會背誤認為指向活躍物件,導致記憶體洩露(假指標指向的物件可能是死物件,但依舊有指標指向——這個假指標指向它)同時我們不能移動任何記憶體區域。
2. 編譯器提示法:如果是靜態語言,編譯器能夠告訴我們每個類當中指標的具體位置,而一旦我們知道物件時哪個類例項化得到的,就能知道物件中所有指標。這是JVM實現垃圾回收的方式,但這種方式並不適合JS這樣的動態語言
3. 標記指標法:這種方法需要在每個字末位預留一位來標記這個欄位是指標還是資料。這種方法需要編譯器支援,但實現簡單,而且效能不錯。V8採用的是這種方式。V8將所有資料以32bit字寬來儲存,其中最低一位保持為0,而指標的最低兩位為01

 

4.3 V8回收策略

自動垃圾回收演算法的演變過程中出現了很多演算法,但是由於不同物件的生存週期不同,沒有一種演算法適用於所有的情況。所以V8採用了一種分代回收的策 略,將記憶體分為兩個生代:新生代和老生代

新生代的物件為存活時間較短的物件,老生代中的物件為存活時間較長或常駐記憶體的物件。分別對新生代和老生代使用 不同的垃圾回收演算法來提升垃圾回收的效率。物件起初都會被分配到新生代,當新生代中的物件滿足某些條件(後面會有介紹)時,會被移動到老生代(晉升)。

 

五、新生代演算法

新生代中的物件一般存活時間較短,使用 Scavenge GC 演算法。在Scavenge的具體實現中,主要是採用一種複製的方式的方法--cheney演算法。

在新生代空間中,記憶體空間分為兩部分,分別為 From 空間和 To 空間。在這兩個空間中,必定有一個空間是使用的,另一個空間是空閒的。新分配的物件會被放入 From 空間中,當 From 空間被佔滿時,新生代 GC 就會啟動了。演算法會檢查 From 空間中存活的物件並複製到 To 空間中,如果有失活的物件就會銷燬。當複製完成後將 From 空間和 To 空間互換,這樣 GC 就結束了。

 

六、老生代演算法

老生代中的物件一般存活時間較長且數量也多,使用了兩個演算法,分別是標記清除演算法標記壓縮演算法

在講演算法前,先來說下什麼情況下物件會出現在老生代空間中:

1、新生代中的物件是否已經經歷過一次 Scavenge 演算法,如果經歷過的話,會將物件從新生代空間移到老生代空間中。

2、To 空間的物件佔比大小超過 25 %。在這種情況下,為了不影響到記憶體分配,會將物件從新生代空間移到老生代空間中。

老生代中的空間很複雜,有如下幾個空間:

enum AllocationSpace {
  // TODO(v8:7464): Actually map this space's memory as read-only.
  RO_SPACE,    // 不變的物件空間
  NEW_SPACE,   // 新生代用於 GC 複製演算法的空間
  OLD_SPACE,   // 老生代常駐物件空間
  CODE_SPACE,  // 老生代程式碼物件空間
  MAP_SPACE,   // 老生代 map 物件
  LO_SPACE,    // 老生代大空間物件
  NEW_LO_SPACE,  // 新生代大空間物件

  FIRST_SPACE = RO_SPACE,
  LAST_SPACE = NEW_LO_SPACE,
  FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
  LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};

在老生代中,以下情況會先啟動標記清除演算法:

1、某一個空間沒有分塊的時候

2、空間中被物件超過一定限制

3、空間不能保證新生代中的物件移動到老生代中

Mark Sweep 是將需要被回收的物件進行標記,在垃圾回收執行時直接釋放相應的地址空間,如下圖所示(紅色的記憶體區域表示需要被回收的區域):

Mark Compact 的思想有點像新生代垃圾回收時採取的 Cheney 演算法:將存活的物件移動到一邊,將需要被回收的物件移動到另一邊,然後對需要被回收的物件區域進行整體的垃圾回收。

在這個階段中,會遍歷堆中所有的物件,然後標記活的物件,在標記完成後,銷燬所有沒有被標記的物件。在標記大型對記憶體時,可能需要幾百毫秒才能完成一次標記。這就會導致一些效能上的問題。為了解決這個問題,2011 年,V8 從 stop-the-world 標記切換到增量標誌。在增量標記期間,GC 將標記工作分解為更小的模組,可以讓 JS 應用邏輯在模組間隙執行一會,從而不至於讓應用出現停頓情況。但在 2018 年,GC 技術又有了一個重大突破,這項技術名為併發標記。該技術可以讓 GC 掃描和標記物件時,同時允許 JS 執行。

清除物件後會造成堆記憶體出現碎片的情況,當碎片超過一定限制後會啟動壓縮演算法。在壓縮過程中,將活的物件像一端移動,直到所有物件都移動完成然後清理掉不需要的記憶體。

 

七、記憶體洩露和優化

7.1 什麼是記憶體洩露?

存洩露是指程式中已分配的堆記憶體由於某種原因未釋放或者無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統奔潰等後果。。

7.2 常見的記憶體洩露的場景

7.2.1 快取

js開發時候喜歡用物件的鍵值來快取函式的計算結果,但是快取中儲存的鍵越多,長期存活的物件就越多,導致垃圾回收在進行掃描和整理時,對這些物件做了很多無用功。

7.2.2 作用域未釋放(閉包)

var leakArray = [];
exports.leak = function () {
    leakArray.push("leak" + Math.random());
}

模組在編譯執行後形成的作用域因為模組快取的原因,不被釋放,每次呼叫 leak 方法,都會導致區域性變數 leakArray 不停增加且不被釋放。

閉包可以維持函式內部變數駐留記憶體,使其得不到釋放。

 

7.2.3 沒有必要的全域性變數

宣告過多的全域性變數,會導致變數常駐記憶體,要直到程式結束才能夠釋放記憶體。

 

7.2.4 無效的DOM引用

//dom still exist
function click(){
    // 但是 button 變數的引用仍然在記憶體當中。
    const button = document.getElementById('button');
    button.click();
}

// 移除 button 元素
function removeBtn(){
    document.body.removeChild(document.getElementById('button'));
}

 

7.2.5 定時器未清除

// vue 的 mounted 或 react 的 componentDidMount
componentDidMount() {
    setInterval(function () {
        // ...do something
    }, 1000)
}

vue 或 react 的頁面生命週期初始化時,定義了定時器,但是在離開頁面後,未清除定時器,就會導致記憶體洩漏。

 

7.2.6 事件監聽為空白

componentDidMount() {
    window.addEventListener("scroll", function () {
        // do something...
    });
}

在頁面生命週期初始化時,繫結了事件監聽器,但在離開頁面後,未清除事件監聽器,同樣也會導致記憶體洩漏。

 

7.3 記憶體洩露優化

7.3.1 解除引用

確保佔用最少的記憶體可以讓頁面獲得更好的效能。而優化記憶體佔用的最佳方式,就是為執行中的程式碼只儲存必要的資料。一旦資料不再有用,最好通過將其值設定為 null 來釋放其引用——這個做法叫做解除引用(dereferencing)

function createPerson(name){
    var localPerson = new Object();
    localPerson.name = name;
    return localPerson;
}

var globalPerson = createPerson("Nicholas");

// 手動解除 globalPerson 的引用
globalPerson = null;

解除一個值的引用並不意味著自動回收該值所佔用的記憶體。解除引用的真正作用是讓值脫離執行環境,以便垃圾收集器下次執行時將其回收

 

7.3.2 提供手動清空變數的方法

var leakArray = [];
exports.clear = function () {
    leakArray = [];
}

 

7.3.3 其他方法

1、在業務不需要的用到的內部函式,可以重構到函式外,實現解除閉包。

2、避免建立過多的生命週期較長的物件,或者將物件分解成多個子物件。

3、避免過多使用閉包。

4、注意清除定時器和事件監聽器。

5、nodejs中使用stream或buffer來操作大檔案,不會受nodejs記憶體限制。

6、使用redis等外部工具來快取資料。

 

八、總結

js是一門具有自動回收垃圾收集的程式語言,在瀏覽器中主要是通過標記清除的方法回收垃圾,在nodejs中主要是通過分代回收,Scavenge,標記清除,增量標記等演算法來回收垃圾。在日常開發中,有一些不引入注意的書寫方式可能會導致記憶體洩露,多注意自己程式碼規範。

 

九、參考

1、V8的垃圾回收機制與記憶體限制

2、node 記憶體限制的問題

3、node記憶體控制

4、深入淺出Nodejs

5、javascript高階程式設計

 

相關文章