JS專題之垃圾回收

amadan發表於2021-09-09

前言

在講 JS 的垃圾回收(Garbage Collection)之前,我們回顧上一篇《JS專題之memoization》,memoization 的原理是以引數作為 key,函式結果作為 value, 用物件進行快取起來,以記憶體空間換 CPU 執行事件。memoization 的潛在陷阱即是嚴格意義的快取有著完善的過期策略,而普通物件的鍵值對並沒有。

用閉包進行快取的物件的記憶體空間,不會在函式執行完後被清除,在執行量大和引數多樣性的情況下,會造成記憶體佔用且得不到釋放。

於是,本篇文章就來講講 JS 的垃圾回收。

JS 的垃圾回收機制的基本原理是:

找出那些不再繼續使用的變數,然後釋放其佔用的記憶體,垃圾收集器會按照固定的時間間隔週期性地執行這一操作。

那我們怎麼知道變數是不是在繼續使用呢?

首先,我之前的文章,《JavaScript之變數及作用域》,《JavaScript之作用域鏈》和《JavaScript之閉包》都有提到過,區域性變數的生存週期是在函式宣告和執行階段,函式執行完畢後,區域性變數就沒有存在的必要了。全域性變數會在瀏覽器關閉或程式關閉才能釋放。

但還有一些場景,比如閉包,通過作用域鏈訪問到函式外部的自由變數,使得自由變數儲存在記憶體中,不會隨著函式執行完畢而結束,以及物件的相互引用等,垃圾收集器就沒這麼容易判斷哪個變數有用,哪個變數沒用了。

// 經典閉包
function closure() {
    var name = "innerName";
    return function() {
        console.log(name);
    }
}

var inner = closure();
inner();  // innerName;
複製程式碼

所以,對於標識無用的變數的策略可能會實現不同,但目前在瀏覽器中,通常有兩種策略:標記清除和引用計數。

二、標記-清除(Mark-Sweep)

從2012年起,所有現代瀏覽器都使用了標記-清除垃圾回收演算法, 那什麼叫標記-清除呢?

當變數進入執行環境時,就標記這個變數為“進入環境”。當變數離開環境時,則將其標記為“離開環境”。從邏輯上講,永遠不能釋放進入環境的變數所佔用的記憶體,因為只要執行流進入相應的環境,就可能會用到他們。

垃圾收集器在執行的時候會給儲存在記憶體中的所有變數都加上標記。

然後,它會去掉環境中的變數以及被環境中的變數引用的標記。而在此之後再被加上標記的變數將被視為準備刪除的變數,原因是環境中的變數已經無法訪問到這些變數了。

最後,垃圾收集器完成記憶體清除工作,銷燬那些帶標記的值,並回收他們所佔用的記憶體空間。

另外,標記-清除有一個問題,就是在清除之後,記憶體空間是不連續的,即出現了記憶體碎片。如果後面需要一個比較大的連續的記憶體空間時,那將不能滿足要求。而標記-整理(Mark-Compact)方法可以有效地解決這個問題。標記階段沒有什麼不同,只是標記結束後,標記-整理方法會將活著的物件向記憶體的一端移動,最後清理掉邊界的記憶體。

三、引用計數

另外一種不太常見的垃圾收集策略叫引用計數(Reference Counting),此演算法把“物件是否不再需要”簡化定義為“物件有沒有其他物件引用到它”。如果沒有引用指向該物件(零引用),物件將被垃圾回收機制回收。

引用計數的策略是跟蹤記錄每個值被使用的次數,當宣告瞭一個變數並將一個引用型別賦值給該變數的時候這個值的引用次數就加 1,如果該變數的值變成了另外一個,則這個值得引用次數減 1,當這個值的引用次數變為 0 的時候,說明沒有變數在使用,這個值沒法被訪問了,因此可以將其佔用的空間回收,這樣垃圾回收器會在執行的時候清理掉引用次數為 0 的值佔用的記憶體。

而引用計數的不繼續被使用,是因為迴圈引用的問題會引發記憶體洩漏。

function problem() {
    var objA = new Object();
    var objB = new Object();
    objA.someObject = objB;
    objB.anotherObject = objA;
}
複製程式碼

objA 和 objB 通過各自的屬性相互引用,也就是說,兩個物件的引用次數都是 2。在函式執行完畢後,objA, objB 還將繼續存在,因為他們的引用計數永遠不會是 0。假如這個函式被多次執行,就會導致大量的記憶體得不到釋放。

四、NodeJs V8 中的垃圾回收機制

在 Node 中,通過 JS 使用記憶體時就會發現只能使用部分記憶體(64 位系統下約為 1.4 GB, 32 位系統下約為 0.7 GB),這導致 Node 無法直接操作大記憶體物件。

這是因為,以 1.5GB 的垃圾回收堆記憶體為例,V8 做一次小的垃圾回收需要 50 毫秒以上,做一次非增量式的垃圾回收要 1 秒以上,而垃圾回收過程會引起 JS 執行緒暫停執行這麼多時間。因此,在當時的考慮下,直接限制堆記憶體是一個好的選擇。

那麼,在這樣的記憶體限制下,V8 的垃圾回收機制又有什麼特點?

4.1、記憶體分代演算法

V8 的垃圾回收策略主要基於分代式垃圾回收機制,在 V8 中,將記憶體分為新生代和老生代,新生代的物件為存活時間較短的物件,老生代的物件為存活事件較長或常駐記憶體的物件。

JS專題之垃圾回收

V8 堆的整體大小等於新生代所用記憶體空間加上老生代的記憶體空間,而只能在啟動時指定,意味著執行時無法自動擴充,如果超過了極限值,就會引起程式出錯。

4.2 Scavenge 演算法

在分代的基礎上,新生代的物件主要通過 Scavenge 演算法進行垃圾回收,在 Scavenge 具體實現中,主要採用了一種複製的方式的方法—— Cheney 演算法。

Cheney 演算法將堆記憶體一分為二,一個處於使用狀態的空間叫 From 空間,一個處於閒置狀態的空間稱為 To 空間。分配物件時,先是在 From 空間中進行分配。

當開始進行垃圾回收時,會檢查 From 空間中的存活物件,將其複製到 To 空間中,而非存活物件佔用的空間將會被釋放。完成複製後,From 空間和 To 空間的角色發生對換。

JS專題之垃圾回收

當一個物件經過多次複製後依然存活,他將會被認為是生命週期較長的物件,隨後會被移動到老生代中,採用新的演算法進行管理。

還有一種情況是,如果複製一個物件到 To 空間時,To 空間佔用超過了 25%,則這個物件會被直接晉升到老生代空間中。

4.3 標記-清除和標記-整理演算法

對於老生代中的物件,主要採用標記-清除和標記-整理演算法。標記-清除 和前文提到的標記一樣,與 Scavenge 演算法相比,標記清除不會將記憶體空間劃為兩半,標記清除在標記階段會標記活著的物件,而在記憶體回收階段,它會清除沒有被標記的物件。

而標記整理是為了解決標記清除後留下的記憶體碎片問題。

4.4 增量標記(Incremental Marking)演算法

前面的三種演算法,都需要將正在執行的 JavaScript 應用邏輯暫停下來,待垃圾回收完畢後再恢復。這種行為叫作“全停頓”(stop-the-world)。

在 V8 新生代的分代回收中,只收集新生代,而新生代通常配置較小,且存活物件較少,所以全停頓的影響不大,而老生代就相反了。

為了降低全部老生代全堆垃圾回收帶來的停頓時間,V8將標記過程分為一個個的子標記過程,同時讓垃圾回收標記和JS應用邏輯交替進行,直到標記階段完成。

JS專題之垃圾回收

經過增量標記改進後,垃圾回收的最大停頓時間可以減少到原來的 1/6 左右。

五、記憶體洩漏

記憶體洩漏(Memory Leak)是指程式中己動態分配的堆記憶體由於某種原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統崩潰等嚴重後果。

六、記憶體洩漏的常見場景

6.1 快取

文章前言部分就有說到,JS 開發者喜歡用物件的鍵值對來快取函式的計算結果,但是快取中儲存的鍵越多,長期存活的物件也就越多,這將導致垃圾回收在進行掃描和整理時,對這些物件做無用功。

6.2 作用域未釋放(閉包)
var leakArray = [];
exports.leak = function () {
    leakArray.push("leak" + Math.random());
}
複製程式碼

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

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

6.3 沒必要的全域性變數

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

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

// 移除 button 元素
function removeBtn(){
    document.body.removeChild(document.getElementById('button'));
}
複製程式碼
6.5 定時器未清除

// vue 的 mounted 或 react 的 componentDidMount
componentDidMount() {
    setInterval(function () {
        // ...do something
    }, 1000)
}
複製程式碼

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

6.6 事件監聽為清空
componentDidMount() {
    window.addEventListener("scroll", function () {
        // do something...
    });
}
複製程式碼

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

七、記憶體洩漏優化

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

var globalPerson = createPerson("Nicholas");

// 手動解除 globalPerson 的引用
globalPerson = null;
複製程式碼

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

  1. 提供手動清空變數的方法
var leakArray = [];
exports.clear = function () {
    leakArray = [];
}
複製程式碼
  1. 在業務不需要用到的內部函式,可以重構在函式外,實現解除閉包
  2. 避免建立過多生命週期較長的物件,或將物件分解成多個子物件
  3. 避免過多使用閉包
  4. 注意清除定時器和事件監聽器
  5. Nodejs 中使用 stream 或 buffer 來操作大檔案,不會受 Nodejs 記憶體限制
  6. 使用 redis 等外部工具快取資料

總結

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

參考:
《深入淺出 NodeJs》
《JavaScript 高階程式設計》

2019/02/09 @Starbucks

歡迎關注我的個人公眾號“謝南波”,專注分享原創文章。

JS專題之垃圾回收

掘金專欄 JavaScript 系列文章

  1. JavaScript之變數及作用域
  2. JavaScript之宣告提升
  3. JavaScript之執行上下文
  4. JavaScript之變數物件
  5. JavaScript之原型與原型鏈
  6. JavaScript之作用域鏈
  7. JavaScript之閉包
  8. JavaScript之this
  9. JavaScript之arguments
  10. JavaScript之按值傳遞
  11. JavaScript之例題中徹底理解this
  12. JavaScript專題之模擬實現call和apply
  13. JavaScript專題之模擬實現bind
  14. JavaScript專題之模擬實現new
  15. JS專題之事件模型
  16. JS專題之事件迴圈
  17. JS專題之去抖函式
  18. JS專題之節流函式
  19. JS專題之函式柯里化
  20. JS專題之陣列去重
  21. JS專題之深淺拷貝
  22. JS專題之陣列展開
  23. JS專題之嚴格模式
  24. JS專題之memoization

相關文章