彻底理解JS的内存机制

859 阅读7分钟

内存机制

数据结构

先进后出「LIFO」

堆是一种无序的树状结构,可以直接通过key-value取出值

队列

先进先出的数据结构,JS引擎的时间循环(Event Loop)的基础结构

JS中变量的存放

  1. 基本类型:基本类型都是保存在栈结构中,这些类型在内存中占有固定的大小空间,通过按值访问,ES6有6种基本类型,undefined、null、boolean、number、string、symbol,ES9新增了bigInt
  2. 引用类型:保存在堆内存中,因为这种值的大小不固定,因此不能保存在栈中,因此放在堆内存中,在栈内存中保存对其地址的引用,当查询变量时,先在栈中查找到引用地址,再根据地址在堆中取出具体值,通过按引用访问

栈比堆的运算速度更快,将引用结构放在堆中是为了不影响栈的执行效率。

简谈闭包

闭包的变量不是存放在栈中的,而是在堆中的,这也是为什么闭包能一直保持对函数内部变量引用的原因

内存空间管理

JS的内存生命周期是:

  1. 分配所需的内存
  2. 使用分配的内存
  3. 不需要时将其释放

js本身有自己的垃圾回收算法,会自动进行垃圾回收,在局部变量中声明的变量,当函数执行完毕后,自然也就不需要了,但是在全局环境中声明的变量,则很难做出判断什么时候释放,所以要尽量避免使用全局变量

引用例题

var a = {n: 1};
var b = a;
a.x = a = {n: 2};

a.x 	// undefined
b.x 	// {n: 2}
  1. a声明为一个对象
  2. 将a赋值给b,b会保持对a的引用
  3. .操作优先级最高,所以a.x先声明,值为undefined,此时a和b都为{n:1,x:undefined}
  4. {n: 2}赋值给a,所以a最后的值为{n:2}
  5. a赋值给a.x,需要注意的是这时候a.x是第一步中的{n: 1, x: undefined}那个对象,其实就是b.x,所以b.x的值为{n:2}

垃圾回收算法

引用计数(现代浏览器都不再使用)

引用计数算法很简单,就是看一个对象是否有指向它的引用,如果没有其他对象指向它了,就说明该对象已经不再需要了 引用计数有一个致命的问题,那就是循环引用,如果两个对象互相引用,那么垃圾回收会判断一直保持有引用,而不会回收,因此可能会导致内存泄露

标记清除

标记清除算法将“不再使用的对象”定义为“无法到达的对象”。从根部(JS全局对象)出发定时扫描内存中的对象,凡是能从根部到达的对象,就保留,如果无法到达的对象,就被标记为不再使用,稍后进行回收,所以现在对于主流浏览器来说,只需要切断需要回收的对象与根部的联系。最常见的内存泄露一般都与DOM元素绑定有关(比如某个对象绑定了DOM元素,后面如果删除DOM元素,但是因为绑定的有对象,所以DOM会一直保持在内存中)

V8下的垃圾回收机制

新生代算法

新生代中对象一般存活时间较短,使用 Scavenge GC 算法。 在新生代空间中,内存空间分为两个部分,分别为From和To空间。在这两个空间中,必定有一个空间是使用的,另一个是空闲的。新分配的对象会被放入From中,当From空间被占满时,新生代GC就会启动。算法会检查From空间中存活的对象并复制到To空间中,如果有失活的对象就会销毁。当复制完成后,将From和To空间互换,GC结束

老生代算法

老生代中的对象一般存活时间较长且数量也多,使用了两个算法, 分别是标记清除算法和标记压缩算法

在讲算法前,先来说下什么情况下对象会出现在老生代空间中: 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。 To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。

在老生代中,以下情况会先启动标记清除算法:

  • 某一个空间没有分块的时候
  • 空间中被对象超过一定限制
  • 空间不能保证新生代中的对象移动到老生代中

清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。

常见的内存泄露

1、意外的全局变量

未定义的变量会在全局创建一个新变量

function foo(arg) {
    bar = "bar";
}

函数内部定义变量,没有使用let或者var,会导致JS将bar挂载到全局变量上,window.bar = 'bar'

另外可能通过this创建

function foo() {
    this.variable = "variable";
}

// Foo 调用自己,this 指向了全局对象(window)
// 而不是 undefined
// 所以这种情况下,也会导致意外的全局变量
foo();

解决方法:

在 JavaScript 文件头部加上 'use strict',使用严格模式避免意外的全局变量,此时上例中的this指向undefined。如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义。

2、计时器未清除

比如setTimeout、setInterval这一类计时器,如果不进行卸载,即使Node节点不在了,计时器依然会存活,除非卸载计时器

setInterval(function() {
    var node = document.getElementById('Node');
    // 其他处理
    ...
}, 1000);

对于addEventListener来说,一旦它们不再需要(或者关联的对象变成不可达),明确地移除它们非常重要。老的 IE 6 是无法处理循环引用的。因为老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,会导致内存泄漏。

但是,现代的浏览器(包括 IE 和 Microsoft Edge)使用了更先进的垃圾回收算法(标记清除),已经可以正确检测和处理循环引用了。即回收节点内存时,不必非要调用 removeEventListener 了

var element = document.getElementById('button');
function onClick(event) {
    element.innerHTML = 'text';
}

element.addEventListener('click', onClick);

3、对于DOM的引用

如果把DOM 存成字典(JSON 键值对)或者数组,此时,同样的 DOM 元素存在两个引用:一个在 DOM 树中,另一个在字典中。那么将来需要把两个引用都清除。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

// elements 字典。button 元素仍旧在内存中,不能被 GC 回收
document.body.removeChild(document.getElementById('button'));

4、闭包

闭包的作用会导致某些变量持久的保存在内存中,比如我们经典的单例模式的应用,闭包中的变量引用,因为作用域链的原因,会保持持久引用,因此闭包的变量都是放在堆内存中的,在闭包保持期间,对于变量的引用是一直保持的,因此也很容易就会造成内存泄露

function MyModal(){}

MyModal.getInstance = (function(){
    let instance = null;
    return function(){
        if(!instance){
           instance = new MyModal();
        }
        return instance
    }
})()