垃圾回收机制

199 阅读15分钟

引用 js垃圾回收机制 jvm垃圾回收机制(GC)详解

介绍背景

我们知道垃圾回收机制是引擎来做的,JS引擎有很多种(各个浏览器都不同),其垃圾回收机制在一些细节及优化上略有不同,==本文我们以一些通用的回收算法作为切入,再由 V8 引擎发展至今对该机制的优化为例==,一步一步深入来助我们了解垃圾回收机制.

主要从以下几个问题进行分析理解:

1. 什么是垃圾回收机制?
2. 垃圾是怎样产生的?
3. 为什么要进行垃圾回收?
4. 垃圾回收是怎样进行的?
5. V8引擎对垃圾回收进行了哪些优化?
6. 垃圾回收时机?

什么是垃圾回收机制?

垃圾回收机制简称 GC(garbage collection), 我们的程序在工作中会产生一些垃圾,这些垃圾是程序中不用的或者是使用过的,在程序完成后,或者进行中,GC会负责进行回收垃圾,这个过程就是垃圾回收机制。

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

说白了也就是定期找出一些不再用到的内存(变量),然后释放其内存。

垃圾是怎样产生的?

在我们开发中会进行变量声明,这些变量都会在占用内存,这些分配过程不需要我们手动显示的给赋值,这个是V8引擎帮助我们分配的。但是很多时候我们只是进行了声明,未对其进行销毁,内存就没有得到释放,虽然V8引擎他自己有自己的一套回收机制,但是也存在他不能回收的,这部分内容就永远的存在了我们的内容中。这部分内容即是垃圾。

for example

let test = {
  name: "六哥霸王色霸气"
};
test = [1,2,3,4,5,6]

引用类型是保存在堆内存中,然后在栈内存中保存一个对堆内存中的实际对象的引用,因此js中对引用数据类型的操作都是对对象的引用的操作,而不是对对象本身进行操作;基本类型是保存在栈中,因此对基本类型的操作就是直接对其操作

我们首先声明了一个变量test,它引用了对象{name:"六哥霸王色霸气"},接着我们把这个变量重新赋值了一个数组对象,也就是该变量引用了一个数组,==那么之前的对象引用关系就没有。也就说之前的值变成了无用的对象,他就需要被处理==。

为什么要进行垃圾回收?

上一节我们介绍了垃圾产生的一种情况,随着无用变量的不断增加,==越来越多的内存被无价值的占用==,那么我们就需要对其进行清理!

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

垃圾回收是怎样进行的?

可达性

在 JavaScript 内存管理中有一个概念叫做 可达性,就是那些以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收。 至于如何回收,其实就是怎样发现这些不可达的对象(垃圾)它并给予清理的问题。

目前存在几种垃圾回收的方法,各个浏览器也采用不同的回收机制。大致有如下几种:

标记清除法(Mark-Sweep)

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

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

大致过程

image.png

优点

标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单

缺点

标记清除算法有一个很大的缺点,==就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的==,出现了 内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题。

image.png

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

image.png

新对象的内存分配采用以下三种策略:

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

第三种看起来挺牛的,不过再考虑分配的速度和效率First-fit 才是最明智的选择。因为第三种在实际使用上会造成更多的小块,行程更多更难处理的内存碎片。

综上所诉,内存碎片化分配速度慢是标记清除算法两个比较明显的缺点。

由标记清除法的缺点进行优化,提出标记整理法(Mark-Compact)

image.png

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

引用计数法(Reference Counting)

目前很少使用这种算法了,因为它的问题很多!

引用计数(Reference Counting),这其实是早先的一种垃圾回收算法,它把 对象是否不再需要 简化定义为 ==对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收==。所以我们只对它的策略进行大概了解即可。

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

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

优点

引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾。相比于标记清除算法,更加可以体现 即时性

缺点

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

V8引擎对垃圾回收进行了哪些优化

每次都要对内存中所有对象进行检查,这样真的合理吗?

内存对象分析

  • 新、小、存活时间短
  • 老、大、存活时间长

如果从这点出发,对两种类型的对象进行 ==不同方式不同频率== 的处理,是不是更好呢?所以V8提出了 分代式垃圾回收

image.png

新、小、存活时间短 称为 新生代(新产生的对象) 将 老、大、存活时间长 称为 老生代(存活事件较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象)

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率。

新生代垃圾回收

image.png

在新生代中又细分为 使用区空闲区

  • 新加入的对象都会存放到 使用区,当 使用区 快被写满时,就需要执行一次垃圾清理操作。

  • 然后开始对使用区做标记,标记后复制一份活动对象空闲区(这里做了整理的操作,也就是排序,避免内存碎片)

  • 再然后清除使用区数据对象,把原来的使用区改称空闲区,把原来的空闲区改成使用区,这样的话新使用区就是空的,继续存数据,当快存满了开始下一轮GC

  • 再看第二轮GC,还是重复上面的步骤,先标记,再把活动对象从使用区复制到空闲区,这个时候假如发现了上次就存在的对象这次还是活动对象,那这个对象就会被晋级,扔到老生代里去。

  • 接着说复制之后,使用区又被清空了,并且再次和空闲区转换,那每一轮GC过后,使用区就会变成空的

需要注意的是:

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

  2. 另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中。

老生代垃圾回收

老生代不会像新生代一般分区然后复制来复制去就会非常耗时,从而导致回收执行效率不高,所以老生代垃圾回收器来管理其垃圾回收执行,它的整个流程就采用的就是上文所说的==标记清除算法==了。

首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉。

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

垃圾回收策略

在介绍并行之前,我们先要了解一个概念 全停顿(Stop-The-World),我们都知道 JavaScript ==是一门单线程的语言,它是运行在主线程上的,那在进行垃圾回收时就会阻塞 JavaScript 脚本的执行==,需等待垃圾回收完毕后再恢复脚本执行,我们把这种行为叫做 全停顿

所以每次进行GC,都会是我们的应用逻辑暂停,加入一次GC的时间过长,那对用户来讲就是可能造成页面卡顿的问题!

切换到程序这边,那我们可不可以引入多个辅助线程来同时处理,

这样是不是就会加速垃圾回收的执行速度呢?因此 V8 团队引入了 并行回收机制

所谓并行,也就是同时的意思,它指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作

image.png

并行操作,也就是说 同一个活多个人同时干 这样会大大减小GC耗时。

新生代对象空间就采用并行策略,在执行垃圾回收的过程中,==会启动了多个线程来负责新生代中的垃圾清理操作==,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针。

对于新生代垃圾回收器能够有很好的优化,但是其实它还是一种全停顿式的垃圾回收方式, 对于老生代来说,它的内部存放的都是一些比较大的对象,对于这些大的对象 GC 时哪怕我们使用并行策略依然可能会消耗大量时间!

所以为了减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到 增量标记

image.png

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记

增量标记中采用了 三色标记法 写屏障 的方法来实现。

三色标记法

作用:==可以很好的配合增量回收进行暂停恢复的一些操作==

即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑

  • 白色指的是未被标记的对象
  • 灰色指自身被标记,成员变量(该对象的引用对象)未被标记
  • 黑色指自身和成员变量皆被标记

写屏障

作用:保证增量标记中暂停后由于逻辑代码修改导致对象指向问题。从而保证下一次增量 GC 标记阶段可以正确标记

懒性清理

增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理(Lazy Sweeping)

增量标记完成后,惰性清理就开始了。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记。

并发回收

image.png

它指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起

V8中GC优化总结

V8 的垃圾回收策略主要基于分代式垃圾回收机制

这我们说过,关于新生代垃圾回收器,我们说使用并行回收可以很好的增加垃圾回收的效率,

那老生代垃圾回收器用的哪个策略呢?我上面说了并行回收、增量标记与惰性清理、并发回收这几种回收方式来提高效率、优化体验,看着一个比一个好,那老生代垃圾回收器到底用的哪个策略?

其实,这三种方式各有优缺点,所以在老生代垃圾回收器中这几种策略都是融合使用的。

老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成) 标记完成之后,再执行并行清理操作。(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)

同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行。

作者:E2coJason 邮箱:keepfightingzzz@163.com