V8 内存管理
V8 本身也是程序,它本身也会申请内存,它申请的内存称为常驻内存,而它又将内存分为堆和栈
栈内存
栈内存介绍
- 栈用于存放JS 中的基本类型和引用类型指针
- 栈空间是连续的,增加删除只需要移动指针,操作速度很快
- 栈空间是有限的,若超出栈空间内存,会抛出栈空间溢出错误
- 栈是在执行函数时创建的,函数执行完毕后,栈销毁
栈的内存回收机制
- 栈中压入一个全局执行上下文,长驻栈底,不会销毁
- 每个函数执行时都会创建一个执行上下文(存储函数变量、文法环境、词法环境的对象)
- 函数执行结束,立马销毁
- 当函数中存在闭包的情况下,函数的执行上下文会被销毁。函数内部被外部引用的变量会被放入堆内存中保存。
堆内存
内存不连续,需要大的内存空间,使用堆
在 32 位系统下是1.4G,64 位下分配 2G
堆内存分类
新生代
新生代内存new space
会保存一些生命周期较短的对象,但新创建的对象无法确定其生命周期,因此会先放入新生代内存中。不过它只有 32M 大小
老生代
新生代内存old space
会保存一些生命周期较长的对象。如果判断长短?两个周期的垃圾回收之后,如果数据会在新生代内存中,则将其放入老生代内存。
比较大的对象直接放入老生代。
代码空间(code space)
运行时代码空间,存放 JIT已编译代码,是唯一拥有执行权限的内存
大对象空间(large object space)
专门存储大对象,新生代存放不下,单独分配内存存储。GC 不会对其进行垃圾回收
Map Space
存放对象的Map 信息,即隐藏类,它是为了提升对象属性的访问速度的。V8 会为每一个对象创建一个隐藏类,记录对象的属性布局,包括所有属性和偏移量。
垃圾回收机制
何为垃圾?程序执行结束后不再需要的数据。更准确一点,从根节点GCRoot
访问不到的就是内存垃圾。
新生代垃圾回收
新生代中有From Space
和To Space
两块区域,新对象会放入From Space
中,如果内存满了就开始垃圾回收。
From Space
和To Space
是双端队列
的数据结构。
执行过程
- 从根节点出发,
广度优先遍历
所有能到达的对象,将存活的对象(可以访问到的对象)按顺序复制到To Space
内存中 - 遍历完成后,清空
To Space
内存 From Space
和To Space
角色互换
晋升机制
- 经过一次GC 还存活
- 达到空间限制
优点:空间连续,不会造成内存碎片;高效
缺陷:浪费空间
老生代垃圾回收
浏览器不会同时采用标记清除和标记整理两种方式,在多次标记清除之后通过标记整理去处理内存碎片的问题。
标记清除
标记:从根节点出发,深度优先
遍历,能够达到的表示活对象,标记为黑色,不能达到的对象表示死对象,标记为白色。
清除:清除死亡对象,释放内存
优点:不需要进行元素移动;不需要空闲区域,节省内存
缺陷:一次标记清楚后,内存空间不连续,会出现许多内存碎片
标记整理
从根节点出发,深度优先
遍历,能够达到的表示活对象,标记为黑色,不能达到的对象表示死对象,标记为白色。这个过程中如果发现内存不连续,则进行元素移动,放到前一个元素后面,保证内存的连续。最后将后面的全部清除。
优点:能够解决标记清除带来的内存碎片问题,特点是一边标记一边进行内存整理。
缺陷:需要移动元素
垃圾回收的性能回收
垃圾回收机制属于宏任务
,需要 JS 主线程调用。如果执行时间过长,会造成卡顿,阻塞 JS 的执行。
为了不阻塞 JS执行,需要进行优化。优化的思想可以是将大任务拆成任务,分步执行,类似 React的fiber;也可以将一些任务放入后台,由其他线程执行,不占用主线程
并行执行
开启辅助线程来执行新生代
的垃圾回收工作,并行执行回收也是会让 JS 进入全停顿的状态,主线程不能进行任何操作,只能辅助线程完成。但由于新生代
比较小,因此不会造成长时间的卡顿。
增量标记
老生代内存空间又大,存储的数据又多,因此不适合并行执行的优化,仍然会占用很长的时间。因此采用分批标记,不一次性完成,在 JS 空闲的时候执行。将标记工作分为多个阶段,每个阶段都只标记一部分对象,和主线程穿插进行。
但是这样就会有一个中间状态出现了,因为除了黑色表示活对象,白色表示死对象以外,还需要有一个种来表示还未被标记的状态。
为了支持增量标记,V8 需要支持垃圾回收的暂停和恢复,因此采用黑白灰三色标记。
-
黑色表示该节点可被 GC 根节点到达,且其全部子节点已经被标记完毕。
-
灰色表示该节点可被 GC 根节点到达,但子节点还没有被标记处理,也表示目前正在处理该节点
-
白色表示该节点还未被被 GC 到达,如果 GC 结束还没有处理,就表明无法到达,没有被引用,就会被回收。
惰性清理
增量标记完成后,如果内存足够则不清理,等 JS 执行完毕再清理。
并发回收
主线程执行过程中,辅助线程在后台完成垃圾回收工作。标记操作由辅助线程完成,清理操作由主线程和辅助线程配合完成。
并发和并行:并发是上下文快速切换;并行是同一时刻多个进程同时进行。
内存泄漏
会产生内部泄漏的情况:
隐式全局变量
全局变量不会被垃圾回收,依次要将不再使用的全局变量及时手动设置为null
function fn(){
a = 10
}
上面的代码中,a
就是全局变量永不回收
剥离的 DOM
在界面中移除 DOM 元素时,还应该相应的移除该其他的引用
<div id='container'>
<div id='box'></div>
</div>
<script>
let container = document.getElementById('container')
let box = document.getElementById('box')
document.body.removeChild(container)
</script>
上面的代码中,DOM 中已经移除了container节点,但是它还被变量container
引用,因此不会被垃圾回收。
定时器
若不手动清除定时器,则其到时执行后也不会被垃圾回收,应该用对应的函数清除定时器
function fn(){
let i = setInterval(()=>{
},1000)
clearInterval(i)
}
fn()
事件监听
如果监听函数中使用到了外部对象数据,则需要进行手动清除事件监听,不然不会被垃圾回收
function fn(){
let data = [1,2,3]
class App {
handle = ()=>{
console.log(data)
}
mount(){
document.addEventListener('click', this.handle)
}
unmount(){
document.removeEventListener('click', this.handle)
}
}
let app = new App()
return app
}
let app = fn()
app.mount()
app.unmount()
Map/Set
不主动清除不会被主动回收
let arr = [1,2,3]
let set = new Set()
set.add(arr)
arr = null
上面的代码中,即使arr
手动指向了null
,但是数据仍然不会被垃圾回收,因为只是清除了arr
对数据对象的引用,而set
还引用了该数据,因此不会被垃圾回收,必须再手动将set
设置为null
,才能释放arr
。但是set
中其他数据的引用可能是必须保留的,因此这种情况下最好的方式是使用WeakSet
,以及WeakMap
let arr = [1,2,3]
let set = new WeakSet()
set.add(arr)
arr = null
上面的代码arr
指向的数组就会被垃圾回收,因为WeakSet
的引用是弱引用,不会阻止垃圾回收
console
浏览器会保存我们输出对象的信息数据引用,因此未清理的console
也会造成内存泄漏
排查内存泄漏
如何知道是哪里的代码造成了内存泄漏呢?
录制⏺监控
- 刷新页面加载
- 监控 JS 堆、文档、节点、监听器、GPU
- 手动进行 GC,触发垃圾回收
内存占用
查看内存占用及性能分析:Performance
<body>
<script>
let arrData = new Array(200000).fill(100)
console.log(arr)
</script>
</body>
能够看到 JS 堆内存是陡峭上升
快照对比:Memory
<body>
<button id="btn">增加内存使用</button>
<script>
let btn = document.getElementById('btn')
btn.onclick = fn()
function Person(name){this.name = name}
function fn(){
var arr = []
for (let index = 0; index < 10000; index++) {
arr.push(new Person(index))
}
console.log(arr)
}
</script>
</body>
对于上面的代码,可以在执行前面进行两次快照录制,对比内存占用情况
可以看到Person
对象时增加了 10000 次,然后我们去看这些对象是在哪里产生的
然后我们就可以找到内存泄漏的问题,进行优化处理
从垃圾回收角度看性能优化
少用全局变量
- 全局执行上下文会一直存在于上下文执行栈中,不会被销毁
- 查找链条比较长,比较销毁性能
- 可以使用局部缓存替代全局变量
通过原型新增方法
减少函数对象方法的内存占用,让对象实例都共用同一个函数对象。
对象结构保持稳定和一致
- V8 会为每个对象增加一个隐藏类,如果对象发生改变(属性改变)就会重置隐藏类,而结构相同的对象会共享隐藏类。因此对象属性尽量保持一致,且尽量不要动态添加和删除属性
- 隐藏类描述了对象的结构和属性的偏移地址,可以增加查找属性的时间
结构一致,共享隐藏类
let p1 = {name:'flten',age:18}
let p2 = {name:'wall',age:20}
对象结构不稳定,隐藏类频繁重建
let p = {}
p.name = 'flten'
p.name = 22
函数参数尽量保持稳定
V8 中的内联缓存会监听函数执行,记录中间数据,如果参数结构不同优化会失效。因此尽量不要使用动态参数,让参数结构保持稳定。