一文搞懂V8引擎的垃圾回收

NO IMAGE

引言

作為目前最流行的JavaScript引擎,V8引擎從出現的那一刻起便廣泛受到人們的關注,我們知道,JavaScript可以高效地運行在瀏覽器和Nodejs這兩大宿主環境中,也是因為背後有強大的V8引擎在為其保駕護航,甚至成就了Chrome在瀏覽器中的霸主地位。不得不說,V8引擎為了追求極致的性能和更好的用戶體驗,為我們做了太多太多,從原始的Full-codegenCrankshaft編譯器升級為Ignition解釋器和TurboFan編譯器的強強組合,到隱藏類,內聯緩存和HotSpot熱點代碼收集等一系列強有力的優化策略,V8引擎正在努力降低整體的內存佔用和提升到更高的運行性能。

本篇主要是從V8引擎的垃圾回收機制入手,講解一下在JavaScript代碼執行的整個生命週期中V8引擎是採取怎樣的垃圾回收策略來減少內存佔比的,當然這部分的知識並不太影響我們寫代碼的流程,畢竟在一般情況下我們很少會遇到瀏覽器端出現內存溢出而導致程序崩潰的情況,但是至少我們對這方面有一定的瞭解之後,能增強我們在寫代碼過程中對減少內存佔用,避免內存洩漏的主觀意識,也許能夠幫助你寫出更加健壯和對V8引擎更加友好的代碼。本文也是筆者在查閱資料鞏固複習的過程中慢慢總結和整理出來的,若文中有錯誤的地方,還請指正。

1、為何需要垃圾回收

我們知道,在V8引擎逐行執行JavaScript代碼的過程中,當遇到函數的情況時,會為其創建一個函數執行上下文(Context)環境並添加到調用堆棧的棧頂,函數的作用域(handleScope)中包含了該函數中聲明的所有變量,當該函數執行完畢後,對應的執行上下文從棧頂彈出,函數的作用域會隨之銷燬,其包含的所有變量也會統一釋放並被自動回收。試想如果在這個作用域被銷燬的過程中,其中的變量不被回收,即持久佔用內存,那麼必然會導致內存暴增,從而引發內存洩漏導致程序的性能直線下降甚至崩潰,因此內存在使用完畢之後理當歸還給操作系統以保證內存的重複利用。

這個過程就好比你向親戚朋友借錢,借得多了卻不按時歸還,那麼你再下次借錢的時候肯定沒有那麼順利了,或者說你的親戚朋友不願意再借你了,導致你的手頭有點兒緊(內存洩漏,性能下降),所以說有借有還,再借不難嘛,畢竟出來混都是要還的。

但是JavaScript作為一門高級編程語言,並不像C語言或C++語言中需要手動地申請分配和釋放內存,V8引擎已經幫我們自動進行了內存的分配和管理,好讓我們有更多的精力去專注於業務層面的複雜邏輯,這對於我們前端開發人員來說是一項福利,但是隨之帶來的問題也是顯而易見的,那就是由於不用去手動管理內存,導致寫代碼的過程中不夠嚴謹從而容易引發內存洩漏(畢竟這是別人對你的好,你沒有付出過,又怎能體會得到?)。

2、V8引擎的內存限制

雖然V8引擎幫助我們實現了自動的垃圾回收管理,解放了我們勤勞的雙手,但V8引擎中的內存使用也並不是無限制的。具體來說,默認情況下,V8引擎在64位系統下最多隻能使用約1.4GB的內存,在32位系統下最多隻能使用約0.7GB的內存,在這樣的限制下,必然會導致在node中無法直接操作大內存對象,比如將一個2GB大小的文件全部讀入內存進行字符串分析處理,即使物理內存高達32GB也無法充分利用計算機的內存資源,那麼為什麼會有這種限制呢?這個要回到V8引擎的設計之初,起初只是作為瀏覽器端JavaScript的執行環境,在瀏覽器端我們其實很少會遇到使用大量內存的場景,因此也就沒有必要將最大內存設置得過高。但這只是一方面,其實還有另外兩個主要的原因:

  • JS單線程機制:作為瀏覽器的腳本語言,JS的主要用途是與用戶交互以及操作DOM,那麼這也決定了其作為單線程的本質,單線程意味著執行的代碼必須按順序執行,在同一時間只能處理一個任務。試想如果JS是多線程的,一個線程在刪除DOM元素的同時,另一個線程對該元素進行修改操作,那麼必然會導致複雜的同步問題。既然JS是單線程的,那麼也就意味著在V8執行垃圾回收時,程序中的其他各種邏輯都要進入暫停等待階段,直到垃圾回收結束後才會再次重新執行JS邏輯。因此,由於JS的單線程機制,垃圾回收的過程阻礙了主線程邏輯的執行。

雖然JS是單線程的,但是為了能夠充分利用操作系統的多核CPU計算能力,在HTML5中引入了新的Web Worker標準,其作用就是為JS創造多線程環境,允許主線程創建Worker線程,將一些任務分配給後者運行。在主線程運行的同時,Worker在後臺運行,兩者互不干擾。等到Worker線程完成計算任務,再把結果返回給主線程。這樣的好處是, 一些計算密集型或高延遲的任務,被Worker線程負擔,主線程(通常負責UI交互)就會很流暢,不會被阻塞或者拖慢。Web Worker不是JS的一部分,而是通過JS訪問的瀏覽器特性,其雖然創造了一個多線程的執行環境,但是子線程完全受主線程控制,不能訪問瀏覽器特定的API,例如操作DOM,因此這個新標準並沒有改變JS單線程的本質。

  • 垃圾回收機制:垃圾回收本身也是一件非常耗時的操作,假設V8的堆內存為1.5G,那麼V8做一次小的垃圾回收需要50ms以上,而做一次非增量式回收甚至需要1s以上,可見其耗時之久,而在這1s的時間內,瀏覽器一直處於等待的狀態,同時會失去對用戶的響應,如果有動畫正在運行,也會造成動畫卡頓掉幀的情況,嚴重影響應用程序的性能。因此如果內存使用過高,那麼必然會導致垃圾回收的過程緩慢,也就會導致主線程的等待時間越長,瀏覽器也就越長時間得不到響應。

基於以上兩點,V8引擎為了減少對應用的性能造成的影響,採用了一種比較粗暴的手段,那就是直接限制堆內存的大小,畢竟在瀏覽器端一般也不會遇到需要操作幾個G內存這樣的場景。但是在node端,涉及到的I/O操作可能會比瀏覽器端更加複雜多樣,因此更有可能出現內存溢出的情況。不過也沒關係,V8為我們提供了可配置項來讓我們手動地調整內存大小,但是需要在node初始化的時候進行配置,我們可以通過如下方式來手動設置。

我們嘗試在node命令行中輸入以下命令:

筆者本地安裝的node版本為v10.14.2,可通過node -v查看本地node的版本號,不同版本可能會導致下面的命令會有所差異。

// 該命令可以用來查看node中可用的V8引擎的選項及其含義
node --v8-options

然後我們會在命令行窗口中看到大量關於V8的選項,這裡我們暫且只關注圖中紅色選框中的幾個選項:

一文搞懂V8引擎的垃圾回收

// 設置新生代內存中單個半空間的內存最小值,單位MB
node --min-semi-space-size=1024 xxx.js
// 設置新生代內存中單個半空間的內存最大值,單位MB
node --max-semi-space-size=1024 xxx.js
// 設置老生代內存最大值,單位MB
node --max-old-space-size=2048 xxx.js

通過以上方法便可以手動放寬V8引擎所使用的內存限制,同時node也為我們提供了process.memoryUsage()方法來讓我們可以查看當前node進程所佔用的實際內存大小。

一文搞懂V8引擎的垃圾回收

在上圖中,包含的幾個字段的含義分別如下所示,單位均為字節:

  • heapTotal:表示V8當前申請到的堆內存總大小。
  • heapUsed:表示當前內存使用量。
  • external:表示V8內部的C++對象所佔用的內存。
  • rss(resident set size):表示駐留集大小,是給這個node進程分配了多少物理內存,這些物理內存中包含堆,棧和代碼片段。對象,閉包等存於堆內存,變量存於棧內存,實際的JavaScript源代碼存於代碼段內存。使用Worker線程時,rss將會是一個對整個進程有效的值,而其他字段則只針對當前線程。

在JS中聲明對象時,該對象的內存就分配在堆中,如果當前已申請的堆內存已經不夠分配新的對象,則會繼續申請堆內存直到堆的大小超過V8的限制為止。

3、V8的垃圾回收策略

V8的垃圾回收策略主要是基於分代式垃圾回收機制,其根據對象的存活時間將內存的垃圾回收進行不同的分代,然後對不同的分代採用不同的垃圾回收算法。

3.1 V8的內存結構

在V8引擎的堆結構組成中,其實除了新生代老生代外,還包含其他幾個部分,但是垃圾回收的過程主要出現在新生代和老生代,所以對於其他的部分我們沒必要做太多的深入,有興趣的小夥伴兒可以查閱下相關資料,V8的內存結構主要由以下幾個部分組成:

  • 新生代(new_space):大多數的對象開始都會被分配在這裡,這個區域相對較小但是垃圾回收特別頻繁,該區域被分為兩半,一半用來分配內存,另一半用於在垃圾回收時將需要保留的對象複製過來。
  • 老生代(old_space):新生代中的對象在存活一段時間後就會被轉移到老生代內存區,相對於新生代該內存區域的垃圾回收頻率較低。老生代又分為老生代指針區老生代數據區,前者包含大多數可能存在指向其他對象的指針的對象,後者只保存原始數據對象,這些對象沒有指向其他對象的指針。
  • 大對象區(large_object_space):存放體積超越其他區域大小的對象,每個對象都會有自己的內存,垃圾回收不會移動大對象區。
  • 代碼區(code_space):代碼對象,會被分配在這裡,唯一擁有執行權限的內存區域。
  • map區(map_space):存放Cell和Map,每個區域都是存放相同大小的元素,結構簡單(這裡沒有做具體深入的瞭解,有清楚的小夥伴兒還麻煩解釋下)。

內存結構圖如下所示:

一文搞懂V8引擎的垃圾回收

上圖中的帶斜紋的區域代表暫未使用的內存,新生代(new_space)被劃分為了兩個部分,其中一部分叫做inactive new space,表示暫未激活的內存區域,另一部分為激活狀態,為什麼會劃分為兩個部分呢,在下一小節我們會講到。

3.2 新生代

在V8引擎的內存結構中,新生代主要用於存放存活時間較短的對象。新生代內存是由兩個semispace(半空間)構成的,內存最大值在64位系統和32位系統上分別為32MB16MB,在新生代的垃圾回收過程中主要採用了Scavenge算法。

Scavenge算法是一種典型的犧牲空間換取時間的算法,對於老生代內存來說,可能會存儲大量對象,如果在老生代中使用這種算法,勢必會造成內存資源的浪費,但是在新生代內存中,大部分對象的生命週期較短,在時間效率上表現可觀,所以還是比較適合這種算法。

Scavenge算法的具體實現中,主要採用了Cheney算法,它將新生代內存一分為二,每一個部分的空間稱為semispace,也就是我們在上圖中看見的new_space中劃分的兩個區域,其中處於激活狀態的區域我們稱為From空間,未激活(inactive new space)的區域我們稱為To空間。這兩個空間中,始終只有一個處於使用狀態,另一個處於閒置狀態。我們的程序中聲明的對象首先會被分配到From空間,當進行垃圾回收時,如果From空間中尚有存活對象,則會被複制到To空間進行保存,非存活的對象會被自動回收。當複製完成後,From空間和To空間完成一次角色互換,To空間會變為新的From空間,原來的From空間則變為To空間。

基於以上算法,我們可以畫出如下的流程圖:

  • 假設我們在From空間中分配了三個對象A、B、C
一文搞懂V8引擎的垃圾回收

  • 當程序主線程任務第一次執行完畢後進入垃圾回收時,發現對象A已經沒有其他引用,則表示可以對其進行回收
一文搞懂V8引擎的垃圾回收

  • 對象B和對象C此時依舊處於活躍狀態,因此會被複制到To空間中進行保存
一文搞懂V8引擎的垃圾回收

  • 接下來將From空間中的所有非存活對象全部清除
一文搞懂V8引擎的垃圾回收

  • 此時From空間中的內存已經清空,開始和To空間完成一次角色互換
一文搞懂V8引擎的垃圾回收

  • 當程序主線程在執行第二個任務時,在From空間中分配了一個新對象D
一文搞懂V8引擎的垃圾回收

  • 任務執行完畢後再次進入垃圾回收,發現對象D已經沒有其他引用,表示可以對其進行回收
一文搞懂V8引擎的垃圾回收

  • 對象B和對象C此時依舊處於活躍狀態,再次被複制到To空間中進行保存
一文搞懂V8引擎的垃圾回收

  • 再次將From空間中的所有非存活對象全部清除
一文搞懂V8引擎的垃圾回收

  • From空間和To空間繼續完成一次角色互換
一文搞懂V8引擎的垃圾回收

通過以上的流程圖,我們可以很清楚地看到,Scavenge算法的垃圾回收過程主要就是將存活對象在From空間和To空間之間進行復制,同時完成兩個空間之間的角色互換,因此該算法的缺點也比較明顯,浪費了一半的內存用於複製。

3.3 對象晉升

當一個對象在經過多次複製之後依舊存活,那麼它會被認為是一個生命週期較長的對象,在下一次進行垃圾回收時,該對象會被直接轉移到老生代中,這種對象從新生代轉移到老生代的過程我們稱之為晉升
對象晉升的條件主要有以下兩個:

  • 對象是否經歷過一次Scavenge算法
  • To空間的內存佔比是否已經超過25%

默認情況下,我們創建的對象都會分配在From空間中,當進行垃圾回收時,在將對象從From空間複製到To空間之前,會先檢查該對象的內存地址來判斷是否已經經歷過一次Scavenge算法,如果地址已經發生變動則會將該對象轉移到老生代中,不會再被複制到To空間,可以用以下的流程圖來表示:

一文搞懂V8引擎的垃圾回收

如果對象沒有經歷過Scavenge算法,會被複制到To空間,但是如果此時To空間的內存佔比已經超過25%,則該對象依舊會被轉移到老生代,如下圖所示:

一文搞懂V8引擎的垃圾回收

之所以有25%的內存限制是因為To空間在經歷過一次Scavenge算法後會和From空間完成角色互換,會變為From空間,後續的內存分配都是在From空間中進行的,如果內存使用過高甚至溢出,則會影響後續對象的分配,因此超過這個限制之後對象會被直接轉移到老生代來進行管理。

3.4 老生代

在老生代中,因為管理著大量的存活對象,如果依舊使用Scavenge算法的話,很明顯會浪費一半的內存,因此已經不再使用Scavenge算法,而是採用新的算法Mark-Sweep(標記清除)Mark-Compact(標記整理)來進行管理。

在早前我們可能聽說過一種算法叫做引用計數,該算法的原理比較簡單,就是看對象是否還有其他引用指向它,如果沒有指向該對象的引用,則該對象會被視為垃圾並被垃圾回收器回收,示例如下:

// 創建了兩個對象obj1和obj2,其中obj2作為obj1的屬性被obj1引用,因此不會被垃圾回收
let obj1 = {
obj2: {
a: 1
}
}
// 創建obj3並將obj1賦值給obj3,讓兩個對象指向同一個內存地址
let obj3 = obj1;
// 將obj1重新賦值,此時原來obj1指向的對象現在只由obj3來表示
obj1 = null;
// 創建obj4並將obj3.obj2賦值給obj4
// 此時obj2所指向的對象有兩個引用:一個是作為obj3的屬性,另一個是變量obj4
let obj4 = obj3.obj2;
// 將obj3重新賦值,此時本可以對obj3指向的對象進行回收,但是因為obj3.obj2被obj4所引用,因此依舊不能被回收
obj3 = null;
// 此時obj3.obj2已經沒有指向它的引用,因此obj3指向的對象在此時可以被回收
obj4 = null;

上述例子在經過一系列操作後最終對象會被垃圾回收,但是一旦我們碰到循環引用的場景,就會出現問題,我們看下面的例子:

function foo() {
let a = {};
let b = {};
a.a1 = b;
b.b1 = a;
}
foo();

這個例子中我們將對象aa1屬性指向對象b,將對象bb1屬性指向對象a,形成兩個對象相互引用,在foo函數執行完畢後,函數的作用域已經被銷燬,作用域中包含的變量ab本應該可以被回收,但是因為採用了引用計數的算法,兩個變量均存在指向自身的引用,因此依舊無法被回收,導致內存洩漏。

因此為了避免循環引用導致的內存洩漏問題,截至2012年所有的現代瀏覽器均放棄了這種算法,轉而採用新的Mark-Sweep(標記清除)Mark-Compact(標記整理)算法。在上面循環引用的例子中,因為變量a和變量b無法從window全局對象訪問到,因此無法對其進行標記,所以最終會被回收。

Mark-Sweep(標記清除)分為標記清除兩個階段,在標記階段會遍歷堆中的所有對象,然後標記活著的對象,在清除階段中,會將死亡的對象進行清除。Mark-Sweep算法主要是通過判斷某個對象是否可以被訪問到,從而知道該對象是否應該被回收,具體步驟如下:

  • 垃圾回收器會在內部構建一個根列表,用於從根節點出發去尋找那些可以被訪問到的變量。比如在JavaScript中,window全局對象可以看成一個根節點。
  • 然後,垃圾回收器從所有根節點出發,遍歷其可以訪問到的子節點,並將其標記為活動的,根節點不能到達的地方即為非活動的,將會被視為垃圾。
  • 最後,垃圾回收器將會釋放所有非活動的內存塊,並將其歸還給操作系統。

以下幾種情況都可以作為根節點:

  1. 全局對象
  2. 本地函數的局部變量和參數
  3. 當前嵌套調用鏈上的其他函數的變量和參數
一文搞懂V8引擎的垃圾回收

但是Mark-Sweep算法存在一個問題,就是在經歷過一次標記清除後,內存空間可能會出現不連續的狀態,因為我們所清理的對象的內存地址可能不是連續的,所以就會出現內存碎片的問題,導致後面如果需要分配一個大對象而空閒內存不足以分配,就會提前觸發垃圾回收,而這次垃圾回收其實是沒必要的,因為我們確實有很多空閒內存,只不過是不連續的。

為了解決這種內存碎片的問題,Mark-Compact(標記整理)算法被提了出來,該算法主要就是用來解決內存的碎片化問題的,回收過程中將死亡對象清除後,在整理的過程中,會將活動的對象往堆內存的一端進行移動,移動完成後再清理掉邊界外的全部內存,我們可以用如下流程圖來表示:

  • 假設在老生代中有A、B、C、D四個對象
一文搞懂V8引擎的垃圾回收

  • 在垃圾回收的標記階段,將對象A和對象C標記為活動的
一文搞懂V8引擎的垃圾回收

  • 在垃圾回收的整理階段,將活動的對象往堆內存的一端移動
一文搞懂V8引擎的垃圾回收

  • 在垃圾回收的清除階段,將活動對象左側的內存全部回收
一文搞懂V8引擎的垃圾回收

至此就完成了一次老生代垃圾回收的全部過程,我們在前文中說過,由於JS的單線程機制,垃圾回收的過程會阻礙主線程同步任務的執行,待執行完垃圾回收後才會再次恢復執行主任務的邏輯,這種行為被稱為全停頓(stop-the-world)。在標記階段同樣會阻礙主線程的執行,一般來說,老生代會保存大量存活的對象,如果在標記階段將整個堆內存遍歷一遍,那麼勢必會造成嚴重的卡頓。

因此,為了減少垃圾回收帶來的停頓時間,V8引擎又引入了Incremental Marking(增量標記)的概念,即將原本需要一次性遍歷堆內存的操作改為增量標記的方式,先標記堆內存中的一部分對象,然後暫停,將執行權重新交給JS主線程,待主線程任務執行完畢後再從原來暫停標記的地方繼續標記,直到標記完整個堆內存。這個理念其實有點像React框架中的Fiber架構,只有在瀏覽器的空閒時間才會去遍歷Fiber Tree執行對應的任務,否則延遲執行,儘可能少地影響主線程的任務,避免應用卡頓,提升應用性能。

得益於增量標記的好處,V8引擎後續繼續引入了延遲清理(lazy sweeping)增量式整理(incremental compaction),讓清理和整理的過程也變成增量式的。同時為了充分利用多核CPU的性能,也將引入並行標記並行清理,進一步地減少垃圾回收對主線程的影響,為應用提升更多的性能。

4、如何避免內存洩漏

在我們寫代碼的過程中,基本上都不太會關注寫出怎樣的代碼才能有效地避免內存洩漏,或者說瀏覽器和大部分的前端框架在底層已經幫助我們處理了常見的內存洩漏問題,但是我們還是有必要了解一下常見的幾種避免內存洩漏的方式,畢竟在面試過程中也是經常考察的要點。

4.1 儘可能少地創建全局變量

在ES5中以var聲明的方式在全局作用域中創建一個變量時,或者在函數作用域中不以任何聲明的方式創建一個變量時,都會無形地掛載到window全局對象上,如下所示:

var a = 1; // 等價於 window.a = 1;
function foo() {
a = 1;
}

等價於

function foo() {
window.a = 1;
}

我們在foo函數中創建了一個變量a但是忘記使用var來聲明,此時會意想不到地創建一個全局變量並掛載到window對象上,另外還有一種比較隱蔽的方式來創建全局變量:

function foo() {
this.a = 1;
}
foo(); // 相當於 window.foo()

foo函數在調用時,它所指向的運行上下文環境為window全局對象,因此函數中的this指向的其實是window,也就無意創建了一個全局變量。當進行垃圾回收時,在標記階段因為window對象可以作為根節點,在window上掛載的屬性均可以被訪問到,並將其標記為活動的從而常駐內存,因此也就不會被垃圾回收,只有在整個進程退出時全局作用域才會被銷燬。如果你遇到需要必須使用全局變量的場景,那麼請保證一定要在全局變量使用完畢後將其設置為null從而觸發回收機制。

4.2 手動清除定時器

在我們的應用中經常會有使用setTimeout或者setInterval等定時器的場景,定時器本身是一個非常有用的功能,但是如果我們稍不注意,忘記在適當的時間手動清除定時器,那麼很有可能就會導致內存洩漏,示例如下:

const numbers = [];
const foo = function() {
for(let i = 0;i < 100000;i++) {
numbers.push(i);
}
};
window.setInterval(foo, 1000);

在這個示例中,由於我們沒有手動清除定時器,導致回調任務會不斷地執行下去,回調中所引用的numbers變量也不會被垃圾回收,最終導致numbers數組長度無限遞增,從而引發內存洩漏。

4.3 少用閉包

閉包是JS中的一個高級特性,巧妙地利用閉包可以幫助我們實現很多高級功能。一般來說,我們在查找變量時,在本地作用域中查找不到就會沿著作用域鏈從內向外單向查找,但是閉包的特性可以讓我們在外部作用域訪問內部作用域中的變量,示例如下:

function foo() {
let local = 123;
return function() {
return local;
}
}
const bar = foo();
console.log(bar()); // -> 123

在這個示例中,foo函數執行完畢後會返回一個匿名函數,該函數內部引用了foo函數中的局部變量local,並且通過變量bar來引用這個匿名的函數定義,通過這種閉包的方式我們就可以在foo函數的外部作用域中訪問到它的局部變量local。一般情況下,當foo函數執行完畢後,它的作用域會被銷燬,但是由於存在變量引用其返回的匿名函數,導致作用域無法得到釋放,也就導致local變量無法回收,只有當我們取消掉對匿名函數的引用才會進入垃圾回收階段。

4.4 清除DOM引用

以往我們在操作DOM元素時,為了避免多次獲取DOM元素,我們會將DOM元素存儲在一個數據字典中,示例如下:

const elements = {
button: document.getElementById('button')
};
function removeButton() {
document.body.removeChild(document.getElementById('button'));
}

在這個示例中,我們想調用removeButton方法來清除button元素,但是由於在elements字典中存在對button元素的引用,所以即使我們通過removeChild方法移除了button元素,它其實還是依舊存儲在內存中無法得到釋放,只有我們手動清除對button元素的引用才會被垃圾回收。

4.5 弱引用

通過前幾個示例我們會發現如果我們一旦疏忽,就會容易地引發內存洩漏的問題,為此,在ES6中為我們新增了兩個有效的數據結構WeakMapWeakSet,就是為了解決內存洩漏的問題而誕生的。其表示弱引用,它的鍵名所引用的對象均是弱引用,弱引用是指垃圾回收的過程中不會將鍵名對該對象的引用考慮進去,只要所引用的對象沒有其他的引用了,垃圾回收機制就會釋放該對象所佔用的內存。這也就意味著我們不需要關心WeakMap中鍵名對其他對象的引用,也不需要手動地進行引用清除,我們嘗試在node中演示一下過程(參考阮一峰ES6標準入門中的示例,自己手動實現了一遍)。

首先打開node命令行,輸入以下命令:

node --expose-gc // --expose-gc 表示允許手動執行垃圾回收機制

然後我們執行下面的代碼。

// 手動執行一次垃圾回收保證內存數據準確
> global.gc();
undefined
// 查看當前佔用的內存,主要關心heapUsed字段,大小約為4.4MB
> process.memoryUsage();
{ rss: 21626880,
heapTotal: 7585792,
heapUsed: 4708440,
external: 8710 }
// 創建一個WeakMap
> let wm = new WeakMap();
undefined
// 創建一個數組並賦值給變量key
> let key = new Array(1000000);
undefined
// 將WeakMap的鍵名指向該數組
// 此時該數組存在兩個引用,一個是key,一個是WeakMap的鍵名
// 注意WeakMap是弱引用
> wm.set(key, 1);
WeakMap { [items unknown] }
// 手動執行一次垃圾回收
> global.gc();
undefined
// 再次查看內存佔用大小,heapUsed已經增加到約12MB
> process.memoryUsage();
{ rss: 30232576,
heapTotal: 17694720,
heapUsed: 13068464,
external: 8688 }
// 手動清除變量key對數組的引用
// 注意這裡並沒有清除WeakMap中鍵名對數組的引用
> key = null;
null
// 再次執行垃圾回收
> global.gc()
undefined
// 查看內存佔用大小,發現heapUsed已經回到了之前的大小(這裡約為4.8M,原來為4.4M,稍微有些浮動)
> process.memoryUsage();
{ rss: 22110208,
heapTotal: 9158656,
heapUsed: 5089752,
external: 8698 }

在上述示例中,我們發現雖然我們沒有手動清除WeakMap中的鍵名對數組的引用,但是內存依舊已經回到原始的大小,說明該數組已經被回收,那麼這個也就是弱引用的具體含義了。

5、總結

本文中主要講解了一下V8引擎的垃圾回收機制,並分別從新生代和老生代講述了不同分代中的垃圾回收策略以及對應的回收算法,之後列出了幾種常見的避免內存洩漏的方式來幫助我們寫出更加優雅的代碼。如果你已經瞭解過垃圾回收相關的內容,那麼這篇文章可以幫助你簡單複習加深印象,如果沒有了解過,那麼筆者也希望這篇文章能夠幫助到你瞭解一些代碼層面之外的底層知識點,由於V8引擎的源碼是用C++實現的,所以筆者也就沒有做這方面的深入了,有興趣的小夥伴兒可以自行探究,文中有錯誤的地方,還希望能夠在評論區指正。

6、交流

如果你覺得這篇文章的內容對你有幫助,能否幫個忙關注一下筆者的公眾號[前端之境],每週都會努力原創一些前端技術乾貨,關注公眾號後可以邀你加入前端技術交流群,我們可以一起互相交流,共同進步。

文章已同步更新至Github博客,若覺文章尚可,歡迎前往star!

你的一個點贊,值得讓我付出更多的努力!

逆境中成長,只有不斷地學習,才能成為更好的自己,與君共勉!

一文搞懂V8引擎的垃圾回收

相關文章

手摸手教你寫個ESLint插件以及瞭解ESLint的運行原理

深入理解flexgrow、flexshrink、flexbasis

2020年史上最全Vue框架整理從基礎到實戰(二)

雲原生基礎及調研