背景
M 同学反映自己负责的 Next.js 项目疑似内存泄漏, 临近 618 需要尽快解决! 通过查看 Easy-Monitor 上的「堆内存趋势」曲线📈在一直上涨且不会下降就基本确定了是内存泄漏
问题排查
M 同学也迅速定位到了造成内存泄漏的 commit, 我仔细 review 了一下并没有发现全局变量缓存、闭包引用等高危操作
Easy-Monitor 下载快照
💡 下载的两个快照要是同一个实例的同一个进程
接着就只能从 Easy-Monitor 上间隔一段时间前后下载了两个堆快照,最后通过 Chrome Devtool Memory 面板的 Comparison 功能进行对比, 发现 StyleRule
对象净新增了 57042 个 ⚠️ !
这里的一个小技巧是不要总盯着碎片化的
(array)
、Object
、(string)
以及系统的(system)
、system / Context
等对象的内存变化, 这些对象既不好定位又不容易看懂, 它们通常只是某个对象的属性值, 受其他对象的泄漏而增长可能性大
所有我们需要优先关注 App 应用中使用到的对象, 比如上图中只在该项目中出现的
StyleRule
对象
使用 devtoolx 分析
Chrome Devtool Memory 面板冗余信息多、可读性也较差, 推荐大家使用开源的 devtoolx 进行下一步的分析
1 | npm install devtoolx -g |
尴尬的是开始跑 devtoolx 命令时遇见了下面的报错
好吧, 我还是使用 lldb 先定位 devtoolx 启动失败的问题, 结果发现通过 lldb 启动 devtoolx 又能够正常跑起来
此时排除了 devtoolx 不能识别该 v8 版本的 .heapsnapshot 文件以及系统调用 api 兼容性问题(松了口气, 还是能用 devtoolx ~)
1 | lldb -- /usr/local/bin/node /usr/local/bin/devtoolx -s /Users/duoxiaokai/Downloads/u-b259269e-6bd4-4336-8fc6-f04478496a47-u-x-heapdump-27-20230606-738634.heapsnapshot |
看了一下 devtoolx 的代码, 猜想可能是打开 .heapsnapshot 文件失败了, 于是增加了如下代码再编译运行日志显示 ParseError: Operation not permitted
1 | std::ifstream jsonfile(parser->filename_); |
所以把 .heapsnapshot 文件从 Downloads 目录移了出来就愉快的跑了起来, 上面的代码也提交了一个 devtoolx/pull/18/, 最后作者发布了 devtoolx@1.0.2
版本 ❤️
回归正题, 通过 devtoolx 分别对两个快照分析发现了
- 对象
Object(674385)
的内存由 1.34MB 涨到了 34.95 MB ⚠️, 一展开发现是StyleRule
的父对象 StyleRule
对象的引用关系是StyleSheet.RuleList.xxx.StyleRule
此时我们可以看看 Chrome Devtool Memory 面板的 Summary 功能查看是否有更多 Object(674385)
对象的信息, 最终确认了 StyleRule
对象的引用关系是 StyleSheet.RuleList.map.StyleRule
接着使用 Chrome Devtool Memory 面板的 Comparison 功能查看发现 StyleRule
的父对象 StyleSheet
与 RuleList
并没有新增
定位泄漏点
根据引用关系定位到了 npm 包 jss 的代码, 我们缩小范围直击 RuleList
对象在何种情况会新增子对象 StyleRule
即可
于是乎发现 RuleList
对象的 register 函数每调用一次会在 this.map
对象上挂载一个 StyleRule
对象, 这妥妥的是缓存泄漏啊 ?
当我本地运行该项目也是印证了 Object.keys(this.map).length
一直在增长
问题分析
你和我说一个较为流行的仓库 cssinjs/jss 会内存泄漏我是不太会相信, 至少可能性很小, 大概率还是业务项目的使用姿势有问题
让我们看看官方给的 Server-Side Rendering 使用的 demo, demo 代码很容易猜想到该代码的目的, 即每一次调用 render 函数需要先 new SheetsRegistry()
, 然后通过 JssProvider
传递给子孙组件进行依赖收集。renderToString 函数运行结束即收集到了运行到的组件需要的样式, 最后通过 sheets.toString()
给吐出来
这个行为和 react-loadable 收集动态模块一毛一样 ~
1 | import React from 'react' |
而 M 同学本次刚好用到了内部组件库二次封装的 jss 组件, node_modules 中相关的代码是这样
1 | exports.sheetsRegistry = new jss_1.SheetsRegistry(); |
这样串联起来就能破案了。官方是希望每次请求都新 new 一个 SheetsRegistry
进行依赖收集, 在 render 函数结束 SheetsRegistry
对象出了作用域就被 GC 了。而二次封装的 jss 组件却单例化缓存了一个 SheetsRegistry
对象, 导致每个请求都是同一个 SheetsRegistry 对象在收集依赖且由于全局引用不会被释放造成了本次的内存泄漏