内存泄漏?你中招了没

338 阅读9分钟

栈内存 & 堆内存

  • 栈内存用于存放基本类型:undefined、null、string、number、boolean等
  • 堆内存用于存放引用类型:object、array、function等

当当前执行上下文执行结束后,函数会被销毁,在函数内部生成的基本类型的变量在栈内存中也会随之被销毁,除非存在闭包等原因,导致变量在内存还保留 而堆内存中的引用类型则是通过垃圾回收机制来确定是否会被销毁

垃圾回收机制:

  1. 标记清除法
    • 垃圾回收器将定期从根开始(全局对象),找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象,仍然被引用的就会标记上需要保留,其他就在垃圾回收启动时会被清除掉,释放对应的内存空间。
    • 解决了循环引用无法被清除的问题
    • 各大浏览器基本都使用这一机制来判断哪些是需要被保留的,哪些被清除
  2. 引用计数法
    • 目前基本已经不再被使用了。
    • 原理大概是跟踪每一个对象被引用的次数,同一个引用类型的数据被两个变量所引用,引用就计数为2,当这两个变量被赋其他值时,说明原来的引用类型数据引用计数为0,在垃圾回收启动的时候就会被销毁
    • 缺点:循环引用的情况会导致引用计数永远不为0,即使已经退出上下文环境了,依然留在内存中,造成内存泄漏

参考:内存管理

V8垃圾回收策略

不同的垃圾回收策略用到垃圾回收机制都是标记清除法 分代回收策略:新生代 老生代

  1. 新生代算法:Scavenge算法 将堆内存一分为二,一个处于使用叫做From空间,一个处于空闲叫做To空间,然后每次都把存活的对象(标记清除法判断出来的)复制到To空间中,留在From空间的不需要的引用类型销毁释放掉内存空间; 然后From与To互换角色,原来的To空间变为From空间将开始筛选存活的对象,周而复始的运作

  2. 老生代算法:Mark-Sweep 与 Mark-Compact相结合的方式

    1. Mark-Sweep 标记清除,主要是把存活的对象标记出来
    2. Mark-Compact 标记压缩,主要是解决 Mark-Sweep 会带来内存碎片的问题,该算法把存活的对象推到堆内存的一端,一次性把留在另一端没用的引用类型都清除掉

聊聊V8引擎的垃圾回收

V8垃圾回收机制

内存泄漏

背景: 传统的简单页面,当完成页面的跳转后,原页面的所有在内存中的数据都会被销毁掉。 当前SPA页面盛行,页面的跳转通过路由进行切换,导致所有的子页面其实都是停留在一个页面上并且都公用这一套上下文机制,如果每个路由页面都不关心内存泄漏的问题,最终会导致页面所占内存越来越大,影响页面性能,甚至当前浏览器标签的崩溃

垃圾回收机制:当可以通过引用链从根访问对象时,浏览器将对象保存在堆内存中。垃圾收集器是JavaScript引擎中的一个后台进程,它识别不可访问的对象,删除它们,并回收底层内存

内存泄漏的原因:当内存中应该在垃圾收集周期中清理的对象通过另一个对象的无意引用保持从根可访问时,就会发生内存泄漏。将冗余对象保留在内存中会导致应用程序内部过度使用内存,从而导致性能下降和性能下降

常见的内存泄漏case

  1. 无意间创建的全局对象
  2. 闭包:作用域未释放
  3. 定时器未清除
  4. 事件监听未清空
  5. 缓存:存在内存中的作为缓存的数据,一直没有被清除掉
  6. 无效的DOM引用(js 代码中有对 DOM 节点的引用,dom 节点被移除的时候,引用却一直还在)

1. 无意间创建的全局对象

挂载在全局window对象的属性值,占用的堆内存永远不会被垃圾回收机制释放掉

  1. 非严格模式下,给未声明的变量赋值
  2. 全局作用域使用 var 变量声明
  3. 将this指向了全局对象
function createGlobalVariables() {
    leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
    this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'

解决方案:使用 ("use strict") 严格模式、使用let、const

2. 闭包

函数作用域的变量将在函数退出调用堆栈之后被清除,如果函数外部没有任何指向它们的引用。尽管函数已经完成执行,并且它的执行上下文和变量环境早已消失,但是闭包将保持引用的变量是活的

function outer() {
    const potentiallyHugeArray = [];
    return function inner() {
        potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
        console.log('Hello');
    };
};
const sayHello = outer(); // contains definition of the function inner

function repeat(fn, num) {
    for (let i = 0; i < num; i++){
        fn();
    }
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray 
 
// now imagine repeat(sayHello, 100000)

如何防止:闭包是JavaScript不可避免且不可分割的一部分, 解决方案: a. 何时创建闭包以及它保留了哪些对象,排除掉不需要的引用类型被闭包引用 b. 了解闭包的预期寿命和使用情况(尤其是作为回调时)。

3. Timer 定时器

定时器的回调函数中,使用了闭包模式引入一些引用类型,同时没用使用clearTimeout/clearInterval 对计时器进行清理

function setCallback() {
    const data = {
        counter: 0,
        hugeString: new Array(100000).join('x') // 这里的数据其实是没必要作为闭包引入的
    };
    return function cb() {
        data.counter++; // data object is now part of the callback's scope
        console.log(data.counter);
    }
}
setInterval(setCallback(), 1000); // how do we stop it?

解决方案: a. 排除掉不需要被闭包引用的引用类型,比如下面例子里的 hugeString b. 使用clearInterval销毁计数器,在不需要的时候(组件销毁等钩子函数中)对其进行清理

function setCallback() {
    // 'unpacking' the data object
    let counter = 0;
    const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns
    return function cb() {
        counter++; // only counter is part of the callback's scope
        console.log(counter);
    }
}

const timerId = setInterval(setCallback(), 1000); // saving the interval ID

// doing something ...

clearInterval(timerId); // stopping the timer i.e. if button pressed

4. 事件监听

活动事件监听器将防止在其范围内捕获的所有变量被垃圾收集。添加之后,事件侦听器将一直有效,直到: a. 用removeEventListener() b. 事件监听函数绑定的DOM元素被删除

const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
    doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});

解决方案: a. 不使用匿名函数作为事件监听的回调,使用具名函数 b. 当不需要继续监听时,使用removeEventListener 移除掉监听函数

function listener() {
    doSomething(hugeString);
}
document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // 移出调指定的监听函数

也可以使用addEventListener自带的第三个参数,指定只监听一次,触发一次后会自动移出事件监听

document.addEventListener('keyup', function listener() {
    doSomething(hugeString);
}, {once: true}); // listener will be removed after running once

5. 缓存

如果我们持续地向缓存追加内存,而没有删除未使用的对象,并且没有一些限制大小的逻辑,缓存可能会无限增长 当我们对引用类型做一些缓存时,当原引用类型变量已经销毁后,cache中的引用不销毁,会导致无用的引用类型一致被cache引用,不会被垃圾回收,造成内存泄漏

let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const mapCache = new Map();

function cache(obj){
    if (!mapCache.has(obj)){
        const value = `${obj.name} has an id of ${obj.id}`;
        mapCache.set(obj, value);

        return [value, 'computed'];
    }

    return [mapCache.get(obj), 'cached'];
}

cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_1); // ['Peter has an id of 12345', 'cached']
cache(user_2); // ['Mark has an id of 54321', 'computed']

console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321")
user_1 = null; // removing the inactive user

// Garbage Collector
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // first entry is still in cache

解决方案: 使用 WeakMap 类型来代替 Map结构,这样当前引用除了WeakMap没有任何外部依赖引用时,会被正确的垃圾回收掉

6. 独立的 DOM 节点

如果一个DOM节点有来自JavaScript的直接引用,它将防止它被垃圾收集,即使该节点已经从DOM树中删除。

function createElement() {
    const div = document.createElement('div');
    div.id = 'detached';
    return div;
}

// this will keep referencing the DOM element even after deleteElement() is called
const detachedDiv = createElement();

document.body.appendChild(detachedDiv);

function deleteElement() {
    document.body.removeChild(document.getElementById('detached'));
}

deleteElement(); // Heap snapshot will show detached div#detached

即使调用document.body.removeChild(document.getElementById('detached'))删除了DOM元素,在堆内存中依然会保留这个DOM节点,因为有个全局的 detachedDiv 变量一直在引用它

解决方案: a. 在合理的位置,将引用DOM节点的变量置为null b. 在函数中,使用局部变量来引用DOM节点,这样当函数执行完后,局部变量会被销毁,DOM节点也就没有被引用了

function createElement() {...} // same as above

// DOM references are inside the function scope

function appendElement() {
    const detachedDiv = createElement(); // 对DOM节点的引用,放到函数中来完成
    document.body.appendChild(detachedDiv);
}

appendElement();

function deleteElement() {
     document.body.removeChild(document.getElementById('detached'));
}

deleteElement(); // no detached div#detached elements in the Heap Snapshot

参考链接:Causes of Memory Leaks in JavaScript and How to Avoid Them

内存泄漏排查方案

内存泄漏排查方案