Skip to content

内存管理

执行环境

执行环境(也叫执行上下文)定义了变量和函数有权访问的其它数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。一般而言,执行环境中所有代码执行完毕后,该环境被销毁,其中定义的变量和函数随之销毁。

全局执行环境

全局执行环境是最外围的一个环境,其变量对象称作全局对象。宿主环境不同,全局对象也不同。

  • 浏览器中是 windowselfframes
  • Web Workers 中只有 self
  • Node.js 中只有 global

松散模式下,如果在全局执行环境中调用函数,则可以在函数中使用 this 访问全局对象,但是在严格模式下,thisundefined

为了简化获取全局对象的操作,JavaScript 提供了 globalThis 来获取不同环境的全局对象。

函数执行环境

每个函数都有自己的执行环境。当一个函数执行的时候,该函数的环境会被推入环境栈中。函数执行完之后,栈将该其环境弹出,把控制权返回给之前的执行环境。

函数执行环境的变量对象最开始只包含 arguments 对象。

作用域

作用域是执行环境的另一种称呼,也是更常用的术语。

JavaScript 的作用域分为四种:

  • 全局作用域:脚本模式运行所有代码的默认作用域
  • 模块作用域:模块模式中运行代码的作用域
  • 函数作用域:由函数创建的作用域
  • 块级作用域:在一对花括号(一个代码块)中使用 letconst 定义的变量,此时这个代码块是这些变量的块级作用域

作用域链

作用域链指的是作用域的层次结构,也即作用域的包含关系。子作用域可以访问父作用域,反过来则不行。

当我们使用变量或函数的时候,解析器会解析标识符,这个过程是沿着作用域链一级一级地搜索,直到找到标识符为止(如果找不到标识符,通常会导致错误发生)。

JavaScript 的内存管理

在 JavaScript 中,当我们创建变量、函数以及其它数据时,它会自动帮我们分配内存。

内存生命周期

  • 分配内存
  • 使用内存(读、写)
  • 不需要内存时释放内存

大多数内存管理的问题在第三个阶段。在这个阶段中需要解决哪些被分配的内存确实已经不再需要了,它要求开发人员确定在程序中哪一块内存需要释放。

高级语言解释器嵌入了垃圾回收器,他的主要工作是跟踪内存的分配和使用,在内存不再被使用时自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。

垃圾回收

引用

在介绍垃圾回收算法之前,我们先介绍一下引用的概念。一个对象[1]如果能够访问另一个对象(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 JavaScript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

引用计数

这是最初级的垃圾收集算法。引用计数算法的思路是把“对象是否不再需要”简化为“有没有其它对象引用它”。通过对对象进行引用计数,每有一个对该对象的引用,计数加 1。如果计数为 0,也就表示没有任何对该对象的引用,该对象可以被垃圾回收。

示例

ts
var obj = {
  a: {
    b: 2,
  },
}
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量 obj
// 我们把最外层的对象称为 a,把内部对象称为 b
// 这两个对象引用计数为 1,没有一个可以被垃圾收集

var o2 = obj // o2 变量是第二个对 a 的引用,此时 a 的引用计数为 2

obj = 1 // 现在,a 只有一个 o2 变量的引用了,此时引用计数为 1,a 的原始引用 obj 已经没有

var oa = o2.a // 引用 a 的 a 属性
// 现在,b 有两个引用了,引用计数为 2,一个是 o2,一个是 oa

o2 = 'yo' // 此时 a 的引用计数为 0 ,可以被垃圾回收了
// 但是由于 b 是它的属性而且被 oa 引用,此时 b 的引用计数为 1,所以还不能回收

oa = null // b 引用计数为 0,可以被垃圾回收了

限制:循环引用

该算法有个限制:无法处理循环引用的情况。如下面这个例子,我们在一个函数中创建了两个对象,这两个对象相互引用。在我们调用了函数之后,该函数的执行环境被销毁,按理说这两个对象的内存也应该被回收。然而,在引用计数算法中,这两个对象的引用计数都是 1,所以垃圾回收无法回收这两个对象。

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

f()

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

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

在上面这个例子中,myDivElement 这个 DOM 元素引用了自己,这是一种循环引用,它的引用计数始终为 1。只要没有显式移除这个属性或者设置为 null,引用计数算法就无法回收它的内存,即使它从 DOM 树中被删除。如果这个元素拥有大量的数据(如上面的 lotsOfData),那么它将永远占用的大量的内存。

标记 - 清除

标记 - 清除算法思路是把“对象是否不再需要”简化为“对象是否可以访问”,也就是我们下面说的可达性(Reachability)

可达性

如果一个值[2]能够以某种方式使用或访问,那么这个值就是可达的。这些值一定是存储在内存中的。

  1. 这里列出固有的可达值的基本集合,这些值明显不能被释放:

    • 当前执行的函数,它的局部变量和参数
    • 调用栈上的其它函数,它们的局部变量和参数
    • 全局变量
    • 还有一些内部的

    这些值被统称为 root

  2. 如果一个值可以通过引用链从 root 访问,则认为该值是可达的

    例如,全局变量中有一个对象,该对象有一个属性引用了另一个对象,则该对象被认为是可达的。而且它引用的对象也是可达的。

在 JS 引擎中有一个被称作 垃圾回收器 的东西在后台执行。它监控着所有值状态,并释放不可达的值的内存。

垃圾回收器定期从 root 开始找所有 root 引用的值,然后找这些值引用的值 …… 这样,垃圾回收器能够找到所有可达值以及收集所有不可达值。

这种算法解决了循环引用的问题。例如上面引用计数的例子,在函数调用之后,由于不能访问这个函数中创建的两个对象,这两个对象都被标记为不能访问,从而能够被垃圾回收器回收。

限制:无法从 root 对象访问的对象都将被清除

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

JS 引擎的优化

我们上面简单说了一下垃圾回收的机制,但在 JS 引擎内部还对垃圾回收做了许多优化以加快垃圾回收速度并且降低垃圾回收对代码执行的延迟。

从 2012 年起,所有现代浏览器都使用了标记 - 清除垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记 - 清除算法的改进,并没有改进标记 - 清除算法本身和它的思路。

下面是一些优化的操作:

  • 分代收集(Generational collection) —— 值被分成新旧两组。在典型的代码中,许多值的生命周期都很短,在这种情况下跟踪新值并将其从内存中释放是有意义的。而那些长期使用的值会变得“老旧”,并且被检查的频次也会降低
  • 增量收集(Incremental collection) —— 如果有许多值,并且我们试图一次遍历并标记整个值的集合,则花费的时间可能有点多,并且会给代码执行带来明显的延迟。因此,引擎将现有的整个集合拆分为多个部分,然后逐一处理。这样就会有很多小型的垃圾收集,而不是一个大型的。虽然需要它们之间有额外的标记来追踪变化,但是只会引起许多轻微的延迟而不会造成明显的延迟
  • 闲时收集(Idle-time collection) —— 垃圾回收器只会在 CPU 空闲时间尝试运行,以减少可能对代码执行的影响

还有其它垃圾回收算法的优化和风格。但是因为不同的引擎会有不同的调整和技巧。而且,更重要的是,随着引擎的发展,情况会发生变化,所以在没有真实需求的时候,“提前”学习这些内容是不值得的。如果你感兴趣,下方的 References 中提供了相关资料。

V8 博客 还不时发布关于内存管理变化的文章。当然,为了学习更多垃圾收集的相关内容,你最好学习 V8 引擎内部知识,并阅读一个名为 Vyacheslav Egorov 的 V8 引擎工程师的博客。这里之所以提及“V8 引擎”,是因为网上关于它的文章是最丰富的。对于其他引擎,许多方法是相似的,但在垃圾收集上许多方面有所不同。

总结

  • 垃圾回收是自动完成的,我们不能强制执行或是阻止执行
  • 当值是可达时,它一定是存在于内存中的
  • 被引用与可访问不同:一组相互引用的值可能整体都不可达

References


  1. 对象:JavaScript 对象以及执行环境的变量对象。我们所讨论的内存的回收便是这些对象及其包含的数据使用的内存的回收。 ↩︎

  2. :变量、函数等以及能够被这些使用的数据。值都被包含在变量对象中。 ↩︎