从V8的垃圾回收看JVM和GO的GC

不知道大家会不会跟我有一样的错觉,垃圾回收?跟我没啥关系啊,JavaScript 是一门自动垃圾回收的语言,不需要我费心内存管理这档子事儿。其实不是这样的,了解垃圾回收机制对我们的开发工作有着很大的帮助。

垃圾数据的产生

先来看一个例子:

const a = new Object();
a.test = new Array(10);

当 JavaScript 在执行这段代码的时候,栈中保存了 a 对象的指针,顺着这个指针可以到达 a 对象,通过 a 对象可以到达test对象,下面是一个示意图。

如果这个时候,创建一个新的对象赋给 a 的 test 属性:

a.test = new Object();

这时,之前定义的数组与 a.test 之间的关系断掉了,没有办法从根对象遍历到这个 Array 对象,这个 Array 也不再被需要。这样就产生了垃圾数据。

其实,不论是什么程序语言,内存声明周期基本是一致的:

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

所有语言的第二部分都是明确的,而第一和第三部分在底层语言是明确的,像是 C 语言,可以通过malloc() 和 free() 来分配和销毁这些内存,如果一段数据不再需要了,有没有主动调用 free() 函数来释放,会造成内存泄漏的问题。但是像是在JavaScript 这些高级语言中,这两部分基本上是隐含的。

我们称 C 语言这种由代码控制何时分配、销毁内存的策略成为手动垃圾回收。而像是 JavaScript、Java等隐藏第一三部分,产生的垃圾数据由垃圾回收器释放的策略成为自动垃圾回收。

调用栈中的垃圾回收

让我们来看一个例子

function test() {
  const a = { name: 'a' };
  function showName() {
    const b = { name: 'b' };
  }
  showName();
}
test();

有一个记录当前执行状态的指针(称为 ESP)指向调用栈中的函数执行上下文。当函数执行完成之后,就需要销毁函数的执行上下文了,这时候,ESP 就帮上忙了,JavaScript 会将 ESP 下移到后面的函数执行上下文,这个下移的过程就是销毁当前函数执行上下文的过程。

堆中的垃圾回收

与栈中的垃圾回收不同的是,栈中无效的内存会被直接覆盖掉,而堆中的垃圾回收需要使用 JavaScript 中的垃圾回收器。

垃圾回收一般分为下面的几个步骤:

  1. 通过 GC Root 标记空间中的活动对象和非活动对象
    目前 V8 采用 可访问性(reachablility)算法来判断堆中的对象是否为活动对象。这个算法其实就将一些 GC Root 作为初始存活对象的集合,从 GC Root 对象触发,遍历 GC Root 中的所有对象。
    1. 能够通过 GC Root 遍历到的对象会被认为是可访问的,我们将其标记为活动对象,必须保留
    2. 如果一个对象无法通过 GC Root 遍历到,那么就认为这个对象是不可访问的,可能需要被回收,并标记为非活动对象。

在浏览器环境中 GC Root通常包括并不限于以下几种:

    • 全局 wimdow 对象(位于每个 iframe 中)
    • 文档 DOM 树,由可以通过遍历文档到达所有原生 DOM 节点组成
    • 存放栈上的变量。

2. 回收非活动对象所占据的内存

3. 内存整理。一般情况下,频繁回收对象后,内存中会产生大量不连贯的空间,及内存碎片,如果在此时需要分配大的连续内存的时候,就有可能产生内存不足的现象,所以需要在最后做一下内存整理的工作。不过有的垃圾回收器并不会产生内存碎片,所以这一步是选的。

 

在垃圾回收领域有一个重要的术语—代际假说,它有以下两个特点:

  • 大部分对象在内存中存在的时间很短,比如说函数内部的变量,或者块级作用域中的变量,当函数或块级代码块执行结束时,作用域内部定义的变量也会被销毁,这一类对象被分配内存后,很快就会变得不可用。
  • 只要不死的对象,都会持续很久的存在,比如说 window、DOM、Web API 等。

既然代际假说将对象大致分为两种,长寿的和短命的,垃圾回收也顺势把堆分为新生代和老生代两块区域,短命对象存放在新生代中,反正新生代中的对象都是短命鬼,那么就没有必要分配很大的内存就管理这一块儿区域,所以新生代一般只支持 1~8M 的容量【当然,最重要的是执行效率的原因,之后会详细讲到】,那么长寿的对象放到哪里呢?老生代存放那些生存时间久的对象,与新生代相比,老生代支持的容量就大的多很多了。

补充说明:老生代内部其实还有更详细的划分:老生指针区、老生数据区、大对象区、代码区、Call区、属性Cell区、Map区等等,这里为了方便说明,简单的将堆划分成新生代与老生代。

既然非活动对象都存放在了两块区域,V8 也就分别使用了两个不同的垃圾回收器来高效的实施垃圾回收:

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。

接下来,让我们来了解一下这两种垃圾回收器。

副垃圾回收器

通常情况下,大多数小的对象都会被分配到新生区,虽然这个区域不大,但是垃圾回收还是进行的非常频繁的。

新生代中采用 Scavenge 算法 处理,就是把新生代空间对半分为对象区域和空闲区域,新加入的对象会放到对象区域,当新生代区域快要被写满的时候就会执行一次垃圾清理的操作。

在垃圾回收的过程中,首先对对象区域做垃圾标记,标记完成后,副垃圾回收器会把存活的对象复制到空闲区域中,同时会把这些对象有序的排列起来,相当于是完成了内存整理的工作,复制后的空闲区域没有内存碎片了。完成复制之后,对象区域与空闲区域会进行角色翻转,这样就完成了垃圾回收的操作。这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

每次执行清理操作,都需要将存活的对象区域复制到空闲区域,复制操作需要时间成本,新生区空间设置的越大,那么每次清理的时间也就会越长,所以说,为了执行效率,一般新生区的空间都会设置的很小。

因为新生区空间不大,所以很容易就会被存活对象填满整个区域,这个时候应该怎么办呢?JavaScript 引擎为了解决这个问题,采用了对象晋升策略,简单的讲,就是经过两次垃圾回收依然存活的对象就会被移动到老生区。

主垃圾回收器

前面我们提到了,主垃圾回收器主要是负责老生区的垃圾回收,除了新生区晋升的对象,一些大的对象会被直接分配到老生区。所以老生区的对象一般有两个特点:

  1. 对象占用空间大
  2. 对象存活时间长

面对这种类型的对象,再使用新生区的 Scavenge 算法进行垃圾回收显然就不合理了,不仅复制对象时间要花费的长,还会浪费一半的空间。因此,主垃圾回收器采用 标记-清除(Mark-Sweep) 的算法进行垃圾回收的。

既然是标记-清除,那么第一步就是进行标记,从一组根元素开始递归这组根元素,在这个遍历过程中,能够到达的元素为活动对象,到达不了的元素可以判断为非活动对象,也就是垃圾数据。

第二步就是进行清除,下面是一个简单的图例,这个清除的过程可以理解为是将灰色的部分清除掉:

从图中可以很明显的看出来,如果对一块内存进行多次的标记-清除算法,就是产生大量的内存碎片,这样会导致如果有一个对象需要一块大的连续的内存出现内存不足的情况。为了解决这个问题,于是又引入了另一种算法:标记-整理(Mask-Compact)。

标记-整理 与 标记-清除 算法中,标记的步骤是一样的,只是后续不是直接对垃圾数据清理,而是先将所有存活的对象向一端移动,然后直接清理掉这一端以外的内存,

优化垃圾回收器的执行效率

JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕之后再恢复脚本执行,我们把这个行为称之为 全停顿(Stop-The-World)。

全停顿会带来什么问题呢?比如说,现在页面正在执行一个 JavaScript 动画,这时候执行垃圾回收,

如果这个垃圾回收执行的时间很长,打个比方,200ms,那么在这200ms内,主线程是没有办法进行其他工作的,动画也就无法执行,这样就会造成页面卡顿的现象出现。

为了解决全停顿带来的用户体验的问题,V8 团队进行多年的努力,向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,这些技术主要是从两个方面解决垃圾回收效率的问题:

  1. 既然一个大任务执行需要花费很长时间,那么就把它拆分成多个小任务去执行。
  2. 将标记、移动对象等任务转移到后台线程进行。这样大大减少主线程暂停的时间,改善页面卡顿的问题。

并行回收

既然主线程执行一次完整的垃圾回收比较耗时,这时大家就会不自觉的想到,在主线程执行任务的时候多开几个辅助线程来并行处理,这样速度不就会加快很多吗?因此,V8 引入了并行回收机制,为孤军奋战苦哈哈执行垃圾回收的主线程搬来了救兵。

采用并行回收时,垃圾回收所消耗的时间,等于总时间除以参与线程的数量,再加上一些同步开销的时间。其实,现在仍然是一种全停顿的垃圾回收模式,在执行垃圾回收的过程中,主线程并不会同步执行 JavaScript 代码,因此,JavaScript 代码不会改变回收的过程,所以我们可以假定内存状态是静态的,只需要保证同时只有一个协助线程在访问对象就好了。

V8 的副垃圾回收器就是采用的这种策略,在执行垃圾回收的过程中同时开启多个辅助线程来对新生代进行垃圾清理的工作,这些线程同时将对象中的数据移动到空闲区域,由于数据地址发生了改变,所以还需要同步跟新引用这些对象的指针。

增量回收

老生代中一般存放着比较大的对象,比如说 window、DOM 等,采用并行回收完整的执行垃圾回收依然需要很长时间,这样依然会出现之前提到的动画卡顿的现象,这个时候,V8又引入了增量标记的方式,我们把这种垃圾回收的方式成为增量垃圾回收。

增量垃圾回收就是垃圾收集器将标记工作分成更小的块穿插在主线程的不同任务之间执行。这样,垃圾回收器就没有必要一次执行完整的垃圾回收过程,只要每次执行其中的一小部分工作就可以了:

 

增量回收也是并发执行的,所以这比全停顿要复杂的多,想要实现增量回收,必须要满足以下两点:

  1. 垃圾回收可以随时暂停和重启,暂停时需要保存当时扫描的结果,等下一波垃圾回收来了才能继续启动。
  2. 在暂停期间,如果被标记好的数据被 JavaScript 修改了,那么垃圾回收器需要能够正确的处理。

为了能够实现垃圾回收的暂停和恢复执行。V8 采用了三色标记法(黑白灰)来标记数据:

  1. 黑色表示这个节点被 GC Root 引用到了,而且这个节点的子节点已经标记完成了。
  2. 灰色表示这个节点被 GC Root 引用到了,但子节点还没有被垃圾回收器处理【目前正在处理这个节点】。
  3. 白色表示这个节点没有被访问到,如果本轮遍历结束,这个节点还是白色的,就表示这个数据是垃圾数据,对应的内存会被回收。

这么看来也不复杂啊?为什么说增量回收要比全停顿复杂呢?这不是骗人吗?

其实不是的,让我们来想象一下,什么是失败的垃圾回收?其实无非就是两点:

  1. 不该回收还有用的内存被回收了
  2. 该回收的没被回收

第二个倒还是小问题,如果出现了第一个问题,那可就严重了。你可能会想,什么时候会出现第一个问题呢?

Dijkstra 在论文里举了一个很顽皮的 mutator:

三个节点 ABC, C 在 AB 之间反复横跳,一会儿只有 A 指向 C,一会儿只有 B 指向 C

  1. 开始扫描 A 时, 只有 B 指向 C,A 扫描完成变为黑色,C 是白色的。


  1. 2. 开始扫描 B 时,只有 A 指向 C, B 扫描完成变成黑色,C 还是白色的,


  1. 3. 由于 A 节点已经变成了黑色,无法继续扫描其子节点,之后继续向后扫描。
    4. 当遍历完成后,虽然 C 是有用数据,却依然是白色的,就被当做垃圾数据干掉了,A 和 B 站在岸边爱莫能助。

为了解决这个问题,增量回收添加了一个约束条件:不能让黑色节点指向白色节点。通常使用写屏障(Write-barrier)机制来实现这个约束条件:当发生了黑色节点引用了白色节点的情况,写屏障会强制将被引用的白色节点变成灰色,这种方法也被成为强三色不变性。

所以上面的例子,当发生A.C = C 时,会将 C 节点着色并推入灰色栈。

 

并发回收

虽然通过三色标记法和写屏障机制能够很好的实现增量垃圾回收,但是由于这些操作都是在主线程上执行的,那么当主线程繁忙的时候,增量回收操作依然会降低主线程处理任务的吞吐量(throughput).

这个时候需要并发回收机制了,所谓并发回收,就是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台执行垃圾回收的操作。

从图中可以看出来,并发回收的优势非常明显,主线程不会被挂起,JavaScript 可以自由的执行,在执行的同时,辅助线程可以执行垃圾回收的操作。

与之相对的,并发回收是这三种技术中最难的一种,主要是由于下面的原因:

  1. 当主线程执行 JavaScript 时,堆中的内容随时可能发生变化,从而使得辅助线程之前做的工作无效
  2. 主线程和辅助线程可能会在同一时间修改同一个对象,为了避免产生这种问题,必须要额外实现读写锁等功能。

尽管并发回收要额外解决上面两个问题,但是权衡利弊来说,这种方式的效率还是远高于其他方式的。

V8并不是单独的使用了上面说的某一种方式来实现垃圾回收,而是融合在一起使用,下面是一个示意图:

 

  • 首先,主垃圾回收器主要采用了并发标记,在 JavaScript 在主线程上执行的时候,辅助线程已经开始执行标记操作了,也就是说,标记工作是在辅助线程上执行的。
  • 标记完成之后,再执行整理操作,主线程在执行整理操作的同时,多个辅助线程也在执行整理操作
  • 另外,主垃圾回收器还采用了增量标记的方式,整理任务会穿插在各个 JavaScript 任务之间执行。

关于引用计数垃圾回收的彩蛋

作为老一代浏览器垃圾回收策略,引用计数也是有优势的:

  1. 可以立即回收垃圾,因为每个对象都知道自己的引用计数,当变为 0 时就可以立即回收。
  2. 最大暂停时间短(因执行垃圾回收而暂停执行程序的最长时间),因为只要程序更新指针时程序就会执行垃圾回收,内存管理的开销分布在整个应用程序执行期间,无需挂起应用程序的运行来做,因此消减了最大的暂停时间(但是增多了垃圾回收的次数)。
  3. 不需要沿指针查找。产生的垃圾会立即连接到空闲列表,所以不需要查找哪些对象时需要回收的。

但是引用计数的问题却是致命的,可能会导致内存泄漏,所以现在流行的浏览器都没有采用引用计数的方式了,那么,引用计数为什么会可能造成内存泄漏这么严重的问题呢?

让我们看一个实例,在 IE6、7 中使用引用计数的方式对 DOM 对象进行垃圾回收,这种方式常常会造成对象被循环引用时内存发生泄漏:

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

在这个例子中,myDivElement 这个 DOM 中的 circularReference 属性引用了 myDivElement, 这样就造成了循环引用,如果这个属性没有被显式的移除或者设置为 null,计数器中的最小值永远是1,不可能为0。如果这个 DOM 元素拥有大量的数据(如上 lotsOfData 属性),而这个数据占用的内存将永远都不会被释放,这就导致了内存泄漏。

posted @ 2021-12-22 01:23  方东信  阅读(188)  评论(0编辑  收藏  举报