Web 应用内存分析与内存泄漏定位

内存分析与内存泄漏定位是笔者现代 Web 开发工程化实践之调试技巧的一部分,主要介绍 Web 开发中须要了解的内存分析与内存泄露定位手段,本部分涉及的参考资料统一声明在Web 开发界面调试资料索引javascript

不管是分布式计算系统、服务端应用程序仍是 iOS、Android 原生应用都会存在内存泄漏问题,Web 应用天然也不可避免地存在着相似的问题。虽然由于网页每每都是即用即走,较少地存在某个网页长期运行的问题,即便存在内存泄漏可能表现地也不明显;可是在某些数据展现型的,须要长期运行的页面上,若是不及时解决内存泄漏可能会致使网页占据过大地内存,不只影响页面性能,还可能致使整个系统的崩溃。前端每周清单推荐过的 How JavaScript works 就是很是不错地介绍 JavaScript 运行机制的系列文章,其也对内存管理与内存泄漏有过度析,本文部分图片与示例代码即来自此系列。前端

相似于 C 这样的语言提供了 malloc()free() 这样的底层内存管理原子操做,开发者须要显式手动地进行内存的申请与释放;而 Java 这样的语言则是提供了自动化的内存回收机制,笔者在垃圾回收算法与 JVM 垃圾回收器综述一文中有过介绍。JavaScript 也是采用的自动化内存回收机制,不管是 Object、String 等都是由垃圾回收进程自动回收处理。自动化内存回收并不意味着咱们就能够忽略内存管理的相关操做,反而可能会致使更不易发现的内存泄漏出现。java

内存分配与回收

笔者在 JavaScript Event Loop 机制详解与 Vue.js 中实践应用一文中介绍过 JavaScript 的内存模型,其主要也是由堆、栈、队列三方面组成:node

其中队列指的是消息队列、栈就是函数执行栈,其基本结构以下所示:算法

JavaScript 栈模型

而主要的用户建立的对象就存放在堆中,这也是咱们内存分析与内存泄漏定位所须要关注的主要的区域。所谓内存,从硬件的角度来看,就是无数触发器的组合;每一个触发器可以存放 1 bit 位的数据,不一样的触发器由惟一的标识符定位,开发者能够根据该标识符读写该触发器。抽象来看,咱们能够将内存当作比特数组,而数据就是在内存中顺序排布:express

1*W7L7JN5q4p7w2E7HbBYS3g

JavaScript 中开发者并不须要手动地为对象申请内存,只须要声明变量,JavaScript Runtime 便可以自动地分配内存:json

var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a string 
var o = {
  a: 1,
  b: null
}; // allocates memory for an object and its contained values
var a = [1, null, 'str'];  // (like object) allocates memory for the
                           // array and its contained values
function f(a) {
  return a + 3;
} // allocates a function (which is a callable object)
// function expressions also allocate an object
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

某个对象的内存生命周期分为了内存分配、内存使用与内存回收这三个步骤,当某个对象再也不被须要时,它就应该被清除回收;所谓的垃圾回收器,Garbage Collector 便是负责追踪内存分配状况、判断某个被分配的内存是否有用,而且自动回收无用的内存。大部分的垃圾回收器是根据引用(Reference)来判断某个对象是否存活,所谓的引用便是某个对象是否依赖于其余对象,若是存在依赖关系即存在引用;譬如某个 JavaScript 对象引用了它的原型对象。最简单的垃圾回收算法便是引用计数(Reference Counting),即清除全部零引用的对象:数组

var o1 = {
  o2: {
    x: 1
  }
};
// 2 objects are created. 
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that 
            // has a reference to the object pointed by 'o1'. 
                                                       
o1 = 1;      // now, the object that was originally in 'o1' has a         
            // single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
                // This object has now 2 references: one as
                // a property. 
                // The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
            // references to it. 
            // It can be garbage-collected.
            // However, what was its 'o2' property is still
            // referenced by the 'o4' variable, so it cannot be
            // freed.

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it. 
           // It can be garbage collected.

不过这种算法每每受制于循环引用问题,即两个无用的对象相互引用:浏览器

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 references o2
  o2.p = o1; // o2 references o1. This creates a cycle.
}

f();

稍为复杂的算法便是所谓的标记-清除(Mark-Sweep)算法,其根据某个对象是否可达来判断某个对象是否可用。标记-清除算法会从某个根元素开始,譬如 window 对象开始,沿着引用树向下遍历,标记全部可达的对象为可用,而且清除其余未被标记的对象。cookie

2012 年以后,几乎全部的主流浏览器都实践了基于标记-清除算法的垃圾回收器,而且各自也进行有针对性地优化。

内存泄漏

所谓的内存泄漏,便是指某个对象被无心间添加了某条引用,致使虽然实际上并不须要了,但仍是能一直被遍历可达,以至其内存始终没法回收。本部分咱们简要讨论下 JavaScript 中常见的内存泄漏情境与处理方法。在新版本的 Chrome 中咱们可使用 Performance Monitor 来动态监测网页性能的变化:

上图中各项指标的含义为:

  • CPU usage - 当前站点的 CPU 使用量;
  • JS heap size - 应用的内存占用量;
  • DOM Nodes - 内存中 DOM 节点数目;
  • JS event listeners- 当前页面上注册的 JavaScript 时间监听器数目;
  • Documents - 当前页面中使用的样式或者脚本文件数目;
  • Frames - 当前页面上的 Frames 数目,包括 iframe 与 workers;
  • Layouts / sec - 每秒的 DOM 重布局数目;
  • Style recalcs / sec - 浏览器须要从新计算样式的频次;

当发现某个时间点可能存在内存泄漏时,咱们可使用 Memory 标签页将此时的堆分配状况打印下来:

Memory Snapshot Take heap snapshot

Memory Snapshot 结果

全局变量

JavaScript 会将全部的为声明的变量当作全局变量进行处理,即将其挂载到 global 对象上;浏览器中这里的 global 对象就是 window:

function foo(arg) {
    bar = "some text";
}

// 等价于

function foo(arg) {
    window.bar = "some text";
}

另外一种常见的建立全局变量的方式就是误用 this 指针:

function foo() {
    this.var1 = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

一旦某个变量被挂载到了 window 对象,就意味着它永远是可达的。为了不这种状况,咱们应该尽量地添加 use strict 或者进行模块化编码(参考 JavaScript 模块演化简史)。咱们也能够扩展相似于下文的扫描函数,来检测出 window 对象的非原生属性,并加以判断:

function scan(o) {
  Object.keys(o).forEach(function(key) {
    var val = o[key];

    // Stop if object was created in another window
    if (
      typeof val !== "string" &&
      typeof val !== "number" &&
      typeof val !== "boolean" &&
      !(val instanceof Object)
    ) {
      debugger;
      console.log(key);
    }

    // Traverse the nested object hierarchy
  });
}

定时器与闭包

咱们常常会使用 setInterval 来执行定时任务,不少的框架也提供了基于回调的异步执行机制;这可能会致使回调中声明了对于某个变量的依赖,譬如:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //This will be executed every ~5 seconds.

定时器保有对于 serverData 变量的引用,若是咱们不手动清除定时器话,那么该变量也就会一直可达,不被回收。而这里的 serverData 也是闭包形式被引入到 setInterval 的回调做用域中;闭包也是常见的可能致使内存泄漏的元凶之一:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // a reference to 'originalThing'
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);

上述代码中 replaceThing 会按期执行,而且建立大的数组与 someMethod 闭包赋值给 theThing。someMethod 做用域是与 unused 共享的,unused 又有一个指向 originalThing 的引用。尽管 unused 并未被实际使用,theThing 的 someMethod 方法却有可能会被外部使用,也就致使了 unused 始终处于可达状态。unused 又会反向依赖于 theThing,最终致使大数组始终没法被清除。

DOM 引用与监听器

有时候咱们可能会将 DOM 元素存放到数据结构中,譬如当咱们须要频繁更新某个数据列表时,可能会将用到的数据列表存放在 JavaScript 数组中;这也就致使了每一个 DOM 元素存在了两个引用,分别在 DOM 树与 JavaScript 数组中:

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    // The image is a direct child of the body element.
    document.body.removeChild(document.getElementById('image'));
    // At this point, we still have a reference to #button in the
    //global elements object. In other words, the button element is
    //still in memory and cannot be collected by the GC.
}

此时咱们就须要将 DOM 树与 JavaScript 数组中的引用皆删除,才能真实地清除该对象。相似的,在老版本的浏览器中,若是咱们清除某个 DOM 元素,咱们须要首先移除其监听器,不然浏览器并不会自动地帮咱们清除该监听器,或者回收该监听器引用的对象:

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers // that don't handle cycles well.

现代浏览器使用的现代垃圾回收器则会帮咱们自动地检测这种循环依赖,而且予以清除;jQuery 等第三方库也会在清除元素以前首先移除其监听事件。

iframe

iframe 是常见的界面共享方式,不过若是咱们在父界面或者子界面中添加了对于父界面某对象的引用,譬如:

// 子页面内
window.top.innerObject = someInsideObject
window.top.document.addEventLister(‘click’, function() { … });

// 外部页面
 innerObject = iframeEl.contentWindow.someInsideObject

就有可能致使 iframe 卸载(移除元素)以后仍然有部分对象保留下来,咱们能够在移除 iframe 以前执行强制的页面重载:

<a href="#">Remove</a>
<iframe src="url" />​

$('a').click(function(){
    $('iframe')[0].contentWindow.location.reload();
    // 线上环境实测重置 src 效果会更好
    // $('iframe')[0].src = "javascript:false";
    setTimeout(function(){
       $('iframe').remove();
    }, 1000);
});​

或者手动地执行页面清除操做:

window.onbeforeunload = function(){
    $(document).unbind().die();    //remove listeners on document
    $(document).find('*').unbind().die(); //remove listeners on all nodes
    //clean up cookies
    /remove items from localStorage
}

Web Worker

现代浏览器中咱们常用 Web Worker 来运行后台任务,不过有时候若是咱们过于频繁且不加容错地在主线程与工做线程之间传递数据,可能会致使内存泄漏:

function send() {
 setInterval(function() { 
    const data = {
     array1: get100Arrays(),
     array2: get500Arrays()
    };

    let json = JSON.stringify( data );
    let arbfr = str2ab (json);
    worker.postMessage(arbfr, [arbfr]);
  }, 10);
}


function str2ab(str) {
   var buf = new ArrayBuffer(str.length*2); // 2 bytes for each char
   var bufView = new Uint16Array(buf);
   for (var i=0, strLen=str.length; i<strLen; i++) {
     bufView[i] = str.charCodeAt(i);
   }
   return buf;
 }

在实际的代码中咱们应该检测 Transferable Objects 是否正常工做:

let ab = new ArrayBuffer(1);

try {
   worker.postMessage(ab, [ab]);

   if (ab.byteLength) {
      console.log('TRANSFERABLE OBJECTS are not supported in your browser!');
   } 
   else {
     console.log('USING TRANSFERABLE OBJECTS');
   }
} 
catch(e) {
  console.log('TRANSFERABLE OBJECTS are not supported in your browser!');
}
相关文章
相关标签/搜索