内存泄漏
定义
我们知道,程序的运行需要操作系统或者运行时提供内存,而对于持续运行的程序,内存会被持续占用,适时地回收当前不执行的程序占用的内存是很重要的,没有及时地回收内存,轻则造成系统性能变差,重则进程崩溃
那么,什么是内存泄漏呢,简单来说,内存中的某个空间被占用,在应当回收的时间没有被回收,就算是内存泄漏了
有些语言需要自己手动去清除占用内存而又没有在执行的程序,而像JavaScript这类语言有自己的垃圾回收机制,可以对内存进行管理
垃圾回收机制
- 标记清除
垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记,然后会去掉环境中的变量以及被环境中的变量引用的变量的标记,此后再被标记的变量为将要删除的变量,最后垃圾收集器完成内存清除工作,销毁那些带标记的变量并回收它们的占用空间 - 引用计数
跟踪每个值被引用的次数。如果一个值被赋予一个变量,则引用次数为1,假如这个值被其他变量引用,那么这个引用次数就加一,如果引用这个值的变量引用了别的变量,那么这个值的引用次数就减一。在下次垃圾收集器运行时,引用次数为0的值所占用的内存就会被释放。
但是引用计数会存在一定的问题
function test(){
var objA = new Object();
var objB = new Object();
objA.quoteObj = objB;
objB.quoteObj = objA;
}
在这个函数中,声明了两个Object对象,而这两个对象又通过属性相互引用,这里的objA和objB的引用次数都变成了2,而且在函数调用完毕后,这两个对象依然存在,即使objA和objB指向了别的值,这里两个new Object()
的引用次数仍会为1,永远不会为0,这样就会一直占用内存空间,无法被清除。
虽然有垃圾回收机制,但我们还是需要对一些用到的内存进行处理,利用垃圾回收机制,尽量让没有用到的内存尽快得到释放
几种内存泄漏的情况
1. 意外的全局变量
全局变量所占用的内存,直到页面关闭才会被回收,所以要谨慎使用全局变量
我们知道,如果我们在声明一个变量的时候,没有添加声明符号var,let,const,那么这个变量就会变成一个全局变量,比如下面的函数
function handle(str){
decoration = '..........';
return str+decoration
}
在执行完这个函数后,由于这里的decoration变量没有使用声明符,所以在这个函数使用完之后,这个变量所占有的内存本该被回收,却因为变成了全局变量而一直被占用,知道手动清除(赋值为null)或者页面关闭,这就造成了内存泄漏
2. 使用计时器
我们在开发的时候经常会用到setTimeout,setInterval这两个计时器,而这两个计时器在使用时往往很容易引发内存泄漏
第一种泄漏的情况比较常见,如果我们使用计时器,却没有及时地清除计时器,那么计时器的回调函数占用的内存会一直存在,假如当前计时器的回调函数用到了页面的DOM元素,那么在页面销毁的时候,由于定时器占有该页面部分引用,会造成页面的内存无法被正常回收,这样重复地打开关闭这个页面,就会造成内存的不停泄漏
第二种比较少见的情况,在使用定时器的时候,第一个参数传入了字符串,比如下面这样
var t1 = setTimeout((function f1(){
var d = 1;
}).toString(),0)
这样的定时器,即使使用clearTimeout清除掉,f1还是存在的,此时如果在控制台打印f1的话,会发现它的函数体被打印出来,这说明这里的内存泄漏了
3. 滥用闭包
我们知道,闭包简单来说就是在一个函数内部,引用另一个函数内部的变量,但是这样会使得在当前函数执行完毕后,因为当前函数内部的变量还被另一个函数引用着,所以当前函数所占用的内存无法被回收,这就造成了内存泄漏
4. 被js引用的DOM对象
对于一般的DOM对象来说,如果不再挂载在DOM树上,那么这个对象就应该被回收了,但如果当前有一个js对象指向这个DOM对象的话,就不能回收这块内存
比如下面的结构和js对象
<div id="father">
<div id="child"></div>
</div>
<script>
let node = document.getElementById('child')
</script>
如果当前的father div块被删除,一般来说子div占用的内存应该也会被回收,但因为node指向了该DOM对象,所以占用的内存不会被回收,这就造成了内存泄漏
所以如果对某个DOM对象进行了引用,要记得做清除处理
底层的垃圾回收(堆和栈)
首先我们要明确一下,JavaScript中有8中类型,其中有7种是基本类型,Number,Boolean,String,BigInt,Symbol,Null,undefined,然后还有一种是引用类型object
在JavaScript中,对这些类型的存储是放在不同位置的,基本类型会被放在栈里面,而引用类型object会被放在堆里面,因此我们说到底层的垃圾回收,实际上就是说到了堆和栈的内存的回收
栈的垃圾回收
首先让我们来看看栈
我们知道,在JavaScript中,栈除了存储基本数据类型之外,它还存储着我们的执行上下文,而这部分空间实际上,就是我们平时所说的调用栈
我们会在什么时候去清除基本数值的类型?简单来说,当我们不再会用到它们的时候,那什么时候不再用到它们呢。举个简单的例子,在一个声明的函数内部,使用var声明的变量存储着一个基本类型(当然let和const也可以,这里只是为了强调不会因没有声明符而使数值去到全局作用域),一开始,假如我们的ESP(记录当前执行状态的指针)指向全局执行上下文,当我们调用这个函数的时候,会将这个函数的执行上下文压入栈中,相应的数据也会被压入栈中,ESP指向这个函数的执行上下文,当我们调用结束后,ESP向下移动,回到原来的位置即指向全局执行上下文,那么原来的函数执行上下文的空间就被废弃掉了,下次执行新的函数的时候,就会把这部分内容覆盖掉
举个具体的例子
function f1(){
var a = 1
}
function f2(){
var b = 2
}
f1()
f2()
图中箭头的指向就是ESP的指向,可以看到,当我们调用f1的时候,会将f1的执行上下文压入栈中,当我们执行完毕时,ESP向下移动,指向全局执行上下文,此时原f1所占的栈空间被废弃掉,所以在下一次f2执行的时候,直接占用了这部分的空间
所以说,JavaScript引擎会通过下移ESP来销毁该执行上下文保存在栈中的内存
堆的垃圾回收
在v8中,将堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的是生存时间久的对象
新生代区域通常只有1~8M的容量,而老生代区域的容量就大多了。对于这两个区域,垃圾回收机制采用不同的垃圾回收器
- 新生代区域:副垃圾回收器
- 老生代区域:主垃圾回收器
垃圾回收器工作流程
虽然新生区和老生区使用的垃圾回收器不一样,但都有一套相同的工作流程
- 标记空间中的活动对象和非活动对象,活动对象指的是还在使用的对象,而非活动对象指的是可以被回收的对象
- 在标记完成后,回收被标记尾可以被回收的对象占据的空间
- 最后进行内存整理,因为我们在回收内存后,内存中可能会出现很多不连续的内存空间,我们称之为内存碎片,这样当我们要去存储比较大的内容时,可能会因为没有连续的内存空间导致无法村粗,这一步是选做的,有的垃圾回收器不会去做这一步
新生代区域的垃圾回收
通常情况下,大多数小的对象都会被分配到新生区,新生去采用Scavenge算法处理,将新生区空间对半划分成两个区域,一半是对象区域,一半是空闲区域
新加入的对象都要先放到对象区域,当对象区域快写满的时候,就执行一次垃圾回收机
在副垃圾回收机器工作的时候,前两部和上面说的一样
- 首先标记空间中的对象
- 然后对标记为非活动对象的内存进行清理
- 第三步,与上面不同的是,没有内存整理,副垃圾回收机器通过将对象区域中还存在的对象,有序地存放到空闲区域,达到将内存中的对象紧凑排列的目的,就不会有内存碎片了,此时,对象区域变成空闲区域,空闲区域变成对象区域
通过这种区域翻转的形式,新生代区域可以一直重复地使用这部分区域空间,也正是为了能使每次清理的时间变短,所以将新生区设置的比较小,这样在每次清理内存、复制对象到空闲区时就不会消耗过多时间
为了解决新生区内存过小很容易被占满的问题,JavaScript引入了对象晋升策略,当一个对象经过两次“翻转”之后还存在,就将其移动到老生区中
老生代区域的垃圾回收
会出现在老生区的,有两种对象,
- 第一种是上面说到的,通过对象晋升策略来到老生区的
- 第二种是对象比较大的,会直接去到老生区
在老生区中,采用的是标记-清除的方法来清除垃圾的
标记-清除
在标记阶段,从一组根元素开始,遍历这组根元素,在这个过程中能遍历到的即为活动对象,不能遍历到的为非活动对象
在标记完毕后,会将非活动对象清除
标记-整理
这里经过标记-清除后,在内存中可能会存在许多内存碎片,此时主垃圾回收器通过将内存存储的内容向一端移动,在端的边界外的内存空间就全部清除,通过这样做达到将所有内存存储挤到内存空间的一边,去掉内存碎片
全停顿与增量标记
由于JavaScript是运行在主线程上的,所以当我们去执行垃圾回收算法的时候,就需要停止执行JavaScript,待垃圾回收工作完成后再继续执行。我们将这种行为称为全停顿
这样做就会导致,如果垃圾清除的工作过多,页面中又有JavaScript实现的动画,就会可能导致动画出现卡帧的情况
为了解决这样的问题,v8采用了增量标记的方法,通过将垃圾回收器的标记和清除工作切分为小部分,穿插在JavaScript执行过程中去执行,通过多次切换线程指向JavaScript和垃圾回收工作,因切分出的每个垃圾回收工作较小,所以不会让用户看到卡帧的情况(实际上JavaScript在这个过程中一直间断执行,只是切换的时间很短所以没有被用户发现)