前端面試查漏補缺--(二) 垃圾回收機制

shotCat發表於2019-02-22

前言

本系列最開始是為了自己面試準備的.後來發現整理越來越多,差不多有十二萬字元,最後決定還是分享出來給大家.

為了分享整理出來,花費了自己大量的時間,起碼是隻自己用的三倍時間.如果喜歡的話,歡迎收藏,關注我!謝謝!

文章連結

合集篇:

前端面試查漏補缺--Index篇(12萬字元合集) 包含目前已寫好的系列其他十幾篇文章.後續新增值文章不會再在每篇新增連結,強烈建議議點贊,關注合集篇!!!!,謝謝!~

後續更新計劃

後續還會繼續新增設計模式,前端工程化,專案流程,部署,閉環,vue常考知識點 等內容.如果覺得內容不錯的話歡迎收藏,關注我!謝謝!

求一份內推

目前本人也在準備跳槽,希望各位大佬和HR小姐姐可以內推一份靠譜的武漢 前端崗位!郵箱:bupabuku@foxmail.com.謝謝啦!~

垃圾回收機制

JavaScript 具有自動垃圾收集機制(GC:GarbageCollecation),也就是說,執行環境會負責管理程式碼執行過程中使用的記憶體。開發人員不用再關心記憶體使用問題,所需記憶體的分配以及無用記憶體的回收完全實現了自動管理。

記憶體生命週期

JS環境中分配的記憶體一般有如下生命週期:

  1. 記憶體分配:當我們申明變數、函式、物件,並執行的時候,系統會自動為他們分配記憶體
  2. 記憶體使用:即讀寫記憶體,也就是使用變數、函式等
  3. 記憶體回收:使用完畢,由垃圾回收機制自動回收不再使用的記憶體

垃圾回收機制策略

標記清除演算法

JavaScript 中最常用的垃圾收集方式是標記清除(mark-and-sweep)。

這個演算法把“物件是否不再需要”簡化定義為“物件是否可以獲得”。

該演算法假定設定一個叫做根(root)的物件(在Javascript裡,根是全域性物件)。垃圾回收器將定期從根開始(在JS中就是全域性物件)掃描記憶體中的物件。凡是能從根部到達的物件,都是還需要使用的。那些無法由根部出發觸及到的物件被標記為不再使用,稍後進行回收。

此演算法可以分為兩個階段,一個是標記階段(mark),一個是清除階段(sweep)。

  1. 標記階段,垃圾回收器會從根物件開始遍歷。每一個可以從根物件訪問到的物件都會被新增一個標識,於是這個物件就被標識為可到達物件。
  2. 清除階段,垃圾回收器會對堆記憶體從頭到尾進行線性遍歷,如果發現有物件沒有被標識為可到達物件,那麼就將此物件佔用的記憶體回收,並且將原來標記為可到達物件的標識清除,以便進行下一次垃圾回收操作。

前端面試查漏補缺--(二) 垃圾回收機制

在標記階段,從根物件1可以訪問到B,從B又可以訪問到E,那麼B和E都是可到達物件,同樣的道理,F、G、J和K都是可到達物件。

在回收階段,所有未標記為可到達的物件都會被垃圾回收器回收。

何時開始垃圾回收?
通常來說,在使用標記清除演算法時,未引用物件並不會被立即回收。取而代之的做法是,垃圾物件將一直累計到記憶體耗盡為止。當記憶體耗盡時,程式將會被掛起,垃圾回收開始執行。

補充: 從2012年起,所有現代瀏覽器都使用了標記-清除垃圾回收演算法。所有對JavaScript垃圾回收演算法的改進都是基於標記-清除演算法的改進,並沒有改進標記-清除演算法本身和它對“物件是否不再需要”的簡化定義。

標記清除演算法缺陷

  • 那些無法從根物件查詢到的物件都將被清除
  • 垃圾收集後有可能會造成大量的記憶體碎片,像上面的圖片所示,垃圾收集後記憶體中存在三個記憶體碎片,假設一個方格代表1個單位的記憶體,如果有一個物件需要佔用3個記憶體單位的話,那麼就會導致Mutator一直處於暫停狀態,而Collector一直在嘗試進行垃圾收集,直到Out of Memory。

引用計數演算法

這是最初級的垃圾收集演算法.現在已經沒有瀏覽器會用這種演算法.

此演算法把“物件是否不再需要”簡化定義為“物件有沒有其他物件引用到它”。如果沒有引用指向該物件(零引用),物件將被垃圾回收機制回收。

引用計數的含義是跟蹤記錄每個值被引用的次數。當宣告瞭一個變數並將一個引用型別值賦給該變數時,則這個值的引用次數就是1。如果同一個值又被賦給另一個變數,則該值的引用次數加1。相反,如果包含對這個值引用的變數又取得了另外一個值,則這個值的引用次數減1。當這個值的引用次數變成0時,則說明沒有辦法再訪問這個值了,因而就可以將其佔用的記憶體空間回收回來。這樣,當垃圾收集器下次再執行時,它就會釋放那些引用次數為零的值所佔用的記憶體。

引用計數缺陷

該演算法有個限制:無法處理迴圈引用。如果兩個物件被建立,並互相引用,形成了一個迴圈。它們被呼叫之後會離開函式作用域,所以它們已經沒有用了,可以被回收了。然而,引用計數演算法考慮到它們互相都有至少一次引用,所以它們不會被回收。

Chrome V8 垃圾回收演算法

Chrome 瀏覽器所使用的 V8 引擎就是採用的分代回收策略。這個和 Java 回收策略思想是一致的。目的是通過區分「臨時」與「持久」物件;多回收「臨時物件區」(新生代younggeneration),少回收「持久物件區」(老生代 tenured generation),減少每次需遍歷的物件,從而減少每次GC的耗時。

V8的記憶體限制

在node中javascript能使用的記憶體是有限制的.

  1. 64位系統下約為1.4GB。
  2. 32位系統下約為0.7GB。

對應到分代記憶體中,預設情況下。

  1. 32位系統新生代記憶體大小為16MB,老生代記憶體大小為700MB。
  2. 64位系統下,新生代記憶體大小為32MB,老生代記憶體大小為1.4GB。

新生代平均分成兩塊相等的記憶體空間,叫做semispace,每塊記憶體大小8MB(32位)或16MB(64位)。

這個限制在node啟動的時候可以通過傳遞--max-old-space-size 和 --max-new-space-size來調整,如:

node --max-old-space-size=1700 app.js //單位為MB
node --max-new-space-size=1024 app.js //單位為kb
複製程式碼

上述引數在V8初始化時生效,一旦生效就不能再動態改變。

記憶體限制的原因:

至於V8為何要限制堆的大小,表層原因:V8最初為瀏覽器而設計,不太可能遇到用大量記憶體的場景。深層原因:V8的垃圾回收機制的限制。官方說法,以1.5GB的垃圾回收堆記憶體為例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。這是垃圾回收中引起JS執行緒暫停執行的時間,在這樣時間花銷下,應用的效能和響應能力都會直線下降。

V8的分代回收(Generation GC)

V8垃圾回收策略主要基於分代式垃圾回收機制。現代的垃圾回收演算法中按物件的存活時間將記憶體的垃圾回收進行不同的分代,然後分別對不同分代的記憶體施以更高效的演算法。

V8的記憶體分代:

在V8中,主要將記憶體分為新生代和老生代,新生代記憶體 儲存的為存活時間較短的物件老生代記憶體 儲存的為存活時間較長或常駐記憶體的物件,如下圖:

前端面試查漏補缺--(二) 垃圾回收機制

V8堆的整體大小就是新生代所用記憶體空間加上老生代的記憶體空間。

V8新生代演算法(Scavenge):

在分代基礎上,新生代中的物件主要通過Scavenge演算法進行垃圾回收。在Scavenge的具體實現中,主要採用了Cheney演算法

Cheney演算法是一種採用複製的方式實現的垃圾回收演算法。它將堆記憶體一分為二,每一部分空間稱為semispace。在這兩個semispace空間中,只有一個處於使用中,另一個處於閒置狀態。處於使用狀態的semispace空間稱為From空間,處於閒置狀態的空間稱為To空間。

當我們分配物件時,先是在From空間中進行分配。當開始進行垃圾回收時,會檢查From空間中的存活物件,這些存活物件將被複制到To空間中,而(From空間內的)非存活物件佔用的空間將會被釋放。完成複製後,From空間和To空間的角色發生對換(即以前的From空間釋放後變為To;To空間在複製存活的物件後,變為From空間)。簡而言之,在垃圾回收過程中,就是通過將存活物件在兩個semispace空間之間進行復制。

Scavenge的缺點:
只能使用堆記憶體中的一半,這是由劃分空間和複製機制所決定的。

Scavenge的優點:
Scavenge由於只複製存活的物件,並且對於生命週期短的場景存活物件只佔少部分,所以它在時間效率上有優異的表現。 Scavenge是典型的犧牲空間換取時間的演算法, 所以無法大規模地應用到所有的垃圾回收中。但可以發現,Scavenge非常適合應用在新生代中,因為新生代中物件的生命週期較短,恰恰適合這個演算法。

晉升:
實際使用的堆記憶體是新生代的兩個semispace空間大小和老生代所用記憶體大小之和。當一個物件經過多次複製依然存活時,它將會被認為是生命週期較長的物件。這種較長生命週期的物件隨後會被移動到老生代中,採用新的演算法進行管理。物件從新生代中移動到老生代中的過程稱為晉升。

在單純的Scavenge過程中,From空間中的存活物件會被複制到To空間中去,然後對From空間和To空間進行角色對換(又稱翻轉)。但在分代式垃圾回收前提下,From空間中的存活物件在複製到To空間之前需要進行檢查。在一定條件下,需要將存活週期長的物件移動到老生代中,也就是完成物件晉升。

晉升條件:
物件晉升的條件主要有兩個,一個是物件是否經歷過Scavenge回收,一個是To空間的記憶體佔用比超過25%限制。

設定25%這個限制值的原因:
當這次Scavenge回收完成後,這個To空間將變成From空間,接下來的記憶體分配將在這個空間中進行。如果佔比過高,會影響後續的記憶體分配。 物件晉升後,將會在老生代空間中作為存活週期較長的物件來對待,接受新的回收演算法處理。

是否經歷過Scavenge回收

To空間的使用應超過25%時

V8老生代演算法(Mark-Sweep && Mark-Compact):

對於老生代中的物件,由於存活物件佔較大比重,再採用Scavenge的方式會有兩個問題:一個是存活物件較多,複製存活物件的效率將會很低;另一個問題依然是浪費一半空間的問題。為此,V8在老生代中主要採用Mark-Sweep和Mark-Compact相結合的方式進行垃圾回收。

Mark-Sweep:
Mark-Sweep是標記清除的意思,它分為標記和清除兩個階段。與Scavenge相比,Mark-Sweep並不將記憶體空間劃分為兩半,所以不存在浪費一半空間的行為。與Scavenge複製活著的物件不同,Mark-Sweep在標記階段遍歷堆中所有物件,並標記活著的物件,在隨後的清除階段中,只清除沒有被標記的物件。 可以看出,Scavenge中只複製活著的物件,而Mark-Sweep只清理死亡物件。 活物件在新生代中只佔較小部分,死物件在老生代中只佔較小部分,這是兩種回收方式能高效處理的原因。

下圖為Mark-Sweep在老生代空間中標記的示意圖,黑色部分標記為死亡物件

前端面試查漏補缺--(二) 垃圾回收機制

Mark-Sweep最大的問題:
在進行一次標記清除回收後,記憶體空間會出現不連續的狀態。這種記憶體碎片會對後續的記憶體分配造成問題,因為很可能出現需要分配一個大物件的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。(注意理解這句話,不要把記憶體想象成液體.而是固體,就像一個個散亂排列的麻將,需要進行排序處理--即後面要講的 Mark-Compact)

Mark-Compact:
為了解決Mark-Sweep的記憶體碎片問題,Mark-Compact被提出來。Mark-Compact是標記整理的意思,是在Mark-Sweep的基礎上演變而來的。它們的差別在於物件在標記為死亡後,在整理的過程中,將活著的物件往一端移動,移動完成後,直接清理掉邊界外的記憶體。 下圖為Mark-Compact完成標記並移動存活物件後的示意圖,白色格子為存活物件,深色格子為死亡物件,淺色格子為存活物件移動後留下的空洞。

前端面試查漏補缺--(二) 垃圾回收機制

完成移動後,就可以直接清除最右邊的存活物件後面的記憶體區域完成回收。

Mark-Sweep、Mark-Compact、Scavenge三種主要垃圾回收演算法的簡單對比

回收演算法 Mark-Sweep Mark-Compact Scavenge
速度 中等 最慢 最快
空間開銷 少(有碎片) 少(無碎片) 雙倍空間(無碎片)
是否移動物件

從表格上看,Mark-Sweep和Mark-Compact之間,由於Mark-Compact需要移動物件,所以它的執行速度不可能很快,所以在取捨上,V8主要使用Mark-Sweep,在空間不足以對從新生代中晉升過來的物件進行分配時才使用Mark-Compact。

增量式標記回收(Incremental Marking):

  • 為了避免出現js應用邏輯與垃圾回收器看到的不一致的情況,垃圾回收的3種基本演算法都需要將應用邏輯暫停下來,待執行完垃圾回收後再恢復執行應用邏輯,這種行為被稱為“全停頓”(stop-the-world)。在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由於新生代預設配置得較小,且其中存活物件通常較少,所以即便它是全停頓的影響也不大。但V8的老生代通常配置得較大,且存活物件較多,全堆垃圾回收(full垃圾回收)的標記、清理、整理等動作造成的停頓就會比較可怕,需要設法改善(PS: 若V8的堆記憶體為1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。)。
  • 為了降低全堆垃圾回收帶來的停頓時間,V8先從標記階段入手,將原本要一口氣停頓完成的動作改為增量標記(incremental marking),也就是拆分為許多小“步進”,每做完一“步進”就讓js應用邏輯執行一小會,垃圾回收與應用邏輯交替執行直到標記階段完成。
  • V8在經過增量標記的改進後,垃圾回收的最大停頓時間可以減少到原本的1/6左右。
  • V8後續還引入了延遲清理(lazy sweeping)與增量式整理(incremental compaction),讓清理與整理動作也變成增量式的。同時還計劃引入並行標記與並行清理,進一步利用多核效能降低每次停頓的時間。

減少垃圾和回收對效能的影響:

主要注意以下兩點:

  • 讓垃圾回收儘量少地進行,尤其是全堆垃圾回收。這部分我們基本上幫不了什麼忙,主要靠v8自己的優化機制.
  • 避免記憶體洩露,讓記憶體及時得到釋放. 這部分是我們需要注意的.具體可以檢視,本系列的記憶體洩露章節,有超詳細講解.

感謝及參考

相關文章