1. 程式人生 > >一文搞懂V8引擎的垃圾回收

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

引言

作為目前最流行的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的選項,這裡我們暫且只關注圖中紅色選框中的幾個選項:

// 設定新生代記憶體中單個半空間的記憶體最小值,單位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位系統上分別為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

  • 當程式主執行緒任務第一次執行完畢後進入垃圾回收時,發現物件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();

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

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

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

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

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

  1. 全域性物件
  2. 本地函式的區域性變數和引數
  3. 當前巢狀呼叫鏈上的其他函式的變數和引數


但是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中為我們新增了兩個有效的資料結構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引擎垃圾回收

引言 作為目前最流行的JavaScript引擎,V8引擎從出現的那一刻起便廣泛受到人們的關注,我們知道,JavaScript可以高效地執行在瀏覽器和Nodejs這兩大宿主環境中,也是因為背後有強大的V8引擎在為其保駕護航,甚至成就了Chrome在瀏覽器中的霸主地位。不得不說,V8引擎為了追求極致的效能和更好的

徹底python的垃圾回收機制

  一 、什麼是記憶體管理和垃圾回收 Python GC主要使用引用計數(reference counting)來跟蹤和回收垃圾。在引用計數的基礎上,通過“標記-清除”(mark and sweep)解決容器物件可能產生的迴圈引用問題,通過“分代回收”(genera

各種 Docker 網絡 - 每天5分鐘玩轉 Docker 容器技術(72)

docker 教程 容器 前面各小節我們先後學習了 Docker Overaly,Macvaln,Flannel,Weave 和 Calico 跨主機網絡方案。目前這個領域是百家爭鳴,而且還有新的方案不斷湧現。本節將從不同維度比較各種網絡方案,大家在選擇的時候可以參考。CloudMan 的建議是:

Java 線程中斷

回復 代碼 信號 過程 執行 except 實例 二維 微信公眾 在之前的一文《如何"優雅"地終止一個線程》中詳細說明了 stop 終止線程的壞處及如何優雅地終止線程,那麽還有別的可以終止線程的方法嗎?答案是肯定的,它就是我們今天要分享的——線程中斷。 下面的這斷代碼大家應

資料結構與演算法隨筆之------二叉樹的遍歷(二叉樹的四種遍歷)

二叉樹的遍歷 二叉樹的遍歷(traversing binary tree)是指從根結點出發,按照某種次序依次訪問二叉樹中所有的結點,使得每個結點被訪問依次且僅被訪問一次。 遍歷分為四種,前序遍歷,中序遍歷,後序遍歷及層序遍歷 前序 中

k近鄰(k-NN)演算法(

分享一下我老師大神的人工智慧教程!零基礎,通俗易懂!http://blog.csdn.net/jiangjunshow 也歡迎大家轉載本篇文章。分享知識,造福人民,實現我們中華民族偉大復興!        

:詞法作用域、動態作用域、回撥函式、閉包

不管什麼語言,我們總要學習作用域(或生命週期)的概念,比如常見的稱呼:全域性變數、包變數、模組變數、本地變數、區域性變數等等。不管如何稱呼這些作用域的範圍,實現它們的目的都一樣: (1)為了避免名稱衝突; (2)為了限定變數的生命週期(本文以變數名說事,其它的名稱在規則上是一樣的)

Python裝飾器是款神奇的神器!你知道怎麼用嗎?它!

    進群:548377875  即可獲取小編精心準備的教程以及大量的PDF呢! 1.引子 #功能函式 def add(x,y): return x+y #裝飾函式 def logger(fn): print('frist') x =

了解JVM全部垃圾回收器,從Serial到ZGC

應用 base garbage 最大收益 監控 fill 前沿 mage 記錄 《對象搜索算法與回收算法》介紹了垃圾回收的基礎算法,相當於垃圾回收的方法論。接下來就詳細看看垃圾回收的具體實現。 上文提到過現代的商用虛擬機的都是采用分代收集的,不同的區域用不同的收集器。常用的

交叉熵在機器學習中的使用,透徹理解交叉熵背後的直覺

關於交叉熵在loss函式中使用的理解 交叉熵(cross entropy)是深度學習中常用的一個概念,一般用來求目標與預測值之間的差距。以前做一些分類問題

db2 的鎖(表鎖、行鎖、共享鎖、排他鎖)

鎖,很好理解,每個人都在自己的房屋上安裝有鎖,你擁有了鎖,房屋只有你能獨佔,別人不能訪問。資料庫中的鎖也一樣,只不過更加細分。 db2 中基本的鎖有兩類: 排他鎖(X鎖),也叫寫鎖,當某行資料正在被修改時,其他程序不能再讀取或修改 共享鎖(S鎖),也叫讀鎖,當某行資料正

Java 執行緒中斷

在之前的一文《如何"優雅"地終止一個執行緒》中詳細說明了 stop 終止執行緒的壞處及如何優雅地終止執行緒,那麼還有別的可以終止執行緒的方法嗎?答案是肯定的,它就是我們今天要分享的——執行緒中斷。 下面的這斷程式碼大家應該再熟悉不過了,執行緒休眠需要捕獲或者丟擲

Raft演算法

  raft是工程上使用較為廣泛的強一致性、去中心化、高可用的分散式協議。在這裡強調了是在工程上,因為在學術理論界,最耀眼的還是大名鼎鼎的Paxos。但Paxos是:少數真正理解的人覺得簡單,尚未理解的人覺得很難,大多數人都是一知半解。本人也花了很多時間、看了很多材料也沒有真正理解。直到看到raft的論文,兩

如何用Python爬取上市公司資訊

1. 概念準備 Python基本概念 tb.to_csv(r'1.csv', mode='a', encoding='utf_8_sig', header=1, index=0) r意思是強制不轉義字串 TableTableTable型表格

裝飾者模式

常見設計模式: 工廠模式,單例模式,裝飾器模式,代理模式,介面卡模式等 Java中IO使用的是裝飾設計模式: 抽象構件:抽象被裝飾者,一般是介面 具體構件:具體被裝飾者,實現抽向構件 抽象角色:抽象裝飾者, 抽象角色也去實現抽向構件:多型使用抽象構件做成員變數

Java基礎-位運算

調優 補碼 成了 指令流水 docke 無符號 三次 cat 還需 在日常的Java開發中,位運算使用的不多,使用的更多的是算數運算(+、-、*、/、%)、關系運算(<、>、<=、>=、==、!=)和邏輯運算(&&、||、!),所以相

C/C++中指標那些事(上篇)

一 指標變數 1.間接存取        指標變數的值為地址;普通變數的值為資料;其中“*”為指標運算子。&是地址操作符,用來引用一個記憶體地址。通過在變數名字前使用&操作符,我們可以得到該變數的記憶體地址。        針對記憶體資料的

Matlab畫圖那些事(上篇)

題記:臨時需要Matlab畫個曲線圖,突然發現有些命令竟然忘掉了,於是各種查。這裡博主整理合並關於畫圖那些命令,只為讓你輕鬆搞定Matlab畫圖這些瑣事,那麼,來吧! 說明:本博文主要是二維圖形的繪製,二維圖形是將平面座標上的資料點連線起來的平面圖形。可以採用

「詞嵌入」在自然語言處理中扮演什麼角色?Word Embeddings的背後原理

原文來源:DATASCIENCE 作者:Ruslana Dalinina 「機器人圈」編譯:嗯~阿童木呀、多啦A亮 「機器人圈」正式更名為「雷克世界」,後臺回覆「雷克世界」檢視更多詳

各種 Docker 網路

前面各小節我們先後學習了 Docker Overaly,Macvaln,Flannel,Weave 和 Calico 跨主機網路方案。目前這個領域是百家爭鳴,而且還有新的方案不斷湧現。 本節將從不同維度比較各種網路方案,大家在選擇的時候可以參考。CloudMan 的建議