如何排查 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

相关文章
|
10月前
|
缓存 监控 Java
说一说 SpringCloud Gateway 堆外内存溢出排查
我是小假 期待与你的下一次相遇 ~
1283 5
|
监控 Java Linux
redisson内存泄漏问题排查
【9月更文挑战第22天】在排查 Redisson 内存泄漏问题时,首先需确认内存泄漏的存在,使用专业工具(如 JProfiler)分析内存使用情况,检查对象实例数量及引用关系。其次,检查 Redisson 使用方式,确保正确释放资源、避免长时间持有引用、检查订阅和监听器。此外,还需检查应用程序其他部分是否存在内存泄漏源或循环引用等问题,并考虑更新 Redisson 到最新版本以修复潜在问题。
593 5
|
搜索推荐 Java API
Electron V8排查问题之分析 node-memwatch 提供的堆内存差异信息来定位内存泄漏对象如何解决
Electron V8排查问题之分析 node-memwatch 提供的堆内存差异信息来定位内存泄漏对象如何解决
390 0
|
Web App开发 监控 Java
Electron V8排查问题之发现的内存泄漏问题如何解决
Electron V8排查问题之发现的内存泄漏问题如何解决
616 0
|
9月前
|
存储
阿里云轻量应用服务器收费标准价格表:200Mbps带宽、CPU内存及存储配置详解
阿里云香港轻量应用服务器,200Mbps带宽,免备案,支持多IP及国际线路,月租25元起,年付享8.5折优惠,适用于网站、应用等多种场景。
2867 0
|
9月前
|
存储 缓存 NoSQL
内存管理基础:数据结构的存储方式
数据结构在内存中的存储方式主要包括连续存储、链式存储、索引存储和散列存储。连续存储如数组,数据元素按顺序连续存放,访问速度快但扩展性差;链式存储如链表,通过指针连接分散的节点,便于插入删除但访问效率低;索引存储通过索引表提高查找效率,常用于数据库系统;散列存储如哈希表,通过哈希函数实现快速存取,但需处理冲突。不同场景下应根据访问模式、数据规模和操作频率选择合适的存储结构,甚至结合多种方式以达到最优性能。掌握这些存储机制是构建高效程序和理解高级数据结构的基础。
948 1
|
9月前
|
存储 弹性计算 固态存储
阿里云服务器配置费用整理,支持一万人CPU内存、公网带宽和存储IO性能全解析
要支撑1万人在线流量,需选择阿里云企业级ECS服务器,如通用型g系列、高主频型hf系列或通用算力型u1实例,配置如16核64G及以上,搭配高带宽与SSD/ESSD云盘,费用约数千元每月。
1123 0
|
存储 编译器 C语言
【C语言篇】数据在内存中的存储(超详细)
浮点数就采⽤下⾯的规则表⽰,即指数E的真实值加上127(或1023),再将有效数字M去掉整数部分的1。
1050 0

热门文章

最新文章