V8垃圾回收与GC算法什么是垃圾 JavaScript 中的垃圾 JavaScript 中内存管理是自动的 对象不再被引
内存管理
- 内存:由可读写单元组成, 表示一片可操作空间
- 管理:人为的去操作一片空间的申请、使用和释放
- 内存管理:开发者主动申请空间、使用空间、释放
- 空间管理流程:申请一使用一释放
// 申请内存空间 let obj = {}; // 使用内存空间 obj.name = "lg"; // 释放内存空间 obj = null;
什么是垃圾
JavaScript 中的垃圾
- JavaScript 中内存管理是自动的
- 对象不再被引用时是垃圾
- 对象不能从根上访问到时是垃圾
JavaScript 中的可达对象
- 可以访问到的对象就是可达对象 (引用、作用域链)
- 可达的标准就是从根出发是否能够被找到
- JavaScript 中的根就可以理解为是全局变量对象
function objGroup(obj1, obj2) {
obj.next = obj2;
obj2.prev = obj1;
return { o1: obj1, o2: obj2 };
}
let obj = objgroup({ name: "obj1" }, { name: "obj2" });
console.log(obj);
常用GC(Garbage Collection)策略
标记清除(Mark-Sweep)
标记清除算法的核心主要分为两部分:标记和清除
标记
标记所有活着的对象(即不是垃圾的对象,如上提到的可达对象(可以访问到的对象))
清除
清除所有没有被标记的对象
优点
思路简单,易于实现
缺点
由于标记到的对象肯定不是连续的内存空间,所以会增加可用内存空间的颗粒度,当然,V8引擎对此做了优化,我们下面就会讲到。还有一个问题就是用标记清除算法执行垃圾清理的时候会暂停程序的运行,由于JS的单线程机制,垃圾回收的过程会阻碍主线程同步任务的执行,待执行完垃圾回收后才会再次恢复执行主任务的逻辑,这种行为被称为全停顿(stop-the-world)
,这个现象在下面也有相应的解决方法。
由于空间碎片化增大,会导致新的对象可能很难找到空间放进内存,之间的计算过程也是一部分额外开销。
引用计数
引用计数的过程是:当有一个引用类型的值被赋值给一个变量的值时,那么它的引用次数就加一,而当该变量的值指向了其他引用类型的值时,它的引用次数就减一,而当引用次数变成0的时候,就会被立即回收。
优点
- 实时回收,引用计数当归零就立即进行回收操作。
- 不会暂停执行栈,标记清除算法定时进行垃圾回收时会先暂停程序运行,来进行垃圾回收,而引用计数是实时回收不会暂停程序的运行
缺点
- 空间开销:在进行引用计数算法时需要有空间来存放计数
- 性能开销:由于是实时清理,所以在程序运行中也会产生一定的性能开销
- 无法解决循环引用问题:如果有A的属性引用B,B的属性引用A,那么两者的引用计数基数都不为零,所以永远都不会被回收掉
function test() {
let A = new Object()
let B = new Object()
B.a = A
A.b = B
}
V8引擎对GC的优化
之前GC的清除算法无论是标记清除还是标记整理,在进行回收时都需要检查内存中的所有对象,但是如果存在一些,体积大,存活时间长,创建早的内存来进行检查,相当于是做了无用功,而新创建,体积小和存活时间短的对象需要更加频繁的检查所以基于这个问题V8提出了新生代和老生代的优化策略。将内存空间划分为新生代和老生代两个部分,不同部分执行不同的回收策略。
分代式优化
新生代
新生代中的对象主要通过 Scavenge 算法进行垃圾回收。这是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间成为 semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。
- 当开始垃圾回收的时候,会检查 From 空间中的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间发生角色对换。
- 因为新生代中对象的生命周期比较短,就比较适合这个算法。
- 当一个对象经过多次复制依然存活,它将会被认为是生命周期较长的对象。这种新生代中生命周期较长的对象随后会被移到老生代中。
- 不过还需要注意一个特殊情况,比如新生代复制一个对象到To空间,此时如果To的使用空间超过25%之后这个对象会被立即复制到老生代,而25%的红线要求是为了保证进行To和From翻转时对于新的对象分配空间操作不会被影响。
老生代
老生代主要采取的是标记清除的垃圾回收算法。与 Scavenge 复制活着的对象不同,标记清除算法在标记阶段遍历堆中的所有对象,并标记活着的对象,只清理死亡对象。活对象在新生代中只占叫小部分,死对象在老生代中只占较小部分,这是为什么采用标记清除算法的原因。
对象晋升(Object-Promotion)
上文中有提到当一个对象经过多次复制依然存活,它将会被认为是生命周期较长的对象。这种新生代中生命周期较长的对象随后会被移到老生代中。
这个现象我们就称之为对象晋升,对象晋升的两个主要条件如下:
- 对象是否经历过一次
Scavenge
算法 To
空间的内存占比是否已经超过25%
两个判断条件的流程图如下:
标记整理(Mark-Compact)
上文中我们有提到过在进行标记清除(Mark-Sweep)算法之后会出现内存空间碎片化严重的现象,针对这种现象标记整理(Mark-Compact)
被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存。具体表示如下:
- 假设在老生代中有A、B、C、D四个对象
- 在垃圾回收的
标记
阶段,将对象A和对象C标记为活动的
- 在垃圾回收的
整理
阶段,将活动的对象往堆内存的一端移动
- 在垃圾回收的
清除
阶段,将活动对象左侧的内存全部回收
增量标记(Incremental-Marking)
上文中有提到,在进行标记清除算法时,垃圾回收的过程会影响JS主线程的执行,这就会造成全停顿
的,针对这一现象,V8引擎又引入了增量标记的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。
该方法有点类似于React的fiber架构,即在浏览器渲染帧空闲时间遍历Fiber Tree中的任务,这样就可以防止阻塞主线程的任务,实现方式比较类似于 requestIdleCallback 这个API,但是fiber并非用它实现。
三色标记法(恢复与暂停)
在引入三色标记法之前的GC标记只是将活动的变量标记为黑色,不活动的变量标记为白色,当GC标记过程结束之后,系统会回收掉所有的白色标记变量,但是这种非黑即白的方法虽然清除起来非常方便但是存在一个问题执行一段时间之后无法知道执行到了哪里,不能进行暂停。所以V8又引入了一个灰色进行暂停和恢复操作。
如图所示,在GC标记开始时所有对象都是白色的,然后从根对象开始进行标记,先将这组对象标记为灰色然后进行记录,如果此时进行中断,后续恢复时既从灰色标记时开始即可,当回收器从标记工作表中弹出对象并访问他们的引用对象时,会将灰色置为黑色,同时将下一个引用对象置为灰色,继续往下进行标记工作。直至无可标记为灰色对象为止,此时表示GC标记过程结束,将所有未标记的变量进行回收工作。所以三色标记法可以渐进执行而不用每次执行都要全盘进行扫描整个内存空间,可以配合增量回收减少全停顿时间,提升体验。
写屏障
在一次完成GC标记暂停中,如果执行任务程序时内存中存在的变量引用关系被改变了,这样会导致此次GC存在问题。所以V8团队提出了写屏障作为保护。
如图所示,现有A、B、C三个对象依次被引用,且在GC过程中已经被标记了,但是在暂停GC任务,插入执行程序任务之后,引用关系被改变了,新增了一个新变量D,但是此时程序中也未存在灰色标记的变量,下一步进行清除机制时,新变量D按清除机制来讲是要被清除掉,但是这是极其不合理的,一个新的变量还存在引用就被回收掉,这会导致程序云行报错。此时写屏障机制就派上用场了,一旦有黑色的对象引用白色的对象,就会强制将被引用的白色变量标记为灰色,保证下一次的增量GC正确运行,这个机制称为强三色不变性(白色变量D被黑色变量B引用之后会被强制置灰保证程序运行正确性)。
惰性清理
在增量GC标记之后下一步就是来真正回收内存空间,通过惰性清理来进行清除释放内存。惰性清理机制运行原理是在进行回收时如果内存足够就可以将这个回收清理时间稍微延迟一下,让JavaScript脚本先执行,清理时也不会一下全部清理掉所有的垃圾,会根据按需进行清理直至所有垃圾都回收完毕,然后继续等待下个GC标记阶段执行结束。
并发回收
虽然增量标记和惰性清理的出现使主线程停顿时间大大减少了,但是总体的停顿时间其实并未减少,如果真正细算起来甚至还增加了,应用程序的吞吐量也被降低,不过用户和浏览器的交互体验大大提升牺牲也是值得的。但是后续V8团队为了使回收更加高效, 又使用了并发回收机制,他是在主线程在执行程序任务时,主动开启辅助线程进行GC回收。而主线程又可以自由执行而不会挂起(标记操作全部由辅助进程操作)。
巧用弱引用
在ES6中为我们新增了两个有效的数据结构WeakMap
和WeakSet
,就是为了解决内存泄漏的问题而诞生的。其表示弱引用
,它的键名所引用的对象均是弱引用,弱引用是指垃圾回收的过程中不会将键名对该对象的引用考虑进去,只要所引用的对象没有其他的引用了,垃圾回收机制就会释放该对象所占用的内存。这也就意味着我们不需要关心WeakMap
中键名对其他对象的引用,也不需要手动地进行引用清除,我们尝试在node中演示一下过程(参考阮一峰ES6标准入门中的示例,自己手动实现了一遍)。
首先打开node命令行,输入以下命令:
node --expose-gc // --expose-gc 表示允许手动执行垃圾回收机制
然后我们执行下面的代码。
// 手动执行一次垃圾回收保证内存数据准确
> global.gc();
undefined
// 查看当前占用的内存,主要关心heapUsed字段,大小约为4.4MB
> process.memoryUsage();
{ rss: 21626880,
heapTotal: 7585792,
heapUsed: 4708440,
external: 8710 }
// 创建一个WeakMap
> let wm = new WeakMap();
undefined
// 创建一个数组并赋值给变量key
> let key = new Array(1000000);
undefined
// 将WeakMap的键名指向该数组
// 此时该数组存在两个引用,一个是key,一个是WeakMap的键名
// 注意WeakMap是弱引用
> wm.set(key, 1);
WeakMap { [items unknown] }
// 手动执行一次垃圾回收
> global.gc();
undefined
// 再次查看内存占用大小,heapUsed已经增加到约12MB
> process.memoryUsage();
{ rss: 30232576,
heapTotal: 17694720,
heapUsed: 13068464,
external: 8688 }
// 手动清除变量key对数组的引用
// 注意这里并没有清除WeakMap中键名对数组的引用
> key = null;
null
// 再次执行垃圾回收
> global.gc()
undefined
// 查看内存占用大小,发现heapUsed已经回到了之前的大小(这里约为4.8M,原来为4.4M,稍微有些浮动)
> process.memoryUsage();
{ rss: 22110208,
heapTotal: 9158656,
heapUsed: 5089752,
external: 8698 }
在上述示例中,我们发现虽然我们没有手动清除WeakMap
中的键名对数组的引用,但是内存依旧已经回到原始的大小,说明该数组已经被回收,那么这个也就是弱引用的具体含义了。
文献参考
muyacode.github.io/FrontEndLea…
到这里就全结束喽!如果你觉得对你有帮助的话,拜托赏个赞再走哦!
-- IndulgeBack 25届前端菜鸟
转载自:https://juejin.cn/post/7415654362973011968