JavaScript 内存相关知识

一、内存基本概念

1.1、生命周期

不管什么程序语言,内存生命周期基本是一致的:

  • 分配你所需要的内存
var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存

var o = {
  a: 1,
  b: null
}; // 给对象及其包含的值分配内存

// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"]; 

function f(a){
  return a + 2;
} // 给函数(可调用的对象)分配内存

// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);
View Code
  • 使用分配到的内存(读、写)
// 有些函数调用结果是分配对象内存:
var d = new Date(); // 分配一个 Date 对象
var e = document.createElement('div'); // 分配一个 DOM 元素
View Code
// 有些方法分配新变量或者新对象:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新数组有四个元素,是 a 连接 a2 的结果
View Code

使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

  • 不需要时将其释放、归还

在所有语言中第一和第二部分都很清晰。最后一步在低级语言(例如C语言)中很清晰,但是在像JavaScript等高级语言中,这一步依赖于垃圾回收机制,一般情况下不用程序员操心。垃圾回收算法我会在后续介绍。

1.2 堆与栈

我们知道,内存空间可以分为栈空间和堆空间,其中

栈空间:由操作系统自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。栈空间主要存储基本数据类型,如undefined,null,boolean,number,string,在内存中占有固定的大小,我们通过按值来访问。

堆空间:一般由程序员分配释放,这部分空间就要考虑垃圾回收的问题。堆空间主要存储引用类型,如Object,Array,Function,在堆内存中为这个值分配空间,然后把它的内存地址保存在栈内存中。(区分变量和对象)

1.3垃圾回收算法

垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

垃圾收集算法中,IE 6, 7采用的是引用计数垃圾收集算法。该算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。该算法有一个弊端就是“循环引用”,是导致内存泄漏的重要原因。

// A不引用B,B和C会被销毁
A ---------> B ------------> C
// A不引用B,B和C不会销毁
A ---------> B ------------> C
         ^、_ _ _ _ _ _ _|

而从2012年起,所有的现代浏览器都换成了标记-清除算法,这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。是否可获得的判断标准就是这个对象是否被root引用(包含直接引用和间接引用),如果不被引用到,就被收回。这样就很好的避免了循环引用的问题。

var a = new A(); //创建A的实例
var b = new B(); //创建B的实例

a.link = b;
b.link = a;

a = null;
b = null;

/*
  上面的例子中  A ,B的实例形成循环引用,  最后把a ,b设为null。

  在引用计数垃圾收集算法中,A ,B的实例相互引用,各自的引用数不为0,所以不会被收回。

  而在标记-清除算法中,由于a, b设为null,A ,B的实例都不会被root也就是window对象引用到,会被收回。

  */
View CodeJavaScript 内存相关知识

二、内存泄漏

本质上,内存泄漏可以定义为:应用程序不再需要占用内存的时候,由于某些原因,内存没有被操作系统或可用内存池回收。编程语言管理内存的方式各不相同。只有开发者最清楚哪些内存不需要了,操作系统可以回收。一些编程语言提供了语言特性,可以帮助开发者做此类事情。另一些则寄希望于开发者对内存是否需要清晰明了。下面介绍了4种常见的内存泄漏:

2.1、全局变量

JavaScript 处理未定义变量的方式比较宽松:未定义的变量会在全局对象创建一个新变量。在浏览器中,全局对象是window

function foo(arg) {
    bar = "some text";
}
// 等同于
function foo(arg) {
    window.bar = "some text";
}

如果bar被假定只在foo函数的作用域里引用变量,但是你忘记了使用var去声明它,一个意外的全局变量就被声明了。

在这个例子里,泄漏一个简单的字符串不会造成很大的伤害,但是它确实有可能变得更糟。

另外一个意外创建全局变量的方法是通过this:

function foo() {
    this.var1 = "potential accidental global";
}
 
// Foo作为函数调用,this指向全局变量(window)
// 而不是undefined
foo();

为了防止这些问题发生,可以在你的JaveScript文件开头使用'use strict';。这个可以使用一种严格的模式解析JavaScript来阻止意外的全局变量。

除了意外创建的全局变量,明确创建的全局变量同样也很多。这些当然属于不能被回收的(除非被指定为null或者重新分配)。特别那些用于暂时存储数据的全局变量,是非常重要的。如果你必须要使用全局变量来存储大量数据,确保在是使用完成之后为其赋值null或者重新赋其他值。

2.2、被遗忘的定时器或者回调

在JavaScript中使用setInterval是十分常见的。

大多数库,特别是提供观察器或其他接收回调的实用函数的,都会在自己的实例无法访问前把这些回调也设置为无法访问。但涉及setInterval时,下面这样的代码十分常见:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if (renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //每5秒执行一次
View Code

定时器可能会导致对不需要的节点或者数据的引用。

renderer对象在将来有可能被移除,让interval处理器内部的整个块都变得没有用。但由于interval仍然起作用,处理程序并不能被回收(除非interval停止)。如果interval不能被回收,它的依赖也不可能被回收。这就意味着serverData,大概保存了大量的数据,也不可能被回收。

在观察者的情况下,在他们不再被需要(或相关对象需要设置成不能到达)的时候明确的调用移除是非常重要的。

在过去,这一点尤其重要,因为某些浏览器(旧的IE6)不能很好的管理循环引用(更多信息见下文)。如今,大部分的浏览器都能而且会在对象变得不可到达的时候回收观察处理器,即使监听器没有被明确的移除掉。然而,在对象被处理之前,要显式地删除这些观察者仍然是值得提倡的做法。例如:

var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
    counter++;
    element.innerHtml = 'text ' + counter;
}

element.addEventListener('click', onClick);

// 做点事

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

// 当元素被销毁
//元素和事件都会即使在老的浏览器里也会被回收
View Code

如今的浏览器(包括IE和Edge)使用现代的垃圾回收算法,可以立即发现并处理这些循环引用。换句话说,先调用removeEventListener再删节点并非严格必要。

jQuery等框架和插件会在丢弃节点前删除监听器。这都是它们内部处理,以保证不会产生内存泄漏,甚至是在有问题的浏览器(没错,IE6)上也不会。

2.3、闭包

闭包是JavaScript开发的一个关键方面:一个内部函数使用了外部(封闭)函数的变量。由于JavaScript运行时实现的不同,它可能以下面的方式造成内存泄漏:

var theThing = null;

var replaceThing = function() {

    var originalThing = theThing;
    var unused = function() {
        if (originalThing) // 引用'originalThing'
            console.log("hi");
    };

    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function() {
            console.log("message");
        }
    };
};

setInterval(replaceThing, 1000);
View Code

这段代码做了一件事:每次ReplaceThing被调用,theThing获得一个包含大数组和新的闭包(someMethod)的对象。同时,变量unused保持了一个引用originalThing(theThing是上次调用replaceThing生成的值)的闭包。已经有点困惑了吧?最重要的事情是一旦为同一父域中的作用域产生闭包,则该作用域是共享的

这里,作用域产生了闭包,someMethod和unused共享这个闭包中的内存。unused引用了originalThing。尽管unused不会被使用,someMethod可以通过theThing来使用replaceThing作用域外的变量(例如某些全局的)。而且someMethod和unused有共同的闭包作用域,unused对originalThing的引用强制oriiginalThing保持激活状态(两个闭包共享整个作用域)。这阻止了它的回收。

当这段代码重复执行,可以观察到被使用的内存在持续增加。垃圾回收运行的时候也不会变小。从本质上来说,闭包的连接列表已经创建了(以theThing变量为根),这些闭包每个作用域都间接引用了大数组,导致大量的内存泄漏。

这个问题被Meteor团队发现,他们有一篇非常好的文章描述了闭包大量的细节。

2.4、DOM外引用

有的时候在数据结构里存储DOM节点是非常有用的,比如你想要快速更新一个表格几行的内容。此时存储每一行的DOM节点的引用在一个字典或者数组里是有意义的。此时一个DOM节点有两个引用:一个在dom树中,另外一个在字典中。如果在未来的某个时候你想要去移除这些排,你需要确保两个引用都不可到达。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};

function doStuff() {
    image.src = 'http://example.com/image_name.png';
}

function removeImage() {
    //image是body元素的子节点
    document.body.removeChild(document.getElementById('image'));
    //这个时候我们在全局的elements对象里仍然有一个对#button的引用。
    //换句话说,buttom元素仍然在内存中而且不能被回收。
}
View Code

当涉及DOM树内部或子节点时,需要考虑额外的考虑因素。例如,你在JavaScript中保持对某个表的特定单元格的引用。有一天你决定从DOM中移除表格但是保留了对单元格的引用。人们也许会认为除了单元格其他的都会被回收。实际并不是这样的:单元格是表格的一个子节点,子节点保持了对父节点的引用。确切的说,JS代码中对单元格的引用造成了整个表格被留在内存中了,所以在移除有被引用的节点时候要当心。

三、Chrome Devtools

3.1、任务管理器

可以了解各个页面的内存的使用总量,发现内存是否占用过高。

3.2、performance

performance的好处是可以看到随着时间的变化,看到内存的使用的情况。通过performance,我们很容易了解到GC操作和内存的分配,从而发现内存是否泄漏和GC是否频繁的问题。

https://developers.google.com/web/tools/chrome-devtools/evaluate-performance

3.3、memory

内存快照的优点是详细的展示了某一时刻的内存的使用情况,包括:什么类型的数据占用了多大的内存,以及变量之间的引用关系。通过这些,我们就可以找到内存使用的问题所在,找到解决内存问题的方法。

https://developers.google.com/web/tools/chrome-devtools/memory-problems/

参考资料

http://web.jobbole.com/92652/

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

http://www.imooc.com/article/13489

http://www.ayqy.net/blog/js内存泄漏排查方法/#articleHeader0

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management

https://segmentfault.com/a/1190000006104910#articleHeader11

相关推荐