JavaScript中的垃圾回收机制
垃圾回收机制
垃圾回收机制即是,释放不再使用的对象和变量占用的内存 这种机制对于防止内存泄漏和优化应用程序性能至关重要
通常是自动的 但某些操作会触发垃圾回收 并且垃圾回收是自动的但不是即时的
垃圾回收器的性能和效率取决于其实现和使用的算法
基本概念
(1)内存分配:当声明变量或创建对象时,JavaScript引擎会在内存中为它们分配空间。
(2)垃圾收集:垃圾回收器定期检查内存中的对象和变量,识别出那些不再被引用或可达的对象,并释放它们占用的内存。
(3)可达性:一个对象被认为是可达的,如果从根对象(如全局对象、活动函数的调用栈或任何全局变量)可以通过引用链访问到它。
为何需要垃圾回收
在V8引擎逐行执行JavaScript代码的过程中,当遇到函数的情况时,会为其创建一个函数执行上下文(Context)环境并添加到调用堆栈的栈顶,函数的作用域(handleScope)中包含了该函数中声明的所有变量,当该函数执行完毕后,对应的执行上下文从栈顶弹出,函数的作用域会随之销毁,其包含的所有变量也会统一释放并被自动回收。试想如果在这个作用域被销毁的过程中,其中的变量不被回收,即持久占用内存,那么必然会导致内存暴增,从而引发内存泄漏导致程序的性能直线下降甚至崩溃,因此内存在使用完毕之后理当归还给操作系统以保证内存的重复利用。
V8引擎
V8引擎的内存限制
在JS中声明对象时,该对象的内存就分配在堆中,如果当前已申请的堆内存已经不够分配新的对象,则会继续申请堆内存直到堆的大小超过V8的限制为止。
虽然V8引擎帮助我们实现了自动的垃圾回收管理,但V8引擎中的内存使用也并不是无限制的。具体来说,默认情况下,V8引擎在64位系统下最多只能使用约1.4GB的内存,在32位系统下最多只能使用约0.7GB的内存,在这样的限制下,必然会导致在node中无法直接操作大内存对象,比如将一个2GB大小的文件全部读入内存进行字符串分析处理,即使物理内存高达32GB也无法充分利用计算机的内存资源,那么为什么会有这种限制呢?这个要回到V8引擎的设计之初,起初只是作为浏览器端JavaScript的执行环境,在浏览器端我们其实很少会遇到使用大量内存的场景,因此也就没有必要将最大内存设置得过高。但这只是一方面,其实还有另外两个主要的原因:
JS单线程机制:作为浏览器的脚本语言,JS的主要用途是与用户交互以及操作DOM,那么这也决定了其作为单线程的本质,单线程意味着执行的代码必须按顺序执行,在同一时间只能处理一个任务。试想如果JS是多线程的,一个线程在删除DOM元素的同时,另一个线程对该元素进行修改操作,那么必然会导致复杂的同步问题。既然JS是单线程的,那么也就意味着在V8执行垃圾回收时,程序中的其他各种逻辑都要进入暂停等待阶段,直到垃圾回收结束后才会再次重新执行JS逻辑。因此,由于JS的单线程机制,垃圾回收的过程阻碍了主线程逻辑的执行。
虽然JS是单线程的,但是为了能够充分利用操作系统的多核CPU计算能力,在HTML5中引入了新的Web Worker标准,其作用就是为JS创造多线程环境,允许主线程创建Worker线程,将一些任务分配给后者运行。在主线程运行的同时,Worker在后台运行,两者互不干扰。等到Worker线程完成计算任务,再把结果返回给主线程。这样的好处是, 一些计算密集型或高延迟的任务,被Worker线程负担,主线程(通常负责UI交互)就会很流畅,不会被阻塞或者拖慢。Web Worker不是JS的一部分,而是通过JS访问的浏览器特性,其虽然创造了一个多线程的执行环境,但是子线程完全受主线程控制,不能访问浏览器特定的API,例如操作DOM,因此这个新标准并没有改变JS单线程的本质。
垃圾回收机制:垃圾回收本身也是一件非常耗时的操作,假设V8的堆内存为1.5G,那么V8做一次小的垃圾回收需要50ms以上,而做一次非增量式回收甚至需要1s以上,可见其耗时之久,而在这1s的时间内,浏览器一直处于等待的状态,同时会失去对用户的响应,如果有动画正在运行,也会造成动画卡顿掉帧的情况,严重影响应用程序的性能。因此如果内存使用过高,那么必然会导致垃圾回收的过程缓慢,也就会导致主线程的等待时间越长,浏览器也就越长时间得不到响应。
基于以上两点,V8引擎为了减少对应用的性能造成的影响,采用了一种比较粗暴的手段,那就是直接限制堆内存的大小 (当然如果在node端需要处理复杂IO操作需要更多的内存的时候也可以在node初始化的时候来通过配置手动调整内存大小 放宽内存限制)
V8引擎中的垃圾回收策略
V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。
这里简单描述一下V8引擎中的垃圾回收策略
其堆结构组成分为以下几个部分
新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。
老生代(old_space):新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区和老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。
大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。
代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域。
map区(map_space):存放Cell和Map,每个区域都是存放相同大小的元素,结构简单(这里没有做具体深入的了解,有清楚的小伙伴儿还麻烦解释下)。
新生代垃圾回收
新生代存放存活时间比较短的对象 新生代内存是由两个semispace(半空间)构成的,内存最大值在64位系统和32位系统上分别为32MB和16MB,在新生代的垃圾回收过程中主要采用了Scavenge算法。 这是一种空间换时间的算法
在Scavenge算法的具体实现中,主要采用了Cheney算法,它将新生代内存一分为二,每一个部分的空间称为semispace,其中处于激活状态的区域我们称为From空间,未激活(inactive new space)的区域我们称为To空间。
这两个空间中,始终只有一个处于使用状态,另一个处于闲置状态。我们的程序中声明的对象首先会被分配到From空间,当进行垃圾回收时,如果From空间中尚有存活对象,则会被复制到To空间进行保存,非存活的对象会被自动回收。当复制完成后,From空间和To空间完成一次角色互换,To空间会变为新的From空间,原来的From空间则变为To空间。
对象晋升
当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升。
对象晋升的条件主要有以下两个:
- 对象是否经历过一次
Scavenge算法 (检查该对象的内存地址) To空间的内存占比是否已经超过25%(如果对象没有经历过Scavenge算法,会被复制到To空间,但是如果此时To空间的内存占比已经超过25%,则该对象依旧会被转移到老生代)
即在垃圾回收过程中 检测到上述任一一个条件都会触发对象晋升
老生代垃圾回收
老生代一开始使用的是引用计数 但如果碰到循环引用的话就会出现问题 无法被回收最终导致内存泄漏
为了避免这种情况发生我们使用了以下两种算法:
(关于根节点:
以下几种情况都可以作为根节点:
- 全局对象
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数)
(1)标记-清除(Mark-Sweep):
- 标记阶段:垃圾回收器从根对象开始,递归地访问所有可达的对象,并将它们标记为活动对象。
- 清除阶段:垃圾回收器遍历整个内存空间,清除所有未标记的对象,释放它们占用的内存。
垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window全局对象可以看成一个根节点。
然后,垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。
最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。
但是Mark-Sweep算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。
为了解决这种内存碎片的问题,Mark-Compact(标记整理)算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存
(2)标记-整理(Mark-Compact):
- 标记阶段与标记-清除算法相同。
- 整理阶段:由于标记-清除算法可能导致内存碎片,标记-整理算法会在清除未标记对象后,将所有活动对象向前移动,填补内存空间的空洞,从而避免碎片化。
由于JS的单线程机制,垃圾回收的过程会阻碍主线程同步任务的执行,待执行完垃圾回收后才会再次恢复执行主任务的逻辑,这种行为被称为全停顿(stop-the-world)。在标记阶段同样会阻碍主线程的执行,一般来说,老生代会保存大量存活的对象,如果在标记阶段将整个堆内存遍历一遍,那么势必会造成严重的卡顿。
因此,为了减少垃圾回收带来的停顿时间,V8引擎又引入了Incremental Marking(增量标记)的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个理念其实有点像React框架中的Fiber架构,只有在浏览器的空闲时间才会去遍历Fiber Tree执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。
得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。
内存泄漏
内存泄漏是指应用程序中存在一些长时间占用内存的对象,这些对象不再被使用,但由于某些原因,垃圾回收器没有释放它们占用的内存。内存泄漏可能导致应用程序的性能下降,甚至崩溃。为了避免内存泄漏,开发者应该:
确保使用var、let或const声明的变量在不需要时被正确地解除引用。
取消不再使用的事件监听器和定时器。
1
2
3
4
5
6
7
8const numbers = [];
const foo = function() {
for(let i = 0;i < 100000;i++) {
numbers.push(i);
}
};
window.setInterval(foo, 1000);在这个示例中,由于我们没有手动清除定时器,导致回调任务会不断地执行下去,回调中所引用的
numbers变量也不会被垃圾回收,最终导致numbers数组长度无限递增,从而引发内存泄漏。尽可能少地创建全局变量
1
2
3
4
5
6
7function foo() {
this.a = 1;
}
foo(); // 相当于 window.foo()
//当foo函数在调用时,它所指向的运行上下文环境为window全局对象,因此函数中的this指向的其实是window,也就无意创建了一个全局变量。因为其会挂载到window全局对象上 当进行垃圾回收时,在标记阶段因为
window对象可以作为根节点,在window上挂载的属性均可以被访问到,并将其标记为活动的从而常驻内存,因此也就不会被垃圾回收,只有在整个进程退出时全局作用域才会被销毁。如果你遇到需要必须使用全局变量的场景,那么请保证一定要在全局变量使用完毕后将其设置为null从而触发回收机制。少用闭包
一些对匿名函数的使用可能会导致作用域无法得到释放 从而导致无法进入垃圾回收阶段
1 | function foo() { |
在这个示例中,foo函数执行完毕后会返回一个匿名函数,该函数内部引用了foo函数中的局部变量local,并且通过变量bar来引用这个匿名的函数定义,通过这种闭包的方式我们就可以在foo函数的外部作用域中访问到它的局部变量local。一般情况下,当foo函数执行完毕后,它的作用域会被销毁,但是由于存在变量引用其返回的匿名函数,导致作用域无法得到释放,也就导致local变量无法回收,只有当我们取消掉对匿名函数的引用才会进入垃圾回收阶段。
使用弱引用(WeakMap和WeakSet)来存储不需要阻止垃圾回收的对象。
ES6中为我们新增了两个有效的数据结构WeakMap和WeakSet,就是为了解决内存泄漏的问题而诞生的。其表示弱引用,它的键名所引用的对象均是弱引用,弱引用是指垃圾回收的过程中不会将键名对该对象的引用考虑进去,只要所引用的对象没有其他的引用了,垃圾回收机制就会释放该对象所占用的内存。这也就意味着我们不需要关心WeakMap中键名对其他对象的引用,也不需要手动地进行引用清除







