闭包和内存泄露的关系

175 阅读10分钟

在这里我们好好了解一下闭包和内存泄露的关系,在探讨这个话题之前先要了解一下垃圾回收这个东西,而在这种之前呢,我们还得讨论一下到底什么是垃圾。

什么是垃圾?

了解垃圾回收之前我们要知道什么是垃圾,垃圾就是不再需要的内存。那什么是不再需要的内存呢?这个不再需要其实是人来决定的,我们需要它那它就不是垃圾,不需要它那它就是垃圾,看下面这个例子:

  <body>
    <button>点击</button>
  </body>
  <script>
    function createIncrease() {
      const doms = new Array(100000).fill(0).map((_, i) => {
        const dom = document.createElement("div");
        dom.innerHTML = i;
        return dom;
      });

      function increase() {
        doms.forEach((dom) => {
          dom.innerHTML = Number(dom.innerHTML) + 1;
        });
      }
      return increase;
    }

    const increase = createIncrease();
    const btn = document.querySelector("button");
    btn.addEventListener("click", increase);
  </script>

这段代码是声明一个函数createIncrease,里面声明了十万个dom元素,还有一个子函数increase,子函数increase对十万个dom元素进行一个循环,把每个dom元素的内容+1,然后再把子函数increase返回,接下来拿到这个子函数,根据闭包的经验拿到这个子函数就相当于拿到这个子函数关联的词法环境,连同那十万个dom元素都在里面,因为后面要调用这个函数increase的时候会用到这些dom元素,最后拿到一个按钮给绑定点击事件,每次点击这个按钮就会调用这个函数increase

那这十万个dom元素算不算垃圾呢?肯定不是啊,因为我要用啊,每次点击按钮调用函数increase的时候都会操作这十万个dom元素,所以这些dom元素是需要的就不是垃圾,所以垃圾跟内存大小无关,只看你需不需要。

上面这个例子还是比较明显的,因为绑定了点击事件,知道每次点击都需要用到那些dom元素,现在来看另一个例子:

    let nums = [1, 2, 3, 4, 5];
    const sum = nums.reduce((total, num) => total + num, 0);
    console.log(sum);

声明一个数组nums,然后对这个数组求和,最后打印这个和sum。那当我们运行完这个代码以后,这个数组nums是需要呢?还是不需要呢?

光从这个代码里根本看不出来,不知道后面需不需要,会有人说这个数组nums不需要,因为都已经运行完求过和了就不再需要了,那我们现在运行这个代码,然后打开控制台,然后找到一个求和的结果。

image.png

到现在这三行代码运行完了,那如果我想在控制台打印这个数组nums呢,就又要用到了这个数组nums,所以这个数组内存还是需要的,所以这个内存到底需不需要从那三行代码里根本看不出来。

image.png

所以需不需要这个问题得问自己,因为自己是写这个代码的,自己清楚后面需不需要用到这个数组nums,所以垃圾的定义很明确了,就是自己确定不需要的内存数据那就是垃圾。

垃圾回收

再说垃圾回收,我们知道JS语言里面是有一个垃圾回收器的,它可以帮助我们回收那些不再需要的内存,那么问题又来了,它咋知道我们需不需要这个内存呢?我们自己有时候都不确定需不需要,那么垃圾回收器是怎么确定的呢?

其实垃圾回收器压根就不知道你需不需要,但是它做了一个假设,虽然它不知道哪些东西是你想要,哪些东西是你不想要的,但是它能够确定的一点就是:你自己都访问不了的东西一定是你不想要的。

比方说第二个例子,我们在第二行重新给nums赋值:

    let nums = [1, 2, 3, 4, 5];
    nums = [6, 7, 8, 9, 10];
    const sum = nums.reduce((total, num) => total + num, 0);
    console.log(sum);

那你看第一行的那个数组空间我们还有任何机会去访问到它吗?我们永远不可能访问到它了,所以垃圾回收器就认为这些东西我们一定不要了,所以说垃圾回收器能够回收哪些东西,是那些我们不想要的内存空间里面的某一些我们都无法访问到的内存,我们叫做无法触达,所以垃圾回收器回收的是那些无法触达的内存空间。

什么叫做内存泄露?

上面讲到,垃圾回收器能回收掉我们不需要的内存里面无法触达的内存空间,那不是还剩了一些我们能够访问到,但是我们又不需要的内存吗,比方说第二个例子:

    let nums = [1, 2, 3, 4, 5];
    const sum = nums.reduce((total, num) => total + num, 0);
    console.log(sum);

我确定打印完这个sum后不会再用到这个数组nums了,不会再像前面那样在控制台打印这个数组nums了,但是由于这个内存空间还能够触达,我还有能力再控制到去打印这个数组nums,虽然我知道我不会再打印它了,但是垃圾回收器不知道,就不会去回收这个数组nums,那么这就是内存泄漏。就是那些我们不在需要却又能够访问得到的内存。

当这个内存泄露变大了以后,就会严重影响程序的运行,所以说如何在JS里面去解决这个内存泄露呢?

让那些内存重新变得无法触发,因为一旦无法触达,垃圾回收器就会回收掉。

再用第二个例子假设,我们想让垃圾回收器把这个数组nums回收掉,那我们就将数组nums设置为null,这样我们就无法再访问到原本的[1,2,3,4,5]这个内存空间了。

    let nums = [1, 2, 3, 4, 5];
    const sum = nums.reduce((total, num) => total + num, 0);
    console.log(sum);
    nums = null;

再比方说第一个例子,我们只是在第一次点击按钮的时候运行函数increase,后续不再运行,那么就需要改造一下,声明一个点击函数handleClick,在这里面运行函数increase,然后给按钮移除这个点击事件,再讲函数increase给设置为null,因为如果不设置为null,那么函数increase就还能够触达,就还能用这个函数,而一用这个函数就需要用到里面的十万个dom元素,所以需要将函数increase设置为null。

  <body>
    <button>点击</button>
  </body>
  <script>
    function createIncrease() {
      const doms = new Array(100000).fill(0).map((_, i) => {
        const dom = document.createElement("div");
        dom.innerHTML = i;
        return dom;
      });

      function increase() {
        doms.forEach((dom) => {
          dom.innerHTML = Number(dom.innerHTML) + 1;
        });
      }
      return increase;
    }

    const increase = createIncrease();
    const btn = document.querySelector("button");
    function handleClick() {
      increase();
      btn.removeEventListener("click", handleClick);
      increase = null;
    }
    btn.addEventListener("click", handleClick);
  </script>

闭包和内存泄漏的关系

目前来看,闭包和内存泄露没什么关系,只是闭包容易让我们放松警惕,比方第一个例子:

  <body>
    <button>点击</button>
  </body>
  <script>
    function createIncrease() {
      const doms = new Array(100000).fill(0).map((_, i) => {
        const dom = document.createElement("div");
        dom.innerHTML = i;
        return dom;
      });

      function increase() {
        doms.forEach((dom) => {
          dom.innerHTML = Number(dom.innerHTML) + 1;
        });
      }
      return increase;
    }

    const increase = createIncrease();
    const btn = document.querySelector("button");
    btn.addEventListener("click", increase);
  </script>

increase只是一个函数,将来我们可能用不到这个函数,但是一个函数能占用多少内存空间呢,所以就容易掉以轻心,在不再需要它的时候没有给它置空,函数内存是很小,但是这个函数背后的闭包环境关联的doms也保留下来了,就造成很大的内存泄露。

所以闭包和内存泄露的关系就是:闭包和其他内存泄露没有什么本质的区别,都是因为持有了不再需要的函数引用,会导致函数关联的词法环境无法被销毁,从而导致内存泄露。

再回到内存泄露上,垃圾回收器能够回收掉无法触达的内存,那么有没有一种情况就是内存空间无法触达但是垃圾回收器又回收不掉呢?其实是有的,比方说浏览器的bug,有一个游离的dom节点浏览器就回收不了,还有一种情况就是来源于闭包。

我们在第一个例子上再做一些改造,把子函数increase里面的代码都清空成为一个空函数,再声明一个函数_temp,然后里面用到doms,最后再把点击事件改一下,不运行函数increase了,因为是个空函数没必要运行,我们主要是看这个doms能不能被回收,所以我们让刚开始的时候不执行函数createIncrease,点击的时候再执行函数createIncrease,去生成doms,然后手动回收一下,看看能不能回收掉。

  <body>
    <button>点击</button>
  </body>
  <script>
    function createIncrease() {
      const doms = new Array(100000).fill(0).map((_, i) => {
        const dom = document.createElement("div");
        dom.innerHTML = i;
        return dom;
      });
      function increase() {}
      function _temp() {
        doms;
      }
      return increase;
    }

    const increase = createIncrease();
    const btn = document.querySelector("button");
    btn.addEventListener("click", increase);
  </script>

现在这个子函数increase是一个空函数,也没有关联到doms,虽然这个函数_temp关联到doms,但是跟它没关系啊,return出去的是子函数increase。所以此时doms是一个无法触达的内存空间。那么这个内存空间能够被回收吗?运行看一下:

点击之前先看一下目前内存空间:

image.png

然后点击以后再手动回收一下,再看一下内存空间:

image.png

会发现多了1.5mb,我们再点击比较看一下会发现读了十万个游离节点没有被回收:

1736785603288.png

这就说明垃圾回收器也没有这么智能,那是怎么造成的呢?其实就是因为函数_temp的存在。

这是因为函数increase和函数_temp的闭包环境是一样的,也就是词法环境是一样的,如果没有函数_temp,那么浏览器会做优化,词法环境里有一个doms,但是函数increase没有用到,就会把doms给优化掉,那么就是一个空的词法环境,这样子确实不会造成内存泄露。但是现在多了一个函数_temp,而这个函数要用到doms,那么这个doms就不敢被销毁。就是说尽管函数increase没有用到doms,但是受到另一个函数_temp的影响,doms不会被销毁。这就出现了一个非常隐蔽的闭包造成的内存泄露。

所以闭包和内存泄漏的关系还有就是:当多个函数共享词法环境时,会导致词法环境膨胀(函数increase没有动doms,本来是能够被优化的,但是函数_temp用到了,所以无法被优化,导致词法环境不得不膨胀来适应所有函数),从而出现无法触达但也无法回收的内存空间。

总的来说闭包和内存泄露的关系就是以下两点:

  • 闭包和其他内存泄露没有什么本质的区别,都是因为持有了不再需要的函数引用,会导致函数关联的词法环境无法被销毁,从而导致内存泄露。
  • 当多个函数共享词法环境时,会导致词法环境膨胀,从而出现无法触达但也无法回收的内存空间。

但是我们也不要纠结这个,日常开发的时候经常用到闭包,不要一直想这个,等出现问题再去优化就可以了。