内存分析与内存泄漏定位

8,857 阅读10分钟

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

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

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

内存分配与回收

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

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

JavaScript 栈模型
JavaScript 栈模型

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

1*W7L7JN5q4p7w2E7HbBYS3g
1*W7L7JN5q4p7w2E7HbBYS3g

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

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 对象开始,沿着引用树向下遍历,标记所有可达的对象为可用,并且清除其他未被标记的对象。

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 Take heap snapshot

Memory 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!');
}