【译】内存管理(Memory Management)

472 阅读8分钟

前言

原文来自MDN JavaScript主题的高阶教程部分,一共5篇。分别涉及继承与原型、严格模式、类型数组、内存管理、并发模型和事件循环。本篇是第4篇,关于内存管理。

原文链接请点我


下面是正文部分:

一些底层语言例如 C,拥有手动的内存管理指令,如malloc()free()。相比之下,JavaScript 在对象创建时自动分配内存并且在它们不再使用时释放内存(垃圾回收机制(garbage collection))。这种机制会使人困惑:它让开发者觉得可以不必担心内存管理。

内存生命周期

任何编程语言的内存生命周期都几乎相同:

  1. 分配所需内存
  2. 使用分配的内存(读和写)
  3. 不再需要时释放该部分内存

在所有编程语言中,第二点是明确的。而第一点和第三点,在底层语言中也是明确的,不过在高阶语言例如 JavaScript 中,多数是隐式的。

在 JavaScript 中分配内存

初始化值

为了使开发者无需关注内存分配,初始化变量时,JavaScript 会自动分配一片内存。

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
);

通过函数调用分配内存

一些函数调用涉及到对象内存分配。

var d = new Date(); // 为一个日期对象分配内存

var e = document.createElement("div"); // 为一个DOM元素对象分配内存

一些方法调用涉及到为原始值或对象分配内存。

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新字符串
// 由于字符串 s 是不可变的值,
// JavaScript 不会继续为 s2 分配内存,
// 它仅仅保存下标 0-3 以获取子串.

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2);
// 拥有4个元素的新数组 a3 由 a 和 a2 合并而成

使用已分配内存的变量

使用变量意味着对分配的内存进行读和写。例如读取一个变量(或者对象属性)的值,为其赋值,或者是函数调用时传入参数。

当内存不再使用时对其进行释放

大部分内存管理的问题都发生在这个阶段。而其中最困难的就是判断何时不再需要这片内存。

低层次的语言要求开发者在程序中自行判断,并进行内存释放。

一些高阶语言,例如 JavaScript,利用了一种自动内存管理的形式,称作垃圾回收机制(garbage collection)(GC)。垃圾回收器的目的是监测内存分配,并确定何时该块内存不再需要,然后回收它。这个自动过程是一个近似过程,因为一块特定内存是否仍然需要是不确定的(undecidable)

垃圾回收机制

如上所述,一块已分配内存是否“不再需要”是不确定的。因此,垃圾回收器只能限制性地解决一般性问题。这个部分将会解释几个概念,它们有助于理解主要的垃圾回收算法以及它们各自的局限性。

引用(References)

垃圾回收算法主要依靠的一个概念叫做引用(reference)。在内存管理中,一个对象是否引用了另一个对象,取决于前一个对象是否访问了另一个(显式或隐式访问)。例如,一个 JavaScript 对象引用了它的原型对象(prototype)(隐式引用)和它自身的属性(显示引用)。

在这里,“对象”的概念不仅指 JavaScript 对象,也包含了函数作用域(或全局作用域)。

基于引用计数的垃圾回收机制

这是最初级的垃圾回收算法。这种算法将判定“该对象是否仍然需要”简化为“是否仍有其他对象引用了该对象”。一个对象被称为“垃圾”,或者说可回收的标准是对它的引用数为 0。

举例

var x = {
  a: {
    b: 2,
  },
};
// 创建了两个对象。其中一个被另一个通过自身属性引用
// 另一个则通过分配给变量 'x' 被引用
// 显然,这俩都无法回收

var y = x; // 变量 'y' 是第二个对象的第二次引用

x = 1; // 现在,被变量 'x' 引用的对象只被变量 'y' 引用了

var z = y.a; // 变量 'z' 引用了 属性 'a' 引用的对象
//   这个对象现在拥有两处引用:一个是属性 'a',
//   另一个是变量 'z'

y = "mozilla"; //  最初被变量 'x' 引用的对象现在没有了引用,因此可以进行回收
//   然而其属性 'a' 对应的对象仍然被变量 'z' 引用,
//   因此该对象暂时无法回收

z = null; // 最初变量 'x' 引用的对象属性 'a' 对应的对象没有了引用
//   可以被回收

限制:循环引用

如果遇到循环引用,上述的回收机制会受限。下面例子中,创建了两个对象,他们同时被各自的属性引用,造成了循环。函数调用完成后,作用域消失,它们本应该被回收。然而引用计数算法不会认为他们可被回收,因为他们都至少存在一个引用。引用计数通常会导致内存泄漏。

function f() {
  var x = {};
  var y = {};
  x.a = y; // x 引用了 y
  y.a = x; // y 也引用了 x

  return "azerty";
}

f();

真实案例

IE6 和 IE7 在 DOM 对象中使用了引用计数策略的垃圾回收器,循环引用是一个导致内存泄漏的常见错误:

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

上面的例子中,名为"myDivElement"的 DOM 元素通过属性”circularReference“循环引用了自身。如果该属性没有被移除或置为null,基于引用计数的垃圾回收器将会认为该 DOM 对象至少存在一个引用,即使 DOM 节点从 DOM 树中移除,仍会占用内存。如果 DOM 元素含有大量数据(上面例子中的lotsOfData属性),那么存储这部分数据的内存将不会被释放,可能会出现内存有关的问题,例如浏览器变得十分卡顿。

标记清除算法

这种算法将判断”该对象是否不再需要“简化为”该对象是否不再能够被访问(an object is unreachable)“。

这种算法假设有一组对象称为”根(Roots)“。JavaScript 中,根就是全局对象。垃圾回收器将定期从根对象开始,寻找所有被根对象引用的对象,然后被这些对象引用的类型,以此类推。在这个过程中,垃圾回收器会找到所有可被访问(reachable)的对象,且回收所有不再能够被访问的对象。

这个算法是对前一个的改进,因为当一个对象有 0 个引用时,可以说该对象也是不再能被访问的。但是反之不成立,例如上面提到过的循环引用。

截止 2012 年,所有现代浏览器都使用了基于标记清除算法的垃圾回收器。在过去几年里,针对 JavaScript 垃圾回收领域的改进都是基于该算法实现上的改进,而没有改变该算法本身,也没有改变”该对象是否不再需要“的简化定义。

循环引用不再是一个问题

在上述的第一个例子中,在函数调用返回后,从全局对象来看,变量xy指向的对象无法被任何对象访问。因此,它们会被垃圾回收器找到,占用的内存会被回收。

限制:手动释放内存

有时候,手动决定何时释放以及释放多少内存会更方便。为了释放一个对象占用的内存,这个对象需要被显示地标记为”不可访问“的。

截止到 2019 年,在 JavaScript 中,暂无方法显式地或以编程的方式触发垃圾回收。

Node.js

Node.js 环境中提供了额外的配置项以及工具,可以用来排除一些内存问题。而在浏览器环境中执行的 JavaScript 可能无法使用这些配置项和工具。

V8 引擎相关的命令行配置

可以通过如下命令来增加最大可供使用的堆内存:

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

我们也可以结合 Chrome Debugger,并使用如下命令来暴露垃圾回收器,从而排查一些内存有关的问题:

node --expose-gc --inspect index.js

参考