淺談 V8 垃圾回收機制

對於 C/C++ 等底層語言,內存需要手動進行申請,使用完後手動進行釋放。而對於 javascript 語言使用者來說,因爲有垃圾回收器的工作,在使用中通常不需要關心內存的使用情況。但有時不當的代碼會意外的導致變量未被垃圾回收器回收,積少成多後造成內存泄漏,潛在的提高應用卡頓的風險。本文從垃圾回收器的工作原理進行分析,總結可能造成內存泄漏的幾個典型場景,避免工作中出現內存泄漏造成應用卡頓。

內存生命週期

無論哪種編程語言,內存的生命週期都是差不多的:申請內存、使用內存(讀寫)、釋放或歸還內存。

爲什麼需要垃圾回收

顯而易見,用戶設備內存是有限的,只申請不釋放,內存被佔滿時,就無法給新創建的對象分配內存。這裏類比我們去公司食堂喫飯的場景:打飯後找到空位(申請內存)、在空位喫飯(使用內存)、喫完飯收拾餐盤放回回收區(釋放內存)。想象一下,我們喫完不收拾餐盤(釋放內存),後來的人就沒有餐桌可以喫飯了(程序崩潰)。

對於大多數學校和公司食堂,都是使用者喫完飯釋放餐桌(收拾餐盤放回回收區),和 C/C++ 等底層語言類似,使用者申請內存空間,使用完畢再釋放內存。

如果我們去外邊餐館喫飯,也是同樣的流程。只不過不需要自己找餐桌,由引導服務員給分配,使用後,不需要關心留在餐桌上的餐盤,由回收餐盤服務員去回收。對於 JS 來說,垃圾回收器(Garbage Collector)就在做類似於餐盤服務員垃圾回收的工作:將不再使用的內存進行釋放回收,從而能夠循環利用有限的內存空間。

function grow() {

   var x = []

   let str = new Array(100000).join('x');
   // 1億個
   for (let i=0; i<100000000; i++) {
      x.push(str)
   }
}

document.getElementById('grow').addEventListener('click', grow);

以上面這段代碼爲例,點擊 grow 按鈕後,會向數組 x 中存入大量的(一億個)字符串,然後這個 tab 就崩潰了。看到下圖,大概率是內存超過了瀏覽器單 tab 的內存上限。以 chrome 爲例,其單 tab 內存上限在 32 位系統上爲 512M,64 位系統上爲 1.4GB 左右。

變量存儲方式

JS 中變量分爲原始類型和引用類型,不同的變量類型存儲方式不同。我們先回顧一下 JS 是如何存儲變量的。原始類型直接存儲在棧(Stack)中,引用類型存儲在堆(Heap)中。

var a = 1;

function doSomething() {
    let b = 2;
    let obj = { c: 3}
    console.log(a, b);
}

doSomething();

以上面的一段代碼爲例,全局執行上下文中存在一個值類型變量 a,doSomething 函數執行上下文中存在一個值類型變量 b,一個引用類型變量 obj。從下面的內存分配圖可以看到,值類型直接存儲在棧中,引用類型存儲在堆中。

棧內存垃圾回收

棧內存回收相對來說很簡單,函數執行完畢後,該函數執行上下文從棧中彈出,存儲在執行上下文中的變量立即被回收掉。還是以上面的一段代碼爲例,當 doSomething 執行完畢後,內存結構如下圖:

doSomething 執行上下文被彈出,該執行上下文中所有變量都被銷燬回收。對於值類型 b 來說,就直接釋放了其佔用的內存,對於引用類型 obj 來說,銷燬的只是變量 obj 對堆內存地址 1001 的引用,obj 的值 {c: 3} 依然存在於堆內存中。那麼堆內存中的變量如何進行回收呢?

堆內存垃圾回收

代際假說(Generational Hypothesis)

代際假說認爲,大部分新對象的生存時間比較短,在一次垃圾回收週期內被回收。

基於此,V8 將堆內存分爲新生代和老生代。新生代又將內存分爲 Nursery 和 Intermediate 兩個區域。新對象存放到 Nursery 區域中,經過一次垃圾回收,存活的對象被複制到 Intermediate 區域。經過兩次垃圾回收仍然存活的對象將被移動到老生代中。有點像我們上學的過程,從幼兒園到小學到中學。

主垃圾回收器(Major GC)

垃圾回收器有一些基本的任務:識別活動對象(marking)、回收或重用垃圾對象內存(sweeping)、整理碎片內存(defragment)。

標記階段(Marking)

標記階段通過變量是否可達(reachability),判斷是否爲活動對象。通常爲從一個根對象進行遞歸遍歷,所有遍歷到的對象都是可達的,爲活動對象。沒有遍歷到的對象爲非活動對象,需要進行回收。

var obj1 = { a: 1};
var obj2 = = { b: 2};

執行如下代碼後 obj2 失去對 1002 的引用,在垃圾回收器遍歷完之後發現沒有對 1002 這塊內存的引用變量,標記爲其非活動變量。

obj2 = null;

清除階段(Sweeping)

GC 會維護一個 freeList 列表,將非活動對象佔用的內存片段地址添加到 freeList。有新對象申請內存時,freeList 裏有合適大小的內存塊,會優先分配給新對象。

整理階段(Defragmenting)

這個階段是可選的。內存在經過垃圾回收之後,活動對象將內存塊分割的很零碎,這個時候會進行整理,將活動對象複製到相同連續的內存區域內。

副垃圾回收器(Minor GC)

副垃圾回收器負責新生代垃圾回收。主要有四個步驟:標記、複製、更新指針、切換角色。新生代將內存分爲 from space(Nursery) 和 to space (Intermediate)。當有新對象申請內存,會分配 from space 區域中的地址,to space 區域爲備用區域。

標記階段同主垃圾回收器,將可達對象標記爲活動對象。

複製階段將 from space 中標記的活動對象複製到 to space 區域,並給活動對象做標記,此時其已經位於 intermediate 中,下一次垃圾回收時如果仍爲活動對象,就要被複制到老生代中。

將活動對象複製到 to space 中之後,需要更新指針引用地址,這樣原引用才能保證正確的指向。

最後切換 from space 和 to space 的角色。在下一次垃圾回收週期後,存活兩次的對象會被複制到老生代區域。

GC 執行時機

在最初,GC 運行在主線程,與 JS 交替執行。在 GC 執行階段,主線程停止 JS 代碼執行,這稱爲全停頓(Stop-the-World)。如果垃圾回收器需要處理(標記 - 複製 - 整理)的對象比較多,就需要比較長的時間才能完成一次週期內的任務。在這期間如果有更高優的任務需要執行,是無法及時響應的,比如用戶輸入、動畫的執行,給用戶的感覺就是卡頓。

提高 GC 執行效率

Goal: Free Main Thread

Orinoco 是 Google 垃圾回收器(Garbage Collector)的項目代號,致力於研究如何提高垃圾回收效率。經過多年的發展,產出了三種能有效提高垃圾回收效率的方案:並行(Parallel)、增量標記(Incremental)、併發(Concurrent)。

並行(Parallel)

在主線程執行垃圾回收任務的同時,開幾個輔助線程同時進行,這樣可以大大減少主線程全停頓(Stop the World)的時間。

增量(increment)

將主線程垃圾回收任務分成多個小任務,與 JS 交替執行。這種方式並沒有縮短 GC 工作的時間,但是給了 JS 響應高優任務的時間,避免了出現卡頓。

併發(concurrent)

併發是主線程專注執行 JS, 開啓輔助線程進行垃圾回收。這種方式沒有了全停頓,完全解放主線程,實現了 Free Main Thread 的目標。

幾個典型場景

通過了解 V8 垃圾回收機制,我們知道垃圾回收器會和 JS 線程爭奪資源和時間。V8 也在不斷通過更先進的技術來減少全停頓(Stop the World)的時間。對於我們開發者來說,能做的就是儘量減少 GC 的工作負擔。總結來說就是,變量不用之後立即釋放。下面我們總結了幾種容易造成內存泄漏的 bad case,大家在工作中可以規避。

減少全局變量

下面這段代碼,函數作用域中變量未使用關鍵字聲明,導致非嚴格模式下掛載到全局作用域。這樣 foo() 函數執行完畢之後,由於 window.bar 的引用一直存在,導致被 GC 識別爲活動對象。這樣只要程序在運行,該對象的內存就會一直存在無法被回收,增加垃圾回收器的工作負擔。

// 非嚴格模式下,bar會被掛在全局上
function foo(arg) {
    bar = { a: 1 };
    this.obj = { b: 1};
    console.log(bar, obj);
}

foo();

對於這種情況建議開啓嚴格模式,或者使用 lint 工具檢查這種錯誤。

及時清理對 DOM 的引用

有了 React 和 Vue 這種 UI 庫,我們就很少直接操作 DOM 了。在我們業務中,需要對富文本內的一些內容進行操作中,有很多直接操作 DOM 的場景。在操作完 DOM 之後,需及時清掉對 DOM 節點的引用,不然也會造成對內存的泄露。

<body>
  <input type="text" id="input">
  <div id="node"></div>

  <script>
    let node = document.getElementById('node');
    node.parentNode.removeChild(node); 
    console.log('node', node) // 對node節點操作完成之後,內存中仍然保存着node節點
    node = null ; // 通過將 node 賦值爲null,切掉對 DOM 節點的引用
  </script>
</body>

事件監聽 & 計時器

在我們業務中經常需要在組件掛載後給元素添加事件監聽。這時需要在組件卸載時將監聽事件移除,來避免無用的內存消耗。

componentDidMount() {
    this.myScaleBar?.addEventListener('mousedown', this.handleMouseDown);
    document.addEventListener('mousemove', this.handleMouseMove);
    document.addEventListener('mouseup', this.handleMouseUp);
}

componentDidMount() {
    this.myScaleBar?.addEventListener('mousedown', this.handleMouseDown);
    document.addEventListener('mousemove', this.handleMouseMove);
    document.addEventListener('mouseup', this.handleMouseUp);
}

如何查看是否存在內存泄漏

chrome devtools 中的 performance 面板可以記錄內存使用的 timeLine, 在錄製之前選中內存,報告中會有內存的使用情況。我們主要關注 JS 堆中內存的使用情況。

我們以下面這段代碼爲例,通過點擊 grow 按鈕,會向 grow 函數內的變量 x 內添加大量的長度爲 100000 的字符串。

<!DOCTYPE html>
<html lang="en">
<head>
   <title>內存測試</title>
</head>
<body>
   <div>
      <button id="grow">grow</button>
   </div>

   <script>
   
      function grow() {
        var x = [];

        const str = new Array(100000).join('x');

        for (let i=0; i<100000000; i++) {
          x.push(str)
        }
      }
      
      document.getElementById('grow').addEventListener('click', grow);
   </script>
</body>
</html>

記錄開始後先點擊【強制垃圾回收】,然後點擊 grow,記錄一段時間後再點擊【強制垃圾回收】後查看報告。可以看到第二次垃圾回收與操作之前的內存相等,說明沒有垃圾泄漏。

我們再稍微改一下代碼,看一下內存的使用情況。

function grow() {
  x = [];

  let str = new Array(100000).join('x');

  for (let i=0; i<100000000; i++) {
    x.push(str)
  }
}

記錄發現強制垃圾回收之後,內存的佔用要高於 grow 函數執行之前。與上面第一次記錄的區別是,grow 內變量 x 的聲明沒有使用關鍵字聲明,非嚴格模式下直接掛載到 window 上。這樣 grow 函數執行完畢,全局對 x 依然 的引用,GC 無法回收 x 佔用的內存。

總結

V8 垃圾回收器幫助 JS 使用者週期性的回收不再使用的內存。過多的對象會對垃圾回收器造成額外的負擔,甚至影響到主線程 JS 的執行,造成頁面的卡頓。作爲開發者應該有意識的減少全局變量的數量、及時移除不再使用 DOM 引用、事件監聽及計時器,來減少垃圾回收器的負擔。

參考資料

[1]

Trash talk: the Orinoco garbage collector · V8: https://v8.dev/blog/trash-talk

[2]

代際假說: https://www.memorymanagement.org/glossary/g.html#term-generational-hypothesis

[3]

代際垃圾回收器: https://www.memorymanagement.org/glossary/g.html#term-generational-garbage-collection

❤️ 謝謝支持

以上便是本次分享的全部內容,希望對你有所幫助 ^_^

歡迎關注公衆號 ELab 團隊 收貨大廠一手好文章~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wT8_cz82Y5Ur74xGKpPgtQ