有空间分配就需要垃圾回收。
一、垃圾回收策略
js采用自动回收策略,产生的垃圾数据由垃圾回收器主动释放,无需工程师手动释放。
二、栈中垃圾回收
栈小而连续,数组结构,由系统自动分配固定大小的内存空间,先进后出。
栈中有个指针,叫做ESP(Extended Stack Pointer):扩展栈指针,是一个特殊的寄存器。用于存放调用栈顶指针。
上老演员:
function testFunc() {
let a = 'cola'
let b = a
let c = { face: 'no' }
let d = c
}
testFunc()
- 程序刚开始,ESP指向全局执行上下文。
- 当执行到testFunc()函数时,js引擎会创建其函数执行上下文,压入到调用栈栈顶,ESP上移。
- 原始类型数据存入栈中,引用类型数据存入堆中。
- 函数执行完成,其函数执行上下文销毁,js引擎会将ESP下移到全局执行上下文。
- 当下一个函数执行上下文入栈时,会将ESP以上的空间直接覆盖。
至此完成执行上下文的销毁和回收。栈分配和销毁的速度较快。
三、堆中垃圾回收
栈中的执行上下文由于ESP指针下移,已处于无效状态;不过c和d变量的值仍然保存在堆中需要回收,这时就需要用到js的垃圾回收器了。
堆内存存储变量没什么逻辑,只会用一块足够大空间来存储变量。
3.1 代际假说和分代收集
代际假说:It has been empirically observed that in many programs, the most recently created objects are also those most likely to become unreachable quickly.
代际假说的两个特点为:
- 大部分对象在内存中的存活时间很短。(例如函数中声明的变量、块级作用域的变量等,代码块执行完就会被清理)
- 不死的对象会活的更久。(好像听君一席话,这类的数据为globalThis、dom等对象)
其实就是对象的生存时间存在两极化的现象。
以代际假说为基础概念做垃圾回收,V8把堆分为新生代和老生代两个区域分代收集,实施不同的垃圾回收算法。
新生代存放生存时间短的对象;容量较小,通常支持1~8M;由副垃圾回收器负责回收;
老生代存放生存时间长的对象;容量较大,支持几M~几百M;由主垃圾回收器负责回收。
3.2 垃圾回收
js内存管理的主要概念是可达性。指可以从一组根节点遍历,遍历到的活动对象(正在使用中)。
根节点包含:
- 全局对象(globalThis);
- 当前的调用栈局部变量;
- 全局作用域中引用的变量;
- Dom节点和其他全局对象。
副垃圾回收器和主垃圾回收器虽负责不同的类型,但有一套共同的流程:
- 标记:通过可达性标记空间中的活动和非活动对象。
- 回收:回收非活动对象所占据的内存。
- 内存整理:回收非活动对象后,内存中会出现大量非连续的内存空间,称为内存碎片。若不整理,如果需要分配较大连续内存,即使总的剩余内存足够,也还是有可能出现没有足够的连续内存而分配失败。
3.3 副垃圾回收器
负责新生代区域的垃圾回收。大多数小的对象会被分配到新生代。该区域的垃圾回收会比较频繁。
在新生代内存中采用Scavenge算法,速度快,空间占用多。该算法把新生代区域划分成from-space(对象区域)和to-space(空闲区域)两个区域。
(1)新加入的对象存放到对象区域。该区域快被写满时,执行一次垃圾回收;
(2)从一组根元素开始遍历,可达元素标记为活动对象。
(3)标记完成后,副垃圾回收器把活动对象复制到空闲区域并有序排列。相当于完成了内存碎片整理。对象区域进行垃圾回收。
(4)完成复制后,对象区域和空闲区域翻转。空闲区域(有序排列的活动对象)变成了对象区域,对象区域变成了空闲区域。
由于新生代内存不大,V8还制定了新生代移动老生代的晋升策略:
- 经过两次垃圾回收后仍存活的对象
- 进行复制的对象超过空闲区域空间大小25%
3.4 主垃圾回收器
负责老生代区域的垃圾回收。除了新生代晋升的对象,一些大的对象会直接被分配到老生代内存中。
不再使用Scavenge算法,一是大对象复制时间长,二是要浪费一半的存储空间。因而采用的是标记-清除算法。
(1)从一组根元素开始遍历,可达元素标记为活动对象。
(2)标记完成后,进行垃圾清理。
由于产生大量不连续的内存碎片,于是产生了标记-整理算法
(1)从一组根元素开始遍历,可达元素标记为活动对象。
(2)标记完成后,让所有活动对象向一端移动,然后清理掉端边界以外的内存。
3.5 全停顿
V8使用副垃圾回收器和主垃圾回收器回收新生代和老生代空间垃圾数据,不过垃圾回收是运行在js主线程上,导致一旦执行垃圾回收,会暂停js脚本执行。待垃圾回收完成,脚本才能恢复执行。这种现象即为全停顿(Stop-The-World)。
若堆中数据较大,垃圾回收需要执行几秒的时间,应用的性能和响应能力直线下降。
STW会造成系统周期性的卡顿,尤其是老生代的垃圾回收时间较长。因此V8进行了一系列的优化策略。
3.6 垃圾回收优化策略
3.6.1 并行回收
如果说一次垃圾回收的时间比较长,那么开启多个辅助线程并行处理,
一个线程的活分给多个线程并行处理,效率提升。这个时候主线程中js脚本执行还是需要暂停,对象之间的引用关系不会改变,实现起来较简单。新生代空间中采用此策略,采用多个线程将对象区域中的活动对象复制到空闲区域。
3.6.2 增量标记
V8将标记过程分为一个一个的子标记过程,与js脚本交替执行,直至标记完成。
增量标记算法把一个长时间的阻塞拆分成很多小的任务,大大降低用户感受到的卡顿。
没有增量标记之前,单纯使用黑色和白色标记就可以了。一次垃圾清理
(1) 把所有的数据置为白色。
(2) 从一组根对象遍历,可达原色标为黑色。
(3) 遍历结束,删除白色数据。
若暂停,在执行,不能恢复。因此加入了灰色,称为三色标记法。
- 白色:未被标记的对象
- 灰色:自身被标记,成员变量(自身引用的对象)未被标记
- 黑色:自身和成员变量均被标记
(1) 最初全置为白色
(2) gc开始,遍历一组根对象,置为灰色。
(3) 访问根对象的引用对象时,将自身置为黑色,将引用对象置为灰色。
(4) 遍历完成,等待白色被回收。
若暂停,恢复时判断是否有灰色节点,如果有从灰色节点恢复继续向下执行。
如果增量标记中间,执行js脚本时修改了引用关系。比如上图中的E,在第五步增量标记前除了引用F和G,又引用了B。后面的增量标记是遍历不到B的,因为E的引用对象已经遍历完成了。这样是不对的。于是V8增加了写屏障机制。
写屏障机制为,如果有黑色对象引用白色对象,会强制将白色对象置为灰色,保证下一次的增量标记可以被正确标记到。
增量标记使主线程停顿时间大大减少,让用户使用起来交互更加流畅。缺点是并没有减少主线程的总暂停时间,甚至会略微增加。
四、内存泄漏
程序中不在使用到的变量,由于某些原因没有被垃圾回收器识别并清理,从而长期保存在内存中,导致可用内存逐渐减少的现象。
内存泄漏的场景:
(1)意外生成的全局变量,在页面关闭前不会被回收。(声明后挂到了window上)
(2)未清除的定时器和监听器。易形成闭包。
(3)闭包。谨慎使用。
(4)Dom引用泄漏。如果在JavaScript代码中持有对DOM元素的引用,即使该元素已经从DOM树中移除,只要这个JS引用还存在,该DOM元素及其子元素就无法被回收。
参考文章:https://cloud.tencent.com/developer/article/1939502
https://blog.csdn.net/sunyctf/article/details/142726200
https://juejin.cn/post/6981588276356317214?searchId=20250503150452EC0B608EF068123BFA5A#heading-19
https://juejin.cn/post/7274146202496090170?searchId=20250503150452EC0B608EF068123BFA5A#heading-1
https://juejin.cn/post/7241096652920324154#heading-3
https://www.cnblogs.com/lhjc/p/18843199
https://time.geekbang.org/column/intro/100033601
https://blog.csdn.net/QIU176161650/article/details/147337438