内存泄漏和垃圾回收

Jan 11 · 42min

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

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

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放/归还

所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像 JavaScript 这些高级语言中,大部分都是隐含的。

在 Chorme 浏览器中,V8 被限制了内存的使用(x64约1.4G/1464MB)/(x86约0.7G/732MB),限制的主要原因是 V8 最初为浏览器而设计,不太可能遇到用大量内存的场景,更深层原因是 V8 垃圾回收机制的限制: 清理大量的内存垃圾很耗时间,这样会引起 JavaScript 线程暂停执行,导致性能和应用直线下降。

内存泄漏

内存泄漏(Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使用的内存。

并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放改段内存之前就失去了对该段内存的控制,从而造成内存的浪费。

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

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

常见的内存泄漏

写得不好的 JavaScript 可能出现难以察觉且有害的内存泄漏问题。

在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。JavaScript 中的内存泄露大部分是由不合理的引用导致的。

意外声明的全局变量

JavaScript 对未声明变量的处理方式: 在全局对象上创建该变量的引用(即全局对象上的属性,不是变量,因为它能通过delete删除)。如果在浏览器中,全局对象就是 window 对象。

如果未声明的变量缓存大量的数据,会导致这些数据只有在窗口关闭或重新刷新页面时才能被释放。这样会造成意外的内存泄漏。

function foo(arg) {
  bar = 'Leet'
}

// 等同于
function foo2(arg) {
  window.bar = 'Leet'
}

通过 this 创建意外的全局变量:

function foo(arg) {
  this.val = 'Leet'
}

// 当在全局作用域下调用foo函数,此时this指向的是全局对象window,而不是'undefined'
foo()

此时,解释器会把变量name当作window的属性来创建(window.name = 'Leet')。只要在window对象上创建的属性,只要window本身不被清理就不会消失。这个问题很容易解决:

  • 只要在变量声明前加上letconst关键字即可,这样变量就会在函数执行完毕后离开作用域。
  • 在文件中添加use strict开启严格模式可以有效避免
function foo(arg) {
  'use strict'
  bar = 'Leet' // 报错bar未声明
}

如果需要在一个函数内使用全局变量,建议:

function foo(arg) {
  window.bar = 'Leet'
}

这样不仅可读性高,而且易维护。

谈到全局变量,需要注意那些用来临时存储大量数据的全局变量,确保在处理玩这些数据后将其设置为 null 或重新赋值。全局变量也常用来做 cache,一般 cache 都是为了性能优化才能用到的,为了性能,最好对 cache 的大小做个上限限制。因为 cache 是不能被回收的,越高的 cache 会导致越高的内存消耗。

console.log

console.log: 向 web 开发控制台打印一条消息,常用来开发时调试分析。有时在开发时,需要打印一些对象信息,但发布时却忘记去掉console.log,这可能造成内存泄漏。

在传递给console.log的对象是不能被垃圾回收的,因为在代码运行之后需要在开发工具能查看对象信息。所以最好不要在生产环境中console.log任何对象,另外还有console.dirconsole.errconsole.warn等都存在类似问题,这些细节需要特别关注。

闭包(Closures)

当一个函数 A 返回一个内联函数 B,即使函数 A 执行完,函数 B 也能访问函数 A 作用域内的变量,这就是一个闭包————本质上闭包是将函数内部和外部连接起来的一座桥梁。

function foo(msg) {
  function closure() {
    console.log(msg)
  }

  return closure
}

const bar = foo('leet')
bar() // 'leet'

在函数 foo 内创建的函数 closure 对象是不能被回收掉的,因为它被全局变量 bar 引用,处于一直可访问状态。通过执行bar()可以打印出leet。如果想释放掉可以将bar = null即可。

由于闭包会携带包含它的函数的作用域,因此会比其他函数占用更多的内存。过度使用闭包可能会导致内存占用过多。

DOM 泄露

DOM 元素的生命周期正常情况下取决于是否挂载在 DOM 树上,当元素从 DOM 树上移除时,就可以被销毁回收了。

但如果某个 DOM 元素在 JS 中也持有它的引用,想要彻底删除这个元素,就需要把两个引用都清楚,这样才能正常回收它。

// 在对象中引用 DOM
const elements = {
  btn: document.getElementById('btn')
}
function doSomeThing() {
  elements.btn.click()
}

function removeBtn() {
  // 移除 DOM 树中的 btn
  document.body.removeChild(document.getElementById('button'))
  // 但是此时全局变量 elements 还是保留了对 btn 的引用, btn 还是存在于内存中,不能被 GC 回收
}

虽然别的地方删除了,但是对象中还存在对 dom 的引用。

解决方法是删除 DOM 节点时,也要释放 JS 对节点的引用:elements.btn = null

timers

在 JavaScript 常用setInterval()来实现一些动画效果。当然也可以使用链式setTimeout()调用模式来实现:

// setTimeout(function () {
//   setTimeout(arg.callee, interval)
// }, interval)

如果在不需要setInterval()时,没有通过clearInterval()方法移除,那么setInterval()会不停的调用函数,直到调用clearInterval()或窗口关闭。如果链式setTimeout()调用模式没有给出终止逻辑,也会一直运行下去。

因此在不需要重复定时器时,确保对定时器进行清除,避免占用系统资源。另外,在使用setInterval()setTimeout()来实现动画时,无法确保定时器按照指定的时间间隔来执行动画。

为了能在 JavaScript 中创建出平滑流畅的动画,浏览器为 JavaScript 动画添加了一个新 APIrequestAnimationFrame()

EventListener

做移动开发时,需要对不同设备尺寸做适配。如在开发组件时,有时候需要考虑处理横竖屏适配问题。一般做法,在横竖屏发生变化时,需要将组件销毁后再重新生成。而在组件中会对其进行相关事件绑定,如果在销毁组件时,没有将组件的事件解绑,在横竖屏发生变化时,就会不断地对组件进行事件绑定。这样会导致一些异常,甚至可能会导致页面崩掉。

在开发中,开发者很少关注事件解绑,因为浏览器已经为我们处理得很好了。不过在使用第三方库时,需要特别注意,因为一般第三方库都实现了自己的事件绑定,如果在使用过程中,在需要销毁事件绑定时,没有调用所解绑方法,就可能造成事件绑定数量的不断增加。

垃圾回收

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在 C 和 C++等底层语言中,跟踪内存使用对开发者来说是个很大的负担,也是很多问题的来源。

JavaScript 为开发者卸下了这个负担,通过自动内存管理实现内存分配和闲置资源回收。基本思路就是: 确定哪个变量不会再使用,然后释放它占用的内存。

这个过程时周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。垃圾回收过程时一个近似且不完美的方案,因为某块内存是否还有用,属于"不可判定的"问题,意味着靠算法是解决不了的。

过去这些年 V8 的垃圾回收器(GC)发生了很多的变化,Orinoco 项目采用了 stop-the-world 垃圾回收器,以使其变成了一个更加并行,并发和增量的垃圾回收器。

附上原视频(youtube)

主垃圾回收器

主垃圾回收器从整个堆(heap)中收集垃圾。

主垃圾回收器主要有三个阶段:标记(marking),清除(sweeping)和整理(compacting):

标记阶段

确定哪些对象可以被回收是垃圾回收中重要的一步。垃圾回收器通过可访问性(reachability)来确定对象的“活跃度”(liveness)。这意味着任何对象如果在运行时是可访问的(reachable),那么必须保证这些对象应该在内存中保留,如果对象是不可访问的(unreachable),那么这些对象就可能被回收。

标记阶段就是找到可访问对象的一个过程;垃圾回收是从一组对象的指针(objects pointers)开始的,我们将其称之为根集(root set),这其中包括了执行栈和全局对象;然后垃圾回收器会跟踪每一个指向 JavaScript 对象的指针,并将对象标记为可访问的,同时跟踪对象中每一个属性的指针并标记为可访问的,这个过程会递归地进行,直到标记到运行时每一个可访问的对象。

清理阶段

清理阶段就是将非活动对象占用的内存空间添加到一个叫空闲列表(free-list)的数据结构中。一旦标记完成,垃圾回收器会找到不可访问对象的内存对象,并将内存空间添加到相应的空闲列表中。空闲列表中的内存块由大小来区分,为什么这样做?为了方便以后需要分配内存,就可以快速的找到大小合适的内存空间并分配个新的对象。

整理阶段

主垃圾回收器会通过一种叫碎片启发式(fragmentation heuristic)的算法来整理内存页,你也可以将整理阶段理解为老式 PC 上的磁盘整理。那么碎片启发式算法是怎么做的呢?我们将活动对象复制到当前没有被整理的其他内存页中(即被添加到空闲列表的内存页); 通过这种做法,我们就可以利用内存中高度小而分散的内存空间。

垃圾回收器复制活动对象到当前没有被整理的其他内存页中有一个潜在的缺点,我们要分配内存空间给很多常驻内存(long-living)的对象时,复制这些对象会带来很高的成本。这就是为什么我们只选择整理内存中高度分散的内存页,并且对其他内存页我们只进行清理而不是也同样复制活动对象的原因。

分代堆布局

堆在 V8 中会分为两个不同的区域,我们将其称之为代(generations);这两块区域分别称之为老生代(old generation)和新生代(young generation),新生代又进一步分为’nursery’子代和’intermediate’子代两块区域;一个对象第一次分配内存时会被分配到新生代中’nursery’子代;如果进过下一次垃圾回收这个对象还存在新生代中,这时候我们移动到’intermediate’子代,再经过下一次垃圾回收这个对象还在新生代,这时候我们就会把这个对象移动到老生代。

V8中堆分成两代,如果经过垃圾回收对象还存活的话会从新生代移动到老生代:

在垃圾回收中有一个重要的术语:“代际假说”(The Generational Hypothesis);代际假说表明很多对象在内存中存在的时间很短(die young)。换句话说,从垃圾回收的角度来看,很多对象一经分配内存空间随即就变成了不可访问的。这个假说不仅仅适用于 V8 和 JavaScript,同样适用于大多数的动态语言

V8 分代堆布局的设计主要是为了利用对象存在生命周期的这个事实;垃圾回收实质上就是整理内存和移动内存中的对象,那这就意味着我们应该多移动对象到空闲列表中的内存中去;这个看上去似乎有点违反直觉,因为在垃圾回收的时候复制对象的成本很高。但是根据代际假说在垃圾回收中,在内存中存活下来的对象其实并不是很多。所以重新分配内存给新创建的对象,这反而变成了隐式的垃圾;这就意味着我们只需花费复制存活对象的成本,并不需要耗费成本去分配新的内存。

副垃圾回收器

V8 有两个垃圾回收器,主垃圾回收器(Full Mark-Compact)从整个堆中回收垃圾,副垃圾回收器(Scavenger)从新生代中回收垃圾。主垃圾回收器可以很有效的从整个堆中回收垃圾,但是代际假说告诉我们新分配内存的对象也极有可能需要垃圾回收。

副垃圾回收器只从新生代中回收垃圾,幸存的对象总是会被分配到内存页中去。V8 为新生代内存采用了‘半空间’(semi-space)的设计,这意味着为了做疏散(译者注:移动对象)这一步骤(evacuation step),有一半的内存空间是空闲的。在清理时,初始的空闲区域称之为“To-Space”,复制对象过来的区域称之为“From-Space”;在最坏的情况下,如果每一个对象在清理的时候存活了下来,那我们就要复制每一个对象。

对于清理,我们会维护一个额外的根集(root set),这个根集里会存放一些从旧到新的引用。这些引用是在旧空间(old-space)中指向新生代中对象的指针。我们使用“写屏障(write barriers)”来维护从旧到新的引用列表,而不是跟踪整个堆中的每一个对象变更。当堆和全局对象结合使用时,我们知道每一个在新生代中对象的引用,而无需追踪整个老生代。

疏散步骤将所有的活动对象移动到连续的一块内存中,这样做的好处就是完全移除内存碎片(清理非活动对象时留下的内存碎片);然后我们把两块内存空间互换,即把 ‘To-Space’ 变成 ‘From-Space’,反之亦然。一旦垃圾回收完成,新分配的内存空间将从 ‘From-Space’ 下一个空闲内存地址开始。

副垃圾回收器移动活动对象到一个新的内存页:

如果仅仅是凭借这一策略,我们就会很快的耗尽新生代的内存空间;为了新生代的内存空间不被耗尽,在下一次垃圾回收的时候,我们会把活动对象移动(evacuate)到老生代,而不是 ‘To-Space’。

清理的最后一步是把移动后的对象的指针地址更新,每一个被复制对象都会留下一个转发地址(forwarding-address),用于更新指针以指向新的地址。

副垃圾回收器移动 ‘intermediate’ 子代的活动对象到老生代:

副垃圾回收器在清理时,实际上执行三个步骤:标记,移动活动对象,和更新对象的指针;这些都是交错进行,而不是在不同阶段。

Orinoco

这些算法和优化在很多垃圾回收相关的文献或着具有垃圾回收机制的编程语言中都是非常常见的,但是这些先进的垃圾回收机制已经经过了漫长发展。测量垃圾回收所花费时间的一个重要指标就是执行垃圾回收时主线程挂起的时间。对于传统的 ‘stop-the-world’ 垃圾回收器来说,垃圾回收所花费的时间可以直接简单相加。而这种垃圾回收的方式直接影响了用户体验,会直接导致页面卡顿,渲染延迟等一系列问题。

V8 垃圾回收器 Orinoco 的 LOGO:

Orinoco 是 V8 垃圾回收器项目的代号,它利用最新的和最好的垃圾回收技术来降低主线程挂起的时间, 比如:并行(parallel)垃圾回收,增量(incremental)垃圾回收和并发(concurrent)垃圾回收。这里有一些术语在垃圾回收的上下文中有特定的含义,所以这是值得去详细的探讨的。

并行垃圾回收

并行是主线程和协助线程同时执行同样的工作,但是这任然是一种‘stop-the-world’的垃圾回收方式,但是垃圾回收所消耗的时间等于总时间除以参与的线程数量(加上一些同步开销)。这是这三种技术中最简单的 JavaScript 垃圾回收方式;因为没有 JavaScript 的执行,因此只需要确保同时只有一个协助线程在访问对象就好了。

主线程和协助线程同在一时间做同样的任务:

增量垃圾回收

增量式垃圾回收是主线程间歇性的去做少量的垃圾回收的方式。我们不会在增量式垃圾回收的时候执行整个垃圾回收的过程,只要整个垃圾回收过程的一小部分工作。做这样的工作是极其困难的,因为 JavaScript 也在做增量式垃圾回收的时候同时执行,这意味着堆的状态已经发生了变化,这可能会导导致之前的增量回收工作完全无效。从图中可以看出并没有减少主线程暂停的时间(事实上,通常会略微增加),只会随着时间的推移而增长。但这仍然是解决问题的好办法,通过 JavaScript 间歇性的执行,同时也间歇性的去做垃圾回收工作,JavaScript 的执行仍然可以在用户输入或者执行动画的时候得到及时的响应。

垃圾回收任务交错的进入主线程执行:

并发垃圾回收

并发是主线程一直执行 JavaScript,而辅助线程在后台完全的执行垃圾回收。这种方式是三种技术中最难的一种,JavaScript 堆里面的内容随时都可能发生变化,从而使之前做的工作完全无效。最重要的是,现在有读/写竞争(read/write races),主线程和辅助线程极有可能在同一时间去更改同一个对象。这种方式的优势也非常明显,主线程不会被挂起,JavaScript 可以自由的执行,尽量为了保证同一对象同一时间只在一个辅助线程在修改而带来的一些同步开销。

垃圾回收任务完全发生在后台,主线程可以自由的执行 JavaScript:

V8 当前使用的机制


Scavenging

现今,V8 在新生代垃圾回收中使用并行清理,每个协助线程会将所有的活动对象都移动到 ‘To-Space’。在每一次尝试将活动对象移动到 ‘To-Space’ 的时候必须通确保原子化的读和写以及比较和交换操作。不同的协助线程都有可能通过不同的路径找到相同的对象,并尝试将这个对象移动到 ‘To-Space’;无论哪个协助线程成功移动对象到 ‘To-Space’,都必须更新这个对象的指针,并且去维护移动这个活动对象所留下的转发地址。以便于其他协助线程可以找到该活动对象更新后的指针。为了快速的给幸存下来的活动对象分配内存,清理任务会使用线程局部分配缓冲区。

并行清理在主线程和多个协助线程之间分配清理任务:

Major GC

V8 中的主垃圾回收器主要使用并发标记,一旦堆的动态分配接近极限的时候,将启动并发标记任务。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用。在 JavaScript 执行的时候,并发标记在后台进行。写入屏障(write barriers)技术在辅助线程在进行并发标记的时候会一直追踪每一个 JavaScript 对象的新引用。

主垃圾回收器并发的去标记和清除对象,并行的去整理内存和更新活动对象的指针:

当并发标记完成或者动态分配到达极限的时候,主线程会执行最终的快速标记步骤;在这个阶段主线程会被暂停,这段时间也就是主垃圾回收器执行的所有时间。在这个阶段主线程会再一次的扫描根集以确保所有的对象都完成了标记;然后辅助线程就会去做更新指针和整理内存的工作。并非所有的内存页都会被整理,之前提到的加入到空闲列表的内存页就不会被整理。在暂停的时候主线程会启动并发清理的任务,这些任务都是并发执行的,并不会影响并行内存页的整理工作和 JavaScript 的执行。

空闲时垃圾回收器

JavaScript 是无法去直接访问垃圾回收器的,这些都是在 V8 的实现中已经定义好的。但是 V8 确实提供了一种机制让 Embedders(嵌入 V8 的环境)去触发垃圾回收,即便 JavaScript 本身不能直接去触发垃圾回收。垃圾回收器会发布一些 “空闲时任务(Idle Tasks)”,虽然这些任务都是可选的,但最终这些任务会被触发。像 Chrome 这些嵌入了 V8 的环境会有一些空闲时间的概念。比如:在 Chrome 中,以每秒 60 帧的速度去执行一些动画,浏览器大约有 16.6 毫秒的时间去渲染动画的每一帧,如果动画提前完成,那么 Chrome 在下一帧之前的空闲时间去触发垃圾回收器发布的空闲时任务。

空闲时垃圾回收器,利用主线程上的空闲时间主动的去执行垃圾回收工作:

参考

JS 内存管理
JS 内存泄漏和垃圾回收机制
常见的 JavaScript 内存泄漏
谈谈 GC:新的 Orinoco 垃圾收集器

CC BY-NC-SA 4.0 2023-PRESENT © Leet