引言
作為目前最流行的JavaScript引擎,V8引擎從出現的那一刻起便廣泛受到人們的關注,我們知道,JavaScript可以高效地運行在瀏覽器和Nodejs這兩大宿主環境中,也是因為背后有強大的V8引擎在為其保駕護航,甚至成就了Chrome在瀏覽器中的霸主地位。不得不說,V8引擎為了追求極致的性能和更好的用戶體驗,為我們做了太多太多,從原始的Full-codegen
和Crankshaft
編譯器升級為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的選項,這里我們暫且只關注圖中紅色選框中的幾個選項:
// 設置新生代內存中單個半空間的內存最小值,單位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進程所占用的實際內存大小。
在上圖中,包含的幾個字段的含義分別如下所示,單位均為字節:
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,每個區域都是存放相同大小的元素,結構簡單(這里沒有做具體深入的了解,有清楚的小伙伴兒還麻煩解釋下)。
內存結構圖如下所示:
上圖中的帶斜紋的區域代表暫未使用的內存,新生代(new_space)被划分為了兩個部分,其中一部分叫做inactive new space,表示暫未激活的內存區域,另一部分為激活狀態,為什么會划分為兩個部分呢,在下一小節我們會講到。
3.2 新生代
在V8引擎的內存結構中,新生代主要用於存放存活時間較短的對象。新生代內存是由兩個semispace(半空間)
構成的,內存最大值在64
位系統和32
位系統上分別為32MB
和16MB
,在新生代的垃圾回收過程中主要采用了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
- 當程序主線程任務第一次執行完畢后進入垃圾回收時,發現對象A已經沒有其他引用,則表示可以對其進行回收
- 對象B和對象C此時依舊處於活躍狀態,因此會被復制到
To
空間中進行保存
- 接下來將
From
空間中的所有非存活對象全部清除
- 此時
From
空間中的內存已經清空,開始和To
空間完成一次角色互換
- 當程序主線程在執行第二個任務時,在
From
空間中分配了一個新對象D
- 任務執行完畢后再次進入垃圾回收,發現對象D已經沒有其他引用,表示可以對其進行回收
- 對象B和對象C此時依舊處於活躍狀態,再次被復制到
To
空間中進行保存
- 再次將
From
空間中的所有非存活對象全部清除
From
空間和To
空間繼續完成一次角色互換
通過以上的流程圖,我們可以很清楚地看到,Scavenge
算法的垃圾回收過程主要就是將存活對象在From
空間和To
空間之間進行復制,同時完成兩個空間之間的角色互換,因此該算法的缺點也比較明顯,浪費了一半的內存用於復制。
3.3 對象晉升
當一個對象在經過多次復制之后依舊存活,那么它會被認為是一個生命周期較長的對象,在下一次進行垃圾回收時,該對象會被直接轉移到老生代中,這種對象從新生代轉移到老生代的過程我們稱之為晉升
。
對象晉升的條件主要有以下兩個:
- 對象是否經歷過一次
Scavenge
算法 To
空間的內存占比是否已經超過25%
默認情況下,我們創建的對象都會分配在From
空間中,當進行垃圾回收時,在將對象從From
空間復制到To
空間之前,會先檢查該對象的內存地址來判斷是否已經經歷過一次Scavenge
算法,如果地址已經發生變動則會將該對象轉移到老生代中,不會再被復制到To
空間,可以用以下的流程圖來表示:
如果對象沒有經歷過Scavenge
算法,會被復制到To
空間,但是如果此時To
空間的內存占比已經超過25%
,則該對象依舊會被轉移到老生代,如下圖所示:
之所以有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();
這個例子中我們將對象a
的a1
屬性指向對象b
,將對象b
的b1
屬性指向對象a
,形成兩個對象相互引用,在foo
函數執行完畢后,函數的作用域已經被銷毀,作用域中包含的變量a
和b
本應該可以被回收,但是因為采用了引用計數
的算法,兩個變量均存在指向自身的引用,因此依舊無法被回收,導致內存泄漏。
因此為了避免循環引用導致的內存泄漏問題,截至2012年所有的現代瀏覽器均放棄了這種算法,轉而采用新的Mark-Sweep(標記清除)
和Mark-Compact(標記整理)
算法。在上面循環引用的例子中,因為變量a
和變量b
無法從window
全局對象訪問到,因此無法對其進行標記,所以最終會被回收。
Mark-Sweep(標記清除)
分為標記
和清除
兩個階段,在標記階段會遍歷堆中的所有對象,然后標記活着的對象,在清除階段中,會將死亡的對象進行清除。Mark-Sweep
算法主要是通過判斷某個對象是否可以被訪問到,從而知道該對象是否應該被回收,具體步驟如下:
- 垃圾回收器會在內部構建一個
根列表
,用於從根節點出發去尋找那些可以被訪問到的變量。比如在JavaScript中,window
全局對象可以看成一個根節點。 - 然后,垃圾回收器從所有根節點出發,遍歷其可以訪問到的子節點,並將其標記為活動的,根節點不能到達的地方即為非活動的,將會被視為垃圾。
- 最后,垃圾回收器將會釋放所有非活動的內存塊,並將其歸還給操作系統。
以下幾種情況都可以作為根節點:
- 全局對象
- 本地函數的局部變量和參數
- 當前嵌套調用鏈上的其他函數的變量和參數
但是Mark-Sweep
算法存在一個問題,就是在經歷過一次標記清除后,內存空間可能會出現不連續的狀態,因為我們所清理的對象的內存地址可能不是連續的,所以就會出現內存碎片的問題,導致后面如果需要分配一個大對象而空閑內存不足以分配,就會提前觸發垃圾回收,而這次垃圾回收其實是沒必要的,因為我們確實有很多空閑內存,只不過是不連續的。
為了解決這種內存碎片的問題,Mark-Compact(標記整理)
算法被提了出來,該算法主要就是用來解決內存的碎片化問題的,回收過程中將死亡對象清除后,在整理的過程中,會將活動的對象往堆內存的一端進行移動,移動完成后再清理掉邊界外的全部內存,我們可以用如下流程圖來表示:
- 假設在老生代中有A、B、C、D四個對象
- 在垃圾回收的
標記
階段,將對象A和對象C標記為活動的
- 在垃圾回收的
整理
階段,將活動的對象往堆內存的一端移動
- 在垃圾回收的
清除
階段,將活動對象左側的內存全部回收
至此就完成了一次老生代垃圾回收的全部過程,我們在前文中說過,由於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中為我們新增了兩個有效的數據結構WeakMap
和WeakSet
,就是為了解決內存泄漏的問題而誕生的。其表示弱引用
,它的鍵名所引用的對象均是弱引用,弱引用是指垃圾回收的過程中不會將鍵名對該對象的引用考慮進去,只要所引用的對象沒有其他的引用了,垃圾回收機制就會釋放該對象所占用的內存。這也就意味着我們不需要關心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!
你的一個點贊,值得讓我付出更多的努力!
逆境中成長,只有不斷地學習,才能成為更好的自己,與君共勉!