大家好我是平平无奇Ben,今天闲着没事做看了一些前端垃圾回收的文章,寻思着自己整理一下。
什么是垃圾回收(gc)?
垃圾回收也叫gc(Garbage Collection)。
背景:
当一个对象没有任何的变量或属性对它进行引用,我们将永远无法操作该对象,此时的对象就是一个垃圾,过多会占用大量内存空间,导致程序变慢,所以必须将这种垃圾及时清理。
原理:
找出那些不再继续使用的变量,然后释放其占用的内存,垃圾收集器会按照固定的时间间隔周期性地执行这一操作。
我们能做什么?
在JS中拥有自动的垃圾回收机制,会自动将这些垃圾对象从内存中销毁,我们不需要也不能进行垃圾回收的操作。 我们需要做的只是将不再使用的对象设置为 null 即可,其余交给垃圾回收机制。
垃圾回收的策略
标记清除
2012年起,所有现代浏览器都使用了 标记-清除垃圾回收策略(算法)
原理:
当前市面上主要有两种说法:
-
当变量进入执行环境时,就标记这个变量为“进入环境”。当变量离开环境时,则将其标记为“离开环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们。(不用关心标记的方式)
-
每一次完整的GC前,垃圾回收器会将全部的数据置为白色,而后垃圾回收器在会从一组跟对象出发,将全部能访问到的数据标记为黑色,遍历结束以后,标记为黑色的数据对象就是活动对象,剩余的白色数据对象也就是待清理的垃圾对象。
经过我查阅源码实际上第二种方法更为准确,这里说的白色 黑色,其实只是标识不同的两个标记,你把他理解为0和1也可以。源码是这样处理的:所有对象都有一个marked属性,默认是0(即未标记),接下来对所有被引用的活动对象标记为1,标记结束后,对剩下的所有0标记的对象进行清除,清除后将所有对象标记重置为0。等待下一轮的标记清除。。。
引申:
标记清除后内存空间是不连续的,也就是出现了 内存碎片
,如果后面需要一个比较大的连续内存空间时,将不能满足要求,怎么解决的?
答:利用 标记-整理(Mark-Compact)方法
可以有效的解决这个问题:标记阶段没有什么不同,只是标记结束后, 标记-整理方法会将活着的对象向内存的一端移动,最后清理掉边界的内存。
引用计数(很多问题,很少使用)
另外一种不常见的垃圾回收策略叫做引用计数,此算法把“对象是否不再需要”简化定义为“对象有没有被其他对象引用”。如果没有被引用,对象将被垃圾回收机制所回收。
原理:
跟踪记录每个值被使用的次数,引用计数就是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数就减1。当这个引用次数变为0时,说明这个变量已经没有价值,因此,在在机回收期下次再运行时,这个变量所占有的内存空间就会被释放出来。
问题:
循环引用会导致内存泄漏。
var objA = new Object()
var objB = new Object()
objA.someObj = objB
objB.someObj = objA
由于 objA 和 objB 通过各自的属性相互引用,两个对象的引用次数都是2。代码执行完毕后,objA和objB依然存在,因为他们的引用次数永远不会是0,如果多次执行这段代码,就会导致大量的内存得不到释放。
标记-清除算法和引用计数算法的缺点
标记-清除算法:
- 内存碎片化
- 分配速度慢(需要分配一块大于对象大小的内存空间给对象)
引用计数算法:
- 循环引用问题
- 需要一个庞大的计数器来统计变量的引用数量,所以这个计数器会占据很大的内存
NodeJS V8垃圾回收机制(分代式垃圾回收机制)
V8的垃圾回收策略主要基于分代式垃圾回收机制
,在V8中,将内存分为新生代
和老生代
,新生代的对象为存活时间较短的对象,老生代的对象为存活时间较长或长驻内存的对象。
V8 堆的整体大小 = 新生代所用内存空间 + 老生代所用内存空间。 只能在启动时指定,这意味着运行时不能够自动扩充,如果超过了极限,就会引起进程出错。
新生代主要使用的是 Scavenge( [ˈskævɪndʒ] )(清除)算法
,而 Scavenge 算法又依靠 Cheney(切尼)算法
对堆内存进行操作,使堆内存一分为二。一个处于使用状态的空间叫做 From 空间
,另一个处于闲置状态的空间叫做 To 空间
。分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会先检查 From 空间中的存活对象,将其复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间的角色发生对换。当一个对象经过多次复制后依然存活,将被认为是生命周期较长的对象(内部有标记对象存活年龄),随后被移动到老生代中。还有一种情况,即对象足够大,在到To空间的时候发现To空间占用超过了25%,就会直接移动到老生代空间中。
老生代主要使用的就是标记-清除和标记-整理算法,和 Scavenge 算法相比,标记清除不会把内存空间划成两半。
引申问题:为什么要分代?
分代式把新、小、存活时间短的对象作为新生代,占据一小块内存,频率较高的快速清理;而老、大、存活时间长的对象作为老生代,使其频率较低的接受检查清理。两者的回收机制和频率是不一致的,这样的机制很大程度提升了垃圾回收的效率。
增量标记是什么?
增量标记(Incremental Marking)算法
意义:
标记-清除,标记-整理,Scavenge 算法都需要将正在执行的 JS应用逻辑暂停下来,等待垃圾回收完毕后再恢复。这种行为又叫做"全停顿"(stop-the-world)
。为了解决全部老生代全堆垃圾回收带来的停顿时间,所以有了增量标记算法。
原理:
V8将标记过程分为一个个的子标记过程,同时让垃圾回收标记和JS应用逻辑交替进行,直到标记阶段完成。
效果:
经过增量标记改进后,垃圾回收的最大停顿时间可以减少到原来的 1/6 左右。
引申:并行回收是什么?
在有增量标记和惰性清理之前,V8使用的优化方式是并行回收
机制,就是垃圾回收在主线程上执行的过程中开启多个辅助线程,在新生代对象空间就采用了并行策略,在执行回收垃圾的过程中,启动了多个线程来负责新生代中的垃圾清除操作,这些线程同时将对象空间中的数据移动到空闲区域(To空间),这个过程当中因为数据地址会发生改变,因此还需要同步更新引用这些对象的指针,这就是并行回收。并行回收的缺点是它仍然会造成全停顿
。
惰性清理(懒性清理/清除)是什么?
增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存V8采用的惰性清理(Lazy Sweeping)
增量标记完成后,如当前的可用内存足以让JS脚本代码先执行,那么无需一次性清理完全部非活动对象内存,而是按需逐一进行清理直到全部非活动对象内存都清理完毕后再接着执行增量标记。
增量标记如何解决JS代码执行导致的对象引用关系被修改的问题?
三色标记法(暂停与恢复)
老生代采用标记-清除算法,只需要单纯使用 黑色和 白色标记数据就够了,垃圾回收器会将全部的数据置为白色,而后垃圾回收器在会从一组跟对象出发,将全部能访问到的数据标记为黑色,遍历结束以后,标记为黑色的数据对象就是活动对象,剩余的白色数据对象也就是待清理的垃圾对象。
如果增量标记也采用非黑即白的模式,当执行了一段增量回收后,暂停启用主线程执行一端JS代码,再去启动垃圾回收时,会发现内存中黑白色都有,不知道下一步走到哪了。
为了解决这个问题,所以有了三色标记法。
- 白色指的是未被标记的对象
- 灰色指的是自身被标记,成员变量(该对象的引用对象)未被标记
- 黑色指的是自身和成员变量皆被标记
用这种方式去标记,恢复执行时只需要判断有无灰色节点即可判断标记是否完成,当灰色标记全部变成黑色标记,表示环境内活动变量全部标记完成,剩下的白色标记就是非活动变量,可以回收。
写屏障(增量中修改引用)
背景:
当一次完整的 GC 标记分块暂停后,执行JS代码时内存中被标记好的对象引用关系可能会被修改
根据示例图,B的指向变成了D,而D对象是白色标记,由于三色标记法,目前环境没有灰色标记,对白色标记变量会做清除,所以D会被回收,这就会出现问题了。
因此,V8增量回收使用 写屏障(Write-barrier)机制
,即一旦有黑色对象引用白色对象,该机制将引用的白色对象改成灰色,从而保证下一次增量GC标记阶段能够正确标记,这个机制也被成为,强三色不变性。
增量标记和惰性清理的优缺点
优点:
防止全停顿,使用户与浏览器交互过程更流畅。
缺点:
由于在增量标记之间执行JS代码,堆中的对象指针可能产生变化,必须使用 写屏障 计数来记录这些引用关系的变化,所以
- 增量标记并不能使主线程总暂停时间变短,反而会略微增长。
- 因为写屏障机制的成本,增量标记可能会下降应用程序的吞吐量。
tips:
- 吞吐量:单位时间内成功地传送数据的数量
老生代到底使用的是什么策略来进行垃圾回收?
上文我们提到标记清除+标记整理、并行回收、增量标记和惰性清理
这三种方式各有优缺点,因此实际上在老生代中这几种策略是融合使用的。
怎么发现内存泄漏?
浏览器环境
旧版chrome浏览器选择Timeline,然后勾选Memory,左上角录制,然后页面随便玩模拟用户点击,然后过段时间比如5次垃圾回收后点stop看看内存是不是占用一次比一次多,如果多了就是内存泄漏
新版chrome浏览器选择Recorder,左上角开始录制,模拟用户操作一段时间,点击右上角Measure performance,跳转到performance Tab,勾选顶部Memory,可以看到中间多了一个Heap的线状图,即是各个时间的内存占用情况
NodeJS 环境
Nodejs环境的话,直接调用process.memoryUsage()可以看到堆内存使用情况,隔一段时间打印一下看看里边的heapUsed
字段堆内存占用情况是不是越来越高即可
总结
前端垃圾回收还有很多很多可挖掘的东西,接下来继续努力,如果还有其他的东西,我会继续补充,谢谢大家阅读。