JavaScript中的垃圾回收和内存泄漏

1,138 阅读12分钟

垃圾回收

垃圾回收的定义

垃圾回收是一种自动的内存管理机制。当计算机上的动态内存不再需要时,就应该予以释放,以让出内存。

垃圾回收的必要性

程序是运行在内存里的,当声明一个变量、定义一个函数时都会占用内存。内存的容量是有限的,如果变量、函数等只有产生没有消亡的过程,那迟早内存有被完全占用的时候。这个时候,不仅自己的程序无法正常运行,连其他程序也会受到影响。好比生物只有出生没有死亡,地球总有被撑爆的一天。所以,在计算机中,我们需要垃圾回收。需要注意的是,定义中的“自动”的意思是语言可以帮助我们回收内存垃圾,但并不代表我们不用关心内存管理,如果操作失当,JavaScript 中依旧会出现内存溢出的情况。

垃圾回收的机制

找出不再使用的变量,然后释放掉其占用的内存,但是这个过程不是时时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。

垃圾回收基于的两个原理

  • 考虑某个变量或对象在未来的程序运行中将不会被访问
  • 向这些对象要求归还内存

而这两个原理中,最主要的也是最艰难的部分就是找到“所分配的内存确实已经不再需要了”

var a = "星梦花随";
var b = "前端工匠";
var a = b; //重写a

这段代码运行之后,“星梦花随”这个字符串失去了引用(之前是被a引用),系统检测到这个事实之后,就会释放该字符串的存储空间以便这些空间可以被再利用。

垃圾回收的方法

垃圾回收机制怎么知道,哪些内存不再需要呢?

垃圾回收有两种方法:标记清除引用计数。引用计数不太常用,标记清除较为常用。

标记清除

这是javascript中最常用的垃圾回收方式。 当变量进入执行环境是,就标记这个变量为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到他们。当变量离开环境时,则将其标记为“离开环境”。

垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量以及被环境中的变量引用的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后。垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收他们所占用的内存空间。

我们用个例子,解释下这个方法:

var m = 0,n = 19      // 把 m,n,add() 标记为进入环境。
add(m, n)             // 把 a, b, c标记为进入环境。
console.log(n)        // a,b,c标记为离开环境,等待垃圾回收。
function add(a, b) {
  a++
  var c = a + b
  return c
}

引用计数

另一种不太常见的垃圾回收策略是引用计数。引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数就减1。当这个引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。这样,垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占的内存。

上图中,左下角的两个值,没有任何引用,所以可以释放。

如果一个值不再需要了,引用数却不为0,垃圾回收机制无法释放这块内存,从而导致内存泄漏。

var arr = [1, 2, 3, 4];
console.log('hello world');
arr = [5, 6, 7]

上面代码中,数组[1, 2, 3, 4]是一个值,会占用内存。变量arr是仅有的对这个值的引用,因此引用次数为1。尽管后面的代码没有用到arr,它还是会持续占用内存。

第三行代码中,数组[1, 2, 3, 4]引用的变量arr又取得了另外一个值,则数组[1, 2, 3, 4]的引用次数就减1,此时它引用次数变成0,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。

但是引用计数有个最大的问题: 循环引用

function func() {
    let obj1 = {};
    let obj2 = {};

    obj1.a = obj2; // obj1 引用 obj2
    obj2.a = obj1; // obj2 引用 obj1
}

当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。

要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。上面的例子可以这么做:

obj1 = null;
obj2 = null;

垃圾回收的使用场景优化

清除引用

优化内存的一个最好的衡量方式就是只保留程序运行时需要的数据,对于已经使用的或者不需要的数据,应该将其值设为 null,这上面说过,叫“清除引用”。需要注意的是,解除一个值的引用不代表垃圾回收器会立即将这段内存回收,这样做的目的是让垃圾回收器在下一个回收周期到来时知道这段内存需要回收。

在内存泄漏部分,我们讨论了无意的全局变量会带来无法回收的内存垃圾。但有些时候,我们会有意识地声明一些全局变量,这个时候需要注意,如果声明的变量占用大量的内存,那么在使用完后将变量声明为null

  • WeakMap

    最好能有一种方法,在新建引用的时候就声明,哪些引用必须手动清除,哪些引用可以忽略不计,当其他引用消失以后,垃圾回收机制就可以释放内存。这样就能大大减轻程序员的负担,你只要清除主要引用就可以了。

    ES6 考虑到了这一点,推出了两种新的数据结构:WeakSetWeakMap。它们对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个"Weak",表示这是弱引用。

    下面以 WeakMap 为例,看看它是怎么解决内存泄漏的。

    const wm = new WeakMap();
    const element = document.getElementById('example');
    
    wm.set(element, 'some information');
    wm.get(element) // "some information"
    

    上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element的引用就是弱引用,不会被计入垃圾回收机制。

    也就是说,DOM 节点对象的引用计数是1,而不是2。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。

    基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。

避免创建对象

new Object() 是一个比较明显的创建对象的方式,另外 const arr = [];、const obj = {};也会创建新的对象。另外下面这种写法在每次调用函数时都会创建一个新的对象:

function func() {
    return function() {};
}
  • 数组array优化

    将[]赋值给一个数组对象,是清空数组的捷径(例如: arr = [];),但是需要注意的是,这种方式又创建了一个新的空对象,并且将原来的数组对象变成了一小片内存垃圾!实际上,将数组长度赋值为0(arr.length = 0)也能达到清空数组的目的,并且同时能实现数组重用,减少内存垃圾的产生。

    const arr = [1, 2, 3, 4];
    console.log('星梦花随');
    arr.length = 0  // 可以直接让数字清空,而且数组类型不变。
    // arr = []; 虽然让a变量成一个空数组,但是在堆上重新申请了一个空数组对象。
    
  • 对象尽量复用

    对象尽量复用,尤其是在循环等地方出现创建新对象,能复用就复用。不用的对象,尽可能设置为null,尽快被垃圾回收掉。

    var t = {} // 每次循环都会创建一个新对象。
    for (var i = 0; i < 10; i++) {
      // var t = {};// 每次循环都会创建一个新对象。
      t.age = 19
      t.name = '123'
      t.index = i
      console.log(t)
    }
    t = null //对象如果已经不用了,那就立即设置为null;等待垃圾回收。
    
  • 在循环中的函数表达式,能复用最好放到循环外面。

    // 在循环中最好也别使用函数表达式。
    for (var k = 0; k < 10; k++) {
      var t = function(a) {
        // 创建了10次  函数对象。
        console.log(a)
      }
      t(k)
    }
    
    // 推荐用法
    function t(a) {
      console.log(a)
    }
    for (var k = 0; k < 10; k++) {
      t(k)
    }
    t = null
    

内存泄漏

不再用到的内存,没有及时释放,就叫做内存泄漏。

虽然JavaScript会自动垃圾回收,但是如果我们的代码写法不当,会让变量一直处于“进入环境”的状态,无法被回收。下面列一下内存泄漏常见的几种情况:

  • 循环引用 (上面已经讲述)

  • 意外的全局变量

    • 情况一:
    function foo(arg) {
        bar = "this is a hidden global variable";
    }
    

    bar没被声明,会变成一个全局变量,在页面关闭之前不会被释放。

    • 情况二:(全局变量可能由 this 创建)
    function foo() {
        this.variable = "potential accidental global";
    }
    // foo 调用自己,this 指向了全局对象(window)
    foo();
    

    在 JavaScript 文件头部加上 'use strict',可以避免此类错误发生。启用严格模式解析 JavaScript ,避免意外的全局变量。

  • 被遗忘的计时器或回调函数

    var someResource = getData();
    setInterval(function() {
        var node = document.getElementById('Node');
        if(node) {
            // 处理 node 和 someResource
            node.innerHTML = JSON.stringify(someResource));
        }
    }, 1000);
    

    上面的例子中,我们每隔一秒就将得到的数据放入到文档节点中去。但在 setInterval 没有结束前,回调函数里的变量以及回调函数本身都无法被回收。那什么才叫结束呢?就是调用了 clearInterval。如果回调函数内没有做什么事情,并且也没有被 clear 掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也没法被回收。上面的例子中,someResource 就没法被回收。同样的,setTiemout 也会有同样的问题。所以,当不需要 interval 或者 timeout 时,最好调用 clearInterval 或者 clearTimeout。

  • 闭包

    function bindEvent(){
        var obj=document.createElement('xxx')
        obj.onclick=function(){
            // Even if it is a empty function
        }
    }
    

    闭包可以维持函数内局部变量,使其得不到释放。上例定义事件回调时,由于是函数内定义函数,并且内部函数--事件回调引用外部函数,形成了闭包。

    // 将事件处理函数定义在外面
    function bindEvent() {
      var obj = document.createElement('xxx')
      obj.onclick = onclickHandler
    }
    // 或者在定义事件处理函数的外部函数中,删除对dom的引用
    function bindEvent() {
      var obj = document.createElement('xxx')
      obj.onclick = function() {
        // Even if it is a empty function
      }
      obj = null
    }
    

    解决之道,将事件处理函数定义在外部,解除闭包,或者在定义事件处理函数的外部函数中,删除对dom的引用。

  • 没有清理的DOM元素引用

    当我们需要多次访问同一个 DOM 元素时,一个好的做法是将 DOM 元素用一个变量存储在内存中,因为访问 DOM 的效率一般比较低,应该避免频繁地反问 DOM 元素。所以我们会这样写:

    const button = document.getElementById('button');
    

    当删除这个按钮时:

    document.body.removeChild(document.getElementById('button'));
    

    虽然这样看起来删除了这个 DOM 元素,但这个 DOM 元素仍然被 button 这个变量引用,所以在内存上,这个 DOM 元素是没法被回收的。所以在使用结束后,还需要将 button 设成 null。

    另外一个值得注意的是,代码中保存了一个列表 ul 的某一项 li 的引用,将来决定删除整个列表时,我们自觉上会认为内存仅仅会保留那个特定的 li,而将其他列表项都删除。但事实并非如此,因为 li 是 ul 的子元素,子元素与父元素是引用关系,所以如果代码保存 li 的引用,那么整个 ul 将会继续呆在内存里。

避免内存泄漏的一些方式

  • 减少不必要的全局变量,或者生命周期较长的对象,及时对无用的数据进行垃圾回收
  • 注意程序逻辑,避免“死循环”之类的
  • 避免创建过多的对象

参考链接