Node的垃圾回收机制与内存溢出捕获(上)
Node的垃圾回收機(jī)制與內(nèi)存溢出捕獲
一、什么是Node的內(nèi)存?
??想必大家在用JavaScript開發(fā)的過程中,不太關(guān)心內(nèi)存的管理,因?yàn)閷τ谇岸藖碚f,瀏覽器的內(nèi)存幾乎不會(huì)出現(xiàn)用完的情況,因?yàn)樗佑|的是那些短時(shí)間執(zhí)行的場景,比如網(wǎng)頁的應(yīng)用、命令工具等。這類場景由于是運(yùn)行短時(shí)間,且運(yùn)行在用戶的機(jī)器上,即使內(nèi)存被消耗過多或者內(nèi)存發(fā)生了泄漏,已只會(huì)影響到終端用戶,并不會(huì)大面積的擴(kuò)散。因此運(yùn)行時(shí)間短,隨著進(jìn)程的退出,內(nèi)存會(huì)自動(dòng)釋放,幾乎沒有內(nèi)存管理的必要。
1.Node的內(nèi)存需要管理嗎?
??答案是必須的。為啥呢?
??因?yàn)镹ode作為后端服務(wù),操作復(fù)雜,并且長期運(yùn)行在服務(wù)器端不重啟。如果不關(guān)注內(nèi)存管理,將會(huì)導(dǎo)致內(nèi)存泄漏,就算是1TB,也會(huì)很快會(huì)被耗盡。
2.Node的內(nèi)存究竟是什么樣的呢?
2.1 Node是在什么環(huán)境下運(yùn)行的呢?
?? 回溯歷史可以發(fā)現(xiàn),Node在發(fā)展的歷程中離不開Chrome V8 (ps:下面會(huì)提到什么是V8),所以在官方的主頁大家可以看到Node是一個(gè)構(gòu)建在Chrome的 JavaScript運(yùn)行上的平臺(tái)(++Node.js? is a JavaScript runtime built on Chrome's V8 JavaScript engine.++)。換句話說,其實(shí)Node.js就是一個(gè)由JavaScript V8引擎控制的C++程序。
?? Google V8是一個(gè)由Google開發(fā)的JavaScript引擎,但它也可以脫離瀏覽器被單獨(dú)使用。 這使得它能夠完美的契合Node.js,實(shí)際上V8也是Node.js平臺(tái)中唯一能夠理解JavaScript的部分。 V8會(huì)將JavaScript代碼向下編譯為本地代碼(native code),然后執(zhí)行它。在執(zhí)行期間,V8會(huì)按需進(jìn)行內(nèi)存的分配和釋放。 這意味著,如果我們在談?wù)揘ode.js的內(nèi)存管理問題,也就是在說V8的內(nèi)存管理問題。
2.2 V8的內(nèi)存管理模式
2.2.1 V8的內(nèi)存設(shè)計(jì)
?? 一個(gè)運(yùn)行的程序通常是通過在內(nèi)存中分配一部分空間來表示的。這部分空間被稱為常駐內(nèi)存(Resident Set)。
?? V8的內(nèi)存管理模式有點(diǎn)類似于Java虛擬機(jī)(JVM),它會(huì)將內(nèi)存進(jìn)行分段:
- 代碼區(qū)(Code Segment):存放即將執(zhí)行的代碼片段
- 棧 Stack:包括所有的攜帶指針引用堆上對象的值類型(原始類型,例如整型和布爾),以及定義程序控制流的指針。
- 堆 Heap:用于保存引用類型(包括對象、字符串和閉包)的內(nèi)存段。
- 堆外內(nèi)存:不通過V8分配,也不受V8管理。Buffer對象的數(shù)據(jù)就存放于此。
2.2.2 V8內(nèi)存模型
?? 除堆外內(nèi)存,其余部分均由V8管理。
- 棧(Stack)的分配與回收非常直接,當(dāng)程序離開某作用域后,其棧指針下移(回退),整個(gè)作用域的局部變量都會(huì)出棧,內(nèi)存收回。
- 最復(fù)雜的部分是堆(Heap)的管理,V8使用垃圾回收機(jī)制進(jìn)行堆的內(nèi)存管理,也是開發(fā)中可能造成內(nèi)存泄漏的部分,是我們需要關(guān)注的重點(diǎn)。
在Node.js中,當(dāng)前的內(nèi)存使用情況可以輕松的使用process.memoryUsage()進(jìn)行查詢, 實(shí)例程序如下:
$ node $ process.memoryUsage() 復(fù)制代碼這是公司內(nèi)部的一個(gè)項(xiàng)目的Node進(jìn)程的內(nèi)存使用狀況:
-
rss是Resident Set Size的縮寫,為常駐內(nèi)存的總大小(單位:bytes),大約21M。
-
heapTotal是V8為堆分配的總大小(單位:bytes),大約9.23M。
-
heapUsed是已使用的堆大小(單位:bytes),大約5.29M。
可以看到,rss是大于heapTotal的,因?yàn)閞ss包括且不限于堆。
- external是堆外內(nèi)存大小(單位:bytes),0.0085M。
當(dāng)我們在代碼中聲明變量并賦值的時(shí)候,所使用對象的內(nèi)存就分配在堆中。如果已申請的堆空間內(nèi)存不夠分配新的對象,將繼續(xù)申請內(nèi)存,直到堆的大小超過V8的限制為止。
2.2.3 V8內(nèi)存限制
?? V8內(nèi)存為何要限制大小呢?V8不就是為了瀏覽器設(shè)計(jì)的么,瀏覽器中不太可能遇到太大的內(nèi)存場景,對于一般正常瀏覽網(wǎng)頁來說,停留的時(shí)間不會(huì)太長,也不太會(huì)進(jìn)行很多復(fù)雜的工作,照理說V8內(nèi)存的限制已經(jīng)綽綽有余了。但是遇到大內(nèi)存的時(shí)候,比如讀取大的文件進(jìn)內(nèi)存,那要怎么辦呢?
?? 其實(shí)引起V8內(nèi)存限制的深層次原因是其垃圾回收機(jī)制的限制。舉個(gè)栗子,官方說法是,以1.5GB的垃圾回收堆內(nèi)存為例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。這是垃圾回收中引起JavaScript線程暫停執(zhí)行的時(shí)間,在這樣的花銷下,應(yīng)用性能和相應(yīng)時(shí)間能力都會(huì)直線下降。這樣的情況不僅僅后端服務(wù)無法接受,前端瀏覽器也無法接受。因此,是時(shí)候需要考慮一下是否改變內(nèi)存的閥值了。
?? 在啟動(dòng)node進(jìn)程的時(shí)候,可以調(diào)整內(nèi)存大小。
node --max-old-space-size=1700 test.js // 單位為MB node --max-new-space-size=1024 test.js // 單位為KB 復(fù)制代碼?? 上述參數(shù)在初始化進(jìn)程的時(shí)候就生效,一旦生效就不能動(dòng)態(tài)擴(kuò)容,一般用來擴(kuò)充內(nèi)存,以免稍微多一些內(nèi)存就崩潰。
2.2.4 V8的內(nèi)存分代
?? V8垃圾回收策略主要基于分代垃圾回收機(jī)制。在實(shí)際應(yīng)用過程中發(fā)現(xiàn),對象的生存周期長短不一,因此只能按照對象的存活時(shí)間將內(nèi)存的垃圾回收進(jìn)行不同的分代。
?? 在V8中,主要將內(nèi)存分為 新生代 和 老生代。新生代中的對象為存活時(shí)間較短的對象,老生代中的對象為存活時(shí)間較長的或常駐內(nèi)存的對象。
V8堆的整體大小就是新生代所用內(nèi)存空間加上老生代的內(nèi)存空間。就是上面所提到的用--max-old-space-size來設(shè)置老生代內(nèi)存空間的最大值,--max-new-space-size來設(shè)置新生代內(nèi)存空間的最大值。?? v8源碼中,我們可以看到這個(gè)說明,在代碼Page::kPageSize=1下:
// semispace_size_ should be a power of 2 and old_generation_size_ should be // a multiple of Page::kPageSize #if defined(V8_TARGET_ARCH_X64) #define LUMP_OF_MEMORY(2 * MB)code_range_size_(512 * MB), #else #define LUMP_OF_MEMORY MBcode_range_size_(0), #endif #if defined(ANDROID)reserved_semispace_size_(4 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),max_semispace_size_(4 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),initial_semispace_size_(Page:: kPageSize),max_old_generation_size_(192 * MB),max_executable_size_(max_old_generation_size_), #elsereserved_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),max_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),initial_semispace_size_(Page:: kPageSize),max_old_generation_size_(700ul * LUMP_OF_MEMORY),max_executable_size_(256l * LUMP_OF_MEMORY), #endif 復(fù)制代碼?? 依照上面的代碼,我們可以看到如果V8標(biāo)記是64位系統(tǒng)的需要*2,32位的不需要。
?? 對于新生代來說,它是由兩個(gè)reserved_semispace_size_所構(gòu)成的,這個(gè)后面會(huì)講到。單個(gè)reserved_semispace_size_在32位上reserved_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),由于Page:: kPageSize為1,所以為8MB,推算出64位的為16MB。因此,新生代內(nèi)存的最大值在64位和32位上分別是32MB和16MB。
?? 對于老生代來說,max_old_generation_size_(700ul * LUMP_OF_MEMORY),32位為700MB,推算出64位的為1400MB。
?? 那堆內(nèi)存的最大值是多少呢? ?? v8堆內(nèi)存的最大保留空間可以從這個(gè)代碼中看出,其公式為:
// Returns the maximum amount of memory reserved for the heap. For // the young generation, we reserve 4 times the amount needed for a // semi space. The young generation consists of two semi spaces and // we reserve twice the amount needed for those in order to ensure // that new space can be aligned to its size intptr_t MaxReserved() {return 4 * reserved_semispace_size_ + max_old_generation_size_; } 復(fù)制代碼?? 因此,在默認(rèn)配置下V8堆內(nèi)存最大值:
- 32位:4*8+700=732MB;
- 64位:4*16+1400=1464MB
2.2.5 V8內(nèi)存算法
?? 在上面的提到了在內(nèi)存分配的時(shí)候分為新生代和老生代。那新老生代之間有什么區(qū)別?如何分配的呢?
?? 接下來我們先講新生代的那些事兒:
2.2.5.1 新生代(Scavenge算法)
?? 新生代主要是存放存活時(shí)間較短的對象,這些對象主要是用Scavenge算法進(jìn)行垃圾回收,在Scavenge的具體 實(shí)現(xiàn)中,主要采用了Cheney算法。
?? Cheney 算法是一種采用復(fù)制的方式實(shí)現(xiàn)的垃圾回收算法。它將堆內(nèi)存一分為二,每一部分空間稱為 semispace。在這兩個(gè) semispace 空間中,只有一個(gè)處于使用中,另一個(gè)處于閑置狀態(tài)。處于使用狀態(tài)的 semispace 空間稱為 From 空間,處于閑置狀態(tài)的空間稱為 To 空間。當(dāng)我們分配對象時(shí),先是在 From 空間中進(jìn)行分配。當(dāng)開始進(jìn)行垃圾回收時(shí),會(huì)檢查 From 空間中的存活對象,這 些存活對象將被復(fù)制到 To 空間中,而非存活對象占用的空間將會(huì)被釋放。完成復(fù)制后,From 空 間和To空間的角色發(fā)生對換。 簡而言之, 在垃圾回收的過程中, 就是通過將存活對象在兩個(gè) semispace 空間之間進(jìn)行復(fù)制。?? Scavenge算法只能使用堆內(nèi)存的一半。但是由于只復(fù)制存活的對象,并且對于生命周期短的場景存活對象只占少部分,所以它具有極高的時(shí)間效率。相當(dāng)于可以理解為犧牲空間換取時(shí)間的算法。
??其實(shí)From空間和To空間進(jìn)行角色交換的時(shí)候是需要進(jìn)行判斷檢查的,在一定條件下,需要將存活周期長的對象移動(dòng)到老生代中,完成對象的晉升。
??對象晉升的主要條件有兩個(gè):
- 對象是否經(jīng)歷過Scavenge回收。
- To空間是否超過25%的限制。
下圖是判斷流程:
2.2.5.2 老生代(Mark-Sweep & Mark-Compact)
?? 由于在老生代中存放對象占較大比重,若再繼續(xù)使用新生代的Scavenge算法會(huì)產(chǎn)生兩個(gè)問題:
- 由于存活對象比較多,復(fù)制存活對象的效率將會(huì)降低。
- 浪費(fèi)一半的空間。
?? 因此在老生代中采用了Mark-Sweep和Mark-Compact相結(jié)合的方式進(jìn)行垃圾的回收。
- Mark-Sweep是標(biāo)記清除的意思,它分為標(biāo)記和清除兩個(gè)階段。Mark-Sweep 在標(biāo)記階段遍歷堆中的所有對象,并標(biāo)記活著的對象,在隨后的清除階段中,只清除沒有被標(biāo)記的對象。可以看出,Scavenge 中只復(fù)制活著的對象,而 Mark-Sweep 只清理死亡對象。
- Mark-Compact是對象在標(biāo)記為死亡后,在整理的過程中,將活著的對象往一端移動(dòng),移動(dòng)完成后,直接清理掉邊界外的內(nèi)存。這是由于Mark-Sweep 在進(jìn)行一次標(biāo)記清除回收后,內(nèi)存空間會(huì)出現(xiàn)不連續(xù)的狀態(tài)引起的,因?yàn)檫@種內(nèi)存碎片會(huì)對后續(xù)的內(nèi)存分配造成問題,很可能出現(xiàn)需要分配一個(gè)大對象的情況,這時(shí)所有的碎片空間都無法完成此次分配,就會(huì)提前觸發(fā)垃圾回收,而這次回收是不必要的。
?? 接下來我們看看3種垃圾回收算法的簡單對比:
| 速度 | 中等 | 最慢 | 最快 |
| 空間開銷 | 少(碎片) | 少(碎片) | 雙倍空間(無碎片) |
| 是否移動(dòng)對象 | 否 | 是 | 是 |
?? V8主要使用Mark-Sweep,在空間不足的情況下對從新生代中晉升過來的對象進(jìn)行分配才使用Mark-Compact。
2.2.5.3 增量標(biāo)記(Incremental Marking)
?? 在執(zhí)行上述三種算法的時(shí)候,垃圾回收機(jī)制會(huì)先把應(yīng)用邏輯暫停下來,待執(zhí)行垃圾回收完后再恢復(fù)執(zhí)行應(yīng)用邏輯。“停頓”現(xiàn)在新老生代中都會(huì)發(fā)生,新生代由于存活對象時(shí)間短,全停頓對全局影響不大,但是在老生代中配置較大,且存活對象較多,全停頓的話影響比較大,因此需要改善。
?? 這時(shí)候就需要引入“增量標(biāo)記”的方式,也就是拆分為許多小的“進(jìn)步”,每做完一“進(jìn)步”就讓JavaScript應(yīng)用邏輯執(zhí)行一會(huì)兒,垃圾回收與應(yīng)用邏輯交替執(zhí)行直到標(biāo)記階段完成。
?? 例如:一次執(zhí)行標(biāo)記可能需要幾百毫秒才能完成一個(gè)大的堆。
??在增量標(biāo)記期間,垃圾收集器將標(biāo)記工作分解為更小的塊,并且允許應(yīng)用程序在塊之間運(yùn)行:
??垃圾收集器選擇在每個(gè)塊中執(zhí)行多少增量標(biāo)記來匹配應(yīng)用程序的分配速率。一般情況下,這極大地提高了應(yīng)用程序的相應(yīng)速度。對內(nèi)存壓力較大的堆,收集器仍然可能出現(xiàn)長時(shí)間的暫停來維持分配。??總的來說,V8經(jīng)過增量標(biāo)記后的,垃圾回收機(jī)制最大停頓時(shí)間可以減少到原本的1/6左右。同時(shí)還引入了延遲清理和增量式整理,讓清理與整理也變成增量式。
2.2.5.4 并行標(biāo)記
??并行標(biāo)記發(fā)生在主線程和工作線程上。應(yīng)用程序在整個(gè)并行標(biāo)記階段暫停。它是 stop-the-world 標(biāo)記的多線程版本。
??并發(fā)標(biāo)記主要發(fā)生在工作線程上。當(dāng)并發(fā)標(biāo)記正在進(jìn)行時(shí),應(yīng)用程序可以繼續(xù)運(yùn)行。
??在并行標(biāo)記的時(shí)候,我們可以假定應(yīng)用都不會(huì)同時(shí)運(yùn)行。這大大的簡化了實(shí)現(xiàn),是因?yàn)槲覀兛梢约俣▽ο髨D是靜態(tài)的,而且不會(huì)改變。為了并行標(biāo)記對象圖,我們需要讓垃圾收集數(shù)據(jù)結(jié)構(gòu)的線程是安全的,而且尋找一個(gè)可以在線程間運(yùn)行的高效共享標(biāo)記的方法。下面的示意圖展示了并行標(biāo)記包含的數(shù)據(jù)結(jié)構(gòu)。箭頭代表數(shù)據(jù)流的方向。簡單來說,示意圖省略了堆碎片處理所需的數(shù)據(jù)結(jié)構(gòu)。
??注意,這些線程只能讀取對象圖,而不能修改它。對象的標(biāo)記位和標(biāo)記列表必須支持讀寫訪問。2.2.5.4 并發(fā)標(biāo)記
??并發(fā)標(biāo)記允許 JavaScript 在主線程上運(yùn)行,而工作線程正在訪問堆上的對象。這為潛在的競態(tài)數(shù)據(jù)打開大門。舉個(gè)例子:當(dāng)工作者線程正在讀取字段時(shí),JavaScript 可能正在寫入對象字段。競態(tài)數(shù)據(jù)會(huì)混淆垃圾回收器釋放活動(dòng)對象或者將原始值和指針混合在一起。
主線程的每個(gè)改變對象圖表的操作將會(huì)是競態(tài)數(shù)據(jù)的潛在來源。由于 V8 是具有多種對象布局優(yōu)化功能的高性能引擎,潛在競態(tài)數(shù)據(jù)來源目錄相當(dāng)長。以下是高層次故障:
-
對象分配
-
寫對象
-
對象布局變化
-
快照反序列化
-
功能脫優(yōu)化實(shí)現(xiàn)
-
年輕代垃圾回收期間的疏散
-
代碼修補(bǔ)
??在以上這些操作上,主線程需要與工作線程同步。同步代價(jià)和復(fù)雜度是操作而定。大部分操作允許輕量級(jí)的同步和院子操作之間的訪問,但是少部分操作需獨(dú)占訪問對象。
??總的來說,并發(fā)標(biāo)記就是為解決數(shù)據(jù)競爭的問題。
??有了平行標(biāo)記與并發(fā)標(biāo)記后,對比上面講的流程,GC的流程變?yōu)?#xff1a; 從root對象開始掃描,填充對象到marking worklist 分布并發(fā)標(biāo)記任務(wù)到worker threads worker threads幫助main thread去更快地消費(fèi)marking worklist中的對象 main thread 偶爾會(huì)通過執(zhí)行bailout worklist 和 marking worklist來marking 一旦marking worklists為空,main thread 就完成GC行為 在結(jié)束之前,main thread重新掃描roots,可能會(huì)發(fā)現(xiàn)其他的白色節(jié)點(diǎn),這些白色節(jié)點(diǎn)會(huì)在worker threads的幫助下,被平行標(biāo)記。課外學(xué)習(xí)
《深入簡出nodeJS》很不錯(cuò)哦~
《新程序員》:云原生和全面數(shù)字化實(shí)踐50位技術(shù)專家共同創(chuàng)作,文字、視頻、音頻交互閱讀總結(jié)
以上是生活随笔為你收集整理的Node的垃圾回收机制与内存溢出捕获(上)的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 滴滴重磅开源跨平台统一 MVVM 框架
- 下一篇: C语言基础:数组作为函数参数传递演示源码