這篇文章帶大家深入了解NodeJS V8引擎的記憶體和垃圾回收器(GC),希望對大家有幫助!
#程式運行需要使用內存,其中記憶體的兩個分區是我們常常會討論的概念:棧區和堆區。
堆疊區是線性的佇列,隨著函數運行結束自動釋放的,而堆區是自由的動態記憶體空間、堆疊記憶體是手動分配釋放或垃圾回收程式(Garbage Collection,後文都簡稱GC)自動分配釋放的。
軟體發展早期或某些語言對於堆記憶體都是手動操作分配和釋放,例如 C、C 。雖然能精準操作內存,達到盡可能的最優內存使用,但是開發效率卻非常低,也容易出現內存操作不當。 【相關教學推薦:nodejs影片教學、程式設計教學】
#隨著科技發展,高階語言(例如Java Node )都不需要開發者手動操作內存,程式語言自動會分配和釋放空間。同時也誕生了 GC(Garbage Collection)垃圾回收器,幫助釋放和整理記憶體。開發者大部分情況不需要關心記憶體本身,可以專注業務開發。後文主要是討論堆記憶體和 GC。
GC運行會消耗CPU資源,GC運行的過程會觸發STW(stop-the-world)暫停業務代碼線程,為什麼會STW 呢?是為了確保在 GC 的過程中,不會和新建立的物件起衝突。
GC主要是伴隨記憶體大小增加而發展演化。大致分為3個大的代表性階段:
單執行緒GC,在它進行垃圾收集時,必須完全暫停其他所有的工作執行緒 ,它是最初階段的GC,效能也是最糟糕的
在多CPU 環境中利用多條GC 執行緒同時並行運行,從而垃圾回收的時間減少、用戶執行緒停頓的時間也減少,這個演算法也會STW,完全暫停其他所有的工作線程
這裡的並發是指:GC多執行緒執行可以和業務程式碼並發運行。 在前面的兩個發展階段的 GC 演算法都會完全 STW,而在 concurrent GC 中,有部分階段 GC 執行緒可以和業務程式碼並發運行,保證了更短的 STW 時間。但這個模式就會存在標記錯誤,因為GC 過程中可能有新物件進來,當然演算法本身會修正並解決這個問題上面的三個階段並不代表GC 一定是上面描述三種的其中一種。不同程式語言的 GC 根據不同需求採用多種演算法組合實作。
新生代是一個小的、儲存年齡小的物件、快速的記憶體池,分成了兩個半空間(semi-space),一半的空間是空閒的(稱為to空間),另一半的空間是儲存了資料(稱為from空間)。
當物件首次建立時,它們被分配到新生代 from 半空間中,它的年齡為1。當from 空間不足或超過一定大小數量之後,會觸發Minor GC(採用複製演算法Scavenge),此時,GC 會暫停應用程式的執行(STW,stop-the-world),標記(from空間)中所有活動對象,然後將它們整理連續移動到新生代的另一個空閒空間(to空間)。最後原本的from 空間的記憶體會被全部釋放而變成空閒空間,兩個空間就完成from 和to 的對換,複製演算法是犧牲了空間換取時間的演算法。
新生代的空間更小,所以此空間會更頻繁的觸發 GC。同時掃描的空間更小,GC效能消耗也更小、它的 GC 執行時間也更短。
每當一次 Minor GC 完成存活的物件年齡就 1,經歷過多次Minor GC還存活的物件(年齡大於N),它們將被移到老生代記憶體池中。
老生代是一個大的記憶體池,用來儲存較長壽命的物件。老生代記憶體採用 標記清除(Mark-Sweep)、標記壓縮演算法(Mark-Compact)。它的一次執行叫做 Mayor GC。當老生代中的物件佔滿一定比例時,即存活物件與總物件的比例超過一定的閾值,就會觸發一次 標記清除 或 標記壓縮。
因為它的空間更大,它的GC執行時間也更長,頻率相對新生代更低。如果老生代完成 GC 回收之後空間還是不足,V8 就會從系統中申請更多記憶體。
可以手動執行 global.gc() 方法,設定不同參數,主動觸發GC。 但是要注意的是,預設情況下,Node.js 是禁用了此方法。如果要啟用,可以透過啟動Node.js 應用程式時新增--expose-gc 參數來開啟,例如:
node --expose-gc app.js
V8 在老生代中主要採用了Mark -Sweep 和Mark-Compact 相結合的方式進行垃圾回收。
Mark-Sweep 是標記清除的意思,它分成兩個階段,標記和清除。 Mark-Sweep 在標記階段遍歷堆中的所有對象,並標記活著的對象,在隨後的清除階段中,只清除未被標記的對象。
Mark-Sweep 最大的問題是在進行一次標記清除回收後,記憶體空間會出現不連續的狀態。這種記憶體碎片會對後續的記憶體分配造成問題,因為很可能出現需要分配一個大物件的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。
為了解決 Mark-Sweep 的記憶體碎片問題,Mark-Compact 被提出來。 Mark-Compact 是標記整理的意思,是在 Mark-Sweep 的基礎上演進而來的。它們的差異在於物件在標記為死亡後,在整理過程中,將活著的物件往一端移動,移動完成後,直接清除邊界外的記憶體。 V8 也會根據某個邏輯,釋放一定空閒的記憶體還給系統。
大物件會直接在大物件空間創建,並且不會移動到其它空間。那麼到底多大的物件會直接在大物件空間創建,而不是在新生代 from 區中創建呢?查閱資料和原始碼終於找到了答案。預設是 256K,V8 似乎並沒有暴露修改指令,原始碼中的 v8_enable_hugepage 設定應該是打包的時候設定的。
// There is a separate large object space for objects larger than // Page::kMaxRegularHeapObjectSize, so that they do not have to move during // collection. The large object space is paged. Pages in large object space // may be larger than the page size.
#
(1 << (18 - 1)) 的结果 256K (1 << (19 - 1)) 的结果 256K (1 << (21 - 1)) 的结果 1M(如果开启了hugPage)
在v12.x 之前:
为了保证 GC 的执行时间保持在一定范围内,V8 限制了最大内存空间,设置了一个默认老生代内存最大值,64位系统中为大约1.4G,32位为大约700M,超出会导致应用崩溃。
如果想加大内存,可以使用 --max-old-space-size 设置最大内存(单位:MB)
node --max_old_space_size=
在v12以后:
V8 将根据可用内存分配老生代大小,也可以说是堆内存大小,所以并没有限制堆内存大小。以前的限制逻辑,其实不合理,限制了 V8 的能力,总不能因为 GC 过程消耗的时间更长,就不让我继续运行程序吧,后续的版本也对 GC 做了更多优化,内存越来越大也是发展需要。
如果想要做限制,依然可以使用 --max-old-space-size 配置, v12 以后它的默认值是0,代表不限制。
参考文档:nodejs.medium.com/introducing…
新生代中的一个 semi-space 大小 64位系统的默认值是16M,32位系统是8M,因为有2个 semi-space,所以总大小是32M、16M。
--max-semi-space-size
--max-semi-space-size 设置新生代 semi-space 最大值,单位为MB。
此空间不是越大越好,空间越大扫描的时间就越长。这个分区大部分情况下是不需要做修改的,除非针对具体的业务场景做优化,谨慎使用。
--max-new-space-size
--max-new-space-size 设置新生代空间最大值,单位为KB(不存在)
有很多文章说到此功能,我翻了下 nodejs.org 网页中 v4 v6 v7 v8 v10的文档都没有看到有这个配置,使用 node --v8-options 也没有查到,也许以前的某些老版本有,而现在都应该使用 --max-semi-space-size。
执行 v8.getHeapStatistics(),查看 v8 堆内存信息,查询最大堆内存 heap_size_limit,当然这里包含了新、老生代、大对象空间等。我的电脑硬件内存是 8G,Node版本16x,查看到 heap_size_limit 是4G。
{ total_heap_size: 6799360, total_heap_size_executable: 524288, total_physical_size: 5523584, total_available_size: 4340165392, used_heap_size: 4877928, heap_size_limit: 4345298944, malloced_memory: 254120, peak_malloced_memory: 585824, does_zap_garbage: 0, number_of_native_contexts: 2, number_of_detached_contexts: 0 }
到 k8s 容器中查询 NodeJs 应用,分别查看了v12 v14 v16版本,如下表。看起来是本身系统当前的最大内存的一半。128M 的时候,为啥是 256M,因为容器中还有交换内存,容器内存实际最大内存限制是内存限制值 x2,有同等的交换内存。
所以结论是大部分情况下 heap_size_limit 的默认值是系统内存的一半。但是如果超过这个值且系统空间足够,V8 还是会申请更多空间。当然这个结论也不是一个最准确的结论。而且随着内存使用的增多,如果系统内存还足够,这里的最大内存还会增长。
容器最大内存 | heap_size_limit |
---|---|
4G | 2G |
2G | 1G |
1G | 0.5G |
1.5G | 0.7G |
256M | 256M |
128M | 256M |
process.memoryUsage() { rss: 35438592, heapTotal: 6799360, heapUsed: 4892976, external: 939130, arrayBuffers: 11170 }
通过它可以查看当前进程的内存占用和使用情况 heapTotal、heapUsed,可以定时获取此接口,然后绘画出折线图帮助分析内存占用情况。以下是 Easy-Monitor 提供的功能:
建议本地开发环境使用,开启后,尝试大量请求,会看到内存曲线增长,到请求结束之后,GC触发后会看到内存曲线下降,然后再尝试多次发送大量请求,这样往复下来,如果发现内存一直在增长低谷值越来越高,就可能是发生了内存泄漏。
使用方法
node --trace_gc app.js // 或者 v8.setFlagsFromString('--trace_gc');
[40807:0x148008000] 235490 ms: Scavenge 247.5 (259.5) -> 244.7 (260.0) MB, 0.8 / 0.0 ms (average mu = 0.971, current mu = 0.908) task [40807:0x148008000] 235521 ms: Scavenge 248.2 (260.0) -> 245.2 (268.0) MB, 1.2 / 0.0 ms (average mu = 0.971, current mu = 0.908) allocation failure [40807:0x148008000] 235616 ms: Scavenge 251.5 (268.0) -> 245.9 (268.8) MB, 1.9 / 0.0 ms (average mu = 0.971, current mu = 0.908) task [40807:0x148008000] 235681 ms: Mark-sweep 249.7 (268.8) -> 232.4 (268.0) MB, 7.1 / 0.0 ms (+ 46.7 ms in 170 steps since start of marking, biggest step 4.2 ms, walltime since start of marking 159 ms) (average mu = 1.000, current mu = 1.000) finalize incremental marking via task GC in old space requested
GCType <heapUsed before> (<heapTotal before>) -> <heapUsed after> (<heapTotal after>) MB
上面的 Scavenge 和 Mark-sweep 代表GC类型,Scavenge 是新生代中的清除事件,Mark-sweep 是老生代中的标记清除事件。箭头符号前是事件发生前的实际使用内存大小,箭头符号后是事件结束后的实际使用内存大小,括号内是内存空间总值。可以看到新生代中事件发生的频率很高,而后触发的老生代事件会释放总内存空间。
展示堆空间的详细情况
v8.setFlagsFromString('--trace_gc_verbose'); [44729:0x130008000] Fast promotion mode: false survival rate: 19% [44729:0x130008000] 97120 ms: [HeapController] factor 1.1 based on mu=0.970, speed_ratio=1000 (gc=433889, mutator=434) [44729:0x130008000] 97120 ms: [HeapController] Limit: old size: 296701 KB, new limit: 342482 KB (1.1) [44729:0x130008000] 97120 ms: [GlobalMemoryController] Limit: old size: 296701 KB, new limit: 342482 KB (1.1) [44729:0x130008000] 97120 ms: Scavenge 302.3 (329.9) -> 290.2 (330.4) MB, 8.4 / 0.0 ms (average mu = 0.998, current mu = 0.999) task [44729:0x130008000] Memory allocator, used: 338288 KB, available: 3905168 KB [44729:0x130008000] Read-only space, used: 166 KB, available: 0 KB, committed: 176 KB [44729:0x130008000] New space, used: 444 KB, available: 15666 KB, committed: 32768 KB [44729:0x130008000] New large object space, used: 0 KB, available: 16110 KB, committed: 0 KB [44729:0x130008000] Old space, used: 253556 KB, available: 1129 KB, committed: 259232 KB [44729:0x130008000] Code space, used: 10376 KB, available: 119 KB, committed: 12944 KB [44729:0x130008000] Map space, used: 2780 KB, available: 0 KB, committed: 2832 KB [44729:0x130008000] Large object space, used: 29987 KB, available: 0 KB, committed: 30336 KB [44729:0x130008000] Code large object space, used: 0 KB, available: 0 KB, committed: 0 KB [44729:0x130008000] All spaces, used: 297312 KB, available: 3938193 KB, committed: 338288 KB [44729:0x130008000] Unmapper buffering 0 chunks of committed: 0 KB [44729:0x130008000] External memory reported: 20440 KB [44729:0x130008000] Backing store memory: 22084 KB [44729:0x130008000] External memory global 0 KB [44729:0x130008000] Total time spent in GC : 199.1 ms
每次GC事件的详细信息,GC类型,各种时间消耗,内存变化等
v8.setFlagsFromString('--trace_gc_nvp'); [45469:0x150008000] 8918123 ms: pause=0.4 mutator=83.3 gc=s reduce_memory=0 time_to_safepoint=0.00 heap.prologue=0.00 heap.epilogue=0.00 heap.epilogue.reduce_new_space=0.00 heap.external.prologue=0.00 heap.external.epilogue=0.00 heap.external_weak_global_handles=0.00 fast_promote=0.00 complete.sweep_array_buffers=0.00 scavenge=0.38 scavenge.free_remembered_set=0.00 scavenge.roots=0.00 scavenge.weak=0.00 scavenge.weak_global_handles.identify=0.00 scavenge.weak_global_handles.process=0.00 scavenge.parallel=0.08 scavenge.update_refs=0.00 scavenge.sweep_array_buffers=0.00 background.scavenge.parallel=0.00 background.unmapper=0.04 unmapper=0.00 incremental.steps_count=0 incremental.steps_took=0.0 scavenge_throughput=1752382 total_size_before=261011920 total_size_after=260180920 holes_size_before=838480 holes_size_after=838480 allocated=831000 promoted=0 semi_space_copied=4136 nodes_died_in_new=0 nodes_copied_in_new=0 nodes_promoted=0 promotion_ratio=0.0% average_survival_ratio=0.5% promotion_rate=0.0% semi_space_copy_rate=0.5% new_space_allocation_throughput=887.4 unmapper_chunks=124 [45469:0x150008000] 8918234 ms: pause=0.6 mutator=110.9 gc=s reduce_memory=0 time_to_safepoint=0.00 heap.prologue=0.00 heap.epilogue=0.00 heap.epilogue.reduce_new_space=0.04 heap.external.prologue=0.00 heap.external.epilogue=0.00 heap.external_weak_global_handles=0.00 fast_promote=0.00 complete.sweep_array_buffers=0.00 scavenge=0.50 scavenge.free_remembered_set=0.00 scavenge.roots=0.08 scavenge.weak=0.00 scavenge.weak_global_handles.identify=0.00 scavenge.weak_global_handles.process=0.00 scavenge.parallel=0.08 scavenge.update_refs=0.00 scavenge.sweep_array_buffers=0.00 background.scavenge.parallel=0.00 background.unmapper=0.04 unmapper=0.00 incremental.steps_count=0 incremental.steps_took=0.0 scavenge_throughput=1766409 total_size_before=261207856 total_size_after=260209776 holes_size_before=838480 holes_size_after=838480 allocated=1026936 promoted=0 semi_space_copied=3008 nodes_died_in_new=0 nodes_copied_in_new=0 nodes_promoted=0 promotion_ratio=0.0% average_survival_ratio=0.5% promotion_rate=0.0% semi_space_copy_rate=0.3% new_space_allocation_throughput=888.1 unmapper_chunks=124
const { writeHeapSnapshot } = require('node:v8'); v8.writeHeapSnapshot()
打印快照,将会STW,服务停止响应,内存占用越大,时间越长。此方法本身就比较费时间,所以生成的过程预期不要太高,耐心等待。
注意:生成内存快照的过程,会STW(程序将暂停)几乎无任何响应,如果容器使用了健康检测,这时无法响应的话,容器可能被重启,导致无法获取快照,如果需要生成快照、建议先关闭健康检测。
兼容性问题:此 API arm64 架构不支持,执行就会卡住进程 生成空快照文件 再无响应, 如果使用库 heapdump,会直接报错:
(mach-o file, but is an incompatible architecture (have (arm64), need (x86_64))
此 API 会生成一个 .heapsnapshot 后缀快照文件,可以使用 Chrome 调试器的“内存”功能,导入快照文件,查看堆内存具体的对象数和大小,以及到GC根结点的距离等。也可以对比两个不同时间快照文件的区别,可以看到它们之间的数据量变化。
一个 Node 应用因为内存超过容器限制经常发生重启,通过容器监控后台看到应用内存的曲线是一直上升的,那应该是发生了内存泄漏。
使用 Chrome 调试器对比了不同时间的快照。发现对象增量最多的是闭包函数,继而展开查看整个列表,发现数据量较多的是 mongo 文档对象,其实就是闭包函数内的数据没有被释放,再通过查看 Object 列表,发现同样很多对象,最外层的详情显示的是 Mongoose 的 Connection 对象。
到此为止,已经大概定位到一个类的 mongo 数据存储逻辑附近有内存泄漏。
再看到 Timeout 对象也比较多,从 GC 根节点距离来看,这些对象距离非常深。点开详情,看到这一层层的嵌套就定位到了代码中准确的位置。因为那个类中有个定时任务使用 setInterval 定时器去分批处理一些不紧急任务,当一个 setInterval 把事情做完之后就会被 clearInterval 清除。
透過程式碼邏輯分析,最終找到了問題所在,是clearInterval 的觸發條件有問題,導致計時器沒有被清除一直循環下去。定時器一直執行,這段程式碼和其中的資料還在閉包之中,無法被 GC 回收,所以記憶體會越來越大直到達到上限崩潰。
這裡使用setInterval 的方式並不合理,順便改成了利用for await 隊列順序執行,從而達到避免同時間大量並發的效果,代碼也要清晰許多。由於這塊程式碼比較久遠,就不考慮為啥當初使用 setInterval 了。
發布新版本之後,觀察了十多天,內存平均保持在100M出頭,GC 正常回收臨時增長的內存,呈現為波浪曲線,沒有再出現洩漏。
至此利用記憶體快照,分析並解決了記憶體洩漏。當然實際分析的時候要曲折一點,這個記憶體快照的內容並不好理解、沒那麼直接。快照資料的展示是類型聚合的,需要透過看不同的建構函數,以及內部的資料詳情,結合自己的程式碼綜合分析,才能找到一些線索。 例如從當時我得到的記憶體快照看,有大量資料是閉包、string、mongo model類別、Timeout、Object等,其實這些增量的資料都是來自於那段有問題的程式碼,並且無法被GC 回收。
不同的語言GC 實作都不一樣,例如Java 和Go:
Java:了解JVM (對應Node V8)的知道,Java 也採用分代策略,它的新生代中還存在一個eden 區,新生的物件都在這個區域創建。而 V8 新生代沒有 eden 區。
Go:採用標記清除,三色標記演算法
不同的語言的 GC 實作不同,但本質上都是採用不同演算法組合實作。在效能上,不同的組合,帶來的各方面效能效率都不一樣,但都是此消彼長,只是偏向不同的應用場景而已。
更多node相關知識,請造訪:nodejs 教學!
以上是圖文詳解Node V8引擎的記憶體和GC的詳細內容。更多資訊請關注PHP中文網其他相關文章!