chrome内存泄露(二)、内存泄漏实例

本文详细探讨了JavaScript中的几种内存泄露类型,包括全局变量、闭包、DOM删除时未解绑事件和被遗忘的定时器。通过实例分析,揭示了内存泄露的原因和解决方法,特别关注了Chrome浏览器下的特殊情况。同时,提到了Vue框架中常见的内存泄露问题,如事件总线上的未解绑事件、定时器未清除和第三方插件生成组件的销毁问题。

一、常见JS内存泄漏

1.1 全局变量引起的内存泄漏

     全局变量使用完毕没有置为null导致内存就无法回收。平常应注意不要引入意外的全局变量,比如定义变量记得加 var声明。

    全局变量引发泄露的实例:

<button onclick="createNode()">添加节点</button>
  <button onclick="removeNode()">删除节点</button>
  <div id="wrapper"></div>
  <script>
  var text = [];
  function createNode() { 
      text.push(new Array(1000000).join('x'));  
      var textNode = document.createTextNode("新节点"),
          div = document.createElement('div');
      div.appendChild(textNode);
      document.getElementById("wrapper").appendChild(div);  
  }
  
  function removeNode() {
      var wrapper = document.getElementById("wrapper"),
          len = wrapper.childNodes.length;
      if (len > 0) {
          wrapper.removeChild(wrapper.childNodes[len - 1]);  
      }
  }
  </script>

     点击添加节点,再点击删除节点,作为一轮操作。通过chrome JS堆动态分配时间轴可以看到每轮操作后,都有JS堆内存得不到释放。    

    滑动鼠标滚轮,查看其中一段,可以发现字符串"xxx.."没有被回收。选中字符串,在下方Retainers可以看到变量是数组window.text的其中一个元素。结合代码,发现是函数createNode()每次都会把字符串"xxx.."添加到全局变量window.text中。 

    如果全局变量引用的是DOM,将会导致DOM节点无法回收。Class Filter搜索Detached可以看到许多分离的节点。比如下面这段代码:

<button onclick="updateNodes()">刷新节点</button>
  <div id="wrapper"></div>
  <script>
  var elements =[];
  function updateNodes() {
      var wrapper = document.getElementById("wrapper"),
          len = wrapper.childNodes.length;
      if (len) {
          for (var j = len - 1; j >= 0; j--) {
              wrapper.removeChild(wrapper.childNodes[j]);
          }
      } 
	  
      var div,
          i = 100,
          frag = document.createElement("div");
      for (; i > 0; i--) {
          div = document.createElement("div");
          div.appendChild(document.createTextNode(i + " - " + new Date().toTimeString()));
          frag.appendChild(div);
      }
	  wrapper.appendChild(frag);
      elements.push(div); 
  }
  </script>

      上面这段代码第一次点击刷新节点不会有问题,但第二次点击刷新节点就会有内存泄漏。因为有JS变量指向DOM节点,导致DOM节点无法回收。虽然JS变量只保存了1个节点,但却影响了101个节点的回收,这101个节点因为是这个节点的相关节点而没有被回收(相关节点包括父级节点、父级节点的子节点等)。如下图所示,有101个未被回收的分离节点,这些节点标红显示,说明JS变量引用节点间接影响了这些节点的回收。

    点击选中Detached HTMLDivElement,可以查看这些节点被哪个JS变量引用。从下图中可以看出分离节点是被window.elements数组间接引用:

1.2 闭包引起的泄漏

    用Meteor 的官方博文 《An interesting kind of JavaScript memory leak》经典的内存泄漏代码作为例子(访问不了可以点击参考文章中的链接)。 

  <button onclick="replaceThing()">第二次点我就有泄漏</button>
  <script>
  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 someMethod() {
              console.log('someMessage');
          }
      };
  };
  </script>

        代码运行之后,点击四次按钮录制的JS堆内存分配时间线如下图所示。选中第一次点击未被回收的JS堆。可以看到字符串"xxx"未被回收,占内存约10%。这个字符串是被originanlThing.longStr引用,originanlThing是在someMethod()函数中定义的,someMethod()又是另一个originalThing的方法...。这就是闭包不断嵌套,可以看到最上面的longStr的Distance是11,距离根节点已经比较远了。如果再多点击几次,查看第一个没有被回收的JS堆,会发现Distance值越来越大。

      这段代码为什么会产生泄漏?先来了解下闭包的原理:

      同一个函数内部的闭包作用域只有一个,所有闭包共享。在执行函数的时候,如果遇到闭包,会创建闭包作用域内存空间,将该闭包所用到的局部变量添加进去,然后再遇到闭包,会在之前创建好的作用域空间添加此闭包会用到而前闭包没用到的变量。函数结束,清除没有被闭包作用域引用的变量。

      上面那段代码泄漏的原因在于有两个闭包:unused和someMethod。unused 这个闭包引用了父作用域中的 originalThing 变量,如果没有后面的 someMethod,则会在函数结束后清除,闭包作用域也跟着清除了。因为后面的 theThing 是全局变量someMethod是全局变量的属性,它引用的闭包作用域(包含了 unused 所引用的 originalThing )不会释放。而随着 replaceThing 不断调用,originalThing 指向前一次的 theThing,而新的theThing.someMethod又会引用originalThing ,从而形成一个闭包引用链,而 longStr是一个大字符串,得不到释放,从而造成内存泄漏。

     这个的解决方法就是在函数结束之后将不需要使用的变量置为null。即在replaceThing函数最后加上originalThing = null。

1.3 DOM删除时没有解绑事件

    删除DOM时如果没有移除DOM,可能会引起泄漏。下面这段代码在chrome下内存能保持稳定,在IE8下测试内存会不断增长:  

  <button onclick="createSomeNodes()">增加节点</button>
  <button onclick="removeSomeNodes()">删除节点</button>
  <div id="wrapper"></div>
  <script type="text/javascript">
  function createSomeNodes() {
      var wrapper = document.getElementById("wrapper"),
          outerDiv = document.createElement("div"),
          text = document.createTextNode("新节点");
      outerDiv.appendChild(text);
      for (var i = 10000 ; i > 0; i--) {
          var div = document.createElement("div");
          div.onclick = function () {
              console.log("click")
          };
          outerDiv.appendChild(div);
      }
      wrapper.appendChild(outerDiv);
  }

  function removeSomeNodes () {
      var wrapper = document.getElementById("wrapper"),
          len = wrapper.childNodes.length;
      if (len) {

          wrapper.removeChild(wrapper.childNodes[len - 1]);
      }
  }
  </script>

    运行上面代码,先点击15次“增加节点“,再点击15次“删除节点”,放置几分钟观察内存。内存最初18M,操作以后上涨到204.806M。(IE8没有找到分析内存消耗的工具,可以借助Window系统任务管理器查看。按Ctrl + Shift + Esc打开任务管理器,找到iexplore.exe进程,查看对应内存。即使只打开一个标签页,也有两个IE进程,一个应该是IE浏览器本身的进程,另一个是标签页对应的进程。如下图所示:)

1.4 被遗忘的定时器

    定时器如果不需要使用,记得及时清除。否则会导致定期器回调函数以及内部依赖的变量没有办法及时回收。

  <button onclick="changePage()">切换页面</button>
  <div id="page1" class="page">
    这是第一个页面<br/>
  </div>
  <div id="page2" class="page hidden">这是第二个页面</div><br/></div>
  <script>
  function changePage() {
      var page1 = document.getElementById("page1"),
          page2 = document.getElementById("page2");
      if (page1.style.display != "none") {
          page1.style.display = "none";
          page1.innerHTML = "";
          page2.style.display = "block";
          page2.innerHTML = "这是第二个页面</div><br/>";
      } else {
          page2.style.display = "none";
          page2.innerHTML = "";
          page1.style.display = "block";
          page1.innerHTML = "这是第一个页面</div><br/>"; 
          createNodeTimer();
      }
      
  }
  
  function createNodeTimer() {
      var wrapper = document.createElement("div");
      wrapper.setAttribute("id", "wrapper");
      setInterval(createNodes, 2000, wrapper);
  }
  
  function createNodes (wrapper) {
      var page1 = document.getElementById("page1"),
          div;
      for (var i = 50; i > 0; i--) {
          div = document.createElement("div");
          div.appendChild(document.createTextNode(i + " - " + new Date().toTimeString()));
          wrapper.appendChild(div);
      }
      page1.appendChild(wrapper);
  }

  createNodeTimer();
  </script>

    运行上面的例子,在第一个页面时就加入了增加节点定时器。当切换到第二个页面时,并没有取消定时器,定时器依然在执行。再次切换到第一个页面时,又新增了一个定时器,此时有两个定时器在执行。每次来回切换页面都会引发泄漏。

    下图是切换到第二个页面时开始录制的JS堆动态分配时间线:

    从图中可以看到每隔2s就会分配内存,且无法回收。可以看到Detached HTMLDivElement有50个对象。如果不知道是哪里产生的泄漏,可以展开查看这些DOM对象,根据DOM对象的信息来判断哪里代码产生泄漏。如下图,鼠标悬浮到DOM对象,可以看到DOM对象,关注outerHTML等标识性属性:

    

    或者也可以录制时间轴,看下每隔2s会进行什么操作。如下图所示每隔2s,节点和内存都会上升。强制垃圾回收后,内存会下降一些,但节点数量一直是上升状态。证明可能是节点没有释放,引用DOM的JS变量并不大。

    接下来看下每个上升点做了什么操作。将鼠标放置Overview区域或者CallStack区域,滑动鼠标滚轮,将选中区域缩小到上升点附近。关注CallStack区域Main区域黄色部分(黄色表示函数调用、触发事件等),选择黄色区域,会悬浮显示函数信息。下方Summary也会显示函数信息。下图中可以看到内存上升时执行了createNodes函数,就能发现是切换页面后定时器没有清除导致。

     

 二、chrome下怪异的内存泄漏

     下面这几个问题都是父元素包含指定类型的元素,删除父元素时没有办法成功释放节点。chrome本机内存也无法被回收,但是JS堆内存能够正常。不同版本的chrome表现不一样,chrome 55-68版本会比chrome 69版本更严重些。

     这几个问题在火狐下测试,不会出现泄露,内存保持稳定。我觉得可能是chrome本身的问题,希望有想法或有规避方法的可以一起交流。

    以下泄漏的常见场景是单页应用。在切换路由时,如果前一个页面包含以下指定类型的元素,就会出现该节点无法成功释放,内存无法被回收。

   下面的实例都是基于jQuery实现的(尝试过用原生JS实现,也存在同样的问题)。

1、删除元素包含密码框

    测试了chrome 45-69版本,都存在这个问题。    

  <div>
    <button onclick="changepage()">切换页面</button>
  </div>
  <div id="wrapper">
    <div id="page1" style="border:1px solid #ddd;padding:20px;margin-top:20px">
      <div id="nodes">
        <input type="password"/>
      </div>
    </div>
  </div>
  <script>
  function changepage(){
      if ($("#page1").length > 0) {
          $("#page1").remove();
          $("#wrapper").html('<div id="page2" style="border:1px solid #ddd;padding:20px;margin-top:20px;">第二个页面</div>');
      } else{
          $("#page2").remove();
			     var html = '<div id="page1" style="border:1px solid #ddd;padding:20px;margin-top:20px">\
			                  <div id="nodes">\
						        <input type="password"/>';
		      for(var i = 1; i < 50000; i++) {
		          html += "<div></div>";
		      }
			    
          html += '</div></div>';
          $("#wrapper").html(html);
      }
  }
  </script>

       运行后,不断点击“切换页面”,可以看到节点数量一直在上升。但放置几个小时不操作,内存虽然不会恢复最初水平,但是会有所下降。记录数据如下:

记录时间执行次数节点个数监听器占用内存空间(M)JS占用内存(M)
2018/12/9 11:372100045241.322.127
2018/12/9 11:4810251506512471.782.166
2018/12/9 11:58202102512632896.222.166
2018/12/9 12:133021535187521325.7282.166
2018/12/9 12:244022045248721750.8522.166
2018/12/9 15:1140220003122206.1162.147

2、删除元素包含按钮,且按钮被用户手动点击过

    测试了chrome 55-69版本。chrome 55-68版本都存在这一问题,chrome 69版本不存在这一问题。

  <div>
    <button onclick="changepage()">切换页面</button>
  </div>
  <div id="wrapper">
    <div id="page1" style="border:1px solid #ddd;padding:20px;margin-top:20px">
      <div id="nodes">
        <button>点击我会出现内存泄露</button>
      </div>
    </div>
  </div>
  <script>
  function changepage(){
      if ($("#page1").length > 0) {
          $("#page1").remove();
          $("#wrapper").html('<div id="page2" style="border:1px solid #ddd;padding:20px;margin-top:20px;">第二个页面</div>');
      } else{
          $("#page2").remove();
		  var html = '<div id="page1" style="border:1px solid #ddd;padding:20px;margin-top:20px">\
			           <div id="nodes">\
					     <button>点击我会出现内存泄露</button>';
		      for(var i = 1; i < 50000; i++) {
		          html += "<div></div>";
		      }
			    
          html += '</div></div>';
          $("#wrapper").html(html);
      }
  }
  </script>

      这个例子在chrome 69版本下不会有泄露。chrome 69版本测试数据如下:

记录时间执行次数节点个数监听器占用内存空间(M)JS占用内存(M)
2018/12/9 12:27250029241.322.127
2018/12/9 12:4110250029246.522.273
2018/12/9 12:5120250029245.2282.293

 

    有兴趣可以用chrome 55-68版本测试,内存和节点数量会一直上升,并且放置几个小时也不说释放。

3、删除元素包含输入框、复选框、下拉框等表单元素(这里不包含按钮),且用户手动点击过

    测试了chrome 55-69版本。chrome 55-69版本都存在这个问题。

  <div>
    <button onclick="changepage()">切换页面</button>
  </div>
  <div id="wrapper">
    <div id="page1" style="border:1px solid #ddd;padding:20px;margin-top:20px">
      <div id="nodes">
        <input type="text"/>
      </div>
    </div>
  </div>
  <script>
  function changepage(){
      if ($("#page1").length > 0) {
          $("#page1").remove();
          $("#wrapper").html('<div id="page2" style="border:1px solid #ddd;padding:20px;margin-top:20px;">第二个页面</div>');
      } else{
          $("#page2").remove();
		  var html = '<div id="page1" style="border:1px solid #ddd;padding:20px;margin-top:20px">\
		               <div id="nodes">\
					     <input type="text"/>';
		  for(var i = 1; i < 50000; i++) {
		      html += "<div></div>";
		  }
			    
          html += '</div></div>';
          $("#wrapper").html(html);
      }
  }
  </script>

    运行后,按照以下操作就会出现泄露:点击输入框-->点击切换页面-->再次点击切换页面。即使放置几个小时不操作,内存也没有下降趋势,在chrome 69下测试数据如下:

记录时间执行次数节点个数监听器占用内存空间(M)JS占用内存(M)
2018/12/9 12:522100069243.3842.152
2018/12/9 13:0510251507542472.5642.152
2018/12/9 13:16202101514542891.4922.172
2018/12/9 14:093021525216821314.7922.172
2018/12/9 14:504022090295921787.5042.172
2018/12/9 15:424022090295921789.42.319
2018/12/9 16:434022090295921789未记录

    如果在切换页面之前先删除输入框、复选框等表单元素(直接删除表单没有效果),内存泄露就很会弱化很多。但是项目中适不适合使用这种方式规避内存泄露也需要权衡,逐个删除表单元素,也会影响性能。修改上面代码中changePage()函数:

 <script>
  function changepage(){
      if ($("#page1").length > 0) {
          $("input").remove();
          $("#page1").remove();
          $("#wrapper").html('<div id="page2" style="border:1px solid #ddd;padding:20px;margin-top:20px;">第二个页面</div>');
      } else{
          //...此处省略
      }
  }
  </script>

    修改后在chrome 69下测试数据如下表,内存和节点增长明显弱化很多:

记录时间执行次数节点个数监听器占用内存空间(M)JS占用内存(M)
2018/12/9 14:22250069238.5242.136
2018/12/9 14:3110250438262.282.156
2018/12/9 14:4020250838263.4862.156
2018/12/9 14:5930251238269.1362.156
2018/12/9 15:1040251654271.542.156

      PS:上面这几个例子如果想看下长时间运行后的效果,可以使用自动化测试工具sikuli,安装教程可以查看sikuli安装,使用可以查看视频

三、Vue常见泄露

1、事件总线上绑定事件没有解绑

    下面这个是事件总线eventbus.js:

define([], function () {
	return new Vue();
})

      下面是music模块用事件总线绑定事件,事件用到了组件。如果把beforeDestroy代码注释掉,就会出现泄露。切换页面后,组件占用的JS堆内存无法被释放。因为绑定事件中引用了组件。

var depends = ["text!music/html/index.html", "js/eventbus.js"];
define(depends, function(tpl, bus){
    var component = Vue.extend({
        template: tpl,
        data: function(){
           return {
               show: false
           }
        },
        methods: {
            changeToKTV: function () {
                this.$router.push({path: "/KTV"});
            }
        },
        created: function(){
            var self = this;
            bus.$on("changePage",  function () {
                 console.log(self);
            });
        },
        beforeDestroy: function () {
            bus.$off("changePage");
        }
    });
    return component;
});

2、定时器没有取消

     如果在页面中添加了定时器,切换页面之前记得清除定时器。下面这段代码如果把beforeDestroy中的代码注释掉就会出现内存泄露。同样也是因为定时器中引用了组件,导致组件占用JS堆内存无法释放。

var depends = ["text!music/html/index.html", "js/eventbus.js"];
define(depends, function(tpl, bus){
    var component = Vue.extend({
        template: tpl,
        data: function(){
           return {
               date: new Date(),
               dateTimer: null
           }
        },
        methods: {
            changeToKTV: function () {
                this.$router.push({path: "/KTV"});
            }
        },
        created: function(){
            var self = this;
            this.dateTimer = setInterval(function () {
                self.date = new Date();
            }, 1000);
        },
        beforeDestroy: function () {
            this.dateTimer && clearInterval(this.dateTimer);
        }
    });
    return component;
});
 

3、引用了三方插件生成Vue组件但没有销毁

    详见vue官网避免内存泄漏

四、参考文章

1、闭包泄漏:An Interesting Kind of JavaScript Memory Leak – Meteor blog(原文可能访问不了,附上其他人转载的文章

五、测试代码下载

    文章中的测试代码都上传到csdn下载

评论 2
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值