垃圾回收
垃圾回收
为什么需要垃圾回收
V8引擎已经帮我们自动进行了内存的分配和管理,JS 不需要手动开辟和释放内存。
存在写代码的过程中不够严谨而容易引发内存泄漏的问题
V8引擎的内存限制
JS 是单线程,垃圾回收的过程阻碍了主线程逻辑的执行。会导致主线程的等待时间长,浏览器长时间得不到响应。
V8引擎直接粗暴的限制了堆内存的大小。在浏览器端一般也不会遇到需要操作几个G内存这样的场景。在node端我们可以手动调整。
经典垃圾回收策略
引用计数
引用计数会跟踪每个值被引用的次数,当引用数为0时变量,内存就会被释放。因为存在循环引用的问题,所以很少使用这种方法。
在代码运行时就会自动的增减,因此效率高。
标记清除
第一个阶段是标记,也就是找垃圾的过程,从根节点出发遍历对象,对所有访问过的对象打上标记,表示对象可达。第二阶段是清除,对那些没有标记的对象进行回收。以下几种情况都可以作为根节点:
- 全局对象
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数
因为需要暂停下来遍历对象,所以效率相对较低。
V8的垃圾回收策略
V8的内存结构
新生代(new_space)
:大多数的对象(如执行上下文)开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。
老生代(old_space)
:新生代中的对象在存活一段时间后就会被转移到老生代内存区(如全局变量、自定义类、函数等),相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区
和老生代数据区
,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。
大对象区(large_object_space)
:存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。
代码区(code_space)
:代码对象,会被分配在这里,唯一拥有执行权限的内存区域。
map区(map_space)
:存放Cell和Map,每个区域都是存放相同大小的元素,结构简单(这里没有做具体深入的了解,有清楚的小伙伴儿还麻烦解释下)。
上图中的带斜纹的区域代表暂未使用的内存,新生代(new_space)被划分为了两个部分,其中一部分叫做inactive new space,表示暂未激活的内存区域,另一部分为激活状态。
新生代
新生代的垃圾回收过程中主要采用了**Scavenge
**算法。
Scavenge
算法是一种典型的牺牲空间换取时间的算法(不需要处理垃圾,只需要复制有用的对象),对于老生代内存来说,可能会存储大量对象,如果在老生代中使用这种算法,势必会造成内存资源的浪费,但是在新生代内存中,大部分对象的生命周期较短,在时间效率上表现可观,所以还是比较适合这种算法。
在Scavenge
算法的具体实现中,主要采用了Cheney
算法,它将新生代内存一分为二,每一个部分的空间称为semispace
,也就是我们在上图中看见的new_space中划分的两个区域,其中处于激活状态的区域我们称为From
空间,未激活(inactive new space)的区域我们称为To
空间。这两个空间中,始终只有一个处于使用状态,另一个处于闲置状态。我们的程序中声明的对象首先会被分配到From
空间,当进行垃圾回收时,如果From
空间中尚有存活对象,则会被复制到To
空间进行保存,非存活的对象会被自动回收。当复制完成后,From
空间和To
空间完成一次角色互换,To
空间会变为新的From
空间,原来的From
空间则变为To
空间。
Scavenge
算法的垃圾回收过程主要就是将存活对象在From
空间和To
空间之间进行复制,同时完成两个空间之间的角色互换,因此该算法的缺点也比较明显,浪费了一半的内存用于复制。
扫描过程
广度优先遍历 + 双指针
- 假设全局变量下的变量A引用了变量B和变量C,变量B引用了变量D,变量E没被引用
1 |
|
- 那么在to区域,存在一个扫描指针和分配指针,起初都指向最开始
- 然后A发现被全局引用,那么A拷贝到to区域,这时候扫描指针指向A,分配指针指向后面一位
1 |
|
- 然后A又引用了B,这时候把B拷贝到to区域,放在A后面,扫描指针还是指向A,分配指针往后移动一位
1 |
|
- 然后再看A又引用了C,这时候把C拷贝到to区域,放在B后面,扫描指针还是指向A,分配指针往后移动一位
1 |
|
- 然后发现A没有引用其他了,那将扫描指针往后移动一位指向B
1 |
|
- 然后发现B没有引用其他了,那再将扫描指针往后移动一位指向C
1 |
|
- 然后C也没有引用其他了,还剩下D,D不存在引用,所以不需要移动到to区域,这时候清空FROM区域,然后将FROM和To交换。
- 这就完成了一次垃圾回收。
对象晋升
当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时(在将对象从From
空间复制到To
空间之前),该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升
。
对象晋升的条件主要有以下两个(满足任意一个则会晋升):
- 对象是否经历过一次
Scavenge
算法 To
空间的内存占比是否已经超过25%
(为了不影响后续FROM空间的分配)
老生代
在老生代中,因为管理着大量的存活对象,如果依旧使用Scavenge
算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge
算法,而是采用新的算法**Mark-Sweep(标记清除)
和Mark-Compact(标记整理)
**来进行管理。
标记清除的问题是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。
为了解决这种内存碎片的问题,Mark-Compact(标记整理)
算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存。
进一步优化
全停顿
增量标记
为了减少垃圾回收带来的停顿时间,V8引擎又引入了Incremental Marking(增量标记)的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。
得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。
快速总结
内存管理
新生代
老生代
大对象区
代码区
map区
新生代垃圾回收
Scavenge 算法,空间换时间。Scavange 的 实现采用了 Cheney 算法。将新生代分为 from 空间 和 to 空间,对象首先会被分到 from 空间,经过一次扫描(广度优先遍历+双指针),我们就知道了哪些对象仍然被引用,这些对象就会被复制到 to 区域,然后 from 区域清空,再将 from 和 to 交换 。
Scavenge 效率高在哪里?
不需要处理垃圾,只需要复制有用的变量即可。
相较于标记清除,需要给每个对象打标记。
老生代垃圾回收
标记清除 + 标记整理
标记清除的问题:内存碎片(经历过一次标记清楚后,对象的内存地址不连续,剩余的零碎的空间无法放置连续的大对象,从而导致提前的垃圾回收。
标记整理解决了该问题:回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存。
进一步优化:
增量标记:将原本需要一次性遍历堆内存的操作改为增量标记的方式
对象晋升
新生代 —> 老生代
条件(满足任一):
1.对象是否经历过一次 Scavenge 算法
2.To 空间的内存占比是否超过 25%
如何避免内存泄漏
- 尽可能少地创建全局变量
会无形地挂载到window
全局对象上,变成根节点,必须要将其设置为 null 才能回收
记得手动清除定时器
少使用闭包
使用弱引用
WeakMap, WeakSet
弱引用是指垃圾回收的过程中不会将键名对该对象的引用考虑进去,只要所引用的对象没有其他的引用了,垃圾回收机制就会释放该对象所占用的内存
参考
https://juejin.cn/post/6844904016325902344#heading-2