點選上方“築夢前端”歡迎訂閱

使用JavaScript進行前端開發時幾乎完全不需要關心記憶體管理問題,對于前端程式設計來說,V8限制的記憶體幾乎不會出現用完的情況,但是由于後端程式往往進行的操作更加複雜,并且長期運作在伺服器不重新開機,如果不關注記憶體管理,導緻記憶體洩漏,就算1TB,也會很快用盡。
Node.js建構于V8引擎之上,是以本文首先講解V8引擎的記憶體管理機制,了解底層原理後,再講解Node開發中的記憶體管理與優化。
一、V8的記憶體管理機制
1.1 記憶體管理模型
Node程式運作中,此程序占用的所有記憶體稱為常駐記憶體(Resident Set)。
- 常駐記憶體由以下部分組成:
- 代碼區(Code Segment):存放即将執行的代碼片段
- 棧(Stack):存放局部變量
- 堆(Heap):存放對象、閉包上下文
- 堆外記憶體:不通過V8配置設定,也不受V8管理。Buffer對象的資料就存放于此。
除堆外記憶體,其餘部分均由V8管理。
- 棧(Stack)的配置設定與回收非常直接,當程式離開某作用域後,其棧指針下移(回退),整個作用域的局部變量都會出棧,記憶體收回。
- 最複雜的部分是堆(Heap)的管理,V8使用垃圾回收機制進行堆的記憶體管理,也是開發中可能造成記憶體洩漏的部分,是程式員的關注點,也是本文的探讨點。
通過
process.memoryUsage()
可以檢視此Node程序的記憶體使用狀況:
rss
是Resident Set Size的縮寫,為常駐記憶體的總大小,
heapTotal
是V8為堆配置設定的總大小,
heapUsed
是已使用的堆大小。可以看到,rss是大于heapTotal的,因為rss包括且不限于堆。
1.2 堆記憶體限制
預設情況下,V8為堆配置設定的記憶體不超過1.4G:64位系統1.4G,32位則僅配置設定0.7G。也就是說,如果你想使用Node程式讀一個2G的檔案到記憶體,在預設的V8配置下,是無法實作的。不過我們可以通過Node的啟動指令更改V8為堆設定的記憶體上限:
//更改老年代堆記憶體
--max-old-space-size=3000 // 機關為MB// 更改新生代堆記憶體
--max-new-space-size=1024 // 機關為KB
堆的記憶體上限在啟動時就已經決定,無法動态更改,想要更改,唯一的方法是關閉程序,使用新的配置重新啟動。
1.3 V8的垃圾回收機制
垃圾回收機制演變至今,已經出現了數種垃圾回收算法,各有千秋,适用于不同場景,沒有一種垃圾回收算法能夠效率最優于所有場景。是以研發者們按照存活時間長短,将對象分類,為每一類特定的對象,制定其最适合的垃圾回收算法,以提高垃圾回收總效率。
1.3.1 V8的記憶體分代
- V8将堆中的對象分為兩類:
- 新生代:年輕的新對象,未經曆垃圾回收或僅經曆過一次
- 老年代:存活時間長的老對象,經曆過一次或更多次垃圾回收的對象
預設情況下,V8為老年代配置設定的空間,大概是新生代的40多倍。新對象都會被配置設定到新生代中,當新生代空間不足以配置設定新對象時,将觸發新生代的垃圾回收。
1.3.2 新生代的垃圾回收新生代中的對象主要通過Scavenge算法進行垃圾回收,這是一種采用複制的方式實作記憶體回收的算法。
Scavenge算法将新生代的總空間一分為二,隻使用其中一個,另一個處于閑置,等待垃圾回收時使用。使用中的那塊空間稱為From,閑置的空間稱為To。
當新生代觸發垃圾回收時,V8将From空間中所有應該存活下來的對象依次複制到To空間。
- 有兩種情況不會将對象複制到To空間,而是晉升至老年代:
- 對象此前已經經曆過一次新生代垃圾回收,這次依舊應該存活,則晉升至老年代。
- To空間已經使用了25%,則将此對象直接晉升至老年代。
From空間所有應該存活的對象都複制完成後,原本的From空間将被釋放,成為閑置空間,原本To空間則成為使用中空間,兩個空間進行角色翻轉。
為何To空間使用超過25%時,就需要直接将對象複制到老年代呢?因為To空間完成垃圾回收後将會翻轉為From空間,新的對象配置設定都在此處進行,如果沒有足夠的空閑空間,将會影響程式的新對象配置設定。
因為Scavenge隻複制活着的對象,而根據統計學指導,新生代中大多數對象壽命都不長,長期存活對象少,則需要複制的對象相對來說很少,是以總體來說,新生代使用Scavenge算法的效率非常高。且由于Scavenge是依次連續複制,是以To空間永遠不會存在記憶體碎片。
不過由于Scavenge會将空間對半劃分,是以此算法的空間使用率較低。
1.3.3 老年代的垃圾回收在老年代中的對象,至少都已經曆過一次甚至更多次垃圾回收,相對于新生代中的對象,它們有更大的機率繼續存活,隻有相對少數的對象面臨死亡,且由于老年代的堆記憶體是新生代的幾十倍,其中生活着大量對象,是以如果使用Scavenge算法回收老年代,将會面臨大量的存活對象需要複制的情況,将老年代空間對半劃分,也會浪費相當大的空間,效率低下。是以老年代垃圾回收主要采用标記清除(Mark-Sweep)和标記整理(Mark-Compact)。
這兩種方式并非互相替代關系,而是配合關系,在不同情況下,選擇不同方式,交替配合以提高回收效率。
新生代中死亡對象占多數,是以采用Scavenge算法隻處理存活對象,提高效率。老年代中存活對象占多數,于是采用标記清除算法隻處理死亡對象,提高效率。
當老年代的垃圾回收被觸發時,V8會将需要存活對象打上标記,然後将沒有标記的對象,也就是需要死亡的對象,全部擦除,一次标記清除式回收就完成了:
(灰色為存活對象,白色為清除後的閑置空間)
一切看起來都完美了,可是随着程式的繼續運作,卻會出現一個問題:被清除的對象遍布各個記憶體位址,空間有大有小,其閑置空間不連續,産生了很多記憶體碎片。當需要将一個足夠大的對象晉升至老年代時,無法找到一個足夠大的連續空間安置這個對象。
為了解決這種空間碎片的問題,就出現了标記整理算法。它是在标記清除的基礎上演變而來,當清理了死亡對象後,它會将所有存活對象往一端移動,使其記憶體空間緊挨,另一端就成為了連續記憶體:
雖然标記整理算法可以避免空間碎片,但是卻需要依次移動對象,效率比标記清除算法更低,是以大多數情況下V8會使用标記清理算法,當空間碎片不足以安放新晉升對象時,才會觸發标記整理算法。
1.3.4 增量标記(Incremental Marking)
早期V8在垃圾回收階段,采用全停頓(stop the world),也就是垃圾回收時程式運作會被暫停。這在JavaScript還僅被用于浏覽器端開發時,并沒有什麼明顯的缺點,前端開發使用的記憶體少,大多數時候僅觸發新生代垃圾回收,速度快,卡頓幾乎感覺不到。但是對于Node程式,使用記憶體更多,在老年代垃圾回收時,全停頓很容易帶來明顯的程式遲滞,标記階段很容易就會超過100ms,是以V8引入了增量标記,将标記階段分為若幹小步驟,每個步驟控制在5ms内,每運作一段時間标記動作,就讓JavaScript程式執行一會兒,如此交替,明顯地提高了程式流暢性,一定程度上避免了長時間卡頓。
二、Node開發中的記憶體管理與優化
2.1 手動變量銷毀
當任一作用域存活于作用域棧(作用域鍊)時,其中的變量都不會被銷毀,其引用的資料也會一直被變量關聯,得不到GC。有的作用域存活時間非常長(越是棧底,存活時間越長,最長的是全局作用域),但是其中的某些變量也許在某一時刻後就沒有用處了,是以建議手動設定為null,斷開引用連結,使得V8可以及時GC釋放記憶體。
注意,不使用var聲明的變量,都會成為全局對象的屬性。前端開發中全局對象為window,Node中全局對象為global,如果global中有屬性已經沒有用處了,一定要設定為null,因為全局作用域隻有等到程式停止運作,才會銷毀。
Node中,當一個子產品被引入,這個子產品就會被緩存在記憶體中,提高下次被引用的速度。也就是說,一般情況下,整個Node程式中對同一個子產品的引用,都是同一個執行個體(instance),這個執行個體一直存活在記憶體中。是以,如果任意子產品中有變量已經不再需要,最好手動設定為null,不然會白白占用記憶體,成為“活着的死對象”。
2.2 慎用閉包
- 2.2.1 V8的閉包實作先來看一段例子:
function outer(){var x = 1; // 真正的局部變量:outer執行完後立即死亡var y = 2; // 上下文變量:閉包死亡後才會死亡// 傳回一個閉包return function(){console.log(y); // 使用了外層函數的變量 y
}
}var inner = outer(); // 通過inner變量持有閉包
有不少開發者認為,如果閉包被引用,那麼閉包的外部函數也不會被釋放,其中的所有變量都不會被銷毀,比如我通過inner變量持有了閉包,此時outer中的 x、y 均活在記憶體中,不會被銷毀。事實真是這樣嗎?
答案是:在V8的實作中,當outer執行完畢,x 立即死亡,僅有 y 存活。
V8是這麼做的:
當程式進入一個函數時,将會為這個函數建立一個上下文(Context),初始狀态這個Context是空的,當讀到這個函數(outer)中的閉包聲明時,将會把此閉包(inner)中使用的外部變量,加入Context。在上面的例子中,由于inner函數使用了變量 y ,是以會将 y 加入Context。outer内部所有的閉包,都會持有這個Context。
每一個閉包都會引用其外部函數的Context,以此通路需要讀取的外部變量。被閉包捕捉,加入Context中的變量,我們稱為Context變量,配置設定在堆。而真正的 局部變量(local variable)是 x ,儲存在棧,當outer執行完畢後,其資訊出棧,變量 x 自然銷毀,而Context被閉包引用,如果有任何一個閉包存活,Context都将存活,y 将不會被銷毀。
舉一反三,再來看一個更複雜的例子:
function outer () { var x; // 真正的局部變量var y; // context variable, 被inner1使用var z; // context variable, 被inner2使用function inner1 () {
use(y);
} function inner2 () {
use(z);
} function inner3 () {
} return [inner1, inner2, inner3];
}
x、y、z 三個變量何時死亡?
x 在outer執行完後立即死亡, y、z 需要等到inner1、inner2、inner3三個閉包都死亡後,才會死亡。
x 未被任何閉包使用,是以是一個真正的局部變量,儲存在棧,函數執行完即被出棧死亡。由于 y、z 兩個變量分别被inner1、inner2使用,則它們會被加入outer的Context。所有閉包都會引用外部函數的Context,即使inner3為空,不使用任何外部函數的變量,也會引用Context,是以需要等到三個閉包都死亡後,y、z 才會死亡。
是以:如果較大的對象成為了Context變量,建議嚴格控制引用此Context的閉包生命周期以及閉包數量,或在不需要時,設定為null,以免引起較多記憶體的長期占用。
- 2.2.2 避免深層閉包嵌套
function outer() { var x = HUGE; // 超大對象function inner() { var y = GIANT; // 大對象
use(x); // x 需要使用,需要成為Context變量function innerF() {
use(y); // y 需要使用,需要成為Context變量
} function innerG() {
} return innerG;
} return inner();
}var o = outer(); // HUGE and GIANT 均得不到釋放
變量 o 持有的是innerG閉包,innerG持有着inner的Context,且内部閉包的Context會持有外部閉包的Context,産生Context鍊。
(上下文鍊)
為了減輕GC壓力,建議避免過深嵌套函數/閉包,或及早手動斷開Context變量所引用的大對象。
2.3 大記憶體使用
- 2.3.1 使用stream當我們需要操作大檔案,應該利用Node提供的stream以及其管道方法,防止一次性讀入過多資料,占用堆空間,增大堆記憶體壓力。
-
2.3.2 使用BufferBuffer是操作二進制資料的對象,不論是字元串還是圖檔,底層都是二進制資料,是以Buffer可以适用于任何類型的檔案操作。
Buffer對象本身屬于普通對象,儲存在堆,由V8管理,但是其儲存的資料,則是儲存在堆外記憶體,是有C++申請配置設定的,是以不受V8管理,也不需要被V8垃圾回收,一定程度上節省了V8資源,也不必在意堆記憶體限制。
* 作者:Erum
* 原文位址:https://www.jianshu.com/p/4129a3fce7bb
* 聲明:轉載文章和圖檔均來自公開網絡,版權歸作者本人所有。如果出處有誤或侵犯到原作者權益,請與我們聯系删除或授權事宜。
築夢前端
微信号:tanzhouweb
點選“