简介

像 C 这样的底层语言一般都有底层的内存管理接口,比如 malloc() 和 free()。相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放,释放的过程称为垃圾回收,这种自动化容易造成潜在的混淆——它会给开发人员一种他们不需要担心内存管理的错误印象。

内存生命周期

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

  • 分配你所需要的内存
  • 使用分配到的内存(读、写)
  • 不需要时将其释放\归还

所有语言第二部分都是明确的。第一和第三部分在低级语言中是明确的,但在像 JavaScript 等高级语言中大多是隐式的。

JavaScript 的内存分配

值的初始化

为了不让开发者费心分配内存,JavaScript 在定义变量时就完成了内存分配。

const n = 123; // 给数值变量分配内存
const s = "azerty"; // 给字符串分配内存

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

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

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

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

通过函数调用分配内存

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

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

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

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

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

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

使用值

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

不再需要内存时释放

大多数内存管理问题都发生在这个阶段。此阶段最困难的方面是确定何时不再需要分配的内存。

低级语言要求开发人员手动确定程序中的哪个点不再需要分配的内存并释放它。

JavaScript 等高级语言利用一种称为垃圾回收(GC) 的自动内存管理形式。它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。

垃圾回收

如上所述自动查找是否一些内存“不再需要”是无法确定的。因此,垃圾回收只能有限的解决一般问题。下面是一些必要的概念、主要的垃圾回收算法和它们的限制。

引用

垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数算法

这是最初级的垃圾回收算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

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

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

o = 1;      // 现在,“这个对象”只有一个 o2 变量的引用了,“这个对象”的原始引用 o 已经没有

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

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

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

限制-循环引用

该算法当循环引用时,会有限制。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。循环引用是内存泄漏的常见原因。现在已经没有引擎使用引用计数进行垃圾回收了。

function f() {
  const x = {};
  const y = {};
  x.a = y;        // x references y
  y.a = x;        // y references x

  return 'azerty';
}

f();

实际例子

IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收。该方式常常造成对象被循环引用时内存发生泄漏:

let div;
window.onload = function(){
  div = document.getElementById("myDivElement");
  div.circularReference = div;
  div.lotsOfData = new Array(10000).join("*");
};

在上面的例子里,myDivElement 这个 DOM 元素里的 circularReference 属性引用了 myDivElement,造成了循环引用。如果该属性没有显示移除或者设为 null,引用计数式垃圾回收器将总是且至少有一个引用,并将一直保持在内存里的 DOM 元素,即使其从 DOM 树中删去了。如果这个 DOM 元素拥有大量的数据 (如上的 lotsOfData 属性),而这个数据占用的内存将永远不会被释放。

标记 - 清除算法

该算法将“对象是否不再需要”简化定义为“对象是否可以访问”。

这个算法假定设置一个叫做根(root)的对象(在 Javascript 里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象...,垃圾回收器将找到所有可以获得的对象和回收所有不能获得的对象。

该算法是对前一个算法的改进,因为具有零引用的对象实际上是无法访问的。正如我们在循环引用中看到的那样,相反的情况并不成立。

从 2012 年起,所有现代浏览器都使用了标记 - 清除垃圾回收算法。过去几年在 JavaScript 垃圾回收(分代/增量/并发/并行垃圾回收)领域所做的所有改进都是对该算法的实现改进,但并没有改进标记 - 清除算法本身和它对“对象是否不再需要”的简化定义。

循环引用不再是问题了

在上面的循环引用示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。第二个示例同样,一旦 div 和其事件处理无法从根获取到,他们将会被垃圾回收器回收。

限制:那些无法从根对象查询到的对象都将被清除

尽管这是一个限制,但实践中我们很少会碰到类似的情况,所以开发者不太会去关心垃圾回收机制。

限制:手动释放内存

有时手动决定何时以及释放什么内存会很方便。为了释放一个对象的内存,它需要显式地变得不可访问。

截至目前,无法在 JavaScript 中显式或以编程方式触发垃圾收集。

Node.js

Node.js 提供了额外的选项和工具来配置和调试在浏览器环境中无法使用的 JavaScript 内存管理。

例如,可以使用标志增加可用堆内存的最大数量:

node --max-old-space-size=6000 index.js