写在前面

JavaScript 是门魅力无限的语言,关于它的 GC(垃圾回收)方面,你了解多少呢?想来大部分人是因为面试才去看一些面试题从而了解的垃圾回收,那在正式开始之前,给大家列几个小问题,大家可以先想一下答案,带着问题及答案再去看文章,最后读完此文如果你的答案可以优化,即有收获

  • 什么是垃圾回收机制?
  • 垃圾是怎样产生的?
  • 为什么要进行垃圾回收?
  • 垃圾回收是怎样进行的?
  • V8 引擎对垃圾回收进行了哪些优化?

GC 是什么

GCGarbage Collection ,程序工作过程中会产生很多 垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC 就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说,GC 过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的 垃圾回收机制

当然也不是所有语言都有 GC,一般的高级语言里面会自带 GC,比如 Java、Python、JavaScript 等,也有无 GC 的语言,比如 C、C++ 等,那这种就需要我们程序员手动管理内存了,相对比较麻烦

垃圾产生&为何回收

我们知道写代码时创建一个基本类型、对象、函数……都是需要占用内存的,但是我们并不关注这些,因为这是引擎为我们分配的,我们不需要显式手动的去分配内存

但是,你有没有想过,当我们不再需要某个东西时会发生什么?JavaScript 引擎又是如何发现并清理它的呢?

我们举个简单的例子

1
2
3
4
let test = {
name: "isboyjc",
};
test = [1, 2, 3, 4, 5];

如上所示,我们假设它是一个完整的程序代码

我们知道 JavaScript 的引用数据类型是保存在堆内存中的,然后在栈内存中保存一个对堆内存中实际对象的引用,所以,JavaScript 中对引用数据类型的操作都是操作对象的引用而不是实际的对象。可以简单理解为,栈内存中保存了一个地址,这个地址和堆内存中的实际值是相关的

那上面代码首先我们声明了一个变量 test,它引用了对象 {name: 'isboyjc'},接着我们把这个变量重新赋值了一个数组对象,也就变成了该变量引用了一个数组,那么之前的对象引用关系就没有了,如下图

没有了引用关系,也就是无用的对象,这个时候假如任由它搁置,一个两个还好,多了的话内存也会受不了,所以就需要被清理(回收)。

用官方一点的话说,程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存,那么对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃。

垃圾回收策略

在 JavaScript 内存管理中有一个概念叫做 可达性,就是那些以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收。

至于如何回收,其实就是怎样发现这些不可达的对象(垃圾)它并给予清理的问题, JavaScript 垃圾回收机制的原理说白了也就是定期找出那些不再用到的内存(变量),然后释放其内存。

你可能还会好奇为什么不是实时的找出无用内存并释放呢?其实很简单,实时开销太大了

我们都可以 Get 到这之中的重点,那就是怎样找出所谓的垃圾?

这个流程就涉及到了一些算法策略,有很多种方式,其中两个最常见的

  • 标记清除算法
  • 引用计数算法

标记清除

标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异。

就像它的名字一样,此算法分为 标记清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。

首先它会遍历堆内存上所有的对象,分别给它们打上标记,然后在代码执行过程结束之后,对所使用过的变量取消标记。在清除阶段再把具有标记的内存对象进行整体清除,从而释放内存空间。

整个标记清除算法大致过程就像下面这样

  • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记
  • 然后从各个根对象开始遍历,把还在被上下文变量引用的变量标记去掉标记
  • 清理所有带有标牌机的变量,销毁并回收它们所占用的内存空间
  • 最后垃圾回收程序做一次内存清理

假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配(如下图)

那如何找到合适的块呢?我们可以采取下面三种分配策略

  • First-fit,找到大于等于 size 的块立即返回
  • Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
  • Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回

这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fitBest-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择。

综上所述,标记清除算法或者说策略就有两个很明显的缺点

  • 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
  • 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢

归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了。

标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存(如下图)

引用计数算法

引用计数(Reference Counting),这其实是早先的一种垃圾回收算法,它把 对象是否不再需要 简化定义为 对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,目前很少使用这种算法了,因为它的问题很多,不过我们还是需要了解一下。

它的策略是跟踪记录每个变量值被使用的次数

  • 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1
  • 如果同一个值又被赋给另一个变量,那么引用数加 1
  • 如果该变量的值被其他的值覆盖了,则引用次数减 1
  • 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存

如下例

1
2
3
4
5
let a = new Object() 	// 此对象的引用计数为 1(a引用)
let b = a // 此对象的引用计数是 2(a,b引用)
a = null // 此对象的引用计数为 1(b引用)
b = null // 此对象的引用计数为 0(无引用)
... // GC 回收此对象

这种方式是不是很简单?确实很简单,不过在引用计数这种算法出现没多久,就遇到了一个很严重的问题——循环引用,即对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A ,如下面这个例子

1
2
3
4
5
6
7
function test() {
let A = new Object();
let B = new Object();

A.b = B;
B.a = A;
}

如上所示,对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,它们的引用数量都是 2,但是,在函数 test 执行完成之后,对象 A 和 B 是要被清理的,但使用引用计数则不会被清理,因为它们的引用数量不会变成 0,假如此函数在程序中被多次调用,那么就会造成大量的内存不会被释放。

我们再用标记清除的角度看一下,当函数结束后,两个对象都不在作用域中,A 和 B 都会被当作非活动对象来清除掉,相比之下,引用计数则不会释放,也就会造成大量无用内存占用,这也是后来放弃引用计数,使用标记清除的原因之一。

优点

引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾

而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS 脚本)运行过程中线程就必须要暂停去执行一段时间的 GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了

缺点

引用计数的缺点想必大家也都很明朗了,首先它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的

V8 对于垃圾回收机制的优化

大多数浏览器都是基于标记清除算法,不同的只是在运行垃圾回收的频率具有差异。V8 对其进行了一些优化加工处理,那接下来我们主要就来看 V8 中对垃圾回收机制的优化。

分代式垃圾回收

试想一下,我们上面所说的垃圾清理算法在每次垃圾回收时都要检查内存中所有的对象,这样的话对于一些大、老、存活时间长的对象来说同新、小、存活时间短的对象一个频率的检查很不好,因为前者需要时间长并且不需要频繁进行清理,后者恰好相反,怎么优化这点呢???分代式就来了

新老生代

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收

新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量,而老生代的对象为存活事件较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大

V8 整个堆内存的大小就等于新生代加上老生代的内存(如下图)

对于新老两块内存区域的垃圾回收,V8 采用了两个垃圾回收器来管控,我们暂且将管理新生代的垃圾回收器叫做新生代垃圾回收器,同样的,我们称管理老生代的垃圾回收器叫做老生代垃圾回收器好了

新生代垃圾回收

新生代对象是通过一个名为 Scavenge 的算法进行垃圾回收,在 Scavenge算法 的具体实现中,主要采用了一种复制式的方法即 Cheney算法 ,我们细细道来

Cheney算法 中将堆内存一分为二,一个是处于使用状态的空间我们暂且称之为 使用区,一个是处于闲置状态的空间我们称之为 空闲区,如下图所示

  • 新加入的对象都会存放在使用区,当使用区快写满时就进行一次垃圾清理操作。
  • 在开始进行垃圾回收时,新生代回收器会对使用区内的对象进行标记
  • 标记完成后,需要对使用区内的活动对象拷贝到空闲区进行排序
  • 而后进入垃圾清理阶段,将非活动对象占用的内存空间进行清理
  • 最后对使用区和空闲区进行交换,使用区->空闲区,空闲区->使用区

当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理

另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配

老生代垃圾回收

相比于新生代,老生代的垃圾回收就比较容易理解了,上面我们说过,对于大多数占用空间大、存活时间长的对象会被分配到老生代里,因为老生代中的对象通常比较大,如果再如新生代一般分区然后复制来复制去就会非常耗时,从而导致回收执行效率不高,所以老生代垃圾回收器来管理其垃圾回收执行,它的整个流程就采用的就是上文所说的标记清除算法了

首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象

清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉

前面我们也提过,标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了我们上文中说的标记整理算法来解决这一问题来优化空间

内存泄漏与优化

内存泄漏,指在 JS 中已经分配内存地址的对象由于长时间未进行内存释放或无法清除,造成了长期占用内存,使得内存资源浪费,最终导致运行的应用响应速度变慢以及最终崩溃的情况。

在代码中创建对象和变量时会占据内存,但是JS 基于自己的内存回收机制是可以确定哪些变量不再需要,并将其进行清除。但是,当你的代码中存在逻辑缺陷时,你以为你已经不需要,但是程序中还存在这引用,这就导致程序运行完后并没有进行合适的回收所占有的内存空间。运行时间越长占用内存越多,随之出现的问题就是:性能不佳、高延迟、频繁崩溃。

造成内存泄漏的常见原因有:

  • 过多的缓存。及时清理过多的缓存。
  • 滥用闭包。尽量避免使用大量的闭包。
  • 定时器或回调太多。与节点或数据相关联的计时器不再需要时,DOM 节点对象可以清除,整个回调函数也不再需要。可是,计时器回调函数仍然没有被回收(计时器停止才会被回收)。当不需要 setTimeout 或 setInterval 时,定时器没有被清除,定时器的糊掉函数以及其内部依赖的变量都不能被回收,会造成内存泄漏。解决方法:在定时器完成工作时,需要手动清除定时器。
  • 太多无效的 DOM 引用。DOM 删除了,但是节点的引用还在,导致 GC 无法实现对其所占内存的回收。解决方法:给删除的 DOM 节点引用设置为 null。
  • 滥用全局变量。全局变量是根据定义无法被垃圾回收机制进行收集的,因此需要特别注意临时存储和处理大量信息的全局变量。如果必须使用全局变量来存储数据,请确保将其指定为 null 或在完成后重新分配它。解决方法:使用严格模式。
  • 从外到内执行 appendChild。此时即使调用 removeChild 也无法进行释放内存。解决方法:从内到外 appendChild。
  • 反复重写同一个数据会造成内存大量占用,但是 IE 浏览器关闭后会被释放。
  • 注意程序逻辑,避免编写『死循环』之类的代码。
  • DOM 对象和 JS 对象相互引用

本文参考:

「硬核 JS」你真的了解垃圾回收机制吗

Javascript 的垃圾回收机制知多少?