前言

像C语言这样的底层语言一般都有底层的内存管理接口,但JavaScript是在创建变量时自动进行了内存分配,并在不使用它们时“自动”释放。释放的过程称为垃圾回收。

1. JavaScript的内存管理

在讲解JavaScript的内存管理前,我们必须清楚,不管是什么程序语言,内存生命周期基本都是一致的:

  1. 分配所需的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放/归还

现在我们来解释JavaScript在第一步(分配内存)中是如何工作的。首先我们知道,JavaScript自己执行了内存分配,同时声明了值,这使得开发人员不用费心分配内存。

  1. var n = 123; // 给数值变量分配内存
  2. var s = "btqf"; // 给字符串分配内存
  3. var o = {
  4. a: 1,
  5. b: null
  6. }; // 给对象及其包含的值分配内存
  7. // 给数组及其包含的值分配内存(就像对象一样)
  8. var a = [1, null, "abc"];
  9. function f(a){
  10. return a + 2;
  11. } // 给函数(可调用的对象)分配内存
  12. // 函数表达式也能分配一个对象
  13. someElement.addEventListener('click', function(){
  14. someElement.style.backgroundColor = 'blue';
  15. }, false);

在接下来的第二步(使用内存)实际是对分配内存进行读取与写入。当我们不再需要使用某部分内存时我们将释放内存。看起来我们作为一名开发人员不大需要关心内存问题,但当我们了解JavaScript的内存管理机制后,才能在以后的开发过程中避免一些问题,如下述代码:

  1. {} == {} // false, 两个操作数并不指向同一个对象
  2. [] == [] // false
  3. '' == '' // true

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

  • 基本数据类型的值放在中,在栈中存放的是对应的值。当被赋值时,生成相同的值但对应不同的地址。
  • 引用数据类型对应的值存放在中,在栈中存放的是指向堆内存的地址。当被赋值时,将保存对象的内存地址赋值给另一个变量,即两个变量指向堆内存中的同一个对象。

因此,对于栈的内存空间,由操作系统自动分配和自动释放;对于堆空间的内存,由于大小不固定,系统无法进行自动释放,这个时候需要JS引擎来手动释放内存。

2. 垃圾回收机制

如上述所言,内存可能由操作系统或者JS引擎进行内存释放。当我们的代码没有正确书写,JS引擎的垃圾回收机制可能无法正确对内存进行释放(内存泄漏),从而使得浏览器占用内存过高,进而导致JS应用、操作系统性能下降。同时,由于自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。本节将解释必要的概念,了解主要的垃圾回收算法和它们的局限性。

2.1 引用计数

该为最初级的算法。此算法的思路是对每个值都记录它被引用的次数,即定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(即引用数为0),对象将被垃圾回收机制回收。
但是当遇到循环引用时,由于两个对象互相引用形成一个循环,引用数至少为1,使得在函数结束后依然存在而不被回收。如果函数被多次调用的时候,则会导致大量内存不被释放。

  1. function problem() {
  2. let objectA = {};
  3. let objectB = {};
  4. objectA.a = objectB;
  5. objectB.a = objectA;
  6. }

正因为引用计数的这些缺点,JS采用了标记清理的垃圾回收策略。

2.2 标记清理

标记清理是JS最常用的算法。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而不在上下文中的变量,逻辑上讲,永远不该释放它们的内存,因为只要上下文的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。
这个算法把“对象是否不在需要”简化定义为“对象是否可以获得”。当我们采用标记清理时,循环引用就不再是问题了。

3.JavaScript常见的内存泄漏

3.1 被忘记的定时器或回调函数

本次以setInterval为例
提供观察者和其他接受回调的工具库通常确保所有对回调的引用在其实例无法访问时也会变得无法访问。然而,下面的代码并不鲜见:

  1. var serverData = loadData();
  2. setInterval(function() {
  3. var renderer = document.getElementById('renderer');
  4. if(renderer) {
  5. renderer.innerHTML = JSON.stringify(serverData);
  6. }
  7. }, 5000);
  1. 在上述代码中,renderer 对象可能会在某些时候被替换或删除,这会使得间隔处理程序封装的块变得冗余。如果发生这种情况,处理程序及其依赖项都不会被收集,因为间隔处理需要先备停止。这是因为事实存储和处理负载数据的 serverData 也不会被收集。

3.2 闭包

在JS中,闭包是一个关键的知识点:能够在函数定义的作用域外,使用函数作用域内的局部变量。但是也有可能以如下方式发生内存泄漏:

  1. var theThing = null;
  2. var replaceThing = function () {
  3. var originalThing = theThing;
  4. var unused = function () {
  5. if (originalThing) console.log("hi");
  6. };
  7. theThing = {
  8. longStr: new Array(1000000).join('*'),
  9. someMethod: function () {
  10. console.log("message");
  11. }
  12. };
  13. };
  14. setInterval(replaceThing, 1000);
  1. 一旦调用了 `replaceThing` 函数,`theThing `就得到一个新的对象,它由一个大数组和一个新的闭包(`someMethod`)组成。然而 `originalThing` 被一个由` unused` 变量(这是从前一次调用 `replaceThing` 变量的 `Thing `变量)所持有的闭包所引用。需要记住的是**一旦为同一个父作用域内的闭包创建作用域,作用域将被共享。**<br />同时,`someMethod `创建的作用域与 `unused` 共享。`unused` 包含一个关于` originalThing` 的引用。即使 `unused` 从未被引用过,`someMethod` 也可以通过 `replaceThing` 作用域之外的 `theThing `来使用它。由于 `someMethod` `unused `共享闭包范围,`unused` 指向 `originalThing` 的引用强制它保持活动状态(两个闭包之间的整个共享范围)。这阻止了它们的垃圾收集