V8引擎垃圾回收机制
v8引擎负责JavaScript的执行。V8引擎具有内置的垃圾回收机制,用于自动管理内存分配和释放
堆与栈
栈空间
栈空间是小而连续的内存空间,主要用于存储局部变量和函数调用的相关信息,同时栈结构是“先进后出”的策略
栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层过深,那么 V8 就有可能抛出栈溢出的错误
- 分配和释放速度快:栈上的资源分配和销毁只需要移动指针,因此速度非常快。
- 固定大小:栈的空间是有限的,一旦函数调用层次过多或数据过大,就会导致栈溢出。
- 遵循LIFO原则:后进先出,即最后进入栈的元素会被最先弹出。
堆空间
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据
- 动态分配:堆上的内存空间是动态分配的,可以根据需要分配不同大小的内存块。
- 灵活性高:堆可以存储各种类型的数据,包括对象、数组等复杂数据结构。
- 管理复杂:由于堆的空间是动态分配的,管理起来相对复杂,容易出现内存泄漏等问题
回收策略
1》标记清除
这种算法在 JS 引擎中是最常用的(大部分浏览器都是V8引擎,使用的是标记清除)
大致过程:
首先标记内存中所有的变量(假设所有的都是垃圾,都标记为0);
然后将在上下文中的变量以及被上下文引用的变量的标记清除;
在此之后(离开环境时不再当前环境定义的变量被标记清除)的再被加上标记的变量就是待删除了
优点:
实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单
缺点:
标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题
标记整理算法:在标记结束后,将活着的对象向内存的一端移动(将不需要清理的对象往内存一端移动),最后清理掉边界的内存
2》引用计数
大致过程:
声明变量并赋值的时候引用次数为1
当同一个值被赋值给另一个比那辆,引用书加1
如果对该值引用的变量被其他值覆盖了,引用书减1
如果引用次数为0时就可以被回收了
优点:
引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾
缺点:
首先它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限
其次,循环引用问题;对象A有一个指针指向对象B,而对象B也引用了对象A
对象A、B的引用次数都是2,在函数执行后,其实引用次数仍然时2;但是它们其实都不在作用域中了,属于可以回收的垃圾,但是内存并没有被释放
function circleRef() {
let A = new Object()
let B = new Object()
A.a = B
B.b = A
}
circleRef()
想要释放必须在最后去掉引用关系
A=null
B=null
IE8及以前是使用的引用清除
V8引擎内存空间划分
**1》新生代(new space)**:用于存储新创建的对象。分为两部分,分别是使用去和空闲区
**2》老生代(old space)**:存储生存时间较长的对象。这些对象在新生代中存活一段时间后,会被移动到老生代。老生代内存区域相对较大,因为存储的是长期存活的对象,所以垃圾回收的频率相对较低。老生代内存区又分为老生代指针区和老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针
**3》大对象区(Large Object Space)**:存放体积超过其他区域大小限制的大对象,这些对象由于体积较大,不会被频繁移动,因此放在单独的区域以避免影响其他对象的垃圾回收
**4》代码区(Code Space)**:存放JavaScript代码,这是唯一拥有执行权限的内存区域
**5》Map区(Map Space)**:用于存放Cell和Map,每个区域都是存放相同大小的元素,结构简单
6》Cell Space和Property Cell Space:用于存储固定大小的Cell对象和与JavaScript对象属性相关的PropertyCell对象,优化属性访问性能
V8引擎的垃圾回收策略
V8 设置了两个垃圾回收器,主垃圾回收器和副垃圾回收器;主垃圾回收器负责收集老生代中的垃圾数据,回收频率较低;副垃圾回收器负责收集新生代中的垃圾数据,回收相比更加频繁
可访问性分析法
V8引擎中采用了这种方法来判断是一个对象是否活跃,具体过程为:将一个称为GC Roots的对象(在浏览器环境中,GC Roots 可以包括:全局的 window 对象、所有原生DOM节点集合等等)作为所有初始存活的对象集合,从这个对象出发,进行遍历,遍历到的就认为是可访问的,为活动对象,需要保留;如果没有遍历到的对象,就是不可访问的,这些就是非活动对象,可能就会被垃圾回收。
新生代
新生代:主要用于存放存活时间较短的对象。使用scavenge算法进行回收
新生代回收过程:
scavenge算法主要使用了一种复制式的方法cheney算法(复制式的方法)来实现;
新生代空间被平等划分为两部分,from space 和to space; 当from space填满之后会进行一次垃圾回收,非存货对象被回收,存活的对象被复制到 to space,from space被清空,from space和to space进行了一次交换
scavenge算法是一种典型的牺牲空间换取时间的算法
新生代垃圾回收采用了并行机制。在新生代垃圾回收的过程中,副垃圾回收器使用并行机制,在整理排序阶段,即活动对象从from-space复制到to-space的时候,启用多个辅助线程并行进行整理。这种并行处理的方式意味着多个线程同时参与垃圾回收的过程,以提高效率。由于多个线程可能竞争同一个新生代的堆内存资源,可能会出现某个活动对象被多个线程进行复制操作的情况。为了解决这个问题,V8在第一个线程对活动对象进行复制完成后,必须去维护复制这个活动对象后的指针转发地址,以便于其他协助线程可以找到该活动对象后可以判断该活动对象是否已被复制。这种机制确保了新生代垃圾回收的正确性和效率
对象晋升(新生代变成老生代)
当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中
晋升的条件:
- 对象是否经历过一次
Scavenge
算法 - 内存占比是否超过
To
空间的25%
(避免内存使用过高影响后续的对象分配)
满足其中任意一个就会晋升
老生代
老生代采用Mark-Sweep(标记清除)和
Mark-Compact(标记整理)`来进行管理
老生代回收过程:
- 标记;递归遍历一组根元素,遍历能达到的元素是活动对象,达不到的是非活动对象 (Mark-Sweep)
- 清除;老生代垃圾回收器直接将非活动对象(数据、垃圾)清理掉 (Mark-Sweep)
- 整理;(Mark-Compact)
以下几种情况都可以作为根节点:
- 全局对象
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数
Mark-Sweep
算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收;所以通过Mark-Compact(标记整理)解决内存碎片的问题
一般 10 次Mark-Sweep会伴随一次Mark-Compact
策略优化
最开始的垃圾回收机制有两个大的缺点
- 垃圾回收器在主线程上执行,执行垃圾回收期间会阻塞js执行
- 一次执行一个完整的垃圾回收流程,阻塞时间还是连续的
对此V8做出了许多优化:
一般来说,老生代会保存大量存活的对象,如果在标记阶段将整个堆内存遍历一遍,那么势必会造成严重的卡顿。
因此,为了减少垃圾回收带来的停顿时间,V8引擎又引入了增量标记 的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。只有在浏览器的空闲时间才会执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。
得益于增量标记的好处,V8引擎后续继续引入了延迟清理 和 增量式整理,让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能
- 增量标记:V8使用增量标记,即不是一次性遍历整个堆,而是分批次进行,使得每次正常执行之间的停顿非常短暂;即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存
- 延迟清理:在浏览器的空闲时间执行垃圾回收任务
- 增量整理:一次整理一部分
- 并发标记:允许垃圾回收的标记阶段与JavaScript代码执行并行进行,减少了主线程的暂停时间
- 并行处理:多个辅助线程并行进行整理,提高了垃圾回收的效率