继上一篇文章 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 指向了全局对象(Windows)
- // 而不是 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
- http://www.cnblogs.com/vajoy/p/3703859.html
- https://blog.csdn.net/yolo0927/article/details/80471220
来源: https://www.cnblogs.com/BoatGina/p/10464033.html