Node - 內存管理和垃圾回收

前言

從前端思惟轉變到後端, 有一個很重要的點就是內存管理。之前寫前端由於只是在瀏覽器上運行, 因此對於內存管理通常不怎麼須要上心, 可是在服務器端, 則須要斤斤計較內存。html

V8的內存限制和垃圾回收機制

內存限制

內存限制
通常的後端語言開發中, 在基本的內存使用是沒有限制的。 但因爲Node是基於V8構建的, 而V8對於內存的使用有必定的限制。 在默認狀況下, 64位的機器大概可使用1.4G, 而32則爲0.7G的大小。關於爲何要限制內存大小, 有兩個方面。一個是V8一開始是爲瀏覽器服務的, 而在瀏覽器端這樣的內存大小是綽綽有餘的。另外一個則是待會提到的垃圾回收機制, 垃圾回收會暫停Js的運行, 若是內存過大, 就會致使垃圾回收的時間變長, 從而致使Js暫停的時間過長前端

固然, 咱們能夠在啓動Node服務的時候, 手動設置內存的大小 以下:node

node --max-old-space-size=768 // 設置老生代, 單位爲MB  
node --max-semi-space-size=64 // 設置新生代, 單位爲MB

查看內存
在Node環境中, 能夠經過process.memoryUsage()來查看內存分配 git

rss(resident set size):全部內存佔用,包括指令區和堆棧

heapTotal:V8引擎能夠分配的最大堆內存,包含下面的 heapUsed

heapUsed:V8引擎已經分配使用的堆內存

external: V8管理C++對象綁定到JavaScript對象上的內存

事實上, 對於大文件的操做一般會使用Buffer, 究其緣由就是由於Node中內存小的緣由, 而使用Buffer是不受這個限制, 它是堆外內存, 也就是上面提到的externalgithub

v8的內存分代

目前沒有一種垃圾自動回收算法適用於全部場景, 因此v8的內部採用的實際上是兩種垃圾回收算法。他們回收的對象分別是生存週期較短和生存週期較長的兩種對象。關於具體的算法, 參考下文。 這裏先介紹v8是怎麼作內存分代的。 算法

新生代
v8中的新生代主要存放的是生存週期較短的對象, 它具備兩個空間semispace, 分別爲From和To, 在分配內存的時候將內存分配給From空間, 當垃圾回收的時候, 會檢查From空間存活的對象(廣度優先算法)並複製到To空間, 而後清空From空間, 再互相交換From和To空間的位置, 使得To空間變爲From空間chrome

該算法缺陷很明顯就是有一半的空間一直閒置着而且須要複製對象, 可是因爲新生代自己具備的內存比較小加上其分配的對象都是生存週期比較短的對象, 因此浪費的空間以及複製使用的開銷會比較小。 segmentfault

在64位系統中一個semisapce爲16MB, 而32位則爲8MB, 因此新生代內存大小分別爲32MB和16MB後端

老生代
老生代主要存放的是生存週期比較長的對象。內存按照 1MB 分頁,而且都按照 1MB 對齊。新生代的內存頁是連續的,而老生代的內存頁是分散的,以鏈表的形式串聯起來。 它的內部有4種類型。 數組

Old Space
Old Space 保存的是老生代裏的普通對象(在 V8 中指的是 Old Object Space,與保存對象結構的 Map Space 和保存編譯出的代碼的 Code Space 相對),這些對象大部分是重新生代(即 New Space)晉升而來。

Large Object Space
當 V8 須要分配一個 1MB 的頁(減去 header)沒法直接容納的對象時,就會直接在 Large Object Space 而不是 New Space 分配。在垃圾回收時,Large Object Space 裏的對象不會被移動或者複製(由於成本過高)。Large Object Space 屬於老生代,使用 Mark-Sweep-Compact 回收內存。

Map Space
全部在堆上分配的對象都帶有指向它的「隱藏類」的指針,這些「隱藏類」是 V8 根據運行時的狀態記錄下的對象佈局結構,用於快速訪問對象成員,而這些「隱藏類」(Map)就保存在 Map Space。

Code Space
編譯器針對運行平臺架構編譯出的機器碼(存儲在可執行內存中)自己也是數據,連同一些其它的元數據(好比由哪一個編譯器編譯,源代碼的位置等),放置在 Code Space 中。

關於Map Space和Code Space推薦你們看這兩篇文章, 由於和本文關係不大, 因此不在這裏贅述。 文章1文章2

v8的內存分配以下圖, 圖出處:

V8的垃圾回收機制

新生代
新生代採用Scavenge垃圾回收算法,在算法實現時主要採用Cheney算法。關於算法的實如今上面中已經大體說明了, 但新生代的對象是怎麼晉升到老生代裏面呢?

在默認狀況下,V8的對象分配主要集中在From空間中。對象從From空間中複製到To空間時,會檢查它的內存地址來判斷這個對象是否已經經歷過一次Scavenge回收。若是已經經歷過了,會將該對象從From空間複製到老生代空間中,若是沒有,則複製到To空間中。這個晉升流程以下圖所示

另外一個判斷條件是To空間的內存佔用比。當要從From空間複製一個對象到To空間時,若是To空間已經使用了超過25%,則這個對象直接晉升到老生代空間中,這個晉升的判斷示意圖以下圖所示。

寫屏障
關於新生代掃描的問題, 因爲咱們想回收的是新生代的對象, 那麼只需檢查指向新生代的引用, 那麼在跟隨根對象->新生代或者新生代->新生代的引用時, 那麼掃描會很快。 可是還可能出現的一種狀況是老生代指向了新生代或者指向了根對象, 若是選擇跟隨, 掃描整個堆, 就會花費太多時間。

對於這個問題,V8 選擇的解決方案是使用寫屏障(write barrier),即每次往一個對象寫入一個指針(添加引用)的時候,都執行一段代碼,這段代碼會檢查這個被寫入的指針是不是由老生代對象指向新生代對象的,這樣咱們就能明確地記錄下全部從老生代指向新生代的指針了。這個用於記錄的數據結構叫作 store buffer,每一個堆維護一個,爲了防止它無限增加下去,會按期地進行清理、去重和更新。這樣,咱們能夠經過掃描,得知根對象->新生代和新生代->新生代的引用,經過檢查 store buffer,得知老生代->新生代的引用,就沒有漏網之魚,能夠安心地對新生代進行回收了。

新生代GC圖:

老生代
老生代在64位和32位下具備的內存分別是1400MB和700MB, 若是還使用新生代的Scavenge算法, 不止浪費一半空間, 還須要複製大塊內存。因此, V8在老生代中的垃圾回收策略採用Mark-Sweep和Mark-Compact相結合。

Mark-Sweep(標記清除)
標記清除分爲標記和清除兩個階段。在標記階段須要遍歷堆中的全部對象,並標記那些活着的對象,而後進入清除階段。在清除階段總,只清除沒有被標記的對象。因爲標記清除只清除死亡對象,而死亡對象在老生代中佔用的比例很小,因此效率較高

標記清除有一個問題就是進行一次標記清楚後,內存空間每每是不連續的,會出現不少的內存碎片。若是後續須要分配一個須要內存空間較多的對象時,若是全部的內存碎片都不夠用,將會使得V8沒法完成此次分配,提早觸發垃圾回收。

圖中黑色部分爲標記的死亡對象

Mark-Compact(標記整理)
標記整理正是爲了解決標記清除所帶來的內存碎片的問題。標記整理在標記清除的基礎進行修改,將其的清除階段變爲緊縮極端。在整理的過程當中,將活着的對象向內存區的一段移動,移動完成後直接清理掉邊界外的內存。緊縮過程涉及對象的移動,因此效率並非太好,可是能保證不會生成內存碎片

因爲標記整理須要移動對象, 因此它的速度相對較慢。 V8在主要使用標記清除算法, 在空間不足以分配新生代晉升的對象時才使用標記整理算法。

白色格子爲存活對象,深色格子爲死亡對象,淺色格子爲存活對象移動後留下的空洞

關於標記的具體算法, 若是將對中的對象看作由指針作邊的有向圖,標記算法的核心就是深度優先搜索。
V8使用每一個對象的兩個mark-bits和一個標記工做棧來實現標記,兩個mark-bits編碼三種顏色:白色(00),灰色(10)和黑色(11)。

  • 白色: 表示對象能夠回收
  • 黑色: 表示對象不能夠回收,而且他的全部引用都被便利完畢了
  • 灰色: 表示對象不可回收,他的引用對象沒有掃描完畢。

當老生代GC啓動時, V8會掃描老生代的對象, 並對其進行標記。 大體的流程以下:

  1. 將全部非根對象標記爲白色。
  2. 將根的全部直接引用對象入棧,並標記爲灰色(marking worklist)
  3. 從這些對象開始作深度優先搜索,每訪問一個對象,就將它 pop 出來,標記爲黑色,而後將它引用的全部白色對象標記爲灰色,push 到棧上
  4. 棧空的時候,回收白色的對象
但這裏須要留意一下, 當對象太大沒法 push 進空間有限的棧的時候,V8 會先把這個對象保留灰色放棄掉,而後將整個棧標記爲溢出狀態(overflowed)。在溢出狀態下,V8 會繼續從棧上 pop 對象,標記爲黑色,再將引用的白色對象標記爲灰色和溢出,但不會將這些灰色的對象 push 到棧上去。這樣沒多久,棧上的全部對象都被標黑清空了。此時 V8 開始遍歷整個堆,把那些同時標記爲灰色和溢出對象按照老方法標記完。因爲溢出後須要額外掃描一遍堆(若是發生屢次溢出還可能掃描多遍),當程序建立了太多大對象的時候,就會顯著影響 GC 的效率。 引用自 文章
增量標記與惰性清理
事實上, v8爲了下降全堆垃圾回收帶來的停頓時間, 使用了 增量標記和惰性清理兩種方式。

增量標記
將本來要一口氣停頓完成的動做改成增量標記(incremental marking),也就是拆分爲許多小「步進」,每作完一「步進」就讓JavaScript應用邏輯執行一小會兒,垃圾回收與應用邏輯交替執行直到標記階段完成。

由於增量標記的過程當中, 頗有可能被標記爲白色的對象又被從新引用, 因此須要一個寫屏障(write-barrier)來實現通知。

// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}

下圖爲增量標記示意圖。

惰性清理
全部的對象已被處理,所以非死即活,堆上多少空間能夠變爲空閒已經成爲定局。此時咱們能夠不急着釋放那些空間,而將清理的過程延遲一下也並沒有大礙。所以無需一次清理全部的頁,垃圾回收器會視須要逐一進行清理,直到全部的頁都清理完畢。

Orinoco

V8將新一代的GC稱爲Orinoco, 在Orinoco下, GC的算法更加高效。

Orinoco 新生代
關於Orinoco在新生代中, 其實比較容易理解, 由於它只是增長了幾個worker線程來幫助處理, 如圖:

Orinoco 老生代

並行標記 parallel marking

並行標記是標記由主線程和工做線程進行, 程序會阻塞

其數據結構如圖所示:

Marking worklist負責決定分給其餘worker thread的工做量,決定了性能與保持本地線程的均衡,V8使用基於內存段的方式去平衡各個線程的工做量,避免線程同步的耗時與儘量的工做。即將內存分爲一段段給每一個線程工做。

併發標記 Concurrent marking

併發標記是由工做線程進行標記, 主線程繼續運行, 程序不會阻塞

併發標記容許標記行爲與應用程序同時進行,極可能發生數據競爭, 因此main thread須要與worker threads在發生數據競爭時進行同步,大多數的數據競爭行爲經過輕量級的原子級內存訪問就能夠同步,可是一些特殊的場景須要獨佔整個對象的訪問。V8是利用一個Bailout worklist來處理被獨佔的整個對象, 並由主線程處理, 如圖:

合併
基於並行標記和併發標記, v8最後的垃圾回收機制如圖:

其步驟以下:

  1. 從root對象開始掃描,填充對象到marking worklist
  2. 分佈併發標記任務到worker threads
  3. worker threads 經過合做耗盡marking worklist來幫助main threads 更快地完成標記。
  4. 有時候, main threads也會經過處理bailout worklist和marking worklist參與標記。
  5. 若是marking worklist爲空, 則主線程完成垃圾回收
  6. 在結束以前,main thread從新掃描roots,可能會發現其餘的白色節點,這些白色節點會在worker threads的幫助下,被平行標記

準確式GC

提到GC不得不提一下準確式GC, 這個也是V8引擎效率比較高的緣由, 如下引用自文章

雖然 ECMAScript 中沒有規定整數類型,Number 都是 IEEE 浮點數,可是因爲在 CPU 上浮點數相關的操做一般比整型操做要慢,大多數的 JavaScript 引擎都在底層實現中引入了整型,用於提高 for 循環和數組索引等場景的性能,並配以必定的技巧來將指針和整數(可能還有浮點數)「壓縮」到同一種數據結構中節省空間。

在 V8 中,對象都按照 4 字節(32 位機器)或者 8 字節(64 位機器)對齊,所以對象的地址都能被 4 或者 8 整除,這意味着地址的二進制表示最後 2 位或者 3 位都會是 0,也就是說全部指針的這幾位是能夠空出來使用的。若是將另外一種類型的數據的最後一位也保留出來另做他用,就能夠經過判斷最後一位是 0 仍是 1,來直接分辨兩種類型。那麼,這另外一種類型的數據就能夠直接塞在前面幾位,而不須要沿着一個指針去讀取它的實際內容。在 V8 的語境內這種結構叫作小整數(SMI, small integer),這是語言實現中歷史悠久的經常使用技巧 tagging 的一種。V8 預留全部的字(word,32位機器是 4 字節,64 位機器是 8 字節)的最後一位用於標記(tag)這個字中的內容的類型,1 表示指針,0 表示整數,這樣給定一個內存中的字,它能經過查看最後一位快速地判斷它包含的指針仍是整數,而且能夠將整數直接存儲在字中,無需先經過一個指針間接引用過來,節省空間。

因爲 V8 可以經過查看字的最後一位,快速地分辨指針和整數,在 GC 的時候,V8 可以跳過全部的整數,更快地沿着指針掃描堆中的對象。因爲在 GC 的過程當中,V8 可以準確地分辨它所遍歷到的每一塊內存的內容屬於什麼類型,所以 V8 的垃圾回收器是準確式的。與此相對的是保守式 GC,即垃圾回收器由於某些設計致使沒法肯定內存中內容的類型,只能保守地先假設它們都是指針而後再加以驗證,以避免誤回收不應回收的內存,所以可能誤將數據看成指針,進而誤覺得一些對象仍然被引用,沒法回收而浪費內存。同時由於保守式的垃圾回收器沒有十足的把握區分指針和數據,也就不能確保本身能安全地修改指針,沒法使用那些須要移動對象,更新指針的算法。

內存觀察&GC日誌

GC日誌
範例中的圖片來自:Are your v8 garbage collection logs speaking to you?Joyee Cheung -Alibaba Cloud(Alibaba Group)

option

--trace_gc

--trace_gc_nvp

--trace_gc_verbose

內存觀察
內存觀察這一塊須要藉助第三方工具, 由於一些緣由我的只是在開發和測試階段開啓了easy-monitor觀察是否內存泄漏, 再使用heapdump + chrome dev tools來定位具體的泄漏緣由。其實業內最好的仍是接入alinode, 可是公司接入的困難度比較高, 緣由你們都懂的啦~

另外推薦一些這方面不錯的資料:
《Node.js 調試指南》
關於Nodejs性能監控思考

還有就是一些可能形成內存泄漏的代碼(這裏就不貼代碼了, 網上例子會更詳細):

  • 全局變量
  • 閉包(包括commonjs規範, 其實質是一個閉包生成)
  • 緩存

總結

關於內存和GC, 相應在編碼的時候須要考慮的細節和客戶端不一樣, 須要比較謹慎的爲每一份資源作出安排。

參考

V8 —— 你須要知道的垃圾回收機制
聊聊V8引擎的垃圾回收
淺談V8引擎中的垃圾回收機制
解讀 V8 GC Log(一): Node.js 應用背景與 GC 基礎知識
解讀 V8 GC Log(二): 堆內外內存的劃分與 GC 算法
Orinoco: young generation garbage collection
Concurrent marking in V8
V8 之旅: 垃圾回收器
Are your v8 garbage collection logs speaking to you?Joyee Cheung -Alibaba Cloud(Alibaba Group)

相關文章
相關標籤/搜索