前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Node.js内存泄漏的原因竟然是……?

Node.js内存泄漏的原因竟然是……?

作者头像
腾讯云开发者
发布2021-10-29 11:40:45
1.7K0
发布2021-10-29 11:40:45
举报
文章被收录于专栏:【腾讯云开发者】

导语 | Node.js内存泄漏的问题经常让开发者头疼,我们应该怎么样解决这类问题呢?本文通过一个V8引擎自身Bug导致Generator内存泄漏案例,来介绍常用的应对手段。

一、背景

最近新开发了一个Node.js服务,却发现上线之后内存一直持续上涨。相信很多使用Node.js做过服务端开发的同学,也遇到过这样的问题,这种情况就是典型的内存泄漏。内存泄漏虽然不会马上让应用停止服务,但是如果不处理的话,轻则会导致你的应用越来越慢,重则会导致应用Crash。所以对于这种情况,我们不能掉以轻心。

二、为什么会内存泄露

(一)C语言中的内存管理(手动管理)

在C语言中,我们如果需要使用一个变量来存储某些值,需要开发者先手动调用malloc函数,向系统申请一块内存,然后才能将相关信息保存到这块内存中。并且使用完之后,开发者还要手动调用free函数将这块内存给释放掉:

代码语言:javascript
复制
# include # include int main(void){    int *p = malloc(sizeof*p); // 申请一块内存    *p = 10; // 将int类型的10写入这块内存中    printf("*p = %d\n", *p); // 输出 *p = 10    free(p); // 释放内存    return 0;}

这种让开发者手动管理内存的方式,严重拖慢了开发效率。而且开发者忘记free的内存块,会一直无法释放。这样也会导致内存泄漏。

(二)Node.js中的内存管理(自动管理)

为了解决手动管理内存带来的问题,V8在内存管理方面做了改进:

  • 开发者在创建数据时,V8会自动分配对应的内存空间,无需再调用malloc。
  • V8引入了GC机制,自动找到程序中不再需要使用的内存,并将其释放

这种方式虽然给我们解决了很大的麻烦,但是也留下了新的问题:开发者习惯于V8帮助我们进行内存管理,从而产生一种不需要关注应用内存的错觉

实际上GC机制并不能完全帮我们回收所有“不需要的内存”(开发者认为不需要的内存,如果没有妥善处理,GC还是不会去回收)

三、问题排查

内存泄漏问题排查起来一般都会比较困难,最常用的方式是通过分析内存泄漏前后的内存快照,对比找出持续增长的内容。

(一)对比内存快照

对比内存快照的方式分为4步

  • 程序启动之后,生成堆快照A。
  • 执行可能导致内存泄漏的操作。
  • 内存上涨后,生成堆快照B。
  • 在Chrome Dev Tool中对比两次快照,找出这段时间内一直增长的内容。
  • 原理
代码语言:javascript
复制
class Person {  constructor(name) {    this.name = name  }}
let persons = []
function leak() {  const bob = new Person('bob')  persons.push(bob)}
genHeapSnapshot() // 伪码: 执行leak函数前, 生成堆快照Aleak()genHeapSnapshot() // 伪码: 执行leak函数后, 生成堆快照B

内存快照A中的信息:

  • 1个array, 变量名为persons。
  • 其他系统对象。

内存快照B的信息:

  • 1个array,变量名为persons。
  • 1个Person,变量名为bob;被persons.0所引用;被leak函数的Context引用(在leak函数中定义)
  • 1个string;被bob中的name属性引用。

把2个快照做对比之后就能发现:leak函数执行完之后,内存中多了1个Person对象和1个string

当leak函数执行10000次后,内存中就会增加10000个Person和string,我们只需要找到这些新增的对象,就能找到内存增长的原因。

  • 实践

获取内存快照的方式有很多,常用的有heapdump、v8-profiler等模块。还可以通过启用Inspector模式,在Chrome Dev Tool中采集Node.js应用的堆内存。

将快照加载到Chrome Dev Tool之后,我们看到增长最多的对象是(system)、(array)、(string)、(compiled code)等。

但是当试图从(system)里边找出问题对象时,就会发现事情没有想象中那么简单。

两次内存快照之间,system新创建了39822个,销毁了39078个,没能正常销毁的只占了1.8%。要找到这1.8%的问题对象,需要耗费不少时间。

虽然对比内存快照的方式,大部分情况下都能帮我们解决问题,但是这次的情况却不太适用。当然,除了快照对比,还有其他的一些方法,比如MAT。

(二)MAT

MAT(Memory Analizer Tool)是Eclipse中的一个插件,经常被用来定位Java中的内存泄漏问题。MAT的思路是:如果发生了内存泄漏,那么这些导致内存泄漏的对象会在内存占很大比重

  • 原理
代码语言:javascript
复制
class Person {}
let persons = []let women = []
function leak() {  const bob = new Person()  const steve = new Person()  const lily = new Person()  persons.push(bob, steve, lily)  women.push(lily)}
leak()genHeapSnapshot() // 伪码: 执行leak函数后, 生成堆快照

这个例子生成的内存快照,其中的对象引用关系,如图中所示(简化版,去掉了各种内置对象):

支配树中的每个节点都有一个Retained Size属性,表示该节点所支配的内存大小,节点自身的Retained Size=所有子节点的Retained Size+节点的Self Size(自己占用的内存大小)

MAT的工作原理是将内存快照转换成一个支配树,将支配树中所支配内存超过一定阈值的对象认为是可疑对象,找到这些对象的支配链,和链上的内存积累点

在我们的例子中,当越来越多的Person被放进persons数组时,persons的Retained Size会变得越来越大。当对象的Retained Size达到一达阈值(可自定义,默认是占总内存的20%),就认为该对象是可疑对象。开发者可以根据对象的支配链路,快速找到问题所在。

  • 实践

可以使用v8-mat这个npm包,把内存快照转换成支配树,并找到内存中的可疑对象。也可以使用Chrome Dev Tool对快照中的对象,按Retained Size进行排序,自行判断。

在服务运行一天后,我们采集了内存快照进行分析,发现了一个内存泄漏可疑点:内存中有一个Generator支配了73%的内存!

虽然找到了可疑的支配链,但是支配链下的对象却是些和业务代码无关的内置对象。

看到这里时,已经有点怀疑是否是Node.js本身存在的Bug。

(三)问题解决

这时在网上发现了一个相似的案例:由于TS将async/await编译成Generator,导致内存泄漏。

(https://github.com/apollographql/apollo-server/issues/3730)

发现是V8引擎存在一个Bug,导致了在11.0.0-12.15.x,使用Generator时,都会出现内存泄漏!

解决方式有2个:去除代码中的Generator,将Node.js将级到12.16以上。

查看了tsconfig.json及编译后的代码,发现并无异常。再到node_modules中查找是否存在yield关键词,结果却搜出来几十个使用了Generator的库。改代码是改不动了,只能尝试升级Node.js到14,看看内存占用是否恢复正常。

可以看到升级之后,Node.js应用的内存消耗已经下降了很多,并且保存在稳定的状态,没有再出现之前持续增长的情况。至此,内存泄漏的问题已经解决。

四、常见的内存泄露场景

最后列举一些常见的内存泄漏场景,在开发过程中,对这些情况稍加注意,能帮助我们避免大部分的内存泄漏问题。

(一)隐式全局变量

没有使用var/let/const声明的变量会直接绑定在Global对象上(Node.js中)或者Windows对象上(浏览器中),哪怕不再使用,仍不会被自动回收:

代码语言:javascript
复制
function test() {  x = new Array(100000);}test();console.log(x); // 输出 [ <100000 empty items> ]

(二)没释放的无用对象(监听器、缓存)

没有释放的监听器,会一直保存在内存中,导致内存无法释放:

代码语言:javascript
复制
class Test {  constructor() {    this.init()  }  init() {    emitter.addListener('message', function() {      // 相关操作    });  }  destroy() {    // 没有removeListener  }}

使用内存作为缓存时,没有释放过期的缓存也是常见的情况:

代码语言:javascript
复制
const app = require('express')()const cache = {};// 设置缓存app.post('/data', (req, res) => {  cache[req.body.key] = req.body.value  res.send('succ')})// 获取缓存app.get('/data', (req, res) => {  res.send(cache[req.params.key])})

(三)闭包

闭包也是导致内存泄漏的常见原因。

代码语言:javascript
复制
const func = function () {  const data = 'inner variable'  return () => {    return data  }}const getData = func()console.log(getData()) // 此时func函数内部的data变量无法释放

五、相关工具介绍

(一)heapdump

(https://github.com/bnoordhuis/node-heapdump)

老牌内存快照生成库,可以通过API或者系统信号的形式,生成内存快照。缺点是只支持内存快照生成,不支持生成CPU Profile文件。

使用API生成快照:

代码语言:javascript
复制
var heapdump = require('heapdump');heapdump.writeSnapshot('/var/local/' + Date.now() + '.heapsnapshot');
代码语言:javascript
复制
使用系统信号生成快照:
代码语言:javascript
复制
kill -USR2 <pid>

(二)v8-profiler

(https://github.com/hyj1991/v8-profiler-next)

支持生成CPU Profile/堆快照/Allocation Profile,缺点是需要登陆机器将生成的文件下载后,使用其他工具进行分析。

生成CPU Profile文件:

代码语言:javascript
复制
const v8Profiler = require('v8-profiler-next');const title = 'good-name';v8Profiler.startProfiling(title, true);setTimeout(() => {  const profile = v8Profiler.stopProfiling(title);  profile.export(function (error, result) {    fs.writeFileSync(`${title}.cpuprofile`, result);    profile.delete();  });}, 5 * 60 * 1000);

生成堆内存快照:

代码语言:javascript
复制
const v8Profiler = require('v8-profiler-next');const snapshot = v8Profiler.takeSnapshot();const transform = snapshot.export();transform.pipe(process.stdout);transform.on('finish', snapshot.delete.bind(snapshot))

生成Allocation Profile:

代码语言:javascript
复制
const v8Profiler = require('v8-profiler-next');const arraytest = [];setInterval(() => {  arraytest.push(new Array(1e2).fill('*').join());}, 20);
v8Profiler.startSamplingHeapProfiling();setTimeout(() => {  const profile = v8Profiler.stopSamplingHeapProfiling();  require('fs').writeFileSync('./shf.heapprofile', JSON.stringify(profile));}, 60 * 1000);

(三)Chrome Inspector

使用--inspect参数启动服务,会默认在9229端口启动一个websocket server,Chrome DevTool连接该端口后,可以对Node.js程序进行Debug。Chrome DevTool功能齐全,缺点是线上机房网络与本地开发网络不通,使用不便,通常只在DevCloud开发机中使用。

开启inspect模式:

代码语言:javascript
复制
node --inspect=0.0.0.0:9229 app.js

访问chrome://inspect/可以对指定进程进行调试,采集CPU Profile、堆快照等。

六、结语

虽然JavaScript、Java等语言能帮我们自动回收内存,提高了开发效率,但是这并不意味着不会出现内存泄漏的情况。作为开发者,在开发过程中也需要对可能的内存泄漏,保持敏锐的嗅觉。同时还需要了解相关的问题排查方法,即便是应用上线之后才发现问题,我们也能够快速将它解决。

 作者简介

王思鸿

腾讯高级前端工程师

腾讯高级前端工程师,毕业于华中科技大学,目前负责腾讯教育企鹅辅导业务的开发工作。专注于前端性能优化与全栈开发,在Node.js监控领域有深入研究。

 推荐阅读

超详细教程!手把手带你使用Raft分布式共识性算法

Pulsar与Rocketmq、Kafka、Inlong-TubeMQ,谁才是消息中间件的王者?

gRPC如何在Golang和PHP中进行实战?7步教你上手!

详细解答!从C++转向Rust需要注意哪些问题?


本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-10-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯云开发者 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • (一)C语言中的内存管理(手动管理)
  • (二)Node.js中的内存管理(自动管理)
  • 内存泄漏问题排查起来一般都会比较困难,最常用的方式是通过分析内存泄漏前后的内存快照,对比找出持续增长的内容。
  • (一)对比内存快照
  • (三)问题解决
  • (一)隐式全局变量
  • (二)没释放的无用对象(监听器、缓存)
  • (三)闭包
  • (一)heapdump
  • (二)v8-profiler
  • (三)Chrome Inspector
相关产品与服务
消息队列 TDMQ
消息队列 TDMQ (Tencent Distributed Message Queue)是腾讯基于 Apache Pulsar 自研的一个云原生消息中间件系列,其中包含兼容Pulsar、RabbitMQ、RocketMQ 等协议的消息队列子产品,得益于其底层计算与存储分离的架构,TDMQ 具备良好的弹性伸缩以及故障恢复能力。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档