js内存深入学习(二)
继上一篇文章 js内存深入学习(一)
3. 内存泄漏
对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。 对于不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)
3.1 node.js内存补充
node.js中V8中的内存分代:
- 新生代:存活时间较短的对象,会被GC自动回收的对象及作用域,比如不被引用的对象及调用完毕的函数等。
- 老生代:存活时间较长或常驻内存的对象,比如闭包因为外部仍在引用内部作用域的变量而不会被自动回收,故会被放在常驻内存中,这种就属于在新生代中持续存活,所以被移到了老生代中,还有一些核心模块也会被存在老生代中,例如文件系统(fs)、加密模块(crypto)等
- 如何调整内存分配大小:
-
启动node进程时添加参数即可 node –max-old-space-size=1700 <project-name>.js 调整老生代内存限制,单位为MB(貌似最高也只能1.8G的样子)(老生代默认限制为 64/32 位 => 1400/700 MB)
-
node –max-new-space-size=1024 <project-name>.js 调整新生代内存限制,单位为KB(老生代默认限制为 64/32 位 => 32/16 MB) 接!
-
内存回收时使用的算法:
-
Scavenge 算法(用于新生代,具体实现中采用 Cheney 算法)
- 算法的结果一般只有两种,空间换时间或时间换空间,Cheney属于前者
- 它将现有的空间分半,一个作为 To 空间,一个作为 From 空间,当开始垃圾回收时会检查 from 空间中存活的对象并赋复制入 To 空间中,而非存活就会被直接释放,完成复制后,两者职责互换,下一轮回收时重复操作,也就是说我们本质上只使用了一半的空间,明显放在老生代这么大的内存浪费一半就很不合适,而且老生代一般生命周期较长,需要复制的对象过多,正因此所以它就被用于新生代中,新生代的生命周期短,一般不会有这么大的空间需要留存,相对来说这是效率最高的选择,刚和适合这个算法
- 前面我们提到过,如果对象存活时间较长或较大就会从新生代移到老生代中,那么何种条件下会过渡呢,满足以下2个条件中的一个就会被过渡
- 在一次 from => to 的过程中已经经历过一次 Scavenge 回收,即经过一次新生代回收后,再下次回收时仍然存在,此时这个对象将会从本次的 from 中直接复制到老生代中,否则则正常复制到 To
- from => to 时,占用 to 的空间达到 25% 时,将会由于空间使用过大自动晋升到老生代中
-
Mark-Sweep & Mark-Compact(用于老生代的回收算法)
- 新生代的最后我们提到过,Cheney 会浪费一半的空间,这个缺点在老生代是不可原谅的,毕竟老生代有 1.4G 不是,浪费一半就是 700M 啊,而且每次都去复制这么多常驻对象,简直浪费,所以我们是不可能继续采纳 Scavenge 的;
- mark-sweep 顾名思义,标记清除,上一条我们提到过,我们要杜绝大量复制的情况,因为大部分都是常驻对象,所以 mark-sweep 只会标记死去的老对象,并将其清除,不会去做复制的行为,因为死对象在老生代中占比是很低的,但此时我们很明显看到它的缺点就是清除死去的部分后,可能会造成内存的不连续而在下次分配大对象前立刻先触发回收,但是其实需要回收的那些在上轮已经被清除了,只是没有将活着的对象连续起来 。缺点举例:这就像 buffer 一样,在一段 buffer 中,我们清除了其中断断续续的部分,这些部分就为空了,但是剩下的部分会变得不连续,下次我们分配大对象进来时,大对象是一个整体,我们不可能将其打散分别插入原本断断续续的空间中,否则将变的不连续,下次我们去调用这个大对象时也将变得不连续,这就没有意义了,这就像你将一个人要塞进一个已经装满了家具的房间里一样,各个家具间可能会存在空隙,但是你一个整体的人怎么可能打散分散到这些空间?并在下次调用时在拼到一起呢(什么纳米单位的别来杠,你可以自己想其他例子)
- 在这个缺点的基础上,我们使用了 mark-compact 来解决,它会在 mark-sweep 标记死亡对象后,将活着的对象全部向一侧移动,移动完成后,一侧全为生,一侧全为死,此时我们便可以直接将死的一侧直接清理,下次分配大对象时,直接从那侧拼接上即可,仿佛就像把家具变成工整了,将一些没用的小家具整理到一侧,将有用的其他家具全部工整摆放,在下次有新家具时,将一侧的小家具全部丢掉,在将新的放到有用的旁边紧密结合。
buffer 声明的都为堆外内存,它们是由系统限定而非 V8 限定,直接由 C++ 进行垃圾回收处理,而不是 V8,在进行网络流与文件 I/O 的处理时,buffer 明显满足它们的业务需求,而直接处理字符串的方式,显然在处理大文件时有心无力。所以由 V8 处理的都为堆内内存。
3.2 识别方法
1、浏览器方法
- 打开开发者工具,选择 Memory
- 在右侧的Select profiling type字段里面勾选 timeline
- 点击左上角的录制按钮。
- 在页面上进行各种操作,模拟用户的使用情况。
- 一段时间后,点击左上角的 stop 按钮,面板上就会显示这段时间的内存占用情况。
2、命令行方法 使用 Node 提供的 process.memoryUsage 方法。
console.log(process.memoryUsage()); // 输出 { rss: 27709440, // resident set size,所有内存占用,包括指令区和堆栈 heapTotal: 5685248, // "堆"占用的内存,包括用到的和没用到的 heapUsed: 3449392, // 用到的堆的部分 external: 8772 // V8 引擎内部的 C++ 对象占用的内存 }
判断内存泄漏,以heapUsed字段为准。
3.3 常见内存泄露场景
-
意外的全局变量
function foo(arg) { bar = "this is a hidden global variable"; // winodw.bar = ... } 或者 function foo() { this.variable = "potential accidental global"; } // Foo 调用自己,this 指向了全局对象(window) // 而不是 undefined foo();
解决方法: 在 JavaScript 文件头部加上 ‘use strict’,使用严格模式避免意外的全局变量,此时上例中的this指向undefined。
尽管我们讨论了一些意外的全局变量,但是仍有一些明确的全局变量产生的垃圾。它们被定义为不可回收(除非定义为空或重新分配)。尤其当全局变量用于临时存储和处理大量信息时,需要多加小心。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。与全局变量相关的增加内存消耗的一个主因是缓存。缓存数据是为了重用,缓存必须有一个大小上限才有用。高内存消耗导致缓存突破上限,因为缓存内容无法被回收。
-
被遗忘的计时器或回调函数
如计时器的使用:
var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // 处理 node 和 someResource node.innerHTML = JSON.stringify(someResource)); } }, 1000);
定义了一个someResource变量,变量在计时器setInterval内部一直被引用着,成为一个闭包使用,即使移除了Node节点,由于计时器setInterval没有停止。其内部还是有对someResource的引用,所以v8不会释放someResource变量的。
var element = document.getElementById('button'); function onClick(event) { element.innerHTML = 'text'; } element.addEventListener('click', onClick);
对于上面观察者的例子,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。因为老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄漏。
但是,现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法(标记清除),已经可以正确检测和处理循环引用 了。即回收节点内存时,不必非要调用 removeEventListener 了。(不是很理解)
-
对DOM 的额外引用
如果把DOM 存成字典(JSON 键值对)或者数组,此时,同一个 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。如果要回收该DOM元素内存,需要同时清除掉这两个引用。
var elements = { button: document.getElementById('button'), image: document.getElementById('image'), text: document.getElementById('text') }; document.body.removeChild(document.getElementById('button')); // 此时,仍旧存在一个全局的 #button 的引用(在elements里面)。button 元素仍旧在内存中,不能被回收。
如果代码中保存了表格某一个 <td> 的引用。将来决定删除整个表格的时候,我们以为 GC 会回收除了已保存的 <td> 以外的其它节点。实际情况并非如此:此 <td> 是表格的子节点,子元素与父元素是引用关系。由于代码保留了 <td> 的引用,导致整个表格仍待在内存中。
所以保存 DOM 元素引用的时候,要小心谨慎。
-
闭包
var theThing = null; var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log(someMessage); } }; }; setInterval(replaceThing, 1000);
代码片段做了一件事情:每次调用 replaceThing ,theThing 得到一个包含一个大数组和一个新闭包(someMethod)的新对象。同时,变量 unused 是一个引用 originalThing 的闭包(先前的 replaceThing 又调用了 theThing )。思绪混乱了吗?最重要的事情是,闭包的作用域一旦创建,它们有同样的父级作用域,作用域是共享的。someMethod 可以通过 theThing 使用,someMethod 与 unused 分享闭包作用域,尽管 unused 从未使用,它引用的 originalThing 迫使它保留在内存中(防止被回收)。当这段代码反复运行,就会看到内存占用不断上升(新建的多个originalThing一直被保存在内存中),垃圾回收器(GC)并无法降低内存占用。本质上,闭包的链表已经创建,每一个闭包作用域携带一个指向大数组的间接的引用,造成严重的内存泄漏。
这时候应该在 replaceThing 的最后添加 originalThing = null,主动解除对象引用。
3.4 具体例子
timeline 标签擅长做这些。在 Chrome 中打开例子,打开 Dev Tools ,切换到 timeline,勾选 memory 并点击记录按钮,然后点击页面上的 The Button 按钮。过一阵停止记录看结果:
两种迹象显示出现了内存泄漏,图中的 Nodes(绿线)和 JS heap(蓝线)。Nodes 稳定增长,并未下降,这是个显著的信号。
JS heap 的内存占用也是稳定增长。由于垃圾收集器的影响,并不那么容易发现。图中显示内存占用忽涨忽跌,实际上每一次下跌之后,JS heap 的大小都比原先大了。换言之,尽管垃圾收集器不断的收集内存,内存还是周期性的泄漏了。
参考文章
https://github.com/yygmind/blog