V8記憶體管理與優化
Node與V8
Node選擇了V8引擎,基於事件驅動、非阻塞I/O模型。
V8的記憶體限制
64位系統約為1.4GB,32位系統約為0.7GB,在這樣限制下,將會導致Node無法直接操作大記憶體物件,比如無法將一個2GB的檔案讀入記憶體中進行字串分析處理,即使實體記憶體有32GB,這樣在單個Node程序的情況下,計算機的記憶體資源無法得到充足的使用。要知曉V8為何限制了記憶體的用量,則需要回歸到V8在記憶體使用上的策略。
V8的物件分配
在V8中,所有的JS物件都是通過堆來進行分配的。
Node提供V8記憶體使用量檢視方式:
$ node $ process.memoryUsage(); { rss: 18702336, heapTotal: 10295296, heapUsed: 5409936 }複製程式碼
heapTotal:已申請到的堆記憶體
heapUsed:當前使用的量
JS宣告變數並賦值時,所使用物件的記憶體就分配在堆中。如果已申請的堆空閒記憶體不夠分配新的物件,將繼續申請堆記憶體,直到對的大小超過V8的限制為止。
至於V8為何要限制堆的大小,表層原因:V8最初為瀏覽器而設計,不太可能遇到用大量記憶體的場景。深層原因:V8的垃圾回收機制的限制。官方說法,以1.5GB的垃圾回收堆記憶體為例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。這是垃圾回收中引起JS執行緒暫停執行的時間,在這樣時間花銷下,應用的效能和響應能力都會直線下降。V8提供選擇來調整記憶體大小的配置,需要在初始化時候配置生效,遇到Node無法分配足夠記憶體給JS物件的情況,可以用如下辦法來放寬V8預設記憶體限制。避免執行過程記憶體用的過多導致崩潰。
node --max-old-space-size=1700 index.js node --max-new-space-size=1024 index.js 複製程式碼
V8的垃圾回收機制
V8垃圾回收策略主要基於分代式垃圾回收機制。
V8的記憶體分代
在 V8 中,主要將記憶體分為新生代和老生代,新生代的物件為存活時間較短的物件,老生代的物件為存活時間較長或常駐記憶體的物件,如下圖:
Scavenge演算法
在分代基礎上,新生代中的物件主要通過 Scavenge 演算法進行垃圾回收。在Scavenge的具體實現中,主要採用了Cheney演算法。
Cheney演算法是一種採用複製的方式實現的垃圾回收演算法。它將堆記憶體一分為二,每一部分空間稱為semispace。在這兩個semispace空間中,只有一個處於使用中,另一個處於閒置狀態。處於使用狀態的semispace空間稱為From空間,處於閒置狀態的空間稱為To空間。當我們分配物件時,先是在From空間中進行分配。當開始進行垃圾回收時,會檢查From空間中的存活物件,這些存活物件將被複制到To空間中,而非存活物件佔用的空間將會被釋放。完成複製後,From空間和To空間的角色發生兌換。簡而言之,在垃圾回收過程中,就是通過將存活物件在兩個semispace空間之間進行復制。
Scavenge的缺點是隻能使用堆記憶體中的一半,這是由劃分空間和複製機制所決定的。但Scavenge由於只複製存活的物件,並且對於生命週期短的場景存活物件只佔少部分,所以它在時間效率上有優異的表現。
由於 Scavenge 是典型的犧牲空間換取時間的演算法,所以無法大規模地應用到所有的垃圾回收中。但可以發現,Scavenge非常適合應用在新生代中,因為新生代中物件的生命週期較短,恰恰適合這個演算法。
V8堆記憶體示意圖:
實際使用的堆記憶體是新生代的兩個semispace空間大小和老生代所用記憶體大小之和。當一個物件經過多次複製依然存活時,它將會被認為是生命週期較長的物件。這種較長生命週期的物件隨後會被移動到老生代中,採用新的演算法進行管理。物件從新生代中移動到老生代中的過程稱為晉升。
在單純的Scavenge過程中,From空間中的存活物件會被複制到To空間中去,然後對From空間和To空間進行角色對換(又稱翻轉)。但在分代式垃圾回收前提下,From空間中的存活物件在複製到To空間之前需要進行檢查。在一定條件下,需要將存活週期長的物件移動到老生代中,也就是完成物件晉升。
物件晉升的條件主要有兩個,一個是物件是否經歷過Scavenge回收,一個是To空間的記憶體佔用比超過限制。
在預設情況下,V8的物件分配主要集中在From空間中。物件從From空間中複製到To空間時,會檢查它的記憶體地址來判斷這個物件是否已經經歷過一次Scavenge回收。如果已經經歷過了,會將該物件從From空間複製到老生代空間中,如果沒有,則複製到To空間中。這個晉升流程如圖:
另一個判斷條件是To空間的記憶體佔用比。當要從From空間複製一個物件到To空間時,如果To空間已經使用了超過25%,則這個物件直接晉升到老生代空間中,這個晉升的判斷示意圖如下圖:
設定25%這個限制值的原因是當這次Scavenge回收完成後,這個To空間將變成From空間,接下來的記憶體分配將在這個空間中進行。如果佔比過高,會影響後續的記憶體分配。物件晉升後,將會在老生代空間中作為存活週期較長的物件來對待,接受新的回收演算法處理。
Mark-Sweep & Mark-Compact
對於老生代中的物件,由於存活物件佔較大比重,再採用Scavenge的方式會有兩個問題:一個是存活物件較多,複製存活物件的效率將會很低;另一個問題依然是浪費一半空間的問題。為此,V8在老生代中主要採用Mark-Sweep和Mark-Compact相結合的方式進行垃圾回收。
Mark-Sweep是標記清除的意思,它分為標記和清除兩個階段。與Scavenge相比,Mark-Sweep並不將記憶體空間劃分為兩半,所以不存在浪費一半空間的行為。與Scavenge複製活著的物件不同,Mark-Sweep在標記階段遍歷堆中所有物件,並標記活著的物件,在隨後的清除階段中,只清除沒有被標記的物件。可以看出,Scavenge中只複製活著的物件,而Mark-Sweep只清理死亡物件。活物件在新生代中只佔較小部分,死物件在老生代中只佔較小部分,這是兩種回收方式能高效處理的原因。
下圖為Mark-Sweep在老生代空間中標記的示意圖,黑色部分標記為死亡物件:
Mark-Sweep最大的問題是在進行一次標記清除回收後,記憶體空間會出現不連續的狀態。這種記憶體碎片會對後續的記憶體分配造成問題,因為很可能出現需要分配一個大物件的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。
為了解決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垃圾回收)的標記、清理、整理等動作造成的停頓就會比較可怕,需要設法改善。
為了降低全堆垃圾回收帶來的停頓時間, V8 先從標記階段入手,將原本要一口氣停頓完成的動作改為增量標記(incremental marking),也就是拆分為許多小“步進”,每做完一“步進”就讓js應用邏輯執行一小會,垃圾回收與應用邏輯交替執行直到標記階段完成。
V8在經過增量標記的改進後,垃圾回收的最大停頓時間可以減少到原本的1/6左右。
V8後續還引入了延遲清理(lazy sweeping)與增量式整理(incremental compaction),讓清理與整理動作也變成增量式的。同時還計劃引入並行標記與並行清理,進一步利用多核效能降低每次停頓的時間。
記憶體洩漏
Node對記憶體洩漏十分敏感,一旦線上應用流量千萬級別,哪怕一個位元組的記憶體洩漏也會造成堆積,垃圾回收過程中將會耗費更多時間進行物件描述,應用響應緩慢,直到程序記憶體溢位,應用崩潰。
在V8的垃圾回收機制下,在通常的程式碼編寫中,很少會出現記憶體洩漏的情況。但是記憶體洩漏通常產生於無意間,較難排查。儘管記憶體洩漏的情況不盡相同,但其實質只有一個,那就是應當回收的物件出現意外而沒有被回收,變成了常駐在老生代中的物件。通常,造成記憶體洩漏的原因有如下幾個。
- 快取:無限制增長的陣列無限制設定屬性和值;
- 佇列消費不及時;
- 作用域未釋放:任何模組內的私有變數和方法均是永駐記憶體的。
總結
從 V8 的自動垃圾回收機制的設計角度可以看到,V8對記憶體使用進行限制的緣由。新生代設計為一個較小的記憶體空間是合理的,而老生代空間過大對於垃圾回收並無特別意義。V8對記憶體限制的設定對於Chrome瀏覽器這種每個選項卡頁面使用一個V8例項而言,記憶體的使用是綽綽有餘,對於Node編寫的伺服器端來說,記憶體限制也並不影響正常場景下的使用。但是對於V8
的垃圾回收特點和js在單執行緒上的執行情況,垃圾回收是影響效能的因素之一。想要高效能執行效率,需要注意讓垃圾回收儘量少地進行,尤其是全堆垃圾回收。
以 Web 伺服器中的會話實現為例,一般通過記憶體來儲存,但在訪問量大的時候會導致老生代中的存活物件驟增,不僅造成清理/整理過程費時,還會造成記憶體緊張,甚至溢位。