JavaScript 内存泄漏扫盲

489 阅读6分钟

什么是内存泄漏?

程序不再需要使用的内存, 但是又没有及时释放, 就叫做内存泄漏!

内存的生命周期:

1、分配内存

在JavaScript中,开发者只要声明变量值,JavaScript就能自己处理内存的分配工作,不用开发者进行干涉, 例如:

var a = 123 // 变量
var obj = { // 为对象分配内存
    a: 1,
    b: 2
}

var arr = [1, 'a', obj] // 为数组分配内存

2、使用内存

JavaScript 中使用分配的内存主要指的是内存读写。 可以通过为变量或者对象属性赋值

3、释放内存

众所周知在javascript中是不需要开发者手动管理内存的, 在Chrome中有V8引擎帮我们自动进行内存的分配和回收, 这就是垃圾回收机制。 但这并不代表我们在编写代码是不需要考虑内存的事情, 因为V8垃圾回收机制是有特定的规则的。 javascript垃圾回收机制常见的两种方法:

1、引用计数算法

IE使用的是引用计数算法, 这种方法无法解决循环引用的垃圾回收问题, 容易造成内存泄漏。

那么什么是引用计数算法呢? 什么又是循环引用问题呢?

所谓引用计数即, 我们有一个变量每次被引用GC机制就会给这个变量计数加一, 当引用减少就计数减一, 如果计数为零, 在下一次垃圾回收时, 就会被释放掉,例如:

// 引用计数算法
var obj = {}  // obj引用计数为0
var a = obj   // obj引用计数为1
var b = obj   // obj引用计数为2
var a = null  // obj引用计数为1
var b = null  // obj引用计数为0, 在下次垃圾回收时, obj会被回收,对应的内存会被释放

以上代码演示的就是引用计数法垃圾回收机制,但是如果对象存在着循环引用,那么引用计数算法将不起作用,例如:

var obj1 = {}
var obj2 = {}
obj1.o = obj2    // obj2的引用计数为1
obj2.o = obj1    // obj1的引用计数为1
// 这就是循环引用, 所以垃圾回收机制并不会对obj, obj2进行内存释放, 变量常驻内存, 导致内存泄漏.

那么说完了引用计数法, 我们再来看看主流浏览器目前所用的垃圾回收算法 -- 标记清除法,

2、标记清除算法

从2012年起,所有现代浏览器都使用了标记清除垃圾回收算法。所有对JavaScript垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。

标记清除算法假定设置一个叫根(root)的对象,在JavaScript中指的是 window,垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后继续找这些对象引用的对象。

这时候补充一下堆和栈的概念:

我们知道, 在javascript中, 除了八大基本类型(截至目前为止是八种), 剩下的都是对象类型 在js中对象类型都是引用类型, 内容的实体是存在堆中的, 如下面我画的这张图所示:

let obj = { name: 'hello' };
let obj1 = { name: 'world' };
// 该对象对应的内存存储如下图所示

duixiangyinyong.png

// 当对obj 和 obj1 重新赋值的时候
obj = null
obj1 = null
// 重新赋值完成后,对应的内存结构会变成如下图所示

duixiangyinyong.png

堆内存中的对象没有人引用他们, 但是他们还占用这内存, 这时候就需要我们的垃圾回收出场销毁他们了, V8引擎的垃圾回收机制不仅销毁掉堆内存中无人引用的空间, 还会对堆内存进行碎片整理, V8的GC(垃圾回收)工作如下面动图所示: duixiangyinyong.png

V8的GC大致可以分为以下几个步骤 第一步,通过 GC Root 标记空间中活动对象和非活动对象。目前V8采用的是可访问性算法, 从GC Root出发遍历所有的对象, 通过GC Root可以遍历到的标记为可访问的, 称为活动对象,必须保留在内存中, GC Root无法遍历到的标记为不可访问的, 称为非活动对象, 这些不可访问的对象将会被GC清理掉.

第二步,回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。

第三步,做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。


代际假说

受到代际假说的影响, V8引擎采用两个垃圾回收器, 主垃圾回收器--Major GC、副垃圾回收器--Minor GC(Scavenger), 你可能会问什么是代际假说:

第一个是大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;

第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。

这两个回收器的作用如下:

  • 主垃圾回收器 -Major GC,主要负责老生代的垃圾回收。
  • 副垃圾回收器 -Minor GC (Scavenger),主要负责新生代的垃圾回收。

这里又会引出新生代内存老生代内存的概念, 将堆内存分成两块区域

新生代的内存区域一般比较小, 但是垃圾回收得会比较频繁, 而老生代内存区的特点就是对象占用空间相对较大, 对象存活时间较长, 垃圾回收的频率也较低.

对了补一句, 垃圾回收时是会阻塞进程的.


常见的内存泄漏情况

1、前面我们提到有些对象是常驻内存的, 视为不死对象, 如window对象, 是浏览器中javascript的顶级对象, 它的存在贯穿这个javascript的生命周期, 如果我们不小心把庞大又用不上的变量挂到了window对象上, 将会造成内存泄漏, 当然这是一个很低级的错误.

function test() {
// 漏掉了声明, 将会自动挂载到window对象下  
str = '';
for (let i = 0; i < 100000; i++) {
    str += 'xx';  }  return str;
}
// test执行结束后, str应该就没用了, 但是它常驻在了内存中
test();

2、定时器没有被回收

3、闭包的滥用

4、DOM相关


<button class="remove">remove bbb</button>
<div class="box">bbb</div>
<script>
    const box = document.querySelector('.box');
    document.querySelector('.remove').addEventListener('click', () => {
        document.body.removeChild(box);
        // 上面移除了box的DOM元素,这边box用不到了但是没有释放内存,从而导致内存泄漏
        console.log(box); 
    })
</script>

作者:GZHDEV

链接:juejin.cn/post/695908…

来源:掘金