淺談V8垃圾回收機制
大廠技術(shù)??堅持周更??精選好文
對于C/C++等底層語言,內(nèi)存需要手動進行申請,使用完后手動進行釋放。而對于javascript語言使用者來說,因為有垃圾回收器的工作,在使用中通常不需要關(guān)心內(nèi)存的使用情況。但有時不當(dāng)?shù)拇a會意外的導(dǎo)致變量未被垃圾回收器回收,積少成多后造成內(nèi)存泄漏,潛在的提高應(yīng)用卡頓的風(fēng)險。本文從垃圾回收器的工作原理進行分析,總結(jié)可能造成內(nèi)存泄漏的幾個典型場景,避免工作中出現(xiàn)內(nèi)存泄漏造成應(yīng)用卡頓。
內(nèi)存生命周期
無論哪種編程語言,內(nèi)存的生命周期都是差不多的:申請內(nèi)存、使用內(nèi)存(讀寫)、釋放或歸還內(nèi)存。

為什么需要垃圾回收
顯而易見,用戶設(shè)備內(nèi)存是有限的,只申請不釋放,內(nèi)存被占滿時,就無法給新創(chuàng)建的對象分配內(nèi)存。這里類比我們?nèi)ス臼程贸燥埖膱鼍埃捍蝻埡笳业娇瘴唬ㄉ暾垉?nèi)存)、在空位吃飯(使用內(nèi)存)、吃完飯收拾餐盤放回回收區(qū)(釋放內(nèi)存)。想象一下,我們吃完不收拾餐盤(釋放內(nèi)存),后來的人就沒有餐桌可以吃飯了(程序崩潰)。
對于大多數(shù)學(xué)校和公司食堂,都是使用者吃完飯釋放餐桌(收拾餐盤放回回收區(qū)),和C/C++等底層語言類似,使用者申請內(nèi)存空間,使用完畢再釋放內(nèi)存。
如果我們?nèi)ネ膺叢宛^吃飯,也是同樣的流程。只不過不需要自己找餐桌,由引導(dǎo)服務(wù)員給分配,使用后,不需要關(guān)心留在餐桌上的餐盤,由回收餐盤服務(wù)員去回收。對于JS 來說,垃圾回收器(Garbage Collector)就在做類似于餐盤服務(wù)員垃圾回收的工作:將不再使用的內(nèi)存進行釋放回收,從而能夠循環(huán)利用有限的內(nèi)存空間。
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按鈕后,會向數(shù)組x中存入大量的(一億個)字符串,然后這個tab就崩潰了。看到下圖,大概率是內(nèi)存超過了瀏覽器單 tab的內(nèi)存上限。以chrome為例,其單tab內(nèi)存上限在32位系統(tǒng)上為 512M,64位系統(tǒng)上為1.4GB左右。

變量存儲方式
JS中變量分為原始類型和引用類型,不同的變量類型存儲方式不同。我們先回顧一下 JS 是如何存儲變量的。原始類型直接存儲在棧(Stack)中,引用類型存儲在堆(Heap)中。
var?a?=?1;
function?doSomething()?{
????let?b?=?2;
????let?obj?=?{?c:?3}
????console.log(a,?b);
}
doSomething();
以上面的一段代碼為例,全局執(zhí)行上下文中存在一個值類型變量 a,doSomething 函數(shù)執(zhí)行上下文中存在一個值類型變量b,一個引用類型變量obj。從下面的內(nèi)存分配圖可以看到,值類型直接存儲在棧中,引用類型存儲在堆中。

棧內(nèi)存垃圾回收
棧內(nèi)存回收相對來說很簡單,函數(shù)執(zhí)行完畢后,該函數(shù)執(zhí)行上下文從棧中彈出,存儲在執(zhí)行上下文中的變量立即被回收掉。還是以上面的一段代碼為例,當(dāng) doSomething 執(zhí)行完畢后,內(nèi)存結(jié)構(gòu)如下圖:

doSomething 執(zhí)行上下文被彈出,該執(zhí)行上下文中所有變量都被銷毀回收。對于值類型b來說,就直接釋放了其占用的內(nèi)存,對于引用類型obj來說,銷毀的只是變量obj對堆內(nèi)存地址 1001 的引用,obj的值 { c: 3 } 依然存在于堆內(nèi)存中。那么堆內(nèi)存中的變量如何進行回收呢?
堆內(nèi)存垃圾回收
代際假說(Generational Hypothesis)
代際假說認(rèn)為,大部分新對象的生存時間比較短,在一次垃圾回收周期內(nèi)被回收。
基于此,V8 將堆內(nèi)存分為新生代和老生代。新生代又將內(nèi)存分為 Nursery 和 Intermediate兩個區(qū)域。新對象存放到Nursery區(qū)域中,經(jīng)過一次垃圾回收,存活的對象被復(fù)制到 Intermediate 區(qū)域。經(jīng)過兩次垃圾回收仍然存活的對象將被移動到老生代中。有點像我們上學(xué)的過程,從幼兒園到小學(xué)到中學(xué)。

主垃圾回收器(Major GC)
垃圾回收器有一些基本的任務(wù):識別活動對象(marking)、回收或重用垃圾對象內(nèi)存(sweeping)、整理碎片內(nèi)存(defragment)。
標(biāo)記階段(Marking)
標(biāo)記階段通過變量是否可達(dá)(reachability),判斷是否為活動對象。通常為從一個根對象進行遞歸遍歷,所有遍歷到的對象都是可達(dá)的,為活動對象。沒有遍歷到的對象為非活動對象,需要進行回收。
var?obj1?=?{?a:?1};
var?obj2?=?=?{?b:?2};

執(zhí)行如下代碼后obj2失去對 1002 的引用,在垃圾回收器遍歷完之后發(fā)現(xiàn)沒有對 1002 這塊內(nèi)存的引用變量,標(biāo)記為其非活動變量。
obj2?=?null;

清除階段(Sweeping)
GC會維護一個 freeList 列表,將非活動對象占用的內(nèi)存片段地址添加到 freeList。有新對象申請內(nèi)存時,freeList里有合適大小的內(nèi)存塊,會優(yōu)先分配給新對象。
整理階段(Defragmenting)
這個階段是可選的。內(nèi)存在經(jīng)過垃圾回收之后,活動對象將內(nèi)存塊分割的很零碎,這個時候會進行整理,將活動對象復(fù)制到相同連續(xù)的內(nèi)存區(qū)域內(nèi)。

副垃圾回收器(Minor GC)
副垃圾回收器負(fù)責(zé)新生代垃圾回收。主要有四個步驟:標(biāo)記、復(fù)制、更新指針、切換角色。新生代將內(nèi)存分為 from space(Nursery) 和 to space (Intermediate)。當(dāng)有新對象申請內(nèi)存,會分配from space 區(qū)域中的地址,to space 區(qū)域為備用區(qū)域。
標(biāo)記階段同主垃圾回收器,將可達(dá)對象標(biāo)記為活動對象。
復(fù)制階段將from space中標(biāo)記的活動對象復(fù)制到 to space區(qū)域,并給活動對象做標(biāo)記,此時其已經(jīng)位于 intermediate中,下一次垃圾回收時如果仍為活動對象,就要被復(fù)制到老生代中。
將活動對象復(fù)制到 to space 中之后,需要更新指針引用地址,這樣原引用才能保證正確的指向。

最后切換 from space 和 to space 的角色。在下一次垃圾回收周期后,存活兩次的對象會被復(fù)制到老生代區(qū)域。

GC執(zhí)行時機
在最初,GC運行在主線程,與 JS交替執(zhí)行。在GC執(zhí)行階段,主線程停止JS代碼執(zhí)行,這稱為全停頓(Stop-the-World)。如果垃圾回收器需要處理(標(biāo)記-復(fù)制-整理)的對象比較多,就需要比較長的時間才能完成一次周期內(nèi)的任務(wù)。在這期間如果有更高優(yōu)的任務(wù)需要執(zhí)行,是無法及時響應(yīng)的,比如用戶輸入、動畫的執(zhí)行,給用戶的感覺就是卡頓。

提高GC執(zhí)行效率
Goal: Free Main Thread
Orinoco是 Google 垃圾回收器(Garbage Collector)的項目代號,致力于研究如何提高垃圾回收效率。經(jīng)過多年的發(fā)展,產(chǎn)出了三種能有效提高垃圾回收效率的方案:并行(Parallel)、增量標(biāo)記(Incremental)、并發(fā)(Concurrent)。
并行(Parallel)
在主線程執(zhí)行垃圾回收任務(wù)的同時,開幾個輔助線程同時進行,這樣可以大大減少主線程全停頓(Stop the World)的時間。

增量(increment)
將主線程垃圾回收任務(wù)分成多個小任務(wù),與JS交替執(zhí)行。這種方式并沒有縮短GC工作的時間,但是給了JS響應(yīng)高優(yōu)任務(wù)的時間,避免了出現(xiàn)卡頓。

并發(fā)(concurrent)
并發(fā)是主線程專注執(zhí)行JS, 開啟輔助線程進行垃圾回收。這種方式?jīng)]有了全停頓,完全解放主線程,實現(xiàn)了 Free Main Thread 的目標(biāo)。

幾個典型場景
通過了解V8垃圾回收機制,我們知道垃圾回收器會和JS線程爭奪資源和時間。V8也在不斷通過更先進的技術(shù)來減少全停頓(Stop the World)的時間。對于我們開發(fā)者來說,能做的就是盡量減少GC的工作負(fù)擔(dān)。總結(jié)來說就是,變量不用之后立即釋放。下面我們總結(jié)了幾種容易造成內(nèi)存泄漏的bad case,大家在工作中可以規(guī)避。
減少全局變量
下面這段代碼,函數(shù)作用域中變量未使用關(guān)鍵字聲明,導(dǎo)致非嚴(yán)格模式下掛載到全局作用域。這樣foo()函數(shù)執(zhí)行完畢之后,由于 window.bar的引用一直存在,導(dǎo)致被GC識別為活動對象。這樣只要程序在運行,該對象的內(nèi)存就會一直存在無法被回收,增加垃圾回收器的工作負(fù)擔(dān)。
//?非嚴(yán)格模式下,bar會被掛在全局上
function?foo(arg)?{
????bar?=?{?a:?1?};
????this.obj?=?{?b:?1};
????console.log(bar,?obj);
}
foo();
對于這種情況建議開啟嚴(yán)格模式,或者使用 lint工具檢查這種錯誤。
及時清理對DOM的引用
有了React和Vue這種UI庫,我們就很少直接操作DOM了。在我們業(yè)務(wù)中,需要對富文本內(nèi)的一些內(nèi)容進行操作中,有很多直接操作DOM的場景。在操作完DOM之后,需及時清掉對DOM節(jié)點的引用,不然也會造成對內(nèi)存的泄露。
??type="text"?id="input">
??"node">
??
事件監(jiān)聽&計時器
在我們業(yè)務(wù)中經(jīng)常需要在組件掛載后給元素添加事件監(jiān)聽。這時需要在組件卸載時將監(jiān)聽事件移除,來避免無用的內(nèi)存消耗。
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);
}
如何查看是否存在內(nèi)存泄漏
chrome devtools 中的 performance 面板可以記錄內(nèi)存使用的timeLine, 在錄制之前選中內(nèi)存,報告中會有內(nèi)存的使用情況。我們主要關(guān)注JS堆中內(nèi)存的使用情況。

我們以下面這段代碼為例,通過點擊grow按鈕,會向grow 函數(shù)內(nèi)的變量x 內(nèi)添加大量的長度為100000的字符串。
"en">
???內(nèi)存測試
???
??????
???
???
記錄開始后先點擊【強制垃圾回收】,然后點擊grow,記錄一段時間后再點擊【強制垃圾回收】后查看報告??梢钥吹降诙卫厥张c操作之前的內(nèi)存相等,說明沒有垃圾泄漏。

我們再稍微改一下代碼,看一下內(nèi)存的使用情況。
function?grow()?{
??x?=?[];
??let?str?=?new?Array(100000).join('x');
??for?(let?i=0;?i<100000000;?i++)?{
????x.push(str)
??}
}
記錄發(fā)現(xiàn)強制垃圾回收之后,內(nèi)存的占用要高于grow函數(shù)執(zhí)行之前。與上面第一次記錄的區(qū)別是,grow內(nèi)變量 x 的聲明沒有使用關(guān)鍵字聲明,非嚴(yán)格模式下直接掛載到window上。這樣grow函數(shù)執(zhí)行完畢,全局對 x依然 的引用,GC無法回收 x 占用的內(nèi)存。

總結(jié)
V8垃圾回收器幫助JS使用者周期性的回收不再使用的內(nèi)存。過多的對象會對垃圾回收器造成額外的負(fù)擔(dān),甚至影響到主線程JS的執(zhí)行,造成頁面的卡頓。作為開發(fā)者應(yīng)該有意識的減少全局變量的數(shù)量、及時移除不再使用DOM引用、事件監(jiān)聽及計時器,來減少垃圾回收器的負(fù)擔(dān)。
參考資料
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
?? 謝謝支持
以上便是本次分享的全部內(nèi)容,希望對你有所幫助^_^
喜歡的話別忘了?分享、點贊、收藏?三連哦~。
歡迎關(guān)注公眾號?趣談前端?收貨大廠一手好文章~
???H5-Dooring,讓H5制作更簡單
歡迎體驗:?http://h5.dooring.cn/h5_plus
???謝謝支持
以上便是本次分享的全部內(nèi)容,希望對你有所幫助^_^
喜歡的話別忘了?分享、點贊、收藏?三連哦~。
歡迎關(guān)注公眾號?趣談前端?收獲前端一手好文章~

