深入理解 JS 垃圾回收
前言
JS之memoization,memoization 的原理是以參數(shù)作為 key,函數(shù)結(jié)果作為 value, 用對象進行緩存起來,以內(nèi)存空間換 CPU 執(zhí)行事件。memoization 的潛在陷阱即是嚴格意義的緩存有著完善的過期策略,而普通對象的鍵值對并沒有。
用閉包進行緩存的對象的內(nèi)存空間,不會在函數(shù)執(zhí)行完后被清除,在執(zhí)行量大和參數(shù)多樣性的情況下,會造成內(nèi)存占用且得不到釋放。
于是,本篇文章就來講講 JS 的垃圾回收。
JS 的垃圾回收機制的基本原理是:
找出那些不再繼續(xù)使用的變量,然后釋放其占用的內(nèi)存,垃圾收集器會按照固定的時間間隔周期性地執(zhí)行這一操作。
那我們怎么知道變量是不是在繼續(xù)使用呢?
首先,局部變量的生存周期是在函數(shù)聲明和執(zhí)行階段,函數(shù)執(zhí)行完畢后,局部變量就沒有存在的必要了。全局變量會在瀏覽器關(guān)閉或進程關(guān)閉才能釋放。
但還有一些場景,比如閉包,通過作用域鏈訪問到函數(shù)外部的自由變量,使得自由變量保存在內(nèi)存中,不會隨著函數(shù)執(zhí)行完畢而結(jié)束,以及對象的相互引用等,垃圾收集器就沒這么容易判斷哪個變量有用,哪個變量沒用了。
// 經(jīng)典閉包 function closure() { var name = "innerName"; return function() { console.log(name); } } var inner = closure(); inner(); // innerName;
所以,對于標識無用的變量的策略可能會實現(xiàn)不同,但目前在瀏覽器中,通常有兩種策略:標記清除和引用計數(shù)。
二、標記-清除(Mark-Sweep)
從2012年起,所有現(xiàn)代瀏覽器都使用了標記-清除垃圾回收算法, 那什么叫標記-清除呢?
當變量進入執(zhí)行環(huán)境時,就標記這個變量為“進入環(huán)境”。當變量離開環(huán)境時,則將其標記為“離開環(huán)境”。從邏輯上講,永遠不能釋放進入環(huán)境的變量所占用的內(nèi)存,因為只要執(zhí)行流進入相應(yīng)的環(huán)境,就可能會用到他們。
垃圾收集器在運行的時候會給存儲在內(nèi)存中的所有變量都加上標記。
然后,它會去掉環(huán)境中的變量以及被環(huán)境中的變量引用的標記。而在此之后再被加上標記的變量將被視為準備刪除的變量,原因是環(huán)境中的變量已經(jīng)無法訪問到這些變量了。
最后,垃圾收集器完成內(nèi)存清除工作,銷毀那些帶標記的值,并回收他們所占用的內(nèi)存空間。
另外,標記-清除有一個問題,就是在清除之后,內(nèi)存空間是不連續(xù)的,即出現(xiàn)了內(nèi)存碎片。如果后面需要一個比較大的連續(xù)的內(nèi)存空間時,那將不能滿足要求。而標記-整理(Mark-Compact)方法可以有效地解決這個問題。標記階段沒有什么不同,只是標記結(jié)束后,標記-整理方法會將活著的對象向內(nèi)存的一端移動,最后清理掉邊界的內(nèi)存。
三、引用計數(shù)
另外一種不太常見的垃圾收集策略叫引用計數(shù)(Reference Counting),此算法把“對象是否不再需要”簡化定義為“對象有沒有其他對象引用到它”。如果沒有引用指向該對象(零引用),對象將被垃圾回收機制回收。
引用計數(shù)的策略是跟蹤記錄每個值被使用的次數(shù),當聲明了一個變量并將一個引用類型賦值給該變量的時候這個值的引用次數(shù)就加 1,如果該變量的值變成了另外一個,則這個值得引用次數(shù)減 1,當這個值的引用次數(shù)變?yōu)?0 的時候,說明沒有變量在使用,這個值沒法被訪問了,因此可以將其占用的空間回收,這樣垃圾回收器會在運行的時候清理掉引用次數(shù)為 0 的值占用的內(nèi)存。
而引用計數(shù)的不繼續(xù)被使用,是因為循環(huán)引用的問題會引發(fā)內(nèi)存泄漏。
function problem() { var objA = new Object(); var objB = new Object(); objA.someObject = objB; objB.anotherObject = objA; }
objA 和 objB 通過各自的屬性相互引用,也就是說,兩個對象的引用次數(shù)都是 2。在函數(shù)執(zhí)行完畢后,objA, objB 還將繼續(xù)存在,因為他們的引用計數(shù)永遠不會是 0。假如這個函數(shù)被多次執(zhí)行,就會導(dǎo)致大量的內(nèi)存得不到釋放。
四、NodeJs V8 中的垃圾回收機制
在 Node 中,通過 JS 使用內(nèi)存時就會發(fā)現(xiàn)只能使用部分內(nèi)存(64 位系統(tǒng)下約為 1.4 GB, 32 位系統(tǒng)下約為 0.7 GB),這導(dǎo)致 Node 無法直接操作大內(nèi)存對象。
這是因為,以 1.5GB 的垃圾回收堆內(nèi)存為例,V8 做一次小的垃圾回收需要 50 毫秒以上,做一次非增量式的垃圾回收要 1 秒以上,而垃圾回收過程會引起 JS 線程暫停執(zhí)行這么多時間。因此,在當時的考慮下,直接限制堆內(nèi)存是一個好的選擇。
那么,在這樣的內(nèi)存限制下,V8 的垃圾回收機制又有什么特點?
4.1、內(nèi)存分代算法
V8 的垃圾回收策略主要基于分代式垃圾回收機制,在 V8 中,將內(nèi)存分為新生代和老生代,新生代的對象為存活時間較短的對象,老生代的對象為存活事件較長或常駐內(nèi)存的對象。
V8 堆的整體大小等于新生代所用內(nèi)存空間加上老生代的內(nèi)存空間,而只能在啟動時指定,意味著運行時無法自動擴充,如果超過了極限值,就會引起進程出錯。
4.2 Scavenge 算法
在分代的基礎(chǔ)上,新生代的對象主要通過 Scavenge 算法進行垃圾回收,在 Scavenge 具體實現(xiàn)中,主要采用了一種復(fù)制的方式的方法—— Cheney 算法。
Cheney 算法將堆內(nèi)存一分為二,一個處于使用狀態(tài)的空間叫 From 空間,一個處于閑置狀態(tài)的空間稱為 To 空間。分配對象時,先是在 From 空間中進行分配。
當開始進行垃圾回收時,會檢查 From 空間中的存活對象,將其復(fù)制到 To 空間中,而非存活對象占用的空間將會被釋放。完成復(fù)制后,F(xiàn)rom 空間和 To 空間的角色發(fā)生對換。
當一個對象經(jīng)過多次復(fù)制后依然存活,他將會被認為是生命周期較長的對象,隨后會被移動到老生代中,采用新的算法進行管理。
還有一種情況是,如果復(fù)制一個對象到 To 空間時,To 空間占用超過了 25%,則這個對象會被直接晉升到老生代空間中。
4.3 標記-清除和標記-整理算法
對于老生代中的對象,主要采用標記-清除和標記-整理算法。標記-清除 和前文提到的標記一樣,與 Scavenge 算法相比,標記清除不會將內(nèi)存空間劃為兩半,標記清除在標記階段會標記活著的對象,而在內(nèi)存回收階段,它會清除沒有被標記的對象。
而標記整理是為了解決標記清除后留下的內(nèi)存碎片問題。
4.4 增量標記(Incremental Marking)算法
前面的三種算法,都需要將正在執(zhí)行的 JavaScript 應(yīng)用邏輯暫停下來,待垃圾回收完畢后再恢復(fù)。這種行為叫作“全停頓”(stop-the-world)。
在 V8 新生代的分代回收中,只收集新生代,而新生代通常配置較小,且存活對象較少,所以全停頓的影響不大,而老生代就相反了。
為了降低全部老生代全堆垃圾回收帶來的停頓時間,V8將標記過程分為一個個的子標記過程,同時讓垃圾回收標記和JS應(yīng)用邏輯交替進行,直到標記階段完成。
經(jīng)過增量標記改進后,垃圾回收的最大停頓時間可以減少到原來的 1/6 左右。
五、內(nèi)存泄漏
內(nèi)存泄漏(Memory Leak)是指程序中己動態(tài)分配的堆內(nèi)存由于某種原因程序未釋放或無法釋放,造成系統(tǒng)內(nèi)存的浪費,導(dǎo)致程序運行速度減慢甚至系統(tǒng)崩潰等嚴重后果。
六、內(nèi)存泄漏的常見場景
6.1 緩存
文章前言部分就有說到,JS 開發(fā)者喜歡用對象的鍵值對來緩存函數(shù)的計算結(jié)果,但是緩存中存儲的鍵越多,長期存活的對象也就越多,這將導(dǎo)致垃圾回收在進行掃描和整理時,對這些對象做無用功。
6.2 作用域未釋放(閉包)
var leakArray = []; exports.leak = function () { leakArray.push("leak" + Math.random()); }
以上代碼,模塊在編譯執(zhí)行后形成的作用域因為模塊緩存的原因,不被釋放,每次調(diào)用 leak 方法,都會導(dǎo)致局部變量 leakArray 不停增加且不被釋放。
閉包可以維持函數(shù)內(nèi)部變量駐留內(nèi)存,使其得不到釋放。
6.3 沒必要的全局變量
聲明過多的全局變量,會導(dǎo)致變量常駐內(nèi)存,要直到進程結(jié)束才能夠釋放內(nèi)存。
6.4 無效的 DOM 引用
//dom still exist function click(){ // 但是 button 變量的引用仍然在內(nèi)存當中。 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 的頁面生命周期初始化時,定義了定時器,但是在離開頁面后,未清除定時器,就會導(dǎo)致內(nèi)存泄漏。
6.6 事件監(jiān)聽為清空
componentDidMount() { window.addEventListener("scroll", function () { // do something... }); }
同 6.5, 在頁面生命周期初始化時,綁定了事件監(jiān)聽器,但在離開頁面后,未清除事件監(jiān)聽器,同樣也會導(dǎo)致內(nèi)存泄漏。
七、內(nèi)存泄漏優(yōu)化
1.解除引用
確保占用最少的內(nèi)存可以讓頁面獲得更好的性能。而優(yōu)化內(nèi)存占用的最佳方式,就是為執(zhí)行中的代碼只保存必要的數(shù)據(jù)。一旦數(shù)據(jù)不再有用,最好通過將其值設(shè)置為 null 來釋放其引用——這個做法叫做解除引用(dereferencing)
function createPerson(name){ var localPerson = new Object(); localPerson.name = name; return localPerson; } var globalPerson = createPerson("Nicholas"); // 手動解除 globalPerson 的引用 globalPerson = null;
解除一個值的引用并不意味著自動回收該值所占用的內(nèi)存。解除引用的真正作用是讓值脫離執(zhí)行環(huán)境,以便垃圾收集器下次運行時將其回收。
2.提供手動清空變量的方法
var leakArray = []; exports.clear = function () { leakArray = []; }
3.在業(yè)務(wù)不需要用到的內(nèi)部函數(shù),可以重構(gòu)在函數(shù)外,實現(xiàn)解除閉包
4.避免創(chuàng)建過多生命周期較長的對象,或?qū)ο蠓纸獬啥鄠€子對象
5.避免過多使用閉包
6.注意清除定時器和事件監(jiān)聽器
7.Nodejs 中使用 stream 或 buffer 來操作大文件,不會受 Nodejs 內(nèi)存限制
8.使用 redis 等外部工具緩存數(shù)據(jù)
總結(jié)
JS 是一門具有自動垃圾收集的編程語言,在瀏覽器中主要通過標記清除方法來回收垃圾,NodeJs 中主要通過分代回收、Scavenge、標記清除、增量標記等算法來回收垃圾。在日常開發(fā)中,有一些不引人注意的書寫方式可能會導(dǎo)致內(nèi)存泄漏,需要多注意自己的代碼規(guī)范。
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
parentElement,srcElement的使用小結(jié)
本篇文章主要是對parentElement,srcElement的使用進行了詳細的介紹,需要的朋友可以過來參考下,希望對大家有所幫助2014-01-01在javaScript中關(guān)于submit和button的區(qū)別介紹
submit是button的一個特例,也是button的一種,它把提交這個動作自動集成了,submit和button,二者都以按鈕的形式展現(xiàn),看起來都是按鈕,所不同的是type屬性和處發(fā)響應(yīng)的事件上2013-10-10javascript函數(shù)命名的三種方式及區(qū)別介紹
下面小編就為大家?guī)硪黄猨avascript函數(shù)命名的三種方式及區(qū)別介紹。小編覺得挺不錯的,現(xiàn)在分享給大家,給大家做個參考。一起跟隨小編過來看看吧2016-03-03js限制文本框輸入長度兩種限制方式(長度、字節(jié)數(shù))
在實際應(yīng)用中根據(jù)需要會用到文本框限制字符長度,以些新手朋友有們可能還不清楚如何應(yīng)付,本人搜集整理了一些常用技巧,曬出來和大家分享一下,希望可以幫助你們2012-12-12JavaScript展開操作符(Spread operator)詳解
在本篇文章里小編給大家整理的是關(guān)于JavaScript展開操作符(Spread operator)的詳細介紹以及用法,需要的讀者們參考下。2019-07-07