# 垃圾回收

# GC是什么

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

# 垃圾产生和为何回收

我们创建变量都是需要占用内存的,虽然我们并不关注这些,因为引擎为我们分配,我们不需要显式手动的去分配内存。 JS中引用数据类型的数据是保存在堆内存中的,栈内存中保存的一个引用。当我们给一个对象重新赋值,原对象数据就没有被引用了,也就是一个无用的对象数据,这个时候假如任由它搁置,一两个还好,多了内存也会受不了,所以就需要被清理(回收) 程序运行需要内存,只要程序提出要求,操作系统或者运行环境就必须提供内存,对于持续运行的服务进程,必须及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃

# 垃圾回收策略

在JS中内存管理中有一个概念叫做可达性,就是可以以某种方式访问或者可用的值,它们被存储在内存中,反之不可访问就需要回收。 那如何进行回收呢?这个就是怎么发现那些不可达的对象(垃圾)并给予清理。 JS中垃圾回收机制的原理简单来说就是定期找出那些不再使用的内存(变量),然后释放其内存。 垃圾回收过程中涉及到了一些算法策略,有多种方式,我们简单介绍两个最常见的:

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

# 标记清除算法

策略

标记清除(Mark-Sweep),目前在JS引擎里这种算法是最常见的,到目前为止的大多数浏览器的JS引擎都采用标记清除算法,只是各大厂商浏览器对此算法进行了优化加工,且不同浏览器在运行垃圾回收的频率上有所差异。 算法分为标记清除两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁

如何标记?

标记的方法有很多,比如:

  • 当变量进入执行环境时,反转某位(通过一个二进制字符来表示标记)
  • 又或者可以维护一个进入环境变量和离开环境变量这样的两个列表,可以自由的把一个变量从一个列表移到另一个列表。

引擎在使用标记清除算法进行GC时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组根对象,而所谓的根对象,其实在浏览器环境中不止全局Window对象、文档DOM树等。

算法过程

标记清除算法大致过如下:

  1. 垃圾收集器在运行时会给内存中的所有变量加上一个标记,假设内存中所有对象都是垃圾,全标记为0
  2. 然后从各个根对象开始遍历,把不是垃圾的节点改成1
  3. 清除所有标记为0的垃圾,销毁并回收它们所占用的内存空间
  4. 随后,把所有内存中标记修改为0,等待下一轮垃圾回收

优点

标记清楚算法的优点只有一个,就是简单,打标记无非打与不打两种情况,这用二进制位(0和1)就可以为其标记

缺点

标记清楚算法,清除垃圾后,剩余的对象内存位置时不变的,会导致空闲的内存空间不连续,出现内存碎片,并且由于剩下空间内存不是一个整块,他是有不同大小的内存组成的内存列表,这就会牵扯出内存分配的问题。

# 引用计数算法

策略

引用计数(Reference Counting),这其实最早的一种垃圾回收算法,它把对象是否不在需要简化为对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将垃圾回收机制回收,目前很少使用这种算法,因为他的问题很多

算法过程

引用计数算法大致过如下:

  1. 当声明一个变量并将其引用类型赋值给该变量的时候这个值的引用次数就为1
  2. 如果同一个值又被赋值给另外一个变量,那么引用次数+1
  3. 如果该变量被赋予了其他值,那么引用次数-1
  4. 当这个值的引用次数变为0的时候,则说明值没有被使用了,那么这个值没法被访问,垃圾回收器会在执行的时候清理掉引用次数位0的值占用的内存

优点 思路相对标记清除法更清晰。 标记清除法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程必须暂停去执行一段时间的GC,另外,标记清楚算法需要遍历堆里面的所有对象,来进行后续的操作,而计数只需要在引用时计数就可以了。

缺点 需要一个计数器,而次计数器需要占用很大的位置,因为我们不知道引用数量的上线,最大的缺点是无法解决循环引用无法回收的问题

# V8中的GC

V8也是采用的标记清除算法,但是V8对其进行了一些优化加工处理。上面说到标记清除算法需要在每次回收时检查内存中所有的对象,对一些大、老、存活时间长度的对象来说,同新、小、存活时间短的对象一个频率的检查肯定不是一个好的方案,因为前者需要时间长且不需要频繁进行处理,后者恰好相反,怎么优化这点呢?分代式就来了

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

# 新生代

新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持1~8M的容量。

新生代对象是通过一个名为Scavenge的算法进行垃圾回收,在Scavenge算法的具体实现中,主要采用了一种复制式的方法即Cheney算法。 Cheney算法将对内存一分二,一个是处于使用状态空间我们暂且称之为使用区,一个是处于闲置状态的空间我们称之为空闲区。 新加入的对象都会存放在使用区,当使用区域快被写满时,就是需要执行一次垃圾清理操作。

当开始进行垃圾回收时,新生代垃圾回收器会对使用区的对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后角色进行互换,将原来的使用区变成空闲区,将原来的空闲区变成使用区(空间互换)

如何进入老生代

当一个对象经历多次新生代垃圾回收依然存活,它将被认为是生命周期较长的对象,随后会被移动到老生代中,采取老生代的回收策略进行处理。 复制一个对象到空闲区时,空闲去占用超过25%,那么这个对象会被直接晋升到老生代空间中。设置25%的原因是最后需要将空闲去翻转成使用去,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。

# 老生代

老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。 老生代整个流程采用标记清除算法。首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的对象可以判断为非活动对象。 清除阶段老生代垃圾回收器会直接将非活动对象内存空间回收,也就是数据清理掉

在标记大型堆内存是,可能需要几百毫秒才能完成一次标记,这就会导致一些性能上的问题,为了解决这个问题,2011年,V8从stop-the-world标记切换到了增量标记。在增量标记期间,GC将标记工作分解为更小的模块,可以让那个JS应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在2018年,GC技术又有了一个重大的突破,这项技术名为并发标记。改技术可以让那个GC扫描和标记对象时,同时允许JS运行

清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将对象向一端移动,直到所有对象都移动完成,然后清理不需要的内存。

老生代中空间很复杂,有如下几个空间

enum AllocationSpace {
   RO_SPACE, // 不变的对象空间
   NEW_SPACE, // 新生代用于GC复制算法的空间
   OLD_SPACE, // 老身代常驻对象空间
   CODE_SPACE, // 老生代代码对象空尽
   MAP_SPACE, // 老生代map对象
   LO_SPACE, // 老生代大空间对象
   NEW_LO_SPACE, // 新生代大空间对象
   
   FIRST_SPACE = RO_SPACE,
   LAST_SPACE = NEW_LO_SPACE,
   FIRST_GROWABLE_SPACE = OLD_SPACE,
   LAST_GROWABLE_PAGED_SPACE = MAP_SPACE,
}

在老生代中,以下情况会先启动标记清楚算法

  • 某个空间没有分块的时候
  • 空间中对象超过一定限制
  • 空间不能保证新生代中的对象移到老生代中

# 参考

  1. 「硬核JS」你真的了解垃圾回收机制吗 (opens new window)
陕ICP备20004732号-3