垃圾回收

179 阅读10分钟

概念

  • JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存,实现内存分配和闲置资源回收
  • 基本思路:确定哪个变量不会再使用,然后释放它占用的内存
  • 这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)自动运行
  • 垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的
  • 我们以函数中局部变量的正常生命周期为例,函数中的局部变量会在函数执行时存在。栈(或堆)内存会分配空间以保存相应的值,函数在内部使用了变量,然后退出。此时,就不再需要那个局部变量了,它占用的内存可以释放,供后面使用
  • 垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存
  • 如何标记未使用的变量也许有不同的实现方式,在浏览器的发展史上,用到过两种主要的标记策略:标记清理引用计数

标记清理

  • JavaScript 最常用的垃圾回收策略是 标记清理(mark-and-sweep)
  • 当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记,逻辑 上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运 行,就有可能用到它们,当变量离开上下文时,也会被加上离开上下 文的标记
  • 给变量加标记的方式有很多种,比如:
    • 当变量进入上下文时,反 转某一位
    • 可以维护 “在上下文中” 和 “不在上下文中 ”两个变量列 表,可以把变量从一个列表转移到另一个列表
    • 标记过程的实现并不 重要,关键是策略
  • 垃圾回收程序运行的时候,会标记内存中存储的所有变量,然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉,在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存
  • 到了2008年,IE、Firefox、Opera、Chrome 和 Safari 都在自己的 JavaScript 实现中采用标记清理(或其变体),只是在运行垃圾回收的频率上有所差异

应用计数

  • 另一种没那么常用的垃圾回收策略是 引用计数(reference counting)
  • 应用计数主要关注对象,每个引用值都有相对应的一个计数器。声明变量并给它赋一个引用值时,这个值的引用数为 1,同一个又被赋给另一个变量,那么引用数加 1。相对应的,如果它被其他值覆盖了,那么引用数减 1
  • 当一个值的引用数为 0 时,垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存
  • 问题:无法解决 循环引用,就是对象A有一个指针指向对象B,而对象B也引用了对象A
function fn() {
  var ele = document.getElementById('div'); // 1
  ele.onclick = function () {
    console.log(id); // +1
  };
}
  • 猜想,在代码中退出环境时,所有该环境中计数器 -1,循环引用的计数器永远无法为 0,如果函数多次调用,则会导致大量内存永远不会被释放
  • 在IE8及更早版本的IE中,并非所有对象都是原生 JavaScript 对象。BOM 和 DOM 中的对象是C++ 实现的组件对象模型(COM,Component Object Model)对象,而 COM 对象使用引用计数实现垃圾回收
  • 因此,即使这些版本IE的 JavaScript 引擎使用标记清理,JavaScript 存取的 COM 对象依旧使用引用计数。换句话说,只要涉及 COM 对象,就无法避开循环引用问题
  • 为避免类似的循环引用问题,应该在确保不使用的情况下切断原生 JavaScript 对象与 DOM 元素之间的连接
function fn() {
  var ele = document.getElementById('div');
  var id = ele.id;
  ele.onclick = function () {
    console.log(id);
  };
  ele = null;
}
  • 把变量设置为 null 实际上会切断变量与其之前引用值之间的关系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收
  • 为了补救这一点,IE9 把 BOM 和 DOM 对象都改成了 JavaScript 对象,这同时也避免了由于存在两套垃圾回收算法而导致的问题,还消除了常见的内存泄漏现象

性能

  • 垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要,特别是内存有限的移动设备上,垃圾回收有可能会引起整个应用程序暂停(Stop-The-World)明显拖慢渲染的速度和帧速率
  • 我们不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作
  • 现代垃圾回收程序会基于对JavaScript运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的
  • 比如,根据V8团队2016年的一篇博文的说法:“在一次完整的垃圾回收之后,V8的堆增长策略会根据活跃对象的数量外加一些余量来确定何时再次垃圾回收”
  • 曾经 IE 的策略是根据分配数,比如分配了 256 个变量、4096 个对象/数组字面量和数组槽位(slot),或者 64KB 字符串,只要满足其中某个条件,垃圾回收程序就会运行
  • 问题在于,有可能有些脚本,在整个生命周期中始终需要那么多变量,结果就会导致垃圾回收程序过于频繁地运行
  • IE7 更新了垃圾回收程序,它的起始阈值与 IE6 的相同,如果垃圾回收程序回收的内存不到已分配的 15%,阈值翻倍,如果有一次回收的内存达到已分配的85%,则阈值重置为默认值。这么一个简单的修改极大地提升了性能
  • 警告:在某些浏览器中是有可能(但不推荐)主动触发垃圾回收的
    • IE:window.CollectGarbage()
    • Opera 7及更高版本:window.opera.collect()

内存管理

  • 在使用垃圾回收的编程环境中,开发者通常无须关心内存管理
  • 不过,JavaScript运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给软件的要少很多,这更多出于安全考虑而不是别的,就是为了避免运行大量JavaScript的网页耗尽系统内存而导致操作系统崩溃
  • 这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量,将内存占用量保持在一个较小的值可以让页面性能更好,优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据
  • 如果数据不再必要,那么把它设置为 null,从而释放其引用。这也可以叫作解除引用,这个建议最适合全局变量全局对象的属性,局部变量在超出作用域后会被自动解除引用
function createPerson(name) {
  let localPerson = new Object();
  localPerson.name = name;
  return localPerson;
}
let globalPerson = createPerson("Nicholas");
// 解除globalPerson对值的引用
globalPerson = null;
  • 注意:解除对一个值的引用并不会自动导致相关内存被回收,关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收

通过 constlet 声明提升性能

  • 这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程
  • 因为它们都是块作用域,相对于 var,它们可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存
  • 在块作用域比函数作用域更早终止的情况下,这就有可能发生

隐藏类和删除操作

  • 根据 JavaScript 所在的运行环境,有时候需要根据浏览器使用的 JavaScript 引擎来采取不同的性能优化策略
  • Chrome 浏览器使用的 V8引擎在将解释后的 JavaScript 代码编译为实际的机器码时会利用隐藏类
  • 运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征,能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化,但不一定总能够做到
function Article() {
  this.title = 'Inauguration Ceremony Features Kazoo Band';
}
let a1 = new Article();
let a2 = new Article();
  • V8会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型
  • 注意:后续动态为其添加属性会破坏隐藏类,可能对性能产生明显影响
a1.author = 'Jake'; // 此时两个 Article 实例会对应两个不同的隐藏类
  • 解决方案:先创建再补充(ready-fire-aim)式的动态属性赋值,并在构造函数中一次性声明所有属性
function Article(opt_author) {
  this.title = 'Inauguration Ceremony Features Kazoo Band';
  this.author = opt_author;
}
let a1 = new Article();
let a2 = new Article('Jake');
  • 这样,两个实例基本上就一样了,可以共享一个隐藏类,从而带来潜在的性能提升
  • 注意:使用 delete 关键字会导致生成相同的隐藏类片段
function Article() {
  this.title = 'Inauguration Ceremony Features Kazoo Band';
  this.author = 'Jake';
}
let a1 = new Article();
let a2 = new Article();
delete a1.author;
  • 在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类,动态删除属性与动态添加属性导致的后果一样
  • 解决方案:是把不想要的属性设置为 null,这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果

内存泄露

  • 写得不好的JavaScript可能出现难以察觉且有害的内存泄漏问题
  • 在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题
  • JavaScript 中的内存泄漏大部分是由不合理的引用导致的
  • 意外声明全局变量是最常见但也最容易修复的内存泄漏问题
function setName() {
  name = 'Jake'; // 没有使用任何关键字声明变量
}
  • 解释器会把变量 name 当作 window 的属性来创建(相当于 window.name = 'Jake'),在 window 上创建的属性,只要 window 本身不被清理就不会消失

  • 只要加上关键字声明变量,变量就会在函数执行完毕后离开作用域

  • 定时器也可能会悄悄地导致内存泄漏

let name = 'Jake';
setInterval(() => {
  console.log(name);
}, 100);
  • 只要定时器一直运行,回调函数中引用的 name 就会一直占用内存,垃圾回收程序当然知道这一点,因而就不会清理外部变量

  • 闭包也很容易在不知不觉间造成内存泄漏

let outer = function () {
  let name = 'Jake';
  return function () {
    return name;
  };
};
  • 这会导致分配给 name 的内存被泄漏,以上代码创建了一个内部闭包,只要 outer 函数存在就不能清理 name,因为闭包一直在引用着它