整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

JavaScript 中内存泄漏的原因以及对策

JavaScript 中内存泄漏的原因以及对策

比过去的网页,今天流行的 SPA 需要开发人员更加关注程序中的内存泄漏情况。因为以前的网站在浏览时会不断刷新页面,可是 SPA 网站往往只有少数几个页面,很少完全重新加载。这篇文章主要探讨 JS 代码中容易导致内存泄漏的模式,并给出改进对策。

本文最初发布于 ditdot.hr 网站,经作者授权由 InfoQ 中文站编译并分享。

什么是内存泄漏?

内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

JavaScript 是一个有垃圾回收机制的语言,我们不需要手动回收内存。当本应在垃圾回收周期中清理的内存中的对象,通过另一个对象的无意引用从根保持可访问状态时,就会发生内存泄漏,并可能导致性能下降的后果。

内存泄漏通常很难发现和定位。泄漏的 JavaScript 代码从任何层面都不会被认为是无效的,并且浏览器在运行时不会引发任何错误。

检查内存使用情况的最快方法是查看浏览器的 任务管理器(不是操作系统的那个任务管理器)。在 Linux 和 Windows 上按 Shift+Esc 来访问 Chrome 的任务管理器;Firefox 则在地址栏中键入 about:performance。我们能用它查看每个选项卡的 JavaScript 内存占用量。如果发现异常的内存使用量持续增长,就很可能出现了泄漏。

开发工具 提供了更高级的内存管理方法。通过 Chrome 的性能工具,我们可以直观地分析页面在运行时的性能。像下面这种模式就是内存泄漏的典型表现:

除此之外,Chrome 和 Firefox 的开发工具都可以用“内存”工具进一步探索内存使用情况。

JS 代码中常见的几个内存泄漏源

全局变量

全局变量总是从根可用,并且永远不会回收垃圾。在非严格模式下,一些错误会导致变量从本地域泄漏到全局域:

  • 将值分配给未声明的变量;
  • 使用“this”指向全局对象。
function createGlobalVariables() {
    leaking1= 'I leak into the global scope'; // assigning value to the undeclared variable
    this.leaking2= 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'

预防措施:使用严格模式("use strict")。

闭包

函数作用域内的变量将在函数退出调用栈后清除,并且如果函数外部没有其他指向它们的引用,则将清理它们。但闭包将保留引用的变量并保持活动状态。

function outer() {
    const potentiallyHugeArray=[];
    return function inner() {
        potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
        console.log('Hello');
    };
};
const sayHello=outer(); // contains definition of the function inner
function repeat(fn, num) {
    for (let i= 0; i < num; i++){
        fn();
    }
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray

// now imagine repeat(sayHello, 100000)

在此示例中,从任何一个函数都不会返回 potentialHugeArray,并且无法到达它,但它的大小可以无限增加,具体取决于我们调用函数 inner() 的次数。

预防措施:闭包是肯定会用到的,所以重要的是:

  • 了解何时创建了闭包,以及它保留了哪些对象;
  • 了解闭包的预期寿命和用法(尤其是用作回调时)。

计时器

如果我们在代码中设置了递归计时器(recurring timer),则只要回调可调用,计时器回调中对该对象的引用就将保持活动状态。

在下面的示例中,由于我们没有对 setInterval 的引用,因此它永远不会被清除,并且 data.hugeString 会一直保留在内存中。

function setCallback() {
    const data={
        counter: 0,
        hugeString: new Array(100000).join('x')
    };
    return function cb() {
        data.counter++; // data object is now part of the callback's scope
        console.log(data.counter);
    }
}
setInterval(setCallback(), 1000); // how do we stop it?

预防措施:尤其是在回调的生命周期不确定或 undefined 的情况下:

  • 了解从计时器的回调中引用了哪些对象;
  • 使用计时器返回的句柄在必要时取消它。
function setCallback() {
    // 'unpacking' the data object
    let counter= 0;
    const hugeString= new Array(100000).join('x'); // gets removed when the setCallback returns
    return function cb() {
        counter++; // only counter is part of the callback's scope
        console.log(counter);
    }
}
const timerId=setInterval(setCallback(), 1000); // saving the interval ID
// doing something ...
clearInterval(timerId); // stopping the timer i.e. if button pressed

事件侦听器

添加后,事件侦听器将一直保持有效,直到:

  • 使用 removeEventListener() 显式删除它;
  • 关联的 DOM 元素被移除。

对于某些类型的事件,应该一直保留到用户离开页面为止。但是,有时我们希望事件侦听器执行特定的次数。

const hugeString= new Array(100000).join('x');
document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
    doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});

在上面的示例中,用一个匿名内联函数作为事件侦听器,这意味着无法使用 removeEventListener() 将其删除。同样,该文档也无法删除,因此即使我们只需要触发它一次,它和它域中的内容就都删不掉了。

预防措施:我们应该始终创建指向事件侦听器的引用并将其传递给 removeEventListener(),来注销不再需要的事件侦听器。

function listener() {
    doSomething(hugeString);
}
document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here

如果事件侦听器仅执行一次,则 addEventListener() 可以使用第三个参数。假设{once: true}作为第三个参数传递给 addEventListener(),则在处理一次事件后,将自动删除侦听器函数。

document.addEventListener('keyup', function listener() {
    doSomething(hugeString);
}, {once: true}); // listener will be removed after running once

缓存

如果我们不删除未使用的对象且不控制对象大小,那么缓存就会失控。

let user_1={ name: "Peter", id: 12345 };
let user_2={ name: "Mark", id: 54321 };
const mapCache= new Map();
function cache(obj){
    if (!mapCache.has(obj)){
        const value=`${obj.name} has an id of ${obj.id}`;
        mapCache.set(obj, value);
        return [value, 'computed'];
    }
    return [mapCache.get(obj), 'cached'];
}
cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_1); // ['Peter has an id of 12345', 'cached']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321")
user_1= null; // removing the inactive user
// Garbage Collector
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // first entry is still in cache

在上面的示例中,缓存仍保留在 user_1 对象上。因此,我们还需要清除不会再重用的条目的缓存。

可能的解决方案:我们可以使用 WeakMap。它的数据结构中,键名是对象的弱引用,它仅接受对象作为键名,所以其对应的对象可能会被自动回收。当对象被回收后,WeakMap 自动移除对应的键值对。在以下示例中,在使 user_1 对象为空后,下一次垃圾回收后关联的条目会自动从 WeakMap 中删除。

let user_1={ name: "Peter", id: 12345 };
let user_2={ name: "Mark", id: 54321 };
const weakMapCache= new WeakMap();
function cache(obj){
    // ...same as above, but with weakMapCache
    return [weakMapCache.get(obj), 'cached'];
}
cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(weakMapCache); // ((…)=> "Peter has an id of 12345", (…)=> "Mark has an id of 54321"}
user_1= null; // removing the inactive user
// Garbage Collector
console.log(weakMapCache); // ((…)=> "Mark has an id of 54321") - first entry gets garbage collected

分离的 DOM 元素

如果 DOM 节点具有来自 JavaScript 的直接引用,则即使从 DOM 树中删除了该节点,也不会对其垃圾回收。

在以下示例中,我们创建了一个 div 元素并将其附加到 document.body。removeChild() 无法正常工作,并且由于仍然存在指向 div 的变量,所以堆快照将显示分离的 HTMLDivElement。

function createElement() {
    const div= document.createElement('div');
    div.id= 'detached';
    return div;
}
// this will keep referencing the DOM element even after deleteElement() is called
const detachedDiv=createElement();
document.body.appendChild(detachedDiv);
function deleteElement() {
    document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // Heap snapshot will show detached div#detached

怎么预防呢?一种方案是将 DOM 引用移入本地域。在下面的示例中,在函数 appendElement() 完成之后,将删除指向 DOM 元素的变量。

function createElement() {...} // same as above
// DOM references are inside the function scope
function appendElement() {
    const detachedDiv=createElement();
    document.body.appendChild(detachedDiv);
}
appendElement();
function deleteElement() {
     document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // no detached div#detached elements in the Heap Snapshot

关注我并转发此篇文章,私信我“领取资料”,即可免费获得InfoQ价值4999元迷你书!

者:京东零售 谢天

在任何语言开发的过程中,对于内存的管理都非常重要,JavaScript 也不例外。

然而在前端浏览器中,用户一般不会在一个页面停留很久,即使有一点内存泄漏,重新加载页面内存也会跟着释放。而且浏览器也有自己的自动回收内存的机制,所以前端并没有特别关注内存泄漏的问题。

但是如果我们对内存泄漏没有什么概念,有时候还是有可能因为内存泄漏,导致页面卡顿。了解内存泄漏,如何避免内存泄漏,都是不可缺少的。

什么是内存

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

这是内存的底层概念,JavaScript 作为一个高级语言,不需要通过二进制进行内存的读写,而是相关的 JavaScript 引擎做了这部分的工作。

内存的生命周期

内存也会有生命周期,不管什么程序语言,一般可以按照顺序分为三个周期:

  • 分配期:分配所需要的内存
  • 使用期:使用分配的内存进行读写
  • 释放期:不需要时将其释放和归还

内存分配 -> 内存使用 -> 内存释放

什么是内存泄漏

在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。

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

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

JavaScript 内存管理机制

像 C 语言这样的底层语言一般都有底层的内存管理接口,但是 JavaScript 是在创建变量时自动进行了内存分配,并且在不使用时自动释放,释放的过程称为“垃圾回收”。然而就是因为自动回收的机制,让我们错误的感觉开发者不必关心内存的管理。

JavaScript 内存管理机制和内存的生命周期是一致的,首先需要分配内存,然后使用内存,最后释放内存。绝大多数情况下不需要手动释放内存,只需要关注对内存的使用(变量、函数、对象等)。

内存分配

JavaScript 定义变量就会自动分配内存,我们只需要了解 JavaScript 的内存是自动分配的就可以了。

let num=1;
const str="名字";
const obj={
  a: 1,
  b: 2
}
const arr=[1, 2, 3];
function func (arg) { ... }

内存使用

使用值的过程实际上是对分配的内存进行读写的操作,读取和写入的操作可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

// 继续上部分
// 写入内存
num=2;
// 读取内存,写入内存
func(num);

内存回收

垃圾回收被称为 GC(Garbage Collection)

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

以前一些老版本的浏览器的 JavaScript 回收机制没有那么完善,经常出现一些 bug 的内存泄漏,不过现在的浏览器一般都没有这个问题了。

这里了解下现在 JavaScript 的垃圾内存的两种回收方式,熟悉一下这两种算法可以帮助我们理解一些内存泄漏的场景。

引用计数

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

// “对象”分配给 obj1
var obj1={
  a: 1,
  b: 2
}
// obj2 引用“对象”
var obj2=obj1;
// “对象”的原始引用 obj1 被 obj2 替换
obj1=1;

当前执行环境中,“对象”内存还没有被回收,需要手动释放“对象”的内存(在没有离开当前执行环境的前提下)

obj2=null;
// 或者 obj2=1;
// 只要替换“对象”就可以了

这样引用的“对象”内存就被回收了。

ES6 中把引用分为强引用弱引用,这个目前只有在 Set 和 Map 中才存在。

强引用才会有引用计数叠加,只有引用计数为 0 的对象的内存才会被回收,所以一般需要手动回收内存(手动回收的前提在于标记清除法还没执行,还处于当前的执行环境)。

而弱引用没有触发引用计数叠加,只要引用计数为 0,弱引用就会自动消失,无需手动回收内存。

标记清除

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

环境可以理解为我们的执行上下文,全局作用域的变量只会在页面关闭时才会被销毁。

// 假设这里是全局上下文
var b=1; // b 标记进入环境
function func() {
  var a=1;
  return a + b; // 函数执行时,a 被标记进入环境
}
func();
// 函数执行结束,a 被标记离开环境,被回收
// 但是 b 没有标记离开环境

JavaScript 内存泄漏的一些场景

JavaScript 的内存回收机制虽然能回收绝大部分的垃圾内存,但是还是存在回收不了的情况。程序员要让浏览器内存泄漏,浏览器也是管不了的。

下面有些例子是在执行环境中,没离开当前执行环境,还没触发标记清除法。所以你需要读懂上面 JavaScript 的内存回收机制,才能更好的理解下面的场景。

意外的全局变量

// 在全局作用域下定义
function count(num) {
  a=1; // a 相当于 window.a=1;
  return a + num;
}

不过在 eslint 帮助下,这种场景现在基本没人会犯了,eslint 会直接报错,了解下就好。

遗忘的计时器

无用的计时器忘记清理,是最容易犯的错误之一。

拿一个 vue 组件举个例子。

<script>
export default {
  mounted() {
    setInterval(()=> {
      this.fetchData();
    }, 2000);
  },
  methods: {
    fetchData() { ... }
  }
}
</script>

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

<script>
export default {
  mounted() {
    this.timer=setInterval(()=> { ... }, 2000);
  },
  beforeDestroy() {
    clearInterval(this.timer);
  }
}
</script>

遗忘的事件监听

无用的事件监听器忘记清理也是最容易犯的错误之一。

还是使用 vue 组件举个例子。

<script>
export default {
  mounted() {
    window.addEventListener('resize', ()=> { ... });
  }
}
</script>

上面的组件销毁的时候,resize 事件还是在监听中,里面涉及到的内存都是没法回收的,需要在组件销毁的时候移除相关的事件。

<script>
export default {
  mounted() {
    this.resizeEvent=()=> { ... };
    window.addEventListener('resize', this.resizeEvent);
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.resizeEvent);
  }
}
</script>

遗忘的 Set 结构

Set 是 ES6 中新增的数据结构,如果对 Set 不熟,可以看这里。

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

let testSet=new Set();
let value={ a: 1 };
testSet.add(value);
value=null;

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

let testSet=new Set();
let value={ a: 1 };
testSet.add(value);

testSet.delete(value);
value=null;

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

let testSet=new WeakSet();
let value={ a: 1 };
testSet.add(value);
value=null;

遗忘的 Map 结构

Map 是 ES6 中新增的数据结构,如果对 Map 不熟,可以看这里。

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

let map=new Map();
let key=[1, 2, 3];
map.set(key, 1);
key=null;

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

let map=new Map();
let key=[1, 2, 3];
map.set(key, 1);

map.delete(key);
key=null;

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

let map=new WeakMap();
let key=[1, 2, 3];
map.set(key, 1);
key=null

遗忘的订阅发布

和上面事件监听器的道理是一样的。

建设订阅发布事件有三个方法,emitonoff 三个方法。

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

<template>
  <div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';

export default {
  mounted() {
    EventEmitter.on('test', ()=> { ... });
  },
  methods: {
    onClick() {
      EventEmitter.emit('test');
    }
  }
}
</script>

上面组件销毁的时候,自定义 test 事件还是在监听中,里面涉及到的内存都是没办法回收的,需要在组件销毁的时候移除相关的事件。

<template>
  <div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';

export default {
  mounted() {
    EventEmitter.on('test', ()=> { ... });
  },
  methods: {
    onClick() {
      EventEmitter.emit('test');
    }
  },
  beforeDestroy() {
    EventEmitter.off('test');
  }
}
</script>

遗忘的闭包

闭包是经常使用的,闭包能提供很多的便利,

首先看下下面的代码:

function closure() {
  const name='名字';
  return ()=> {
    return name.split('').reverse().join('');
  }
}
const reverseName=closure();
reverseName(); // 这里调用了 reverseName

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

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

当然这种也是极端情况,很少人会犯这种低级错误。这个例子可以让我们更清楚的认识内存泄漏。

DOM 的引用

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

class Test {
  constructor() {
    this.elements={
      button: document.querySelector('#button'),
      div: document.querySelector('#div')
    }
  }
  removeButton() {
    document.body.removeChild(this.elements.button);
    // this.elements.button=null
  }
}

const test=new Test();
test.removeButton();

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

如何发现内存泄漏

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

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

<html>
  <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 秒后点击了停止按钮,内存平稳,不再递增。我们可以使用内存走势图判断是否存在内存泄漏。

查找内存泄漏的位置

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

访问上面的代码页面,打开开发者工具,切换至 Memory 选项。页面上点击运行按钮,然后点击开发者工具左上角的录制按钮,录制完成后继续点击录制,直到录制完成三个为止。然后点击页面上的停止按钮,在连续录制三次内存(不要清理之前的录制)。

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

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

从内存记录中,发现 array 对象占用最大,展开后发现,第一个 object elements 占用最大,选择这个 object elements 后可以在下面看到 newArr 变量,然后点击后面的高亮链接,就可以跳转到 newArr 附近。

是 JavaScript 工作原理的第三章。

我们将会讨论日常使用中另一个被开发者越来越忽略的重要话题,这都是日益成熟和复杂的编程语言的锅,即内存管理问题。我们将会提供在创建 SessionStack 的时候所遵循的处理 JavaScript 内存泄漏的几条小技巧,因为我们需要保证 SessionStack 不会引起内存泄漏或者不会增加我们所集成的 web 应用程序的内存消耗。

概述

像 C 语言拥有底层的内存管理原语如 malloc() 和 free()。开发者使用这些原语来显式从操作系统分配和释放内存。

与此同时,当创建事物(对象,字符串等)的时候,JavaScript 分配内存并且当它们不再使用的时候 "自动释放" 内存,这一过程称为内存垃圾回收。这个乍看起来本质上是 "自动化释放内存" 的释放资源是引起混乱的原因,并且给予 JavaScript(及其它高级语言)开发者一个错误的印象即他们可以选择忽略内存管理。这是一个巨大的错误。

即使是当使用高级语言的时候,开发者也应该要理解内存管理(或者至少是一些基础)。有时候自动化内存管理会存在一些问题(比如垃圾回收中的 bugs 或者实施的局限性等等),为了能够合理地处理内存泄漏问题(或者以最小代价和代码缺陷来寻找一个合适的方案),开发者就必须理解内存管理。

内存生命周期

不管你使用哪种编程语言,内存生命周期几乎是一样的:

以下是每一步生命周期所发生事情的一个概述:

  • 分配内存-内存是由操作系统分配,这样程序就可以使用它。在底层语言(例如 C 语言),开发者可以显式地操作内存。而在高级语言中,操作系统帮你处理。
  • 使用内存-这是程序实际使用之前分配的内存的阶段。当你在代码中使用已分配的变量的时候,就会发生内存读写的操作。
  • 释放内存-该阶段你可以释放你不再使用的整块内存,该内存就可以被释放且可以被再利用。和内存分配操作一样,该操作也是用底层语言显式编写的。

为快速浏览调用堆栈和动态内存管理的概念,你可以阅读第一篇文章。

啥是内存?

在直接跳向 JavaScript 内存管理之前,先来简要地介绍一下内存及其工作原理。

从硬件层面看,计算机内存是由大量的 flip flops 所组成的(这里大概查了下,即大量的二进制电路所组成的)。每个 flip flop 包含少量晶体管并能够存储一个比特位。单个的 flip flops 可以通过一个唯一标识符寻址,所以就可以读和覆写它们。因此,理论上,我们可以把整个计算机内存看成是由一个巨大的比特位数组所组成的,这样就可以进行读和写。

作为猿类,我们并不擅长用位来进行所有的逻辑思考和计算,所以我们把位组织成一个更大的组,这样就可以用来表示数字。8 位称为一字节。除了字节还有字(16 或 32 位)。

内存中存储着很多东西:

  • 所有变量及所有程序使用的其它数据。
  • 程序代码,包括操作系统的代码。

编译器和操作系统一起协作来为你进行内存管理,但是建议你了解一下底层是如何实现的。

当编译代码的时候,编译器会检查原始数据类型并提前计算出程序运行所需要的内存大小。在所谓的静态堆栈空间中,所需的内存大小会被分配给程序。这些变量所分配到的内存所在的空间之所以被称为静态内存空间是因为当调用函数的时候,函数所需的内存会被添加到现存内存的顶部。当函数中断,它们被以 LIFO(后进先出) 的顺序移出内存。比如,考虑如下代码:

int n; // 4 字节
int x[4]; // 4 个元素的数组,每个数组元素 4 个字节
double m; // 8 字节

编译器会立即计算出代码所需的内存:4 + 4 x 4 + 8=28 字节。

编译器是这样处理当前整数和浮点数的大小的。大约 20 年前,整数一般是 2 字节而 浮点数是 4 字节。代码不用依赖于当前基础数据类型的字节大小。

编译器会插入标记,标记会和操作系统协商从堆栈中获取所需要的内存大小,以便在堆栈中存储变量。

在以上示例中,编译知道每个变量的准确内存地址。事实上,当你编写变量 n 的时候,会在内部把它转换为类似 "内存地址 412763" 的样子。

注意到这里当我们试图访问 x[4] 时候,将会访问到 m 相关的数据。这是因为我们访问了数组中不存在的数组元素-它超过了最后一个实际分配到内存的数组元素 x[3] 4 字节,并且有可能会读取(或者覆写) m 的位。这几乎可以确定会产生其它程序所预料不到的后果。

当函数调用其它函数的时候,各个函数都会在被调用的时候取得其在堆栈中的各自分片内存地址。函数会把保存它所有的本地变量,但也会有一个程序计数器用来记住函数在其执行环境中的地址。当函数运行结束时,其内存块可以再次被用作其它用途。

动态内存分配

不幸的是,想要知道编译时一个变量需要多少内存并没有想象中那般容易。设想一下若要做类似如下事情:

int n=readInput(); // 从用户读取信息
...
// 创建一个含有 n 个元素的数组

这里,编译器并不知道编译时数组需要多少内存,因为这是由用户输入的数组元素的值所决定的。

因此,就不能够在堆栈中为变量分配内存空间。相反,程序需要在运行时显式地从操作系统分配到正确的内存空间。这里的内存是由动态内存空间所分配的。静态和动态内存分配的差异总结如下图表:

*静态和动态分配内存的区别*

为了完全理解动态内存分配的工作原理,我们需要花点时间了解指针,这个就可能有点跑题了 ^.^。如果你对指针感兴趣,请留言,然后我们将会在以后的章节中讨论更多关于指针的内容。

JavaScript 中的内存分配

现在,我们将会介绍在 JavaScript 中是如何分配内存的((第一步)。

JavaScript 通过声明变量值,自己处理内存分配工作而不需要开发者干涉。

var n=374; // 为数字分配内存
var s='sessionstack'; // 为字符串分配内存
var o={
 a: 1,
 b: null
}; // 为对象及其值分配内存
var a=[1, null, 'str']; // (类似对象)为数组及其数组元素值分配内存
function f(a) {
 return a + 3;
} // 分配一个函数(一个可调用对象)
// 函数表达式也分配一个对象
someElement.addEventListener('click', function() {
 someElement.style.backgroundColor='blue';
}, false);

一些函数调用也会分配一个对象:

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

可以分配值或对象的方法:

var s1='sessionstack';
var s2=s1.substr(0, 3); // s2 为一个新字符串
// 因为字符串是不可变的,所以 JavaScript 可能会选择不分配内存而只是存储数组 [0, 3] 的内存地址范围。
var a1=['str1', 'str2'];
var a2=['str3', 'str4'];
var a3=a1.concat(a2);
// 包含 4 个元素的新数组由 a1 和 a2 数组元素所组成

JavaScript 中的内存使用

JavaScript 中使用分配的内存主要指的是内存读写。

可以通过为变量或者对象属性赋值,亦或是为函数传参来使用内存。

释放不再使用的内存

大多数的内存管理问题是出现在这一阶段。

痛点在于检测出何时分配的内存是闲置的。它经常会要求开发者来决定程序中的这段内存是否已经不再使用,然后释放它。

高级程序语言集成了一块称为垃圾回收器的软件,该软件的工作就是追踪内存分配和使用情况以便找出并自动释放闲置的分配内存片段。

不幸的是,这是个近似的过程,因为判定一些内存片段是否闲置的普遍问题在于其不可判定性(不能为算法所解决)。

大多数的垃圾回收器会收集那些不再被访问的内存,比如引用该内存的所有变量超出了内存寻址范围。然而还是会有低于近似值的内存空间被收集,因为在任何情况下仍然可能会有变量在内存寻址范围内引用该内存地址,即使该内存是闲置的。

内存垃圾回收

由于找出 "不再使用" 的内存的不可判定性,针对这一普遍问题,垃圾回收实现了一个有限的解决方案。本小节将会阐述必要的观点来理解主要的内存垃圾回收算法及其局限性。

内存引用

引用是内存垃圾回收算法所依赖的主要概念之一。

在内存管理上下文中,如果对象 A 访问了另一个对象 B 表示 A 引用了对象 B(可以隐式或显式)。举个栗子,一个 JavaScript 对象有引用了它的原型(隐式引用)和它的属性值(显式引用)。

在这个上下文中,"对象" 的概念被拓展超过了一般的 JavaScript 对象并且包含函数作用域(或者全局词法作用域)。

词法作用域定义了如何在嵌套函数中解析变量名。即使父函数已经返回,内部的函数仍然会包含父函数的作用域。

垃圾回收引用计数

这是最简单的内存垃圾回收算法。当一个对象被 0 引用,会被标记为 "可回收内存垃圾"。

看下如下代码:

var o1={
 o2: {
 x: 1
 }
};
// 创建两个对象。
// 'o1' 引用对象 'o2' 作为其属性。全部都是不可回收的。
// 'o3' 是第二个引用 'o1' 对象的变量
var o3=o1;
o1=1; // 现在,原先在 'o1' 中的对象只有一个单一的引用,以变量 'o3' 来表示
// 引用对象的 'o2' 属性。
// 该对象有两个引用:一个是作为属性,另一个是 'o4' 变量
var o4=o3.o2;
// 'o1' 对象现在只有 0 引用,它可以被作为内存垃圾回收。
// 然而,其 'o2' 属性仍然被变量 'o4' 所引用,所以它的内存不能够被释放。
o3='374';
o4=null;
// 'o1' 中的 'o2' 属性现在只有 0 引用了。所以 'o1' 对象可以被回收。

循环引用是个麻烦事

循环引用会造成限制。在以下的示例中,创建了两个互相引用的对象,这样就会造成循环引用。函数调用之后他们将会超出范围,所以,实际上它们是无用且可以释放对他们的引用。然而,引用计数算法会认为由于两个对象都至少互相引用一次,所以他们都不可回收的。

function f() {
 var o1={};
 var o2={};
 o1.P=O2; // O1 引用 o2
 o2.p=o1; // o2 引用 o1. 这就造成循环引用
}
f();

标记-清除算法

为了判断是否需要释放对对象的引用,算法会确定该对象是否可获得。

标记-清除算法包含三个步骤:

  • 根:一般来说,根指的是代码中引用的全局变量。就拿 JavaScript 来说,window 对象即是根的全局变量。Node.js 中相对应的变量为 "global"。垃圾回收器会构建出一份所有根变量的完整列表。
  • 随后,算法会检测所有的根变量及他们的后代变量并标记它们为激活状态(表示它们不可回收)。任何根变量所到达不了的变量(或者对象等等)都会被标记为内存垃圾。
  • 最后,垃圾回收器会释放所有非激活状态的内存片段然后返还给操作系统。

标记-清除算法的动态图示

该算法比之前的算法要好,因为对象零引用可以让对象不可获得。反之则不然,正如之前所看到的循环引用。

从 2012 年起,所有的现代浏览器都内置了一个标记-清除垃圾回收器。前些年所有对于 JavaScript 内存垃圾收集(分代/增量/并发/并行 垃圾收集)的优化都是针对标记-清除算法的实现的优化,但既没有提升垃圾收集算法本身,也没有提升判定对象是否可获得的能力。

你可以查看这篇文章 来了解追踪内存垃圾回收的详情及包含优化了的标记-清除算法。

循环引用不再让人蛋疼

在之前的第一个示例中,当函数返回,全局对象不再引用这两个对象。结果,内存垃圾回收器发现它们是不可获得的。

即使两个对象互相引用,也不能够从根变量获得他们。

内存垃圾回收器的反直观行为

虽然内存垃圾回收器很方便,但是它们也有其一系列的代价。其中之一便是不确定性。意思即内存垃圾回收具有不可预见性。你不能确定内存垃圾收集的确切时机。这意味着在某些情况下,程序会使用比实际需要更多的内存。在其它情况下,在特定的交互敏感的程序中,你也许需要注意那些内存垃圾收集短暂停时间。虽然不确定性意味着不能够确定什么时候可以进行内存垃圾收集,但是大多数 GC 的实现都是在内存分配期间进行内存垃圾回收的一般模式。如果没有进行内存分配,大多数的内存垃圾回收就会保持闲置状态。考虑以下情况:

  • 分配一段固定大小的内存。
  • 大多数的元素(或所有)被标记为不可获得(假设我们赋值我们不再需要的缓存为 null )
  • 不再分配其它内存。

在该情况下,大多数的内存垃圾回收器不会再运行任何的内存垃圾回收。换句话说,即使可以对该不可获得的引用进行垃圾回收,但是内存收集器不会进行标记。虽然这不是严格意义上的内存泄漏,但是这会导致高于平常的内存使用率。

内存泄漏是啥?

正如内存管理所说的那样,内存泄漏即一些程序在过去时使用但处于闲置状态,却没有返回给操作系统或者可用的内存池。

编程语言喜欢多种内存管理方法。然而,某个内存片段是否被使用是一个不确定的问题。换句话说,只有开发人员清楚某个内存片段是否可以返回给操作系统。

某些编程语言会为开发者提供功能函数来解决这个问题。其它的编程语言完全依赖于开发者全权掌控哪个内存片段是可回收的。维其百科上有关于手动和自动内存管理的好文章。

四种常见的 JavaScript 内存泄漏

1: 全局变量

JavaScript 以一种有趣的方式来处理未声明变量:当引用一个未声明的变量,会在全局对象上创建一个新的变量。在浏览器中,全局对象是 window,这意味着如下代码:

function foo(arg) {
 bar="some text";
}

等同于:

function foo(arg) {
 window.bar="some text";
}

变量 bar 本意是只能在 foo 函数中被引用。但是如果你没有用 var 来声明变量,那么将会创建一个多余的全局变量。在上面的例子中,并不会造成大的事故。但你可以很自然地想象一个更具破坏性的场景。

你也可以使用 this 关键字不经意地创建一个全局变量。

function foo() {
 this.var1="potential accidental global";
}
// 调用 foo 函数自身,this 会指向全局对象(window)而不是未定义

你可以通过在 JavaScript 文件的顶部添加 'use strict' 来避免以上的所有问题,'use strict' 会切换到更加严格的 JavaScript 解析模式,这样就可以防止创建意外的全局变量。

意外的全局变量的确是个问题,而代码经常会被显式定义的全局变量所污染,根据定义这些全局变量是不会被内存垃圾回收器所收集的。你需要特别注意的是使用全局变量来临时存储和处理大型的位信息。只有在必要的时候使用全局变量来存储数据,记得一旦你不再使用的时候,把它赋值为 null 或者对其再分配。

2:定时器及被遗忘的回调函数

因为经常在 JavaScript 中使用 setInterval,所以让我们以它为例。

框架中提供了观察者和接受回调的其它指令通常会确保当他们的实例不可获得的时候,所有对回调的引用都会变成不可获得。很容易找到如下代码:

var serverData=loadData();
setInterval(function() {
 var renderer=document.getElementById('renderer');
 if (renderer) {
 renderer.innerHTML=JSON.stringify(serverData);
 }
}, 5000); // 这将会每隔大约 5 秒钟执行一次

以上代码片段展示了使用定时器来引用不再需要的节点或数据的后果。

renderer 对象会在某些时候被替换或移除,这样就会导致由定时处理程序封装的代码变得冗余。当这种情况发生的时候,不管是定时处理程序还是它的依赖都不会被垃圾回收,这是由于需要先停止定时器(记住,定时器仍然处于激活状态)。这可以归结为保存和处理数据加载的 serverData 变量也不会被垃圾回收。

当使用观察者的时候,你需要确保一旦你不再需要它们的时候显式地移除它们(不再需要观察者或者对象变得不可获得)。

幸运的是,大多数现代浏览器都会替你进行处理:当被观察者对象变得不可获得时,即使你忘记移除事件监听函数,浏览器也会自动回收观察者处理程序。以前,一些老掉牙的浏览器处理不了这些情况(如老旧的 IE6)。

那么,最佳实践是当对象被废弃的时候,移除观察者处理程序。查看如下例子:

var element=document.getElementById('launch-button');
var counter=0;
function onClick(event) {
 counter++;
 element.innerHTML='text' + counter;
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 现在当元素超出范围
// 即使在不能很好处理循环引用的浏览器中也会回收元素和 onClick 事件

在让一个 DOM 节点不可获得之前,你不再需要调用 removeEventListener,因为现代浏览器支持用内存垃圾回收器来检测并适当地处理 DOM 节点的生命周期。

如果你使用 jQuery API(其它的库和框架也支持的 API),你可以在废弃节点之前移除事件监听函数。jQuery 也会确保即使在老旧的浏览器之中,也不会产生内存泄漏。