js内存深入学习(二)

继上一篇文章: js内存深入学习(一)

3. 内存泄漏

对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。 对于不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)

3.1 v8堆内存补充

node.js中V8中的内存分代:

  • 新生代:存活时间较短的对象,会被GC自动回收的对象及作用域,比如不被引用的对象及调用完毕的函数等。
  • 老生代:存活时间较长或常驻内存的对象,比如闭包因为外部仍在引用内部作用域的变量而不会被自动回收,故会被放在常驻内存中,这种就属于在新生代中持续存活,所以被移到了老生代中,还有一些核心模块也会被存在老生代中,例如文件系统(fs)、加密模块(crypto)等
  • 如何调整内存分配大小:
    • 启动node进程时添加参数即可
      node --max-old-space-size=1700 .js 调整老生代内存限制,单位为MB(貌似最高也只能1.8G的样子)(老生代默认限制为 64/32 位 => 1400/700 MB)

    • node --max-new-space-size=1024 .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 元素仍旧在内存中,不能被回收。
    

    如果代码中保存了表格某一个 的引用。将来决定删除整个表格的时候,我们以为 GC 会回收除了已保存的 以外的其它节点。实际情况并非如此:此 是表格的子节点,子元素与父元素是引用关系。由于代码保留了 的引用,导致整个表格仍待在内存中。

    所以保存 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 按钮。过一阵停止记录看结果:

Chrome

两种迹象显示出现了内存泄漏,图中的 Nodes(绿线)和 JS heap(蓝线)。Nodes 稳定增长,并未下降,这是个显著的信号。

JS heap 的内存占用也是稳定增长。由于垃圾收集器的影响,并不那么容易发现。图中显示内存占用忽涨忽跌,实际上每一次下跌之后,JS heap 的大小都比原先大了。换言之,尽管垃圾收集器不断的收集内存,内存还是周期性的泄漏了。

具体如何使用谷歌F12的工具来分析内存泄漏会再整理,在实战中,如果用到服务端 渲染页面的话,比如SSR渲染,需要更加关注服务端渲染的内存泄漏问题。可以使用pm2对服务端进程进行管理,然后进行压测,观察一段时间,查看内存是否有出现飞涨情况,如果有的话,需要对业务代码和node.js中间件进行排查。

参考文章

https://github.com/yygmind/blog

http://www.cnblogs.com/vajoy/p/3703859.html

https://jinlong.github.io/2016/05/01/4-Types-of-Memory-Leaks-in-JavaScript-and-How-to-Get-Rid-Of-Them/

https://blog.csdn.net/yolo0927/article/details/80471220

你可能感兴趣的:(js内存深入学习(二))