likes
comments
collection
share

GC 垃圾回收机制

作者站长头像
站长
· 阅读数 60

GC 垃圾回收 garbage collection

什么是垃圾回收机制?

程序在运行过程中会产生未使用或者不再使用的内存, GC 负责回收这些垃圾。

垃圾回收是怎么进行的?

通过 可达性 确定是否需要回收。 可访达说明内存还要使用, 不可访达说明需要被回收。 通过定期找到不可访达的内存并清理。

如何确定可达性呢?

  1. 标记清除
  2. 引用计数

标记清除法

目前大多使用这种清除方法。不同浏览器厂商有各自优化和检查频率的不同。

过程分为两个阶段标记清除

策略:

  1. 初始所有变量都标识成0
  2. 当进入执行环境后,对于使用到的变量标记为 1
  3. 离开执行环境后,清除标识为 0 的变量,销毁所占内存
  4. 把所有变量标识改为 0,等待下次垃圾回收

优点

简单, 使用 0 、 1 就可以标记

缺点

清除内存后,空闲内存不连续,导致后续再次分配内存时,需要重新计算内存空间,进行内存分配。

标记整理解决内存碎片问题

标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片(如下图) GC 垃圾回收机制

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

引用计算清除

很少使用了,有循环引用的重大 BUG

策略: 跟踪每个值的引用次数。

  1. 当声明了一个变量并且将一个引用类型值复制给该值引用计数就为 1
  2. 如果同一个值又被复制给了另外一个变量,应用值加 1
  3. 如果引用这个值的变量改变了引用对象,这个值就减 1
  4. 当这个值的引用次数变成 0 时,说明值不再被引用,清除值占用的内存

优点

引用为 0 时立即回收。

缺点

  1. 计数器占用空间
  2. 循环引用是无法清除内存

循环引用的例子:

function test(){
  let A = new Object()
  let B = new Object()
  
  A.b = B
  B.a = A
}

引用计数的过程

  1. 声明 A 并指向一个应用类型,引用计数为 1
  2. 同上,B 的引用计数为 1
  3. A.b = B B 新增了一个引用,计数为 2
  4. 同上,A 的计数为 2
  5. 函数域退出,由于 A 、B 的引用计数为 2, 不能释放内存。

V8 引擎在垃圾回收上做了哪些优化?

使用分代式垃圾回收: 新生代、老生代。

新、老生代的分配策略

将内存分为两个生代: 新生代、老生代。 新生代中的对象为活动较短的对象,老生代为活动时间较长的对象,针对新生代和老生代采用不同的回收策略。

对象一开始都分配到新生代。如果新生代不够,直接分配到老生代。 新生代在满足某些条件后被移动到老生代(这个过程叫做晋升)。

32 位系统中(64 位所有分配x2)默认情况下新生代分配为 16MB,老生代分配为 700MB。

新生代机制

新生代分成两块相同的内存,每块内存分为 8MB。分别为使用区和空闲区,当使用区快要被写满时开始进行垃圾回收。

新生代策略

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

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

当使用区快要被写满时开始进行垃圾回收。

首先对使用区进行标记,分为活动对象和非活动对象,将活动对象复制到空闲区并排序,对非活动对象进行清除。最后将空闲区和使用区进行互换,空闲区变成使用区,使用区变成空闲区。

晋升过程

当在进行新生代 Cheney算法复制时, 如果空闲区的内存占用超过 25%, 会直接将这个对象分配到老生代内存中。这是为了避免影响到后续的 Cheney算法内存分配。

老生代机制

对于活动时间长、占用空间大的内存直接分配到老生代中。 老生代因为空间大,不能采用新生代频繁的复制操作,老生代直接采用标记清除的方法。

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

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

对于标记清除产生的大量内存碎片,老生态使用标记整理的方式处理。

为什么使用迭代的方式

提升来及回收效率: 把内存小,存活时间短使用新生代快速频率高的方式快速清理。对于内存占用大,存货时间长的使用老生代频率低的清除,提升垃圾回收机制的效率。

并行回收

在了解并行回收前,需要知道什么是全停顿。

js 是一门单线程的语言,当GC 时会堵塞主线程, 主线程需要等 GC 完成后才能继续, 这就是全停顿

并行回收是指:GC 过程中,会在主线程之外,添加多个辅助线程,加快 GC 处理。

GC 垃圾回收机制

虽然使用并行回收会加快 GC, 但是依旧会存在全停顿的情况。新生代(数据小, 活动时间短)就是使用并行回收的方式加快 GC 过程。这些辅助线程会同时将使用区的数据移动到空闲区,并改变引用的指针。

增量标记

但是对于老生代数据量大,活动时间长, 就算使用并行回收,终究还是全停顿的方式,GC 的过程依旧会消耗大量时间。

增量标记就是将一次 GC 过程分为多个步骤,每次执行一小段就让主线程继续执行, 整个 GC 过程分为多步执行。 这个就是增量

GC 垃圾回收机制

增量处理需要面临两个问题:

  1. 每一步执行完毕后,如果暂停执行主线程,而后又如何恢复?
  2. 当上一步执行完后,主线程又更新了标记好的内存的引用,怎么处理呢?

三色标记法

三色标记法用来应对第一个问题。

通常情况下的标记,是对所有数据都标记为白色(0), GC 过程中会把活动数据标记为黑色(1),之后会归并整理,并清理。

而为了应对增量标记的情况,需要多增加一个标记表(灰色)来实现标记

  • 白色 未被标记的对象
  • 灰色 对象本身被标记,该对象的引用对象未被标记
  • 黑色 自身和引用对象都被标记

GC 垃圾回收机制

上图的步骤为:

  1. 最初的对象都没有被引用,为白色
  2. 从根部对象开始标记数据,A、B 推进到标记表,为灰色
  3. 从标记表中弹出对象并访问他们的引用对象,将自身变成黑色 A 、B 为黑色
  4. 进行下一层标记,C 没有被引用, 不标记,D 被引用,推进到标记表
  5. 从标记表弹出 D,标记为黑色,F 、G 被引用进入标记表,都为灰色
  6. 标记表弹出 F 、G 标记为黑色

通过上面的流程 C 、E 没有被引用,依旧是白色,等待回收。

通过三色标记法,恢复增量标记时只要判断是否有灰色节点,有灰色节点说明还在标记阶段,从灰色节点继续标记,没有灰色节点,直接进入清理阶段。

通过增量的方式,减少全停顿的过程,又通过三色标记的形式完成增量的暂停和继续。

写屏障(增量过程中修改引用)

解决增量标记的第二个问题,需要使用写屏障。

一次 GC 标记分块暂停进入主线程后,已经标记好的引用关系可能会被改变。 GC 垃圾回收机制

如图 A、B 、C 三个变量,在第一步增量标记时,B 指向 C,但是让给主线程后,把 B 的指向改为了 D, C 这时已经没有了引用关系。

这种情况,C 可以先不用管, 因为在下一次 GC 过程中 C 因为没有应用会被标记清除。

D 因为是初始阶段,是白色的, 按照增量标记结束判断,由于没有了灰色,增量标记过程会结束,D 因为还没有标记会被清理。所以为了解决这个问题,v8 引擎使用 写屏障机制,一旦有黑色对象引用白色对象,会立即将白色对象变成灰色,从而保障增量标记的下一步正确执行。

懒性清理

上面的增量标记和三色标记都是老生代在标记阶段的方法,在清理阶段,V8 引擎并不是在标记完立即清理的,引擎会根据当前内存大小进行惰性清理,先让主线程代码运行,并且也不是一次性清理完的,而是内存需求逐步清理。当都清理完毕后,才会进行下一次的增量标记。

增量标记&惰性求值的优缺

  • 优点:减少了主线程单次的暂停时间,让程序的操作更加流畅
  • 缺点:但是由于分段执行,增加了增量标记、写屏障的流程也增加了运行成本,所以在主线程的总暂停时间上可能会略长。

总结 V8 引擎的优化

  1. 使用新生代&老生代方案
  2. 新生代使用空闲区、使用区分进行标记、整理、复制操作。标记和整理使用并行回收
  3. 老生代标记阶段使用增量标记、三色标记、写屏障技术, 清理阶段使用惰性清理,按需、分批清理。
转载自:https://juejin.cn/post/7374249487076524041
评论
请登录