前端内存泄露及管理


前端内存泄露及管理


正文

什么是内存?

在硬件级别上,计算机内存由大量触发器组成。每个触发器包含几个晶体管,能够存储一个位。 单个触发器可以通过唯一标识符寻址,因此我们可以读取和覆盖它们。 因此,从概念上讲,我们可以把我们的整个计算机内存看作是一个巨大的位数组,我们可以读和写。

像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()free()用于分配内存和释放内存。 而对于JavaScript来说,会在创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放内存,这个自动释放内存的过程称为垃圾回收。 因为自动垃圾回收机制的存在,让大多Javascript开发者感觉他们可以不关心内存管理,所以会在一些情况下导致内存泄漏。

内存生命周期

JavaScript 内存管理机制和内存的生命周期是一一对应的。首先需要分配内存,然后使用内存,最后释放内存

JS 环境中分配的内存有如下生命周期:

  1. 内存分配:当我们申明变量、函数、对象的时候,系统会自动为他们分配内存
  2. 内存使用:即读写内存,也就是使用变量、函数等
  3. 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存

其中 JavaScript 语言不需要程序员手动分配内存,绝大部分情况下也不需要手动释放内存, 对 JavaScript 程序员来说通常就是使用内存(即使用变量、函数、对象等)

JS的内存分配

为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。

// 给数值变量分配内存
let number = 123; 
// 给字符串分配内存
const string = "xianshannan"; 
// 给对象及其包含的值分配内存
const object = {
    a: 1,
    b: null
}; 
// 给数组及其包含的值分配内存(就像对象一样)
const array = [1, null, "abra"]; 
// 给函数(可调用的对象)分配内存
function func(a){
    return a;
} 
// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
    someElement.style.backgroundColor = 'blue';
}, false);

有些函数调用结果是分配对象内存:

var d = new Date(); // 分配一个 Date 对象
 
var e = document.createElement('div'); // 分配一个 DOM 元素

有些方法分配新变量或者新对象:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,JavaScript 可能决定不分配内存,只是存储了 [0-3] 的范围。
 
var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新数组a3有四个元素,是 a 连接 a2 的结果

JS的内存使用

使用值的过程实际上是对分配内存进行读取与写入的操作。

读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

// 写入内存
number = 234;
// 读取 number 和 func 的内存,写入 func 参数内存
func(number);

JS的内存回收

前端界一般称垃圾内存回收为 GC(Garbage Collection,即垃圾回收)。

JS 有自动垃圾回收机制,那么这个自动垃圾回收机制的原理是什么呢? 其实很简单,就是找出那些不再继续使用的值,然后释放其占用的内存。

大多数内存管理的问题都在这个阶段。 在这里最艰难的任务是找到不再需要使用的变量。

不再需要使用的变量也就是生命周期结束的变量,是局部变量,局部变量只在函数的执行过程中存在, 当函数运行结束,没有其他引用(闭包),那么该变量会被标记回收。

全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收。

因为自动垃圾回收机制的存在,开发人员可以不关心也不注意内存释放的有关问题,但对无用内存的释放这件事是客观存在的。 不幸的是,即使不考虑垃圾回收对性能的影响,目前最新的垃圾回收算法,也无法智能回收所有的极端情况。

内存泄漏一般都是发生在这一步,JavaScript 的内存回收机制虽然能回收绝大部分的垃圾内存, 但是还是存在回收不了的情况,如果存在这些情况,需要我们手动清理内存。

接下来我们来探究一下 JS 垃圾回收的机制。

引用计数垃圾收集

这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

垃圾回收算法主要依赖于引用的概念。 在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。 例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。 在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。 如果没有其他对象指向它了,说明该对象已经不再需了。

var o = {
    a: {
        b: 2
    }
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集

var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1;   // 现在,“这个对象”的原始引用o被o2替换了

var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 最初的对象现在已经是零引用了
// 他可以被垃圾回收了
// 然而它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
// 它可以被垃圾回收了

由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用

如果两个对象相互引用,尽管他们已不再使用,垃圾回收不会进行回收,导致内存泄露。

来看一个循环引用的例子:

function f() {
    var o = {};
    var o2 = {};
    o.a = o2; // o 引用 o2
    o2.a = o; // o2 引用 o 这里

    return "azerty";
}

f();

// 结果:
o = {
    a: {}
};
o2 = {
    a: {
        a: {}
    }
}

上面我们申明了一个函数 f ,其中包含两个相互引用的对象。 在调用函数结束后,对象 o 和 o2 实际上已离开函数范围,因此不再需要了。 但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。

再来看一个实际的例子:

var div = document.createElement("div");
div.onclick = function () {
    console.log("click");
};

上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。 此时变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用(div变量可在函数内被访问)! 一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。

ES6 把引用有区分为强引用弱引用,这个目前只有在 Set 和 Map 中才有。 强引用才会有引用计数叠加,只有引用计数为 0 的对象的内存才会被回收,所以一般需要手动回收内存(手动回收的前提在于标记清除法还没执行,还处于当前执行环境)。 而弱引用没有触发引用计数叠加,只要引用计数为 0,弱引用就会自动消失,无需手动回收内存。

标记清除算法

标记清除算法将“不再使用的对象”定义为“无法达到的对象”。 简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。 凡是能从根部到达的对象,都是还需要使用的。 那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象)。 但反之未必成立。

也可以理解成: 当变量进入执行环境时标记为“进入环境”,当变量离开执行环境时则标记为“离开环境”,被标记为“进入环境”的变量是不能被回收的, 因为它们正在被使用,而标记为“离开环境”的变量则可以被回收。

环境可以理解为我们的作用域,但是全局作用域的变量只会在页面关闭才会销毁。

工作流程

  1. 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
  2. 从根部出发将能触及到的对象的标记清除。
  3. 那些还存在标记的变量被视为准备删除的变量。
  4. 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。

循环引用不再是问题了

再看之前循环引用的例子:

function f() {
    var o = {};
    var o2 = {};
    o.a = o2; // o 引用 o2
    o2.a = o; // o2 引用 o 这里

    return "azerty";
}

f();

函数调用返回之后,两个循环引用的对象在垃圾收集时从全局对象出发无法再获取他们的引用。 因此,他们将会被垃圾回收器回收。

内存泄漏

什么是内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存。 对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。 否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。 本质上讲,内存泄漏就是由于疏忽或错误造成程序未能释放那些已经不再使用的内存,造成内存的浪费。

如果内存不需要时,没有经过生命周期的释放期,那么就存在内存泄漏。

内存泄漏简单理解:无用的内存还在占用,得不到释放和归还。比较严重时,无用的内存会持续递增,从而导致整个系统卡顿,甚至崩溃。

内存泄漏的识别方法

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。

这就要求实时查看内存的占用情况。

在 Chrome 浏览器中,我们可以这样查看内存占用情况

  1. 打开开发者工具,选择 Performance 面板
  2. 在顶部勾选 Memory
  3. 点击左上角的 record 按钮
  4. 在页面上进行各种操作,模拟用户的使用情况
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况

来看一张效果图:

我们有两种方式来判定当前是否有内存泄漏:

  1. 多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏
  2. 某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏

常见的内存泄露案例

意外的全局变量
function foo() {
    bar1 = 'some text'; // 没有声明变量 实际上是全局变量 => window.bar1
    this.bar2 = 'some text' // 全局变量 => window.bar2
}

foo();

在这个例子中,意外的创建了两个全局变量 bar1 和 bar2

被遗忘的计时器

在很多库中,如果使用了观察者模式,都会提供回调方法,来调用一些回调函数。

要记得回收这些回调函数。举一个 setInterval的例子:

var serverData = loadData();
setInterval(function () {
    var renderer = document.getElementById('renderer');
    if (renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); // 每 5 秒调用一次

如果后续 renderer 元素被移除,整个定时器实际上没有任何作用。

但如果你没有回收定时器,整个定时器依然有效, 不但定时器无法被内存回收,定时器函数中的依赖也无法回收。 在这个案例中的 serverData 也无法被回收。

拿一个 vue 组件来做例子:

<template>
    <div></div>
</template>

<script>
export default {
    methods: {
        refresh() {
            // 获取一些数据
        },
    },
    mounted() {
        setInterval(function() {
            // 轮询获取数据
            this.refresh()
        }, 2000)
    },
}
</script>

上面的组件销毁的时候,setInterval 还是在运行的,里面涉及到的内存都是没法回收的(浏览器会认为这是必须的内存,不是垃圾内存), 需要在组件销毁的时候清除计时器,如下:

<template>
    <div></div>
</template>

<script>
export default {
    methods: {
        refresh() {
            // 获取一些数据
        },
    },
    mounted() {
        this.refreshInterval = setInterval(function() {
            // 轮询获取数据
            this.refresh()
        }, 2000)
    },
    beforeDestroy() {
        clearInterval(this.refreshInterval)
    },
}
</script>
被遗忘的事件监听器

继续使用 vue 组件做例子:

<template>
  <div></div>
</template>

<script>
export default {
    mounted() {
        window.addEventListener('resize', () => {
            // 这里做一些操作
        })
    },
}
</script>

上面的组件销毁的时候,resize 事件还是在监听中,里面涉及到的内存都是没法回收的(浏览器会认为这是必须的内存,不是垃圾内存), 需要在组件销毁的时候移除相关的事件,如下:

<template>
  <div></div>
</template>

<script>
export default {
    mounted() {
        this.resizeEventCallback = () => {
            // 这里做一些操作
        }
        window.addEventListener('resize', this.resizeEventCallback)
    },
    beforeDestroy() {
        window.removeEventListener('resize', this.resizeEventCallback)
    },
}
</script>
被遗忘的 ES6 Set 成员

如果对 Set 不熟悉,可以看这里

如下是有内存泄漏的(成员是引用类型的,即对象):

let map = new Set();
let value = { test: 22};
map.add(value);

value= null;

需要改成这样,才没内存泄漏:

let map = new Set();
let value = { test: 22};
map.add(value);

map.delete(value);
value = null;

有个更便捷的方式,使用 WeakSet,WeakSet 的成员是弱引用,内存回收不会考虑到这个引用是否存在。

let map = new WeakSet();
let value = { test: 22};
map.add(value);

value = null;
被遗忘的 ES6 Map 键名

如果对 Map 不熟悉,可以看这里

如下是有内存泄漏的(键值是引用类型的,即对象):

let map = new Map();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);
key = null;

需要改成这样,才没内存泄漏:

let map = new Map();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);

map.delete(key);
key = null;

有个更便捷的方式,使用 WeakMap,WeakMap 的键名是弱引用,内存回收不会考虑到这个引用是否存在。

let map = new WeakMap();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);

key = null;
被遗忘的订阅发布事件监听器

这个跟上面的被遗忘的事件监听器的道理是一样的。

假设订阅发布事件有三个方法 emit 、on 、off 三个方法。

还是继续使用 vue 组件做例子:

<template>
  <div @click="onClick"></div>
</template>

<script>
import customEvent from 'event'

export default {
  methods: {
    onClick() {
      customEvent.emit('test', { type: 'click' })
    },
  },
  mounted() {
    customEvent.on('test', data => {
      // 一些逻辑
      console.log(data)
    })
  },
}
</script>

上面的组件销毁的时候,自定义 test 事件还是在监听中,里面涉及到的内存都是没法回收的(浏览器会认为这是必须的内存,不是垃圾内存), 需要在组件销毁的时候移除相关的事件,如下:

<template>
  <div @click="onClick"></div>
</template>

<script>
import customEvent from 'event'

export default {
  methods: {
    onClick() {
      customEvent.emit('test', { type: 'click' })
    },
  },
  mounted() {
    customEvent.on('test', data => {
      // 一些逻辑
      console.log(data)
    })
  },
  beforeDestroy() {
    customEvent.off('test')
  },
}
</script>
被遗忘的闭包

在 JS 开发中,我们会经常用到闭包,一个内部函数,有权访问包含其的外部函数中的变量。

下面这种情况下,闭包也会造成内存泄露:

var theThing = null;
var replaceThing = function () {
    var originalThing = theThing;
    var unused = function () {
        if (originalThing) // 对于 'originalThing'的引用
            console.log("hi");
    };
    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
            console.log("message");
        }
    };
};
setInterval(replaceThing, 1000);

这段代码,每次调用 replaceThing 时,theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象。 同时 unused 是一个引用了 originalThing 的闭包。

这个范例的关键在于,闭包之间是共享作用域的,尽管 unused 可能一直没有被调用, 但是 someMethod 可能会被调用,就会导致无法对其内存进行回收。 当这段代码被反复执行时,内存会持续增长。

看下这个代码:

function closure() {
    const name = 'xianshannan'
    return () => {
        return name
            .split('')
            .reverse()
            .join('')
    }
}

const reverseName = closure()
// 这里调用了 reverseName
reverseName();

上面有没有内存泄漏?

上面是没有内存泄漏的,因为name 变量是要用到的(非垃圾)。这也是从侧面反映了闭包的缺点,内存占用相对高,量多了会有性能影响。

但是改成这样就是有内存泄漏的:

function closure() {
    const name = 'xianshannan'
    return () => {
        return name
            .split('')
            .reverse()
            .join('')
    }
}

const reverseName = closure()

在当前执行环境未结束的情况下,严格来说,这样是有内存泄漏的,name 变量是被 closure 返回的函数调用了,但是返回的函数没被使用, 这个场景下 name 就属于垃圾内存。name 不是必须的,但是还是占用了内存,也不可被回收。

脱离 DOM 的引用

每个页面上的 DOM 都是占用内存的,假设有一个页面 A 元素,我们获取到了 A 元素 DOM 对象,然后赋值到了一个变量(内存指向是一样的), 然后移除了页面的 A 元素,如果这个变量由于其他原因没有被回收,那么就存在内存泄漏,如下面的例子:

class Test {
    constructor() {
        this.elements = {
            button: document.querySelector('#button'),
            div: document.querySelector('#div'),
            span: document.querySelector('#span'),
        }
    }

    removeButton() {
        document.body.removeChild(this.elements.button)
        // this.elements.button = null
    }
}

const a = new Test()
a.removeButton()

上面的例子 button 元素 虽然在页面上移除了,但是内存指向换为了 this.elements.button,内存占用还是存在的。 所以上面的代码还需要这样写: this.elements.button = null,手动释放这个内存。

另外需要注意的一个点是,对于一个 Dom 树的叶子节点的引用。

举个例子: 如果我们引用了一个表格中的td元素,一旦在 Dom 中删除了整个表格,我们直观的觉得内存回收应该回收除了被引用的 td 外的其他元素。 但是事实上,这个 td 元素是整个表格的一个子元素,并保留对于其父元素的引用。 这就会导致对于整个表格,都无法进行内存回收。所以我们要小心处理对于 Dom 元素的引用。

如何避免内存泄漏

记住一个原则:不用的东西,及时归还。

  1. 减少不必要的全局变量,使用严格模式避免意外创建全局变量。
  2. 在你使用完数据后,及时解除引用(闭包中的变量,dom引用,定时器清除)。
  3. 组织好你的逻辑,避免死循环等造成浏览器卡顿,崩溃的问题。

如何发现内存泄漏

内存泄漏时,内存一般都是会周期性的增长,我们可以借助谷歌浏览器的开发者工具进行判别。

这里针对下面例子进行一步一步的排查和找到问题出现在哪里:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <div id="app">
      <button id="run">运行</button>
      <button id="stop">停止</button>
    </div>
    <script>
      const arr = []
      for (let i = 0; i < 200000; i++) {
        arr.push(i)
      }
      let newArr = []

      function run() {
        newArr = newArr.concat(arr)
      }

      let clearRun

      document.querySelector('#run').onclick = function() {
        clearRun = setInterval(() => {
          run()
        }, 1000)
      }

      document.querySelector('#stop').onclick = function() {
        clearInterval(clearRun)
      }
    </script>
  </body>
</html>
第一步:确定是否是内存泄漏问题

访问上面的代码页面,打开谷歌开发者工具,切换至 Performance 选项,勾选 Memory 选项。

在页面上点击运行按钮,然后在开发者工具上面点击左上角的录制按钮,10 秒后在页面上点击停止按钮,5 秒后停止内存录制。得到的内存走势如下:

由上图可知,10 秒之前内存周期性增长,10 后点击了停止按钮,内存平稳,不再递增。

我们可以使用内存走势图判断当前页面是否有内存泄漏。经过测试上面的代码 20000 个数组项改为 20 个数组项,内存走势也一样能看出来。

第二步:查找内存泄漏出现的位置

上一步确认是内存泄漏问题后,我们继续利用谷歌开发者工具进行问题查找。

访问上面的代码页面,打开谷歌开发者工具,切换至 Memory 选项。 页面上点击运行按钮,然后点击开发者工具左上角录制按钮,录制完成后继续点击录制,直到录制完三个为止。 然后点击页面的停止按钮,再连续录制 3 次内存(不要清理之前的录制)。下图就是进行这些步骤后的截图:

从这里也可以看出,点击运行按钮后,内存在不断递增。点击停止按钮后,内存就平稳了。虽然我们也可以使用这样的方式来判别是否存在内存泄漏,但是不够第一步的方法便捷,走势图也更直观。

然后第二步的主要目的来了,记录 JavaScript 堆内存才是内存录制的主要目的,我们可以看到哪个堆占用的内存更高。

在刚才的录制中选择 Snapshot 3 ,然后按照 Shallow Size 进行逆序排序,如下:

从内存记录中,发现 array 对象占用最大,展开后发现,第一个 object elements 占用最大, 选择这个 object elements 后可以在下面看到 newArr 变量,然后点击 index.html:18, 只要是高亮下划线的地方都可以进去看看 (测试页面是 index.html),可以跳转到 newArr 附近。






参考资料

中高级前端必须了解的JS中的内存管理 https://www.jb51.net/article/164566.htm

深入了解 JavaScript 内存泄露 https://segmentfault.com/a/1190000020231307

Set 数据结构 https://es6.ruanyifeng.com/#docs/set-map#Set

Map 数据结构 https://es6.ruanyifeng.com/#docs/set-map#Map

前端为什么操作 DOM 是最耗性能的呢 https://www.zhihu.com/question/324992717


返回