JS專題之垃圾回收

前言

在講 JS 的垃圾回收(Garbage Collection)以前,咱們回顧上一篇《JS專題之memoization》,memoization 的原理是以參數做爲 key,函數結果做爲 value, 用對象進行緩存起來,之內存空間換 CPU 執行事件。memoization 的潛在陷阱便是嚴格意義的緩存有着完善的過時策略,而普通對象的鍵值對並無。javascript

用閉包進行緩存的對象的內存空間,不會在函數執行完後被清除,在執行量大和參數多樣性的狀況下,會形成內存佔用且得不到釋放。vue

因而,本篇文章就來說講 JS 的垃圾回收。java

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

找出那些再也不繼續使用的變量,而後釋放其佔用的內存,垃圾收集器會按照固定的時間間隔週期性地執行這一操做。redis

那咱們怎麼知道變量是否是在繼續使用呢?算法

首先,我以前的文章,《JavaScript之變量及做用域》,《JavaScript之做用域鏈》和《JavaScript之閉包》都有提到過,局部變量的生存週期是在函數聲明和執行階段,函數執行完畢後,局部變量就沒有存在的必要了。全局變量會在瀏覽器關閉或進程關閉才能釋放。編程

但還有一些場景,好比閉包,經過做用域鏈訪問到函數外部的自由變量,使得自由變量保存在內存中,不會隨着函數執行完畢而結束,以及對象的相互引用等,垃圾收集器就沒這麼容易判斷哪一個變量有用,哪一個變量沒用了。數組

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

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

因此,對於標識無用的變量的策略可能會實現不一樣,但目前在瀏覽器中,一般有兩種策略:標記清除和引用計數。瀏覽器

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

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

當變量進入執行環境時,就標記這個變量爲「進入環境」。當變量離開環境時,則將其標記爲「離開環境」。從邏輯上講,永遠不能釋放進入環境的變量所佔用的內存,由於只要執行流進入相應的環境,就可能會用到他們。

垃圾收集器在運行的時候會給存儲在內存中的全部變量都加上標記。

而後,它會去掉環境中的變量以及被環境中的變量引用的標記。而在此以後再被加上標記的變量將被視爲準備刪除的變量,緣由是環境中的變量已經沒法訪問到這些變量了。

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

另外,標記-清除有一個問題,就是在清除以後,內存空間是不連續的,即出現了內存碎片。若是後面須要一個比較大的連續的內存空間時,那將不能知足要求。而標記-整理(Mark-Compact)方法能夠有效地解決這個問題。標記階段沒有什麼不一樣,只是標記結束後,標記-整理方法會將活着的對象向內存的一端移動,最後清理掉邊界的內存。

3、引用計數

另一種不太常見的垃圾收集策略叫引用計數(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。假如這個函數被屢次執行,就會致使大量的內存得不到釋放。

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

在 Node 中,經過 JS 使用內存時就會發現只能使用部份內存(64 位系統下約爲 1.4 GB, 32 位系統下約爲 0.7 GB),這致使 Node 沒法直接操做大內存對象。

這是由於,以 1.5GB 的垃圾回收堆內存爲例,V8 作一次小的垃圾回收須要 50 毫秒以上,作一次非增量式的垃圾回收要 1 秒以上,而垃圾回收過程會引發 JS 線程暫停執行這麼多時間。所以,在當時的考慮下,直接限制堆內存是一個好的選擇。

那麼,在這樣的內存限制下,V8 的垃圾回收機制又有什麼特色?

4.一、內存分代算法

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

V8 堆的總體大小等於新生代所用內存空間加上老生代的內存空間,而只能在啓動時指定,意味着運行時沒法自動擴充,若是超過了極限值,就會引發進程出錯。

4.2 Scavenge 算法

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

Cheney 算法將堆內存一分爲二,一個處於使用狀態的空間叫 From 空間,一個處於閒置狀態的空間稱爲 To 空間。分配對象時,先是在 From 空間中進行分配。

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

當一個對象通過屢次複製後依然存活,他將會被認爲是生命週期較長的對象,隨後會被移動到老生代中,採用新的算法進行管理。

還有一種狀況是,若是複製一個對象到 To 空間時,To 空間佔用超過了 25%,則這個對象會被直接晉升到老生代空間中。

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

對於老生代中的對象,主要採用標記-清除和標記-整理算法。標記-清除 和前文提到的標記同樣,與 Scavenge 算法相比,標記清除不會將內存空間劃爲兩半,標記清除在標記階段會標記活着的對象,而在內存回收階段,它會清除沒有被標記的對象。

而標記整理是爲了解決標記清除後留下的內存碎片問題。

4.4 增量標記(Incremental Marking)算法

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

在 V8 新生代的分代回收中,只收集新生代,而新生代一般配置較小,且存活對象較少,因此全停頓的影響不大,而老生代就相反了。

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

通過增量標記改進後,垃圾回收的最大停頓時間能夠減小到原來的 1/6 左右。

5、內存泄漏

內存泄漏(Memory Leak)是指程序中己動態分配的堆內存因爲某種緣由程序未釋放或沒法釋放,形成系統內存的浪費,致使程序運行速度減慢甚至系統崩潰等嚴重後果。

6、內存泄漏的常見場景

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, 在頁面生命週期初始化時,綁定了事件監聽器,但在離開頁面後,未清除事件監聽器,一樣也會致使內存泄漏。

7、內存泄漏優化

  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

歡迎關注個人我的公衆號「謝南波」,專一分享原創文章。

掘金專欄 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
相關文章
相關標籤/搜索