如何排查 Electron V8 引发的内存 OOM 问题(上)

简介: 如何排查 Electron V8 引发的内存 OOM 问题(上)




经过长达大半年时间的崩溃治理后,基于 Electron 框架开发的新版 PC 淘宝直播推流客户端的稳定性终于赶超基于QT 框架开发的旧版本了。剩下的崩溃问题中有 40% 是跟内存 OOM 有关,其中 V8FatalErrorCallback js heap OOM 问题整整困扰了我一个多月。历经千辛万苦终于破案并解决了这个问题,作为技术人来说还是非常兴奋的。为了了解该问题的来龙去脉,本文会从 V8FatalErrorCallback 崩溃问题的堆栈分析开始讲起,然后通过堆栈信息尝试各种解决方案,并对 v8 堆内存进行源码分析和尝试编译 electron 源码提升 v8 堆内存上限都不奏效后(如果对于编译 electron 源码不感兴趣,可以直接跳到如何用 Memory 和 Performance 工具分析内存泄漏问题“章节查看最终解决问题的方案),最终借助 chrome devtools 提供的 Memory 和 Performance 工具一步步排查和解决 Electron v8 引发的内存 OOM 问题,并且触类旁通解决其他内存 OOM 问题。


背景


 为啥会上报 V8FatalErrorCallback 崩溃问题


让我们先来看下 V8FatalErrorCallback 崩溃上报的堆栈信息:


从上面的堆栈信息可以得知,由于 v8 执行老生代 GC 算法时 JaveScript heap out of memory 导致触发了 V8FatalErrorCallback 的崩溃上报。


既然是 v8 heap 堆内存 GC 后仍然无法回收空间导致 OOM,那会不会是缓存一直增长造成的?顺着这个思路发现在 Node.js 的 vm 中编译一段脚本时,最终依赖的对象叫 UnboundScript。在编译过程中,会逐步调用到下面的代码:


CompilationCache* compilation_cache = isolate->compilation_cache();
// 从 Compilation Cache 中查找是否命中maybe_result = compilation_cache->LookupScript(    source, script_details.name_obj, script_details.line_offset,    script_details.column_offset, origin_options, isolate->native_context(),    language_mode);
if (!maybe_result.is_null()) {  // 若命中,则标记命中  compile_timer.set_hit_isolate_cache();} else if (can_consume_code_cache) {  // 反序列化  if (CodeSerializer::Deserialize(isolate, cached_data, source, origin_options).ToHandle(&inner_result) &&      inner_result->is_compiled()) {    // 将反序列化后的内容加入 Compilation Cache    compilation_cache->PutScript(source, isolate->native_context(), language_mode, inner_result);  }}


大致意思是用源码去检索 Compilation Cache 中是否存在相同 key 的对象,若存在则直接返回已经存在的缓存,否则以源码字符串作为 key 将结果储存在 Compilation Cache 中(v8 分配的堆内存上)。


使用 Compilation Cache 的好处是可以加快脚本的编译速度,但副作用是该 Compilation Cache 只有在 CollectAllAvailableGarbage 时才会被回收,而正常的 GC 并不会回收该 Cache,导致 v8 堆内存一直上涨。当Node.js 14 / 16 对应的 v8 在堆内存抵达上限后,GC 时就会触发 V8FatalErrorCallback OOM 的“Bug”。


 尝试解决 V8FatalErrorCallback 崩溃问题


若要解决该问题可通过设置 --no-compilation-cache 关闭 Compilation Cache,但如此一来则无法享受 Compilation Cache。经过权衡之后,我们把主进程的 require('v8-compile-cache') 代码去掉,并且设置如下命令关闭 Compilation Cache,然后高高兴兴地发了个修复版本。


app.commandLine.appendSwitch('js-flags', '--no-compilation-cache')


过两天一看,怎么还是有一堆 V8FatalErrorCallback 崩溃问题上报?通过进一步分析崩溃堆栈信息发现,除了 v8 老生代堆内存 OOM 外,还有下面两类 v8 新生代堆内存 OOM 问题:

  1. v8 新生代内存申请时报 “young object promotion failed”导致 OOM 崩溃
  2. v8 新生代内存申请时报 “reach heap limit”导致 OOM 崩溃



于是尝试将 v8 新生代内存最大值从默认的 16M 提高到 64M(从默认的 16M 设置到 64M 时,Node 应用的整体 GC 性能是有显著提升的,并且反映到压测 QPS 上大约提升了 10%。但是进一步将 Semi space 增大到 128M 和 256M 时,收益确并不明显。而且 Semi space 本身也是作用于新生代对象快速内存分配,本身不宜设置的过大,因此这次优化最终选取最优运行时 Semi space 的值为 64M),对应设置如下,然后抱着试一试的心态再次发了个修复版本。


app.commandLine.appendSwitch('js-flags', '--max-semi-space-size=64')


果不其然,这次发版并没有彻底修复问题。那还有什么解决方案呢?绞劲脑汁想了半天,还是毫无头绪,看来只能通过提升 v8 堆内存上限来延缓 V8FatalErrorCallback 崩溃问题了,对应设置如下:


app.commandLine.appendSwitch('js-flags', '--max-old-space-size=8192')


但没想到这种设置也有坑,设置后死活不生效,v8 还是默认的 4G 堆内存上限。没办法,只能硬着头皮查看 v8 源码分析下堆内存限制的原理。


 通过 v8 源码分析堆内存限制原理


接下来我们通过 v8 源码一步步分析堆内存限制的实现原理,代码逻辑图如下所示:



  1. 首先打开 src\third_party\blink\renderer\core\timing\memory_info.h 文件,看注释里得知 performance.memory 方法也是从这里获取的 v8 堆内存信息,包括 jsHeapSizeLimit 方法获取的 info.js_heap_size_limit 变量值就是 v8 堆内存上限。

  2. 然后打开 src\third_party\blink\renderer\core\timing\memory_info.cc 文件,发现是从 heap_statistics 的 heap_size_limit 方法获取的值赋值给 info.js_heap_size_limit 变量。

  3. 接着打开 src\third_party\electron_node\deps\v8\include\v8.h 文件,发现 heap_size_limit 方法返回的是 heap_size_limit_ 变量值。


  4. 紧接着打开 src\third_party\electron_node\deps\v8\src\api\api.cc 文件,发现是从 heap 的 MaxReserved 方法获取的值赋值给 heap_size_limit_ 变量。


  5. 继续打开 src\third_party\electron_node\deps\v8\src\heap\heap.cc 文件,终于在 MaxReserved 方法找到详细的实现逻辑了。从下面的代码逻辑可以得知,v8 堆内存上限就等于 3 * max_semi_space_size_ + max_old_generation_size_。


  6. 最后我们在 src\third_party\electron_node\deps\v8\src\heap\heap.cc 文件的 ConfigureHeap 方法里找到了初始化 max_semi_space_size_ 和 max_old_generation_size_ 这两个变量的逻辑:



max_semi_space_size_

其中 kSystemPointerSize 等于 sizeof(void*),在 32 位系统是 4 个字节,64 位系统是 8 个字节。也就是说,默认情况下 max_semi_space_size_ 的初始值就是 8MB(32 位)/ 16MB(64 位)。

constexpr int kSystemPointerSize = sizeof(void*);max_semi_space_size_ = 8 * (kSystemPointerSize / 4) * MB;


当然,我们也可以通过下面的指令重设 max_semi_space_size_ 的值。


app.commandLine.appendSwitch('js-flags', '--max-semi-space-size=xxx')


max_old_generation_size_

同理,默认情况下 max_old_generation_size_ 的初始值是 700MB(32 位)/ 1400MB(64 位)。

constexpr int kSystemPointerSize = sizeof(void*);size_t max_old_generation_size = 700ul * (kSystemPointerSize / 4) * MB;


如果这两个变量都按默认值来算的话,32 位系统下 v8 堆内存上限等于 724M(3 * 8M + 700M),64 位系统下 v8 堆内存上限等于 1448M(3 * 16M + 1400M)。但为啥我的 64 位电脑系统下 v8 堆内存上限有 4096M(heapSizeLimit 字段对应的值) 呢?


{  totalHeapSize: 26332,          totalHeapSizeExecutable: 768,  totalPhysicalSize: 26332,  totalAvailableSize: 4174396,  usedHeapSize: 19029,  heapSizeLimit: 4194048,  mallocedMemory: 512,  peakMallocedMemory: 9096,  doesZapGarbage: false}


这是因为我们刚刚看的计算逻辑只是默认情况下的初始值,实际上现在的 v8 还会根据设备的性能来设置限制,所以我们需要针对这个再往下深挖,先看下面这段代码逻辑:


if (constraints.max_old_generation_size_in_bytes() > 0) {  max_old_generation_size = constraints.max_old_generation_size_in_bytes();}



其中 max_old_generation_size_in_bytes 只是获取 max_old_generation_size_ 的 getter 方法,我们需要看具体是哪里调用 set_max_old_generation_size_in_bytes 这个 setter 方法设置该值的。


/** * The maximum size of the old generation. * When the old generation approaches this limit, V8 will perform series of * garbage collections and invoke the NearHeapLimitCallback. * If the garbage collections do not help and the callback does not * increase the limit, then V8 will crash with V8::FatalProcessOutOfMemory. */// gettersize_t max_old_generation_size_in_bytes() const {  return max_old_generation_size_;}// settervoid set_max_old_generation_size_in_bytes(size_t limit) {  max_old_generation_size_ = limit;}


细查可见是在 src\third_party\electron_node\deps\v8\src\api\api.cc 文件里 ConfigureDefaults 方法调用 set_max_old_generation_size_in_bytes,然后传入 old_generation 变量值进行赋值的。而跟该变量值相关的 GenerationSizesFromHeapSize 只是个简单的二分查找,先将 old_generation 设置为 heap_size 的一半,然后计算 young_generation 的值,看二者加起来是否大于 heap_size,若大于则再将 old_generation 减半,以此再迭代。可以看出,核心还是要看 heap_size 是如何计算的。



继续看 HeapSizeFromPhysicalMemory 方法里 heap_size 的计算实现逻辑,原来 old_generation 取的是物理内存通过系数计算出来的值(如电脑物理内存为 16G,则计算得到的值为 8G)和 v8 的最大内存限制(如电脑物理内存为 16G,则计算得到的值为 4G)二者中的最小值。


其中 MaxOldGenerationSize 方法中定义了 v8 的最大老生代的限制,如果按照我的 64 位电脑物理内存 16G 配置的话,则计算得出 old_generation 为 4096M,最终这个值就是 v8 堆内存上限,跟前面 heapSizeLimit 字段值可以对上。

static constexpr size_t kPhysicalMemoryToOldGenerationRatio = 4;static const int kHeapLimitMultiplier = kSystemPointerSize / 4;static constexpr size_t kMaxSize = 1024u * Heap::kHeapLimitMultiplier * MB;



经过上面的源码分析后,电脑物理内存 16G 配置的话 v8 堆内存上限确实只有 4G,主要还是因为 v8 的 v9.2 版本默认使用了指针压缩导致。


那要怎么突破 v8 堆内存上限呢?办法总归是有的,请继续阅读下文。


更多精彩内容,欢迎观看:

如何排查 Electron V8 引发的内存 OOM 问题(中):https://developer.aliyun.com/article/1263249?groupCode=taobaotech

相关文章
|
4月前
|
监控 Java Linux
redisson内存泄漏问题排查
【9月更文挑战第22天】在排查 Redisson 内存泄漏问题时,首先需确认内存泄漏的存在,使用专业工具(如 JProfiler)分析内存使用情况,检查对象实例数量及引用关系。其次,检查 Redisson 使用方式,确保正确释放资源、避免长时间持有引用、检查订阅和监听器。此外,还需检查应用程序其他部分是否存在内存泄漏源或循环引用等问题,并考虑更新 Redisson 到最新版本以修复潜在问题。
141 5
|
5月前
|
JavaScript Java 开发工具
Electron V8排查问题之接近堆内存限制的处理如何解决
Electron V8排查问题之接近堆内存限制的处理如何解决
333 1
|
6月前
|
监控 安全 Java
JVM内存问题之排查Direct Memory泄漏有哪些常用方法
JVM内存问题之排查Direct Memory泄漏有哪些常用方法
182 2
|
5月前
|
搜索推荐 Java API
Electron V8排查问题之分析 node-memwatch 提供的堆内存差异信息来定位内存泄漏对象如何解决
Electron V8排查问题之分析 node-memwatch 提供的堆内存差异信息来定位内存泄漏对象如何解决
139 0
|
5月前
|
Web App开发 监控 Java
Electron V8排查问题之发现的内存泄漏问题如何解决
Electron V8排查问题之发现的内存泄漏问题如何解决
209 0
|
2月前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
393 1
|
1月前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
2月前
|
Java
JVM内存参数
-Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80
|
2月前
|
Java
JVM运行时数据区(内存结构)
1)虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧 (2)本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一 (3)程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
27 3
|
2月前
|
存储 缓存 监控
Elasticsearch集群JVM调优堆外内存
Elasticsearch集群JVM调优堆外内存
56 1

热门文章

最新文章