在 V8 引擎逐行执行 JavaScript 代码的过程中,当遇到函数的情况时,会为其创建一个函数执行上下文环境并添加到调用堆栈的栈顶,函数的作用域中包含了该函数中声明的所有变量,当该函数执行完毕后,对应的执行上下文从栈顶弹出,函数的作用域会随之销毁,其包含的所有变量也会统一释放并被自动回收。
V8引擎的内存限制
V8 引擎帮助我们实现了自动的垃圾回收管理。但 V8 引擎中的内存使用并不是无限制的。默认情况下, V8 引擎在 64 位系统下最多只能使用约 1.4GB 的内存,在 32 位系统下最多只能使用约 0.7GB 的内存。
V8的垃圾回收策略
V8 的垃圾回收策略主要是基于 分代式垃圾回收机制 ,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。
V8 的内存结构:
- 新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。
- 老生代(old_space):新生代中的对象存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区和老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。
- 大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。
- 代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域。
- map区(map_space):存放 Cell 和 Map ,每个区域都是存放相同大小的元素,结构简单。
新生代
在 V8 引擎的内存结构中,新生代主要用于存放存活时间较短的对象。新生代内存是由两个 semispace(半空间)构成的,内存最大值在 64 位系统和 32 位系统上分别是 32MB 和 16MB ,在新生代的垃圾回收过程中主要采用了 Scavenge 算法。
Scavenge 算法是一种典型的牺牲空间换时间的算法,对于老生代内存来说,可能会存储大量对象,如果在老生代中使用这种算法,势必会造成内存资源的浪费,但是在新生代内存中,大部分对象的生命周期较短,在时间效率上表现可观,所以还是比较适合这种算法。
在
Scavenge
算法的具体实现中,主要采用了Cheney
算法,它将新生代内存一分为二,每一个部分的空间称为semispace
,也就是我们在上图中看见的new_space中划分的两个区域,其中处于激活状态的区域我们称为From
空间,未激活(inactive new space)的区域我们称为To
空间。这两个空间中,始终只有一个处于使用状态,另一个处于闲置状态。我们的程序中声明的对象首先会被分配到From
空间,当进行垃圾回收时,如果From
空间中尚有存活对象,则会被复制到To
空间进行保存,非存活的对象会被自动回收。当复制完成后,From
空间和To
空间完成一次角色互换,To
空间会变为新的From
空间,原来的From
空间则变为To
空间。
老生代
在老生代中,因为管理着大量的存活对象,如果依旧使用 Scavenge 算法的话,很明显会浪费一半的内存,因此已经不再使用 Scavenge 算法,而是采用新的算法 Mark-Sweep(标记清除) 和 Mark-Compact(标记整理)来进行管理。
Mark-Sweep(标记清除)分为标记和清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep 算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:
- 垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在 JavaScript 中, window 全局对象可以看成一个根节点。
- 然后,垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。
- 最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。
以下几种情况都可以作为根节点:
- 全局对象
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数
但是 Mark-Sweep 算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。
为了解决这种内存碎片的问题, Mark-Compact(标记整理)算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理调边界外的全部内存。即标记-整理-清理阶段。
垃圾回收的过程会阻碍主线程同步任务的执行,待执行完垃圾回收后才会再次恢复执行主任务的逻辑,这种行为被称为全停顿(stop-the-world)。
为了减少垃圾回收带来的停顿时间,V8 引擎又引入了 Incremental Marking(增量标记)的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给 JS 主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。
得益于增量标记的好处, V8 引擎后续继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核 CPU 的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。
如何避免内存泄漏
尽可能少地创建全局变量
手动清除定时器
少用闭包
清除DOM引用
弱引用
在 ES6 中,新增了两个有效的数据结构 WeakMap 和 WeakSet ,就是为了解决内存泄漏的问题而诞生的。其表示弱引用,它的键名所引用的对象均是弱引用,弱引用是指垃圾回收的过程中不会讲键名对该对象的引用考虑进去,只要所引用的对象没有其他的引用了,垃圾回收机制就会释放该对象所占用的内存。
参考文章: