Skip to content

内存泄露剖析

作者:Atom
字数统计:5.4k 字
阅读时长:18 分钟

在本文中,我们将探讨客户端 JavaScript 代码中常见的内存泄漏类型。以及如何使用 Chrome 开发工具来查找它们

介绍

内存泄漏是每个开发人员最终都必须面对的问题。即使使用内存管理语言,也存在内存泄漏的情况。内存泄漏会导致一系列问题:速度减慢、崩溃、高延迟,甚至其他应用程序的问题。

什么是内存泄漏?

本质上,内存泄漏可以定义为应用程序不再需要的内存,由于某种原因没有归还给操作系统或可用内存池。编程语言支持不同的内存管理方式。这些方法可能会减少内存泄漏的机会。然而,某块内存是否未被使用,实际上是一个无法判定的问题。也就是说,一块内存是否可以归还给操作系统,只有开发者才能明确。某些编程语言提供的功能可以帮助开发人员做到这一点。其他人则希望开发人员能够完全明确地了解何时未使用一块内存。维基百科也有关于手动自动内存管理的一些资料

JavaScript 中的内存管理

JavaScript 属于垃圾收集语言之一。垃圾收集语言通过定期检查哪些先前分配的内存片段仍然可以从应用程序的其他部分“访问”,从而帮助开发人员管理内存。换句话说,垃圾收集语言将管理内存的问题从“还需要什么内存?” 转化成 “仍然可以从应用程序的其他部分访问哪些内存?”。虽然只有开发人员知道将来是否需要一块分配的内存,但可以通过算法确定无法访问的内存并将其标记为返回给操作系统

非垃圾收集语言

非垃圾收集语言通常采用其他技术来管理内存, 这些技术都有自己的权衡以及泄漏的潜在因素

  • 显式管理,开发人员明确告诉编译器何时不需要一块内存
  • 引用计数,其中使用计数与每个内存块相关联(当计数达到零时,它会返回到操作系统)

JavaScript 中的内存泄露

垃圾收集语言中泄漏的主要原因是不需要的内存引用。要了解什么是不需要的引用,首先我们需要了解垃圾收集器如何确定一块内存是否可以到达

标记清除

大多数垃圾收集器使用一种称为标记清除的算法。该算法由以下步骤组成:

  • 垃圾收集器构建一个“根”列表。根通常是全局变量,其引用保存在代码中。在 JavaScript 中,“window”对象是可以充当根的全局变量的示例。window对象始终存在,因此垃圾收集器可以认为它及其所有子对象始终存在(即不是垃圾)

  • 所有根都经过检查并标记为活动根(即不是垃圾根),所有子项也会被递归检查,从根可以到达的所有东西都不被视为垃圾

  • 所有未标记为活动的内存现在都可以被视为垃圾,收集器现在可以释放该内存并将其归还给操作系统

现代垃圾收集器以不同的方式改进了该算法,但本质是相同的:可到达的内存块被标记为这样,其余的被认为是垃圾

不需要的引用是指开发人员知道该内存不再需要但由于某种原因保留在活动根树内的内存片段的引用。在 JavaScript 上下文中,不需要的引用是保存在代码中某处的变量,这些变量将不再使用,并指向本来可以释放的内存块

因此,要了解 JavaScript 中最常见的泄漏,我们需要了解引用通常被遗忘的方式

常见 JavaScript 泄露的三种类型

意外的全局变量

JavaScript 允许赋值给未声明的变量:对未声明变量的引用会在全局对象内创建一个新变量,对于浏览器来说,全局对象是window

javascript
function foo(arg) {
  bar = "this is a hidden global variable";
  // 事实上等同于
  // window.bar = "this is an explicit global variable";
}

如果变量bar应该仅存在于函数foo范围内保持着对变量的引用,即开发者忘记通过使用关键词varletconst来局部声明它,则会创建一个意外的全局变量。在这个例子中,泄漏一个简单的字符串不会造成太大的危害,但如果是一个大对象肯定情况会更糟

创建意外全局变量的另一种方法是通过this

javascript
function foo() {
    this.variable = "potential accidental global";
}

// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

解决方法

为了防止发生这些错误,请在 JavaScript 文件的开头添加'use strict';。这将启用更严格的 JavaScript 解析模式,以防止意外的全局变量

关于全局变量的注释

尽管这种问题存在,但许多代码仍然充斥着显式全局变量。根据定义,这些是不可收集的(除非清空或重新分配)。特别是用于临时存储和处理大量数据的全局变量更值得关注。如果必须使用全局变量来存储大量数据,请确保在使用完毕后将其清空或重新分配。与全局相关的内存消耗增加的一个常见原因是缓存。缓存存储重复使用的数据,为了提高效率,缓存的大小必须有一个上限。无限制增长的缓存可能会导致高内存消耗,因为无法收集其内容

忘记定时器或回调

JavaScript 中使用setInterval很常见。第三方库中一般会提供观察者和回调函数来进行处理,这些库中的大多数都会负责在自己的实例变得无法访问后使对回调的任何引用都无法访问。然而凡事均有例外,请看下面的示例代码

javascript
var someResource = getData();
setInterval(function(){
    var node = document.getElementById('Node');
    if(node) {
        // Do stuff with node and someResource.
        node.innerHTML = JSON.stringify(someResource);
    }
}, 1000);

这个示例主要是说明未记录定时器ID的计时器可能发生的情况:引用了不再需要的节点或数据,也无法将其收集。

内部的对象node将来可能会被删除,从而使得setInterval整个代码块变得不再需要。但是,由于setInterval仍然处于活动状态,所以内部的词法环境变得无法被回收(需要停止setInterval才能阻止这种情况)。如果无法停止setInterval处理程序,则也无法收集其依赖项。假如someResource存储大量的数据,即使占用了大量的内存,也将无法被收集后回收。

对于上述对象观察者的情况,一旦不再需要它们(或者关联的对象将变得不可访问),进行显式调用以将其删除非常重要。在过去,这曾经特别重要,因为某些浏览器(IE6)无法很好地管理循环引用(有关更多信息,请参阅下文)。如今,一旦检测到对象变得无法访问,大多数浏览器都可以收集观察者处理程序,即使侦听器没有显式删除。然而,在处理对象之前显式删除这些观察者仍然是一种很好的做法

javascript
// 例如下面的代码

var element = document.getElementById('button');

function onClick(event) {
    element.innerHtml = 'text';
}

element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers that don't
// handle cycles well.

关于对象观察者和循环引用的注释

观察者和循环引用曾经是 JavaScript 开发人员的隐患。这是由于 IE 垃圾收集器中的BUG造成的。旧版本的 IE 无法检测 DOM 节点和 JavaScript 代码之间的循环引用。这是观察者的典型特征,它通常保留对可观察对象的引用(如上例所示)。换句话说,每次将观察者添加到 IE 中的节点时,都会导致泄漏。这就是开发人员开始显式删除节点之前的处理程序或将观察者内部的引用清空的原因。如今,现代浏览器(包括 IEMicrosoft Edge)使用现代垃圾收集算法,可以检测这些周期并正确处理它们。换句话说,removeEventListener在使节点不可访问之前调用并不是严格必要的。

一般框架和库(例如jQuery)中,当使用其特定 API 时,会在处理节点之前删除侦听器。这是由库内部处理的,确保不会产生泄漏,即使在有问题的浏览器(例如旧版 IE)下运行也是如此

游离的 DOM 引用

游离的DOM引用是指活动的变量中保存着对DOM节点的引用,但节点又被从DOM树中删除,导致其仍然保留在内存中

场景一、缓存DOM节点导致的内存泄露

有时,将 DOM 节点存储在数据结构中可能很有用。假设您想要快速更新表中几行的内容。将每个 DOM 行的引用存储在Map或数组中可能是有意义的。

发生这种情况时,会保留对同一DOM元素的两个引用:一个在 DOM 树中,另一个在Map或数组中。如果将来某个时候您决定删除这些行,则需要同时删除Map或数组中的引用。

javascript
var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // Much more logic
}

function removeButton() {
    // The button is a direct child of body.
    document.body.removeChild(document.getElementById('button'));

    // At this point, we still have a reference to #button in the global
    // elements dictionary. In other words, the button element is still in
    // memory and cannot be collected by the GC.
}

此外,还有另一种 DOM 树的内部节点或子节点的引用情况。假设在 JavaScript 代码中保留对表格<table>的特定单元格(例如<td>)的引用。在将来的某个时刻,您决定从 DOM 中删除该表格,但保留对该单元格<td>的引用。凭直觉可能会认为 GC 会收集除该单元格<td>之外的所有表格内容。实际上却是:单元格<td>是该表格的子节点,子节点保留了对其父节点的引用。换句话说,JavaScript 代码对单元格<td>的引用会导致整个表格保留在内存中。在保留对 DOM 元素的引用时请仔细考虑这一点

场景二、聚焦的表单元素所致内存泄露

假设您有一个表单,其中包含一些输入字段。如果表单元素已经处于聚焦状态, 此时将表单元素从DOM树销毁, 那么该聚焦的表单元素将会被保留在内存中,无法被垃圾回收

这个Chromium BUG - Issue 866872: [Performance tab] nodes leaking on focus已经持续了多年,最新的Chrome仍旧未修复。官方给出的回复是,该场景下只会有最后一个聚焦的元素被保留在内存中,而不是所有表单元素,因此暂不修复

以下是具体案例, 也可以参考作者的demos仓库-聚焦的游离节点的内存泄露

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>聚焦的游离节点的内存泄露</title>
</head>
<body>
  <h1>
    聚焦的游离节点的内存泄露
  </h1>

  <div>
    <button class="btn" onclick="createInput(false)">正常情况(移除未聚焦的游离节点)</button>
    <button class="btn" onclick="createInput(true)">泄露情况(移除已聚焦的游离节点)</button>
  </div>

<script>
    function createInput(isFocus) {
      const input = document.createElement('input')
      input.setAttribute('placeholder', isFocus ? '已聚焦的游离节点' : '未聚焦的游离节点')
      document.body.appendChild(input)
      isFocus && input.focus()
      setTimeout(() => {
        input.remove()
      }, 2000)
    }
  </script>
</body>
</html>

闭包

JavaScript 开发的一个关键方面是闭包:从父作用域捕获变量的匿名函数。Meteor 开发人员发现了一种特殊情况,由于 JavaScript 运行时的实现细节,可能会以隐蔽的方式泄漏内存

这种情况主要是由于同一作用域下的多个闭包共享词法环境所导致,这种情况下也会随着浏览器的不同版本实现而有一些差异

场景一、闭包链表的内存泄露 已修复

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(someMessage);
    }
  };
  // If you add `originalThing = null` here, there is no leak.
};
setInterval(replaceThing, 1000);

代码段只做一件事:每次周期性调用 replaceThing 时,theThing 都会获取一个新对象,其中包含一个大数组longStr和一个新闭包someMethod。同时,变量 unused 包含一个引用 originalThing(上一次调用 replaceThingtheThing)的闭包。已经有些混乱了吧?重要的是,一旦为同一父作用域中的闭包创建了作用域,该作用域就会被共享。在这种情况下,为闭包 someMethod 创建的作用域被 unused 共享。unused 具有对 originalThing 的引用。即使unused从未执行过,也可以通过 theThing 使用 someMethod。因此 someMethodunused 共享闭包范围,即使 unused 从未使用过,它对 originalThing 的引用也会强制它保持活动状态(阻止其被系统收集)。重复运行此代码段时,可以观察到内存使用量稳步增加。当 GC 运行时,它不会变小。从本质上讲,创建了一个闭包链表(其根以 theThing 变量的形式存在),并且每个闭包的作用域都带有对大数组的间接引用,从而导致相当大的泄漏

解决方案

Meteor 博客文章中提供了该闭包泄露情况下的具体解决方案, 那么就是不再使用的闭包, 通过赋值null来明确告诉 GC 该闭包不再需要了

场景二、作用域扩张的内存泄露

在下面的示例中,函数 inner 从未被调用,但保留对 elem 的引用。但由于闭包中的所有内部函数共享相同的上下文,内部函数inner(第 7 行)与外部函数返回的 function(){} (第 12 行)共享相同的上下文。

现在,每 5 毫秒,我们对外层闭包函数outer进行一次函数调用,并将其新值(每次调用后)分配给全局变量 newElem。只要引用指向此 function(){},共享作用域(也叫做执行上下文)就会被保留(即相对outer返回的匿名函数来说,作用域扩张了),并且 someText 也会被保留,因为它是内部函数的一部分,即使内部函数从未被调用。

每次调用 outer 时,我们都会将之前的 function(){} 保存在新函数的 elem 中。因此,必须再次保留先前的共享范围/上下文。所以在第n次调用outer函数时,第(n-1)次调用outersomeText不能被垃圾回收。此过程将持续进行,直到您的系统最终耗尽内存。

解决方案

出现这种情况的问题是因为对 function(){} 的引用保持活动状态。如果实际调用了外部函数,则不会出现 JavaScript 内存泄漏(在第 15 行调用外部函数,如 newElem = outer()())。 小型的应用中一次独立的 JavaScript 内存泄漏可能不需要任何关注。 然而,随着每次迭代的重复和增长,周期性泄漏可能会严重损害代码的性能。

js
var newElem;

function outer() {
  var someText = new Array(1000000);
  var elem = newElem;

  function inner() {
    if (elem) return someText;
  }

  return function () { };
}

setInterval(function () {
  newElem = outer();
}, 5);

垃圾收集器的不直观行为

尽管垃圾收集器很方便,但它们也有自己的一套权衡。这些权衡之一就是不确定性。换句话说,GC 是不可预测的。通常不可能确定何时执行收集。这意味着在某些情况下,使用的内存多于程序实际需要的内存。在其他情况下,在特别敏感的应用程序中,短暂的暂停可能会很明显。尽管不确定性意味着无法确定何时执行收集,但大多数 GC 都实现了在分配期间执行收集的共享模式。如果不执行任何分配,大多数 GC 将保持静止状态。

考虑以下场景:

  • 执行一组相当大的分配
  • 这些元素中的大多数(或全部)都被标记为无法访问(假设我们将指向不再需要的缓存的引用置空)
  • 不再执行进一步的分配

在这种情况下,大多数 GC 将不会运行任何进一步的收集过程。换句话说,即使存在可供收集的无法访问的引用,收集器也不会声明这些引用。这些并不是严格意义上的泄漏,但仍然会导致内存使用量高于平常

Chrome 提供了一组很好的工具来分析 JavaScript 代码的内存使用情况。有两个与内存相关的基本视图:性能视图(Profermance)内存视图(Memory)

性能视图(Profermance)

Google 开发工具性能视图

性能视图对于发现代码中异常的内存模式至关重要。如果我们正在寻找大泄漏,那么在收集后收缩幅度没有增长那么多的周期性跳跃是一个危险信号。在此屏幕截图中,我们可以看到泄漏对象的稳定增长是什么样子的。即使在最后进行了大收集之后,使用的内存总量也比开始时要高。节点数量也更高。这些都是代码中某处 DOM 节点泄露的迹象。

内存视图(Memory)

Google 开发工具内存视图

这是您将花费大部分时间观看的视图。内存视图允许您获取快照并比较 JavaScript 代码的内存使用情况的快照。它还允许您记录一段时间内的分配情况。在每个结果视图中都可以使用不同类型的列表,但与我们的任务最相关的是Summary列表和比较列表。

Summary视图为我们提供了分配的不同类型对象及其聚合大小的概述:浅层大小(特定类型的所有对象的总和)和保留大小(浅层大小加上由于该对象而保留的其他对象的大小) )。它还为我们提供了一个对象相对于其 GC 根的距离(距离)的概念。

比较列表为我们提供了相同的信息,但允许我们比较不同的快照。这对于查找泄漏特别有用。

示例:使用 Chrome 查找泄漏

如果需要使用Chrome工具查找内存泄露,主要是搭配以上几个面板来对比分析,如需了解更多Chrome调试知识请查看Chrome Devtools 官方文档

结论

内存泄漏可能并且确实发生在垃圾收集语言(例如 JavaScript)中。这些可能会在一段时间内被忽视,最终会造成严重破坏。因此,内存分析工具对于查找内存泄漏至关重要。分析运行应该是开发周期的一部分,特别是对于中型或大型应用程序。开始这样做是为了给你的用户提供最好的体验。

声明

本文源自4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them

笔者仅提供翻译,如有笔误或翻译不当之处,请联系修改。

参考资料