垃圾回收机制(GC:Garbage Collection):执行环境负责管理代码执行过程中使用的内存。JS的垃圾回收机制是为了以防内存泄漏,内存泄漏的含义就是当已经不需要某块内存时这块内存还存在着,没有被释放,导致该内存无法被使用,垃圾回收机制(GC)会定期(周期性)找出那些不再继续使用的变量,然后释放其内存。
内存管理
1) 内存:由可读写单元组成,表示一片可操作空间
2) 管理:人为的去操作一片空间的申请,使用和释放
3) 内存管理:开发者主动申请空间,使用空间,释放空间
4) 管理流程:申请————使用————释放
1.JavaScript中的内存管理
分为下面三个步骤
申请内存空间
使用内存空间
释放内存空间
// 申请
let obj = {}
// 使用
obj.name = 'lg'
// 释放
js中没有释放api,我们这里就把它设置为null
obj = null
2.JavaScript中的垃圾回收
概念
对我们前端而言JS的内存管理是自动的,每次我们创建一个对象、数组、函数的时候会自动分配一个内存空间。
那么什么是垃圾呢?
对象不再被引用的时候就是垃圾
对象不能从根上访问到的时候也是垃圾。
知道什么是垃圾之后,JS引擎就会出来工作,把它们占据的对象空间进行回收,这就叫做JS的垃圾回收。
下面我们引入一个概念,叫可达对象
可达对象
可以访问到的对象就是可达对象(引用、作用域链)
可达的标准就是从根出发是否能够被找到
JavaScript中的根就可以理解为是全局变量对象
可达样例
//可达对象
//我们定义了一个函数去接收两个变量obj1,obj2,互相指引
function objGroup(obj1,obj2) {
obj1.next = obj2
obj2.prev = obj1
return {
o1:obj1,
o2:obj2
}
}
let obj = objGroup({name:'obj1'},{name:'obj2'})
console.log(obj)
说明:
首先从全局的根出发,我们能找到一个可达的对象obj,它是通过一个函数调用之后,指向了一个内存空间(里面就是o1和o2),又通过相应的属性指向obj1和obj2的空间,这两个空间之间通过next和prev互相指向。
所以在这个例子里面,我们能从根访问到任何一个内存空间。
然后我们删除掉两行代码之后
function objGroup(obj1,obj2) {
obj1.next = obj2
//obj2.prev = obj1
return {
//o1:obj1,
o2:obj2
}
}
let obj = objGroup({name:'obj1'},{name:'obj2'})
console.log(obj)
这之后,我们所有能够找到obj1的线条都被删除了,于是obj1空间就会被认为是垃圾,js引擎就会找到它把它删除。
3.GC算法介绍
GC定义与作用
GC就是垃圾回收机制的简写
GC可以找到内存中的垃圾、并释放和回收空间。
GC算法是什么
GC是一种机制,垃圾回收器完成具体的工作
工作的内容就是查找垃圾释放空间、回收空间
算法就是工作时查找和挥手所遵循的规则
常见GC算法
引用计数:通过一个数字判断当前的是不是垃圾
标记清除:进行工作的时候给到活动对象添加一个标记,判断是否垃圾
标记整理:和标记清除类似,回收过程中做的事情不太一样
分代回收:不同生命周期的对象可以采取不同的收集方式,以便提高回收效率
引用计数:
-
这种方式常常会引起内存泄漏,低版本的IE使用这种方式。机制就是跟踪一个值的引用次数,当声明一个变量并将一个引用类型赋值给该变量时该值引用次数加1,当这个变量指向其他一个时该值的引用次数便减一。当该值引用次数为0时,则说明没有办法再访问这个值了,被视为准备回收的对象,每当过一段时间开始垃圾回收的时候,就把被引用数为0的变量回收。
a的引用次数
function f1(){
var a ={}//a的引用次数0
var b=a//a的引用次数1
var c=a //a的引用次数2
var b={}//a的引用次数1
var c=[]//a的引用次数0
}
引用计数优点:
- 发现垃圾时立即回收
- 最大限度减少程序暂停(当内存快满的时候就立刻去找引用计数为0的删掉)
引用计数的缺点:
- 造成比较大的性能开销
- 造成循环引用的问题
//对象之间的循环引用
//虽然obj1和obj2全局作用域下找不到了,但是引用还存在的,obj1和obj2互相在他们的作用域内引用
//用引用计数算法无法释放这部分空间。
function fn() {
const obj1 = {}
const obj2 = {}
obj1.name = obj2
obj2.name = obj1
return 'hello world'
}
fn()
这种情况下,就要手动释放变量占用的内存:
obj1.a = null
obj2.a = null
标记清除:
这个原理实现要比引用计数算法更加简单,还能解决一些问题。在后续学习的v8当中会被大量用到。
核心思想:将整个垃圾回收操作分成标记和清除两个阶段完成。第一个阶段会遍历所有对象,找到活动对象标记。第二个阶段仍然遍历所有对象,把那些身上没有标记的对象进行清除。还会把第一个阶段的标记抹掉,便于GC下次正常工作。通过两次遍历行为,把我们当前的垃圾空间进行回收,最终交给相应的空闲列表去维护。
function fn() {
let obj1 = {};
let obj2 = {};
obj1.a = obj2;
obj2.a = obj1;
return {
a: obj1,
b: obj2
}
}
let obj3 = fn()
优点 可以找到一些局部作用域的垃圾,可以解决引用计数算法中无法回收循环引用的问题
缺点 在清除垃圾之后,剩余对象的内存位置是不变的,就会导致空闲内存空间不连续,这样就出现了内存碎片,并且由于剩余空间不是整块,就需要内存分配的问题
标记整理
标记整理可以看作是标记清除的增强
标记阶段的操作和标记清除一致
清除阶段会先执行整理,移动对象位置,地址变为连续,这样回收后就可以最大化利用空间
优点:
解决内存碎片化的问题
缺点:
性能消耗大,每一次清楚之前,都要去整理,合并
JavaScript V8 引擎的垃圾回收机制
现在大多数浏览器都是基于标记清除算法,V8 亦是,当然 V8 肯定也对其进行了一些垃圾回收机制的优化。
在JavaScript脚本中,绝大多数对象的生存期很短,只有部分对象的生存期较长。所以,V8 中的垃圾回收主要使用的是 分代回收 (Generational collection)机制。
分代回收机制
V8 中将堆内存分为 新生代 和 老生代 两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。
新生代 的内存一般都不大,所以使用 Scavenge 算法 进行垃圾回收效果比较好。老生代 一般占用内存较大,因此采用的是 标记清除算法。
新生代 的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量,而老生代的对象为存活事件较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大。
V8 引擎将保存对象的 堆 (heap) 进行了分代,V8 整个堆内存的大小就等于新生代加上老生代的内存(如下图)
新生代垃圾回收
新生代 对象是通过 Scavenge算法 进行垃圾回收,在 Scavenge算法 的具体实现中,主要采用了一种复制式的方法即 Cheney算法 。
Cheney算法 中将堆内存一分为二,一个是处于使用状态的空间我们暂且称之为 使用区,一个是处于闲置状态的空间我们称之为 空闲区,如下图所示。
新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。
当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区。
因为新生代存储容量小,很容易写满,所以经过两次垃圾回收之后依然活动的对象,就会被移动到老生代中,这个策略被称为对象晋升策略。
另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。
老生代垃圾回收
大多数占用空间大、存活时间长的对象会被分配到老生代里,因为老生代中的对象通常比较大,如果再用新生代一般分区复制方法就会非常耗时,从而导致回收执行效率不高。老生代垃圾回收器采用的就是上文所说的 标记清除算法 了。
标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了我们上文中说的 标记整理算法 来解决这一问题来优化空间。
并行回收(Parallel)
在介绍并行之前,我们先要了解一个概念 全停顿, JavaScript 是一门单线程的语言,它是运行在主线程上的,那在进行垃圾回收时就会阻塞 JavaScript 脚本的执行,需等待垃圾回收完毕后再恢复脚本执行,我们把这种行为叫做 全停顿。
比如一次 GC 需要 60ms ,那我们的应用逻辑就得暂停 60ms ,假如一次 GC 的时间过长,对用户来说就可能造成页面卡顿等问题。
因此 V8 团队引入了并行回收机制。所谓并行,它指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作。
简单来说,使用并行回收,假如本来是主线程一个人干活,它一个人需要 3 秒,现在叫上了 2 个辅助线程和主线程一块干活,那三个人一块干 1 秒就完事了,但是由于多人协同办公,所以需要加上一部分多人协同(同步开销)的时间我们算 0.5 秒好了,也就是说,采用并行策略后,本来要 3 秒的活现在 1.5 秒就可以干完了。
不过虽然 1.5 秒就可以干完了,时间也大大缩小了,但是这 1.5 秒内,主线程还是需要让出来的,这个过程内存是静态的,不需要考虑内存中对象的引用关系改变,只需要考虑协同。
新生代对象空间就采用并行策略,在执行垃圾回收的过程中,会启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针。
增量标记
对于一个堆很大,活跃对象有很多的脚本时,标记清除 效率可能会很慢,为减少垃圾回收引起的停顿,引入了 增量标记
我们上面所说的并行策略虽然可以增加垃圾回收的效率,对于新生代垃圾回收器能够有很好的优化,但是其实它还是一种全停顿式的垃圾回收方式,对于老生代来说,它的内部存放的都是一些比较大的对象,对于这些大的对象 GC 时哪怕我们使用并行策略依然可能会消耗大量时间。
所以为了减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到增量标记。
什么是增量
增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记(如下图)。
试想一下,将一次完整的 GC 标记分次执行,那在每一小次 GC 标记执行完之后如何暂停下来去执行任务程序,而后又怎么恢复呢?那假如我们在一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了又怎么办呢?
可以看出增量的实现要比并行复杂一点,V8 对这两个问题对应的解决方案分别是 三色标记法 与 写屏障。
三色标记法(暂停与恢复)
因为新生区的内存一般都不大,所以使用 Scavenge 算法进行垃圾回收效果比较好。老生区一般占用内存较大,因此采用的是 标记-清除算法 与 标记紧缩算法。
老生代是采用标记清除算法,在没有采用增量算法之前,单纯使用黑色和白色来标记数据就可以了,其标记流程即在执行一次完整的 GC 标记前,垃圾回收器会将所有的数据置为白色,然后垃圾回收器在会从一组跟对象出发,将所有能访问到的数据标记为黑色,遍历结束之后,标记为黑色的数据对象就是活动对象,剩余的白色数据对象也就是待清理的垃圾对象。
如果采用非黑即白的标记策略,在垃圾回收器执行了一段增量回收后,暂停后启用主线程去执行了应用程序中的一段 JavaScript 代码,随后当垃圾回收器再次被启动,这时候内存中黑白色都有,我们无法得知下一步走到哪里了。
为了解决这个问题,V8 团队采用了一种特殊方式: 三色标记法
三色标记法即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑。
● 白色指的是未被标记的对象。
● 灰色指自身被标记,成员变量(该对象的引用对象)未被标记。
● 黑色指自身和成员变量皆被标记。
三色标记法的核心是 深度优先搜索,具体过程为:
● 在标记的初期,位图是空的,所有对象也都是白的。
● 从根可达的对象会被染色为灰色,并被放入标记用的一个单独分配的双端队列。
● 标记阶段的每次循环,GC会将一个对象从双端队列中取出,染色为黑,然后将它的邻接对象染色为灰,并把邻接对象放入双端队列。
● 这一过程在双端队列为空且所有对象都变黑时结束。
● 特别大的对象,如长数组,可能会在处理时分片,以防溢出双端队列。如果双端队列溢出了,则对象仍然会被染为灰色,但不会再被放入队列(这样他们的邻接对象就没有机会再染色了)。
● 因此当双端队列为空时,GC仍然需要扫描一次,确保所有的灰对象都成为了黑对象。对于未被染黑的灰对象,GC会将其再次放入队列,再度处理。
采用三色标记法后我们在恢复执行时就好办多了,可以直接通过当前内存中有没有灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行就可以
三色标记法的 mark 操作可以渐进执行的而不需每次都扫描整个内存空间,可以很好的配合增量回收进行暂停恢复的一些操作,从而减少 全停顿 的时间。
写屏障(增量中修改引用)
一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了,增量中修改引用。举个例子(如图)
假如我们有 A、B、C 三个对象依次引用,在第一次增量分段中全部标记为黑色(活动对象),而后暂停开始执行应用程序也就是 JavaScript 脚本,在脚本中我们将对象 B 的指向由对象 C 改为了对象 D ,接着恢复执行下一次增量分段。
这时其实对象 C 已经无引用关系了,但是目前它是黑色(代表活动对象)此一整轮 GC 是不会清理 C 的,不过我们可以不考虑这个,因为就算此轮不清理等下一轮 GC 也会清理,这对我们程序运行并没有太大影响。
我们再看新的对象 D 是初始的白色,按照我们上面所说,已经没有灰色对象了,也就是全部标记完毕接下来要进行清理了,新修改的白色对象 D 将在次轮 GC 的清理阶段被回收,还有引用关系就被回收,后面我们程序里可能还会用到对象 D 呢,这肯定是不对的。
为了解决这个问题,V8 增量回收使用 写屏障 (Write-barrier) 机制,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性。
惰性清理
增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理(Lazy Sweeping)。
惰性清理是指在标记完成后,并不急着释放空间,无需一次清理所有的页,垃圾回收器会视情况逐一清理,直到所有页都清理完成。
增量标记完成后,惰性清理就开始了。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记。
并发回收(Concurrent)
前面我们说并行回收依然会阻塞主线程,增量标记同样有增加了总暂停时间、降低应用程序吞吐量两个缺点,那么怎么才能在不阻塞主线程的情况下执行垃圾回收并且与增量相比更高效呢?
这就要说到并发回收了,它指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起(如下图)。
辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起,这是并发的优点,但同样也是并发回收实现的难点,因为它需要考虑主线程在执行 JavaScript 时,堆中的对象引用关系随时都有可能发生变化,这时辅助线程之前做的一些标记或者正在进行的标记就会要有所改变,所以它需要额外实现一些 读写锁 机制来控制。
再说V8中GC优化
V8 的垃圾回收策略主要基于分代式垃圾回收机制,关于新生代垃圾回收器,使用并行回收可以很好的增加垃圾回收的效率,那老生代垃圾回收器用的哪个策略呢?并行回收、增量标记与惰性清理、并发回收这几种回收方式来提高效率、优化体验,看着一个比一个好,那老生代垃圾回收器到底用的哪个策略?难道是并发?
其实,这三种方式各有优缺点,所以在老生代垃圾回收器中这几种策略都是融合使用的。
老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成)
标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)
同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行。
以上是 V8 引擎为我们的垃圾回收所做的一些主要优化了,虽然引擎有优化,但并不是说我们就可以完全不用关心垃圾回收这块了,我们的代码中依然要主动避免一些不利于引擎做垃圾回收操作,因为不是所有无用对象内存都可以被回收的,不再用到的内存,没有及时回收时,我们叫它 内存泄漏。