一直以来,跟踪 Node.js 的内存泄漏是一个反复出现的话题,人们始终但愿对其复杂性和缘由了解更多。html
并不是全部的内存泄漏都显而易见。可是,一旦咱们肯定了其模式,就必须在内存使用率,内存中保存的对象和响应时间之间寻找关联。在检查对象时,应该根据本身所用的框架或技术(例如服务器端渲染),研究收集了多少对象,以及它们是否正常。但愿在完成本文结束以后,你将可以理解并寻找一种策略来调试 Node.js 程序的内存消耗。node
JavaScript 是一种垃圾回收语言,而 Google 的 V8 最初是为 Google Chrome 建立的JavaScript引擎,在许多状况下均可以用做独立的运行时。Node.js 中垃圾收集器的两个重要操做是:算法
肯定有用的或无用的对象,而且chrome
须要记住的要点:在垃圾回收器运行时,它将彻底暂停你的程序,直到完成工做为止。所以,你须要经过维护对象的引用来最大程度地减小其工做。npm
V8 JavaScript 引擎会自动分配和取消分配 Node.js 进程使用的全部内存。让咱们看看实际状况是怎样的。编程
若是你将内存视为一个树结构,那么能够想象 V8 从“根节点”开始保存程序中全部的变量。这多是你的 window 对象,也多是 Node.js 模块中的全局对象,一般称为控制者。须要牢记的一点是,你没法对怎样取消分配“根”节点进行控制。json
接下来,你将找到一个 Object 节点,一般被称为叶子(没有子引用的节点)。最后 JavaScript 中有 4 种数据类型:布尔值,字符串,数字和对象。数组
V8 将遍历该树并尝试识别没法从“根”节点访问的数据组。若是没法从“根”节点访问该数据,则 V8 假定再也不使用该数据,并释放内存。请记住:要肯定某个对象是否处于活动状态,须要检查是否可经过被定义为活动对象的某个指针链到达;其余全部的状况,例如没法从根节点访问,或没法被根节点或另外一个活动对象引用的对象,都会被视为垃圾。服务器
简而言之,垃圾收集器有两个主要任务:markdown
跟踪
当你须要跟踪来自另外一个进程的远程引用时,它可能会变得很棘手,可是在 Node.js 程序中,咱们一般用单进程,这样使咱们更加轻松。
V8 使用相似于 Java 虚拟机的方案,并将内存划分为多个段。实现这种包装方案的东西被称为“驻留集”,它是指在 RAM 中驻留的进程所占用的内存部分。
在驻留集中,你会发现:
代码段:代码实际执行的位置。
栈 : 包含局部变量和全部值类型,其指针引用堆上的对象或定义程序的控制流。
还有重要的两点要记住:
对象的浅大小 :保存对象自己所需的内存大小
Node.js 有一个对象,以字节为单位描述 Node.js 进程的内存使用状况。在对象内部,你会发现:
rss : 是指驻留集大小。
heapTotal 和 heapUsed : 是指 V8 的内存使用状况。
Chrome DevTools 是一个很棒的工具,可用于经过远程调试来诊断 Node.js 程序中的内存泄漏。也有其余为你提供相似功能的工具。可是,你须要记住,概要分析是一项繁重的 CPU 任务,可能会对你的程序产生负面影响,必定要注意这一点!
咱们将要介绍的 Node.js 程序是一个简单的 HTTP API Server,它具备多个端点,向使用该服务的人返回不一样的信息。你能够克隆这个程序的repository。
1const http = require('http') 2 3const leak = [] 4 5function requestListener(req, res) { 6 7 if (req.url === '/now') { 8 let resp = JSON.stringify({ now: new Date() }) 9 leak.push(JSON.parse(resp)) 10 res.writeHead(200, { 'Content-Type': 'application/json' }) 11 res.write(resp) 12 res.end() 13 } else if (req.url === '/getSushi') { 14 function importantMath() { 15 let endTime = Date.now() + (5 * 1000); 16 while (Date.now() < endTime) { 17 Math.random(); 18 } 19 } 20 21 function theSushiTable() { 22 return new Promise(resolve => { 23 resolve('寿司'); 24 }); 25 } 26 27 async function getSushi() { 28 let sushi = await theSushiTable(); 29 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) 30 res.write(`Enjoy! ${sushi}`); 31 res.end() 32 } 33 34 getSushi() 35 importantMath() 36 } else { 37 res.end('Invalid request') 38 } 39} 40 41const server = http.createServer(requestListener) 42server.listen(process.env.PORT || 3000)
启动Node.js应用程序:
咱们一直在使用 3S(3 Snapshot)方法进行诊断并肯定可能的内存问题。有趣的是,咱们发现这是 Gmail 团队的 Loreena Lee 长期使用的一种解决内存问题的方法。此方法的步骤:
打开 Chrome DevTools 并访问 chrome://inspect。
注意: 要确保已将 Inspector 附加到要分析的 Node.js 程序。你还能够用 ndb 链接到 Chrome DevTools。
当应用运行时,你将在控制台的输出中看到一条 Debugger Connected 消息。
转到 Chrome DevTools > Memory
在这种状况下,咱们获得了第一个快照,而服务没有进行任何负载或处理。这是针对某些用例的提示:若是咱们可以肯定在接受请求或进行某些处理以前不须要对程序进行任何预热,那就很好了。有时,在获取第一个堆快照以前先进行热身操做是有意义的,由于在某些状况下,你可能会在第一次调用时对全局变量进行了延迟初始化。
在这种状况下,咱们将运行 npm run load-mem。这将启动 ab 来模拟 Node.js 应用程序中的流量或负载。
2 .获得堆快照
3 .再次在你的程序中执行你认为会致使内存泄漏的操做。
4 .获取最终的堆快照
5.选择最新获得的快照。
6.在窗口顶部,找到显示 “All objects” 的下拉列表,并将其切换为“Objects allocated between snapshots 1 and 2”。(若是须要,你也能够对 2 和 3 执行相同的操做)。这将大大减小你看到的对象数量。
比较视图也能够帮你识别那些对象:
在该视图中,你将看到泄漏对象的列表:顶级条目(每一个构造函数一行)、对象到GC根的距离、对象实例数、浅大小和保留大小。你能够经过选择一行来查看其内容。一个好的经验法则是,首先忽略括号中的项目,由于它们是内置结构。@ 字符是对象的惟一 ID,可以让你比较每一个对象的堆快照。
典型的内存泄漏多是经过意外地将对对象的引用存储在没法进行垃圾回收的全局对象中,从而保留了预期仅在一个请求周期内持续存在的对象的引用。
这个例子故意留下了一个内存泄漏的问题,在请求一个从 API 查询返回的对象时生成带有日期时间戳的随机对象,并将其存储在全局数组中来泄漏该对象。经过查看几个保留的对象,你会看到一些泄漏数据的示例,可用于跟踪应用程序中的泄漏。
NSolid 很是适合这种类型的用例,由于它可使你很好地了解在执行的每一个任务或负载测试中内存是怎样增长的。若是你感到好奇,还能够实时查看每一个性能分析动做如何影响 CPU。
demo
在实际项目中,你不可能老是盯着用于监视程序的工具。NSolid 的一大优势是能够为应用程序的不一样指标设置阈值和限制。例如,你能够将 NSolid 设置为在使用的内存量超过 X 时,或者在 X 时间内还没有从高消耗高峰恢复内存的状况下,进行堆快照。听起来不错吧?
V8 的垃圾收集器主要基于 Mark-Sweep 收集算法,该算法包括跟踪垃圾收集,该操做经过标记可达的对象,而后清理内存并回收未标记的对象(必须没法访问),将其归入释放列表。这也称为世代垃圾收集器,对象能够在新声代、重新生代到老生代、以及老生代中移动。
移动对象的代价很是打,由于须要将对象的基础内存复制到新位置,而且指向这些对象的指针也须要更新。
用人话解释:
V8 递归查找全部对象到“根”节点的引用路径。例如:在 JavaScript 中,“window” 对象是能够充当 Root 的全局变量的示例。window 对象始终存在,所以垃圾收集器能够认为它及其全部子对象始终存在(即不是垃圾)。若是有任何引用,则没有指向“根”节点的路径。特别是当它以递归方式查找未引用的对象时,将被标记为垃圾,稍后将会被清除以释放该内存并将其返回给操做系统。
可是,现代的垃圾收集器以不一样的方式对这种算法进行了改进,但本质是相同的:可访问的内存被标记为一类,其他的被视为垃圾。
请记住,从根能够访问到的全部内容均不视为垃圾。不须要的引用是保留在代码中某个位置的变量,这些变量将再也不使用,而且指向能够释放的内存,所以,要了解 JavaScript 中最多见的泄漏,咱们须要了解一般忘记引用的方式。
Orinoco 是最新 GC 项目的代号,它利用最新的增量和并发技术进行垃圾回收,并有释放主线程的功能。描述 Orinoco 性能的重要指标之一是垃圾回收器执行时主线程暂停的频率和时间。对于经典的“世界末日”收集者而言,这些时间间隔会由于延迟、质量差的渲染以及响应时间的增长而影响程序的用户体验。
V8 在新声代内存中的辅助流之间分配垃圾回收工做(清除)。每一个流接收一组指针,而后将全部活动对象移动到“to-space”。
将对象移至“to-space”时,线程须要经过读、写、比较和交换的原子操做进行同步,以免出现另外一个线程找到相同的对象但遵循不一样路径并尝试移动的状况。
引用自 V8 官网:
在现有 GC 中添加并行、增量和并发技术是一项多年的努力,但已取得了回报,将大量工做移交给了后台任务。它大大改善了暂停时间、延迟和页面加载,使动画、滚动和用户交互更加顺畅。并行的 Scavenger 根据工做量将主线程新声代垃圾收集的总时间减小了大约 20%–50%。Idle-time GC 能够在 Gmail 空闲时将其 JavaScript 堆内存减小 45%。并发标记和清除能够将笨重的 WebGL 游戏中的暂停时间减小多达 50%。
Mark-Evacuate 收集器包括三个阶段:标记、复制和更新指针。为了不在新声代中清理页面以维护空闲列表,仍然使用 semi-space 来维护新生代,它始终保持紧凑状态,即在垃圾回收期间将活动对象复制到 “to-space” 中。并行进行的好处是能够得到“exact liveness”信息。经过仅移动和从新连接主要包含活动对象的页面,能够用此信息来避免复制,这也能够由完整的 Mark-Sweep-Compact 收集器执行。它经过和标记清除算法相同的方式标记堆中的活动对象来工做,这意味着堆一般会被碎片化。V8 当前随附有并行的 Scavenger,可在大量基准测试中减小主线程新生代垃圾回收约 20%–50% 的总时间。
与暂停主线程、响应时间和页面加载有关的全部方面都获得了显着改善,这使得页面上的动画、滚动和用户交互更加流畅。并行收集器能够将新内存的总处理时间减小 20–50%,具体取决于负载。可是工做尚未结束:减小停顿仍然是一项重要任务,咱们将继续寻找使用更先进的技术来实现这一目标的可能性。
大多数开发人员在开发 JavaScript 程序时无需考虑 GC,可是了解一些内部知识能够帮助你考虑内存使用状况和有用的编程模式。例如考虑到 V8 中基于世代的堆结构,从 GC 角度来讲,维护低生存期的对象的成本其实是至关低的,由于咱们主要为存在的对象付出代价。这种模式不只特定于 JavaScript,并且对于许多支持垃圾回收的语言也都有效。
请勿使用过期或不推荐的软件包(例如,node-memwatch,node-inspector 或 v8-profiler)来检查内存。你须要的一切都已经集成在了 Node.js 的二进制文件中(尤为是 node.js 检查器和调试器)。若是你须要更专业的工具,则可使用 NSolid、Chrome DevTools 或其余知名软件。
考虑在什么时候何地触发堆快照和 CPU profile。因为要在生产环境中进行快照,你将会但愿同时触发这二者(主要是在测试中),因此这会须要大量的 CPU 操做。另外,在关闭进程和进行冷重启以前,请确认有多少堆转储被写入了。