首頁 >web前端 >js教程 >圖文詳解Node V8引擎的記憶體和GC

圖文詳解Node V8引擎的記憶體和GC

青灯夜游
青灯夜游轉載
2023-03-29 18:02:082171瀏覽

這篇文章帶大家深入了解NodeJS V8引擎的記憶體和垃圾回收器(GC),希望對大家有幫助!

圖文詳解Node V8引擎的記憶體和GC

一、為什麼需要GC

#程式運行需要使用內存,其中記憶體的兩個分區是我們常常會討論的概念:棧區和堆區。

堆疊區是線性的佇列,隨著函數運行結束自動釋放的,而堆區是自由的動態記憶體空間、堆疊記憶體是手動分配釋放或垃圾回收程式(Garbage Collection,後文都簡稱GC)自動分配釋放的。

軟體發展早期或某些語言對於堆記憶體都是手動操作分配和釋放,例如 CC 。雖然能精準操作內存,達到盡可能的最優內存使用,但是開發效率卻非常低,也容易出現內存操作不當。 【相關教學推薦:nodejs影片教學程式設計教學

#隨著科技發展,高階語言(例如Java Node )都不需要開發者手動操作內存,程式語言自動會分配和釋放空間。同時也誕生了 GC(Garbage Collection)垃圾回收器,幫助釋放和整理記憶體。開發者大部分情況不需要關心記憶體本身,可以專注業務開發。後文主要是討論堆記憶體和 GC

二、GC發展

GC運行會消耗CPU資源,GC運行的過程會觸發STW(stop-the-world)暫停業務代碼線程,為什麼會STW 呢?是為了確保在 GC 的過程中,不會和新建立的物件起衝突。

GC主要是伴隨記憶體大小增加而發展演化。大致分為3個大的代表性階段:

  • 階段一單執行緒GC(代表:serial)

單執行緒GC,在它進行垃圾收集時,必須完全暫停其他所有的工作執行緒 ,它是最初階段的GC,效能也是最糟糕的

    ##階段二並行多執行緒GC(代表:Parallel Scavenge, ParNew)
在多CPU 環境中利用多條GC 執行緒同時並行運行,從而垃圾回收的時間減少、用戶執行緒停頓的時間也減少,這個演算法也會STW,

完全暫停其他所有的工作線程

    階段三多線程並發concurrent GC(代表:CMS (Concurrent Mark Sweep) G1)
這裡的並發是指:GC多執行緒執行可以和業務程式碼並發運行。

在前面的兩個發展階段的 GC 演算法都會完全 STW,而在 concurrent GC 中,有部分階段 GC 執行緒可以和業務程式碼並發運行,保證了更短的 STW 時間。但這個模式就會存在標記錯誤,因為GC 過程中可能有新物件進來,當然演算法本身會修正並解決這個問題

上面的三個階段並不代表GC 一定是上面描述三種的其中一種。不同程式語言的 GC 根據不同需求採用多種演算法組合實作。

三、v8 記憶體分區與GC

堆記憶體設計與GC設計是緊密相關的。 V8 把堆記憶體分為幾大區域,採用分代策略。

盜圖:

圖文詳解Node V8引擎的記憶體和GC

  • 新生代(new-space 或young-generation):空間小,分成了兩個半空間(semi-space),其中的資料存活期短。
  • 老生代(old-space 或old-generation):空間大,可增量,其中的資料存活期長
  • 大物件空間( large-object-space):預設超過256K的物件會在此空間下,下文解釋
  • 程式碼空間(code-space):即時編譯器(JIT)在這裡儲存已編譯的程式碼
  • 元空間(cell space):這個空間用來儲存小的、固定大小的JavaScript對象,像是數字和布林值。
  • 屬性元空間(property cell space):這個空間用來儲存特殊的JavaScript對象,例如存取器屬性和某些內部物件。
  • Map Space:這個空間用來儲存用於JavaScript物件的元資訊和其他內部資料結構,例如Map和Set物件。

3.1 分代策略:新生代與老生代

圖文詳解Node V8引擎的記憶體和GC

#在Node.js 中,GC 採用分代策略,分為新、老生代區,記憶體資料大都在這兩個區域。

3.1.1 新生代

新生代是一個小的、儲存年齡小的物件、快速的記憶體池,分成了兩個半空間(semi-space),一半的空間是空閒的(稱為to空間),另一半的空間是儲存了資料(稱為from空間)。

當物件首次建立時,它們被分配到新生代 from 半空間中,它的年齡為1。當from 空間不足或超過一定大小數量之後,會觸發Minor GC(採用複製演算法Scavenge),此時,GC 會暫停應用程式的執行(STW,stop-the-world),標記(from空間)中所有活動對象,然後將它們整理連續移動到新生代的另一個空閒空間(to空間)。最後原本的from 空間的記憶體會被全部釋放而變成空閒空間,兩個空間就完成fromto 的對換,複製演算法是犧牲了空間換取時間的演算法。

新生代的空間更小,所以此空間會更頻繁的觸發 GC。同時掃描的空間更小,GC效能消耗也更小、它的 GC 執行時間也更短。

每當一次 Minor GC 完成存活的物件年齡就 1,經歷過多次Minor GC還存活的物件(年齡大於N),它們將被移到老生代記憶體池中。

3.1.2 老生代

老生代是一個大的記憶體池,用來儲存較長壽命的物件。老生代記憶體採用 標記清除(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 -SweepMark-Compact 相結合的方式進行垃圾回收。

Mark-Sweep 是標記清除的意思,它分成兩個階段,標記和清除。 Mark-Sweep 在標記階段遍歷堆中的所有對象,並標記活著的對象,在隨後的清除階段中,只清除未被標記的對象。

Mark-Sweep 最大的問題是在進行一次標記清除回收後,記憶體空間會出現不連續的狀態。這種記憶體碎片會對後續的記憶體分配造成問題,因為很可能出現需要分配一個大物件的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。

為了解決 Mark-Sweep 的記憶體碎片問題,Mark-Compact 被提出來。 Mark-Compact 是標記整理的意思,是在 Mark-Sweep 的基礎上演進而來的。它們的差異在於物件在標記為死亡後,在整理過程中,將活著的物件往一端移動,移動完成後,直接清除邊界外的記憶體。 V8 也會根據某個邏輯,釋放一定空閒的記憶體還給系統。

3.2 大物件空間 large object space

大物件會直接在大物件空間創建,並且不會移動到其它空間。那麼到底多大的物件會直接在大物件空間創建,而不是在新生代 from 區中創建呢?查閱資料和原始碼終於找到了答案。預設是 256KV8 似乎並沒有暴露修改指令,原始碼中的 v8_enable_hugepage 設定應該是打包的時候設定的。

chromium.googlesource.com/v8/v8.git/ …

 // 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.

source.chromium.org/ chromium/ch…

圖文詳解Node V8引擎的記憶體和GC

#

圖文詳解Node V8引擎的記憶體和GC

(1 << (18 - 1)) 的结果 256K
(1 << (19 - 1)) 的结果 256K
(1 << (21 - 1)) 的结果 1M(如果开启了hugPage)

四、V8 新老分区大小

4.1 老生代分区大小

在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…

4.2 新生代分区大小

新生代中的一个 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

五、 内存分析相关API

5.1 v8.getHeapStatistics()

执行 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

5.2 process.memoryUsage

process.memoryUsage()
{
  rss: 35438592,
  heapTotal: 6799360,
  heapUsed: 4892976,
  external: 939130,
  arrayBuffers: 11170
}

通过它可以查看当前进程的内存占用和使用情况 heapTotalheapUsed,可以定时获取此接口,然后绘画出折线图帮助分析内存占用情况。以下是 Easy-Monitor 提供的功能:

圖文詳解Node V8引擎的記憶體和GC

建议本地开发环境使用,开启后,尝试大量请求,会看到内存曲线增长,到请求结束之后,GC触发后会看到内存曲线下降,然后再尝试多次发送大量请求,这样往复下来,如果发现内存一直在增长低谷值越来越高,就可能是发生了内存泄漏。

5.3 开启打印GC事件

使用方法

node --trace_gc app.js
// 或者
v8.setFlagsFromString(&#39;--trace_gc&#39;);
  • --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

上面的 ScavengeMark-sweep 代表GC类型,Scavenge 是新生代中的清除事件,Mark-sweep 是老生代中的标记清除事件。箭头符号前是事件发生前的实际使用内存大小,箭头符号后是事件结束后的实际使用内存大小,括号内是内存空间总值。可以看到新生代中事件发生的频率很高,而后触发的老生代事件会释放总内存空间。

  • --trace_gc_verbose

展示堆空间的详细情况

v8.setFlagsFromString(&#39;--trace_gc_verbose&#39;);

[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
  • --trace_gc_nvp

每次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

5.4 内存快照

const { writeHeapSnapshot } = require(&#39;node:v8&#39;);
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 列表,发现同样很多对象,最外层的详情显示的是 MongooseConnection 对象。

圖文詳解Node V8引擎的記憶體和GC

圖文詳解Node V8引擎的記憶體和GC

到此为止,已经大概定位到一个类的 mongo 数据存储逻辑附近有内存泄漏。

再看到 Timeout 对象也比较多,从 GC 根节点距离来看,这些对象距离非常深。点开详情,看到这一层层的嵌套就定位到了代码中准确的位置。因为那个类中有个定时任务使用 setInterval 定时器去分批处理一些不紧急任务,当一个 setInterval 把事情做完之后就会被 clearInterval 清除。

圖文詳解Node V8引擎的記憶體和GC圖文詳解Node V8引擎的記憶體和GC

洩漏解決和最佳化

透過程式碼邏輯分析,最終找到了問題所在,是clearInterval 的觸發條件有問題,導致計時器沒有被清除一直循環下去。定時器一直執行,這段程式碼和其中的資料還在閉包之中,無法被 GC 回收,所以記憶體會越來越大直到達到上限崩潰。

這裡使用setInterval 的方式並不合理,順便改成了利用for await 隊列順序執行,從而達到避免同時間大量並發的效果,代碼也要清晰許多。由於這塊程式碼比較久遠,就不考慮為啥當初使用 setInterval 了。

發布新版本之後,觀察了十多天,內存平均保持在100M出頭,GC 正常回收臨時增長的內存,呈現為波浪曲線,沒有再出現洩漏。

圖文詳解Node V8引擎的記憶體和GC

至此利用記憶體快照,分析並解決了記憶體洩漏。當然實際分析的時候要曲折一點,這個記憶體快照的內容並不好理解、沒那麼直接。快照資料的展示是類型聚合的,需要透過看不同的建構函數,以及內部的資料詳情,結合自己的程式碼綜合分析,才能找到一些線索。 例如從當時我得到的記憶體快照看,有大量資料是閉包、string、mongo model類別、Timeout、Object等,其實這些增量的資料都是來自於那段有問題的程式碼,並且無法被GC 回收。

六、 最後

不同的語言GC 實作都不一樣,例如JavaGo

Java:了解JVM (對應Node V8)的知道,Java 也採用分代策略,它的新生代中還存在一個eden 區,新生的物件都在這個區域創建。而 V8 新生代沒有 eden 區。

Go:採用標記清除,三色標記演算法

不同的語言的 GC 實作不同,但本質上都是採用不同演算法組合實作。在效能上,不同的組合,帶來的各方面效能效率都不一樣,但都是此消彼長,只是偏向不同的應用場景而已。

更多node相關知識,請造訪:nodejs 教學

以上是圖文詳解Node V8引擎的記憶體和GC的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.cn。如有侵權,請聯絡admin@php.cn刪除