45

前言

像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()free()。相反,JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

内存的生命周期

生命周期概念

无论是是使用什么编程语言,内存生命周期几乎都是一样的:

生命周期的概述:

  • 内存分配(Allocate memory ):当我们申明变量、函数、对象的时候,系统会自动为他们分配内存
  • 内存使用(Use memory ):即读写内存,也就是使用变量、函数等
  • 内存释放(Release memory ):使用完毕,由垃圾回收机制自动回收不再使用的内存

内存的概念

在硬件层面,计算机内存是由大量的触发器)组成的。每一个触发器都包含有一些晶体管,能够存储1比特。单个触发器可通过一个唯一标识符来寻址,这样我们就可以读和写了。因此从概念上讲,我们可以把计算机内存看作是一个巨大的比特数组,我们可以对它进行读和写。

但是作为人类,我们并不善于用比特来思考和运算,因此我们将其组成更大些的分组,这样我们就可以用来表示数字。8个比特就是一个字节。比字节大的有字(16比特或32比特)。

有很多东西都存储在内存中:

  1. 所有被程序使用的变量和其他数据
  2. 程序的代码,包括操作系统自身的代码

当你编译你的代码时,编译器可以检查原始的数据类型并且提前计算出将会需要多少内存。然后把所需的(内存)容量分配给调用栈空间中的程序。这些变量因为函数被调用而分配到的空间被称为堆栈空间,它们的内存增加在现存的内存上面(累加)。如它们不再被需要就会按照 LIFO(后进,先出)的顺序被移除。例如,参见如下声明:

int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

编译器可以立即清楚这段代码需要4 + 4 × 4 + 8 = 28字节。

这就是它怎样工作于当前的 integers 和 doubles 型的大小。约20年前,integers通常(占用)2字节,double占4字节。你的代码不应该依赖于此时刻的基本数据类型的大小。

编译器将插入些会互相作用于操作系统在堆栈上去请求必要的字节数来存储变量代码。

在以上例子中,编译器知道每个变量精确的内存地址。事实上,无论我们何时写入变量n,而本质上这会被翻译为如“内存地址 4127963 ”。

JS内存分配

为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。

//给数值分配内存空间
var num = 1; 

//给字符串分配内存
var str = "hehe";

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

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

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

// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);

有些函数调用结果是分配对象内存 如下:

var d = new Date(); // 分配一个 Date 对象

var e = document.createElement('div'); // 分配一个 DOM 元素

有些方法是分配新变量或者新对象 如下:

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 的结果

JS使用内存

基本上在 JavaScript 中使用分配的内存,就是对它进行读和写操作。

可以读写变量的值或某个对象的属性,甚至是给某个函数传递一个参数。

var a = 10; // 分配内存
console.log(a); // 对内存的使用

JS内存回收

当内存不再需要的时候要释放掉

大部分的内存管理问题出现在这个阶段。

这里面最难的任务是指出,在什么时候分配的内存不再被需要。这通常需要开发者来决定程序中的那一块内存不再需要了,并释放。

高级语言嵌入了一个叫垃圾收集器的程序,它可以跟踪内存分配和使用情况,以找出在哪种情况下某一块已分配的内存不再被需要,并自动的释放它。

不幸的是,这种程序只是一种近似的操作,因为知道某块内存是否被需要是不可判定的)(并不能通过算法来解决)。

大部分的垃圾收集器的工作方式是收集那些不能够被再次访问的内存,比如超出作用域的变量。但是,能够被收集的内存空间是低于近似值的,因为在任何时候都可能存在一个在作用域内的变量指向一块内存区域,但是它永远不能够被再次访问。

不再需要使用的变量也就是生命周期结束的变量,是局部变量,局部变量只在函数的执行过程中存在, 当函数运行结束,没有其他引用(闭包),那么该变量会被标记回收。
全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收。

因为自动垃圾回收机制的存在,开发人员可以不关心也不注意内存释放的有关问题,但对无用内存的释放这件事是客观存在的。 不幸的是,即使不考虑垃圾回收对性能的影响,目前最新的垃圾回收算法,也无法智能回收所有的极端情况。

垃圾回收

引用

垃圾回收算法主要依赖于引用的概念。

在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。

例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数垃圾收集

这是最初级的垃圾回收算法。

引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。 如果没有其他对象指向它了,说明该对象已经不再需了。

var o = { 
  a: {
    b:2
  }
}; 
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集


var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1;      // 现在,“这个对象”的原始引用o被o2替换了

var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 最初的对象现在已经是零引用了
           // 他可以被垃圾回收了
           // 然而它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
           // 它可以被垃圾回收了

由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。

如果两个对象相互引用,尽管他们已不再使用,垃圾回收不会进行回收,导致内存泄露。

来看一个循环引用的例子:

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o  这里

  return "azerty";
}

f();

上面我们申明了一个函数 f ,其中包含两个相互引用的对象。 在调用函数结束后,对象 o1 和 o2 实际上已离开函数范围,因此不再需要了。 但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。

再来看一个实际的例子:

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};

上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。 此时变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。 一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。

为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收。

标记清除算法

标记清除算法将“不再使用的对象”定义为“无法达到的对象”。 简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。 凡是能从根部到达的对象,都是还需要使用的。 那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象)。 但反之未必成立。

工作流程:

  1. 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
  2. 从根部出发将能触及到的对象的标记清除。
  3. 那些还存在标记的变量被视为准备删除的变量。
  4. 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。

img

循环引用不再是问题了

再看之前循环引用的例子:

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();

函数调用返回之后,两个循环引用的对象在垃圾收集时从全局对象出发无法再获取他们的引用。 因此,他们将会被垃圾回收器回收。

内存泄露

概念

程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存。

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

本质上讲,内存泄漏就是由于疏忽或错误造成程序未能释放那些已经不再使用的内存,造成内存的浪费。

常见的JS内存泄露

1.意外的全局变量

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


function foo(arg) { 
    bar = "this is a hidden global variable"; 
}

事实上变量bar被解释成下面的情况:

function foo(arg) { 
    window.bar = "this is a hidden global variable"; 
}

函数 foo 内部忘记使用 var ,意外创建了一个全局变量。此例泄露了一个简单的字符串,无伤大雅,但是有更糟的情况。

由this创建的意外的全局变量:

function foo() { 
    this.variable = "potential accidental global"; 
} 
 
// Foo 调用自己,this 指向了全局对象(window) 
// 而不是 undefined 
foo()

在 JavaScript 文件头部加上 'use strict',可以避免此类错误发生。启用严格模式解析 JavaScript ,避免意外的全局变量。

全局变量使用注意事项

  1. 尽管我们讨论了一些意外的全局变量,但是仍有一些明确的全局变量产生的垃圾。它们被定义为不可回收(除非定义为空或重新分配)。
  2. 全局变量用于 临时存储和处理大量信息时,需要多加小心。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义
  3. 与全局变量相关的增加内存消耗的一个主因是缓存。缓存数据是为了重用,缓存必须有一个大小上限才有用。
  4. 高内存消耗导致缓存突破上限,因为缓存内容无法被回收。

2.没有释放的计时器或者回调函数

在 JavaScript 中使用 setInterval 非常平常。一段常见的代码:

var someResource = getData(); 
setInterval(function() { 
    var node = document.getElementById('Node'); 
    if(node) { 
        // 处理 node 和 someResource 
        node.innerHTML = JSON.stringify(someResource)); 
    } 
}, 1000);

与节点或数据关联的计时器不再需要,node 对象可以删除,整个回调函数也不需要了。可是,计时器回调函数仍然没被回收(计时器停止才会被回收)。同时,someResource 如果存储了大量的数据,也是无法被回收的。

对于观察者的例子,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。如今,即使没有明确移除它们,一旦观察者对象变成不可达,大部分浏览器是可以回收观察者处理函数的。

var element = document.getElementById('button'); 
function onClick(event) { 
    element.innerHTML = 'text'; 
} 
 
element.addEventListener('click', onClick); 

对象观察者和循环引用注意事项

老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄露。如今,现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法,已经可以正确检测和处理循环引用了。换言之,回收节点内存时,不必非要调用 removeEventListener 了。

3.脱离DOM的引用

有时,保存 DOM 节点内部数据结构很有用。假如你想快速更新表格的几行内容,把每一行 DOM 存成字典(JSON 键值对)或者数组很有意义。此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。将来你决定删除这些行时,需要把两个引用都清除。

var elements = { 
    button: document.getElementById('button'), 
    image: document.getElementById('image'), 
    text: document.getElementById('text') 
}; 
 
function doStuff() { 
    image.src = 'http://some.url/image'; 
    button.click(); 
    console.log(text.innerHTML); 
    // 更多逻辑 
} 
 
function removeButton() { 
    // 按钮是 body 的后代元素 
    document.body.removeChild(document.getElementById('button')); 
 
    // 此时,仍旧存在一个全局的 #button 的引用 
    // elements 字典。button 元素仍旧在内存中,不能被 GC 回收。 

此外还要考虑 DOM 树内部或子节点的引用问题。假如你的 JavaScript 代码中保存了表格某一个 <td> 的引用。将来决定删除整个表格的时候,直觉认为 GC 会回收除了已保存的 <td> 以外的其它节点。实际情况并非如此:此<td> 是表格的子节点,子元素与父元素是引用关系。由于代码保留了 <td> 的引用,导致整个表格仍待在内存中。保存 DOM 元素引用的时候,要小心谨慎。

4.闭包

闭包是 JavaScript 开发的一个关键方面:匿名函数可以访问父级作用域的变量。

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 迫使它保留在内存中(防止被回收)。当这段代码反复运行,就会看到内存占用不断上升,垃圾回收器(GC)并无法降低内存占用。本质上,闭包的链表已经创建,每一个闭包作用域携带一个指向大数组的间接的引用,造成严重的内存泄露。

避免内存泄露

记住一个原则:不用的东西,及时归还。

  1. 减少不必要的全局变量,使用严格模式避免意外创建全局变量。
  2. 在你使用完数据后,及时解除引用(闭包中的变量,dom引用,定时器清除)。
  3. 组织好你的逻辑,避免死循环等造成浏览器卡顿,崩溃的问题。

兰俊秋雨
5.1k 声望3.5k 粉丝

基于大前端端技术的一些探索反思总结及讨论