在实际项目中,像内存泄漏、递归爆栈等这样的问题,对于一个前端来说,属于家常便饭了,你会经常遇到的。遇到了,也别慌,从原理开始分析造成问题的原因,这篇文章会结合实战来由浅入深的分享内存泄漏那些事。
老大,不好了,我页面卡死了,线上项目内存飙到了 3万多 M 测试小姐姐在呼唤我们老大。3万多 M 所指是浏览器内存占用空间如下图所示的地方。
为了处于好奇,我就过去瞅了一眼,整个页面处于卡死状态,内存飙到这么高,肯定是长时间运行项目,程序中有些占用的内存无法得到回收,导致了内存不断的泄漏,最终页面出现卡死状态。
1、什么是内存泄漏?
一句话总结,不再用到的内存,没有及时释放,就叫做内存泄漏。
当内存泄漏时,内部是通过垃圾回收机制来解决的,但是不同语言的垃圾回收策略不通,有的语言自动可以管理内存,比如 JavaScript;有的语言却需要手动的去释放,比如 C 语言。
举个例子:
char *p = (char*)malloc(10); // 常见 10 个字节区域在堆区
free(p);//释放
2、什么造成内存泄漏?
内存存储数据无非最常用的是两种数据结构,分别是栈和堆,还有一种不常打交道的池结构。
内存泄漏无非就是栈内存和堆内存,这个在文章后边会展开分析。先说说在 JS 中,是什么原因会造成内存的泄漏?
在 JS 中,内存泄漏是指我们已经无法再通过 js 代码来引用到某个对象,但垃圾回收器却认为这个对象还在被引用,因此在回收的时候不会释放它。导致了分配的这块内存永远也无法被释放出来。如果这样的情况越来越多,会导致内存不够用而系统崩溃。
嗯,这样以来,确实和我上边在实战中所提到的情况差不多。为了更好的去了解如何查看是否是内存泄漏,我去网上搜集了一些检测内存泄露的方法。
3、如何检测内存泄漏?
第一种方式,我们可以使用任务管理器去查看程序的占用情况。在谷歌浏览器种,打开设置 —> 更多工具 —> 任务管理器。
第二种方式,适合程序执行的时候,那个阶段内存的占用情况。
打开谷歌控制台,切换到 Preformance 面板,在 Memory 复选框打勾,点击左上角的开始或者刷新按钮,我在上图标出的红色区域就是内存的在每个阶段的实时占用情况。
如果内存的占用情况基本稳定,那么说明不存在内存泄漏的情况,如果内存随着时间的推移,不断的进行上升,说明内存有泄漏的可能。
4、垃圾回收机制
对于 JS 的垃圾回收机制,主要做三件事,分别是标记、回收、整理。
标记的是用不到的内存,回收的是已标记的内存,整理的是回收后的零碎不连续的内存空间。
那么回收的栈内存和堆内存,垃圾回收器是如何进行不同的方式进行回收的呢?
首先我们要知道栈内存和堆内存分别存储是什么类型的数据,分别是怎样存储的,这个在小册子中具体也提到过,如下(不理解的建议可以先看小册子内容):
4.1 栈内存的数据如何被回收的?
我们知道,程序的执行遇到函数是在调用栈中依次入栈执行的,每个函数都有一个执行上下文环境,当函数执行完成,该函数的执行上下文就会出栈,因此,存在每个执行上下文环境中的栈内存中的变量也会被释放,具体动画过程可以看下边这篇文章。
动画:一个底层运行函数的自白!
举个例子,如下代码:
1function fn1(){
2 let num = 1;
3 let obj1 = { name:"小鹿"}
4 function fn2() {
5 let str = "xiaolu";
6 let obj2 = { name:" 小鹿动画学编程"}
7 }
8 fn2();
9}
10fn1();
执行示意图如下:
不对,你说清楚,出栈的这块执行上下文中的栈内存如何销毁的?难道出栈就销毁了吗?虽然是出栈了,那块内存确实还存在调用栈中呀?
没错,确实这块内存并没有销毁,而是变成了无效的内存的状态。当另一个函数的执行上下文进入调用栈的时候,就会把这个无效内存给覆盖掉,那么我们认为之前存在的栈内存被销毁或者重新利用了。
4.2 堆内存中的数据如何回收?
上边讲到的栈内存,根本用不到咱们的垃圾回收器,因为它会被下一个执行上下文的函数所覆盖或者说重新利用起来。
但是不要忘记,在堆内存中,大多数存储的是引用类型,而引用类型的地址是存储在栈内存中,栈内存这时候已经销毁,无法引用到该引用类型,那么这个无法引用这块堆内存空间又是如何销毁和回收的呢?
这不得不派出我们 V8 的垃圾回收器了。但是在堆内存中,V8 主要分把堆内存为两块区域,分别为新生代区域和老生代区域,咱们先主要了解一下这两块区域是干嘛的。
新生代区域主要存放时是存放时间比较短的而且占用内存比较小引用类型数据,而老生代区域存放时间比较长占用内存比较大的引用类型数据。而且新生代区域占据的内存比较小,反而老生区占用的内存很大。
所以呢,V8 引擎不得不用两个垃圾回收器分别回收对应区域无效数据。
对于垃圾回收机制的执行过程,小册也具体写到。
小册获取方式:关-注-公-众-号:小鹿动画学编程 后台回复:「pdf」
但是具体如何进行标记和回收的没有提到过,先看看新生代区域的垃圾数据是如何回收的。
具体的回收过程如下:
垃圾回收机制
从以上动画中,可以看出,新生代区域被一分为二,一边存放的是数据(称它为 Form 空间),另一边是空闲区域(称它为To空间)。当数据区域空间快被占满的时候,就会执行一次垃圾回收机制。
对用不到的数据进行打标记。然后将没有被标记的数据进行复制到 To 空间,然后对标记的数据进行回收,回收之后 Form 空间就没有任何数据了,然后两个空间位置就会互换,To 空间就变成了 Form 空间,而此时有数据的成为了 To 空间。
此时用到的数据在复制的过程中已经被整理好,那么新生代区域的垃圾回收是这样进行回收的,再次进行垃圾回收的时候,会依次执行上述的动画。这个垃圾回收算法被称为 Scavenge 算法。
然而这个算法并不适合老生区域的数据回收,我们上边提到,老生区域数据的特点,数据占用内存大,当我们复制的时候,非常耗时,这也是为什么只有新生代区域设置空间小的原因,为了保证垃圾回收的执行效率嘛。
在老生代区域,垃圾回收机制使用的是标记-清除法。
我们先来说说如何进行对可回收的数据如何标记的,首先我们从一个根数据开始进行一次循环遍历,看看哪些数据对象没有被引用使用到。然后要进行标记,当下次进行垃圾回收的时候对标记的对象进行销毁。
那么问题来了,经过几次垃圾回收之后,虽然这些没有被引用到的数据销毁了,但是内存中的空间很零碎,连续的内存空间逐渐变小,比如我们想声明连续一块比较大的内存空间的时候,突然发现内存都是零碎的,从而会导致申请失败,那我们改怎么办呢?
那么以上的标记-清除法子就不管用了,V8 引擎垃圾回收是这么改进的。
JS 标记-整理
当进行从根元素进行遍历的时候,发现可以被引用到的数据我们就将它进行移动到头部位置,然后依次排列,最后末尾剩下的都是引用不到的数据,也就是我们所说的要回收的数据,我们一次性进行回收,同时我们已存在的对象也已经在内存中被整理好了。
小结
以上就是我们今天所有内容了,总结一下。
我们由浅入深的了解了一下内存泄漏的问题,以及如何查看内存泄漏。
分别从栈内存和堆内存角度用动画形式分享了 V8 的两种垃圾回收机制算法。