JS内存回收机制

本文参考了以下文章:

由于字符串、对象和数组没有固定大小,当他们的大小已知时,才能对他们进行动态的存储分配。JavaScript 程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。 只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript 的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。

在 JavaScript 中,数据类型分为基本类型和引用类型。

  • 基本类型:在内存中占有固定大小的空间,值保存在栈内存(stack)中的,是按值来访问的。
  • 引用类型:值的大小不固定,在栈内存(stack)中存放地址指向堆内存(heap)中的对象。是按引用访问的。

程序的内存分配

  • 栈(stack):栈内存空间,只保存简单数据类型的内存,由操作系统自动分配和释放。
  • 堆(heap):堆内存空间,由于大小不固定,系统无法进行自动释放,需要 JS 引擎(如 Chrome V8)来手动释放这些内存。

JavaScript 中调用栈中的数据回收

JavaScript 引擎会通过向下移动 ESP(记录当前执行状态的指针) 来销毁该函数保存在栈中的执行上下文。

JavaScript 堆中的数据回收

在 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。

新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。

不论什么类型的垃圾回收器,它们都有一套共同的执行流程:

  1. 第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
  2. 第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  3. 第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片。(这步其实是可选的,因为有的垃圾回收器不会产生内存碎片).

内存碎片整理

内存回收次数多了以后,会有很多内存碎片,如果不加清理,会导致过载触发不必要的内存回收动作。清除非活动对象,前移活动对象时,对象的内存地址是汇编的,因为要通过移动把连续的空间腾出来。为了要保持活动对象的可访问,处理方式可能是记录变化前后的地址,然后在取对象内容时加上这个变化的偏移量。

全停顿

由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿。

在 V8 新生代的垃圾回收中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代就不一样了。如果执行垃圾回收的过程中,占用主线程时间过久,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在垃圾回收过程中无法执行,这将会造成页面的卡顿现象。

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法.

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

现在各大浏览器通常用采用的垃圾回收有两种方法:标记清除、引用计数。

标记清除

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

引用计数

引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量 a 并将一个引用类型值(比如 { a: 1 })赋值给该变量时,则这个值 { a: 1 } 的引用次数就是 1。 相反,如果包含对这个值的引用的变量(可以是直接引用,也可以间接引用,比如在 a 已经是上述所说的对象时, const b = a 或者 const b = { d: a } 中的 b 都是包含对这个值的引用的变量)又取得了另外一个值,则这个值 { a: 1 } 的被引用次数就减少了 1。

当这个值引用次数变成 0 时,则说明没有办法再访问这个值了,因而就可以将其所占的内存空间给收回来。

这样,垃圾收集器下次再运行时,它就会释放那些引用次数为 0 的值所占的内存。

该策略容易在循环引用时出现问题。因为计数记录的是被引用的次数,循环引用时计数并不会消除。导致无法释放内存。

如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的联系。当垃圾收集器下次运行时,就会删除这些值并回收他们占用的内存。

Memory Overflow 内存溢出

内存溢出一般是指执行程序时,程序会向系统申请一定大小的内存,当系统现在的实际内存少于需要的内存时,就会造成内存溢出。

内存溢出造成的结果是先前保存的数据会被覆盖或者后来的数据会没地方存。

Memory Leak 内存泄漏

不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。内存泄漏是指程序执行时,一些变量没有及时释放,一直占用着内存。而这种占用内存的行为就叫做内存泄漏。

作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积。

内存泄漏如果一直堆积,最终会导致内存溢出问题。

内存泄漏发生的原因

全局变量

除了常规设置了比较大的对象在全局变量中,还可能是意外导致的全局变量,如:

javascript
function foo(arg) {
    bar = "this is a hidden global variable";
}

在函数中,没有使用 var/let/const 定义变量,这样实际上是定义在 window 全局对象上面,变成了 window.bar。再比如由于 this 导致的全局变量:

javascript
function foo() {    
    this.bar = "this is a hidden global variable";
}
foo()

这种函数,在 window 作用域下被调用时,函数里面的 this 指向了 window,执行时实际上为 window.bar = "this is a hidden global variable",这样也产生了全局变量。

计时器中引用没有清除

先看如下代码:

javascript
var someData = getData();
setInterval(function() {    
    var node = document.getElementById('Node');
    if (node) {
        node.innerHTML = JSON.stringify(someData);
    }
}, 1000);

这里定义了一个计时器,每隔 1s 把一些数据写到 Node 节点里面。但是当这个 Node 节点被删除后,这里的逻辑其实都不需要了,可是这样写,却导致了计时器里面的回调函数无法被回收,同时,someData 里的数据也是无法被回收的。

闭包

javascript
var theThing = null;
var replaceThing = function () {  
    var originalThing = theThing;  
    var unused = function () {
       if (originalThing)  console.log("hi");
    };
    theThing = {
        longStr: new Array(1000000).join('*'), 
        someMethod: function () {
           console.log(originalThing);
        }
    };
};
setInterval(replaceThing, 1000);

当这段代码反复运行,就会看到内存占用不断上升,垃圾回收器(GC)并无法降低内存占用。

闭包与内存泄漏

内存泄露是指你「用不到」(访问不到)的变量,依然占据着内存空间,不能被再次利用起来。闭包里面的变量就是我们需要的变量,不能说是内存泄露。

闭包是一个非常强大的特性,但人们对其也有诸多误解。一种耸人听闻的说法是闭包会造成内存泄露,所以要尽量减少闭包的使用。

局部变量本来应该在函数退出的时候被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。从这个意义上看,闭包的确会使一些数据无法被及时销毁。使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些变量放在闭包中和放在全局作用域,对内存方面的影响是一致的,这里并不能说成是内存泄露。如果在将来需要回收这些变量,我们可以手动把这些变量设为 null

跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些 DOM 节点,这时候就有可能造成内存泄露。但这本身并非闭包的问题,也并非 JavaScript 的问题。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。

同样,如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。

内存泄漏的识别方法

利用 Chrome 浏览器性能面板记录可视化内存泄漏

Performance 面板的时间轴能够直观实时显示JS内存使用情况、节点数量、侦听器数量等。

打开 Chrome 浏览器,调出调试面板(DevTools),点击 Performance 选项(低版本是 Timeline),勾选 Memory 复选框。一种比较好的做法是使用强制垃圾回收开始和结束记录。在记录时点击 Collect garbage 按钮 (强制垃圾回收按钮) 可以强制进行垃圾回收。所以录制顺序可以这样:开始录制前先点击垃圾回收 --> 点击开始录制 --> 点击垃圾回收 --> 点击结束录制。面板介绍如图:

录制结果如图:

首先,从图中我们可以看出不同颜色的曲线代表的含义,这里主要关注 JS 堆内存、节点数量、侦听器数量。鼠标移到曲线上,可以在左下角显示具体数据。在实际使用过程中,如果您看到这种 JS 堆大小或节点大小不断增大的模式,则可能存在内存泄漏。

使用堆快照发现已分离 DOM 树的内存泄漏

只有页面的 DOM 树或 JavaScript 代码不再引用 DOM 节点时,DOM 节点才会被作为垃圾进行回收。如果某个节点已从 DOM 树移除,但某些 JavaScript 仍然引用它,我们称此节点为“已分离”,已分离的 DOM 节点是内存泄漏的常见原因。

同理,调出调试面板,点击 Memory,然后选择 Heap Snapshot,然后点击进行录制。录制完成后,选中录制结果,在 Class filter 文本框中键入 Detached,搜索已分离的 DOM 树。以这段代码为例:

html
<html>
    <head></head>
    <body>
        <button id="createBtn">增加节点</button>
        <script> 
            function create() {
                var ul = document.createElement('ul');
                for (var i = 0; i < 10; i++) {    
                    var li = document.createElement('li');
                    ul.appendChild(li);
                }
                detachedTree = ul;
            }
            document
                .getElementById('createBtn')
                .addEventListener('click', create);
        </script>
    </body>
</html>

点击几下,然后记录。可以得到以下信息:

如上图,点开节点,可以看到下面的引用信息,上面可以看出,有个 HTMLUListElement(ul 节点)被 window.detachedTree 引用。再结合代码,原来是没有加 var/let/const 声明,导致其成了全局变量,所以 DOM 无法释放。

避免内存泄漏的方法

  • 少用全局变量,避免意外产生全局变量。
  • 使用闭包要及时注意,及时清楚对 DOM 元素的引用。
  • 计时器里的回调没用的时候要记得销毁。
  • 为了避免疏忽导致的遗忘,我们可以使用 WeakSetWeakMap 结构,它们对于值的引用都是不计入垃圾回收机制的,表示这是弱引用。举个例子:
javascript
const wm = new WeakMap();
const element = document.getElementById('example');
 
wm.set(element, 'some information');

// "some information"
wm.get(element)

这种情况下,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。WeakMap 保存的这个键值对,也会自动消失。

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