JavaScript内存泄漏

104 阅读10分钟

为什么我们应该关心内存?

  • 内存是一种有限的资源。当人们购买内存更大的机器时,他们期望获得更好的体验,这意味着高效地使用内存。
  • 性能硬币的另一面:程序的运行速度和我们使用的内存量被认为是高效的。
  • 性能监控和内存分析。 chrome devtools 提供的一项工具是内存面板,可以在其中调查应用程序中的任何内存问题。
  • 打造更好的网络体验。在处理内存密集型进程(例如图像处理和网页动画)时,了解如何修复内存问题(例如频繁出现的内存泄漏)非常重要。

内存泄漏

在计算机科学中,内存泄漏(Memory leak)是一种资源泄漏,当计算机程序以不释放不再需要的内存的方式错误地管理内存分配时发生。当对象存储在内存中但无法被运行代码访问(即无法访问内存)时,也可能会发生内存泄漏。内存泄漏的症状与许多其他问题类似,通常只能由有权访问程序源代码的程序员来诊断。

简单来说,内存泄漏是 JavaScript 引擎无法回收的已分配内存。在应用程序中创建对象和变量时,JavaScript 引擎会分配内存,并且当不再需要这些对象时,它会足够智能地清除内存。内存泄漏是由于逻辑缺陷引起的,它们会导致应用程序性能不佳。

JavaScript 通过自动分配内存并在不使用时释放(垃圾收集)来消除内存管理的麻烦。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。他们不知道何时释放内存,或者是否释放内存。如果忘记释放内存,可能会导致内存泄漏。

全局变量

全局变量(Global variable)是具有全局作用域的变量,这意味着它在整个程序中都是可见的(因此可访问),除非被隐藏。所有全局变量的集合称为全局环境或全局状态。在编译语言中,全局变量通常是静态变量,其范围(生命周期)是程序的整个运行时,但在解释语言(包括命令行解释器)中,全局变量通常在声明时动态分配,因为它们是未知的提前时间。

当数据存储在全局变量中时,可能导致的内存泄漏:在全局环境下使用 var 而不是 let 或 const;对未声明变量的引用会在全局对象内创建一个新变量,其根将是全局对象;声明函数存储在全局范围中。

// 以下都可以通过 window 来访问
name = 'John' // 未声明的变量
var nickname = 'Johnny' // 使用var
function sayName() {}  // 函数

当然,并不是所有的全局变量都称之为内存泄漏。

闭包

在编程语言中,闭包(Closure,也称为词法闭包或函数闭包)是一种在具有一流函数的语言中实现词法作用域名称绑定的技术。从操作上来说,闭包是一个将函数与环境一起存储的记录。 环境是一个映射,将函数的每个自由变量(在本地使用但在封闭范围内定义的变量)与创建闭包时名称绑定的值或引用相关联。与普通函数不同,闭包允许函数通过闭包的值或引用的副本来访问这些捕获的变量,即使在函数作用域之外调用该函数也是如此。

函数作用域变量在函数退出调用堆栈后被清除,而闭包在执行后保留引用的外部作用域变量。外部作用域变量即使未使用也驻留在内存中,因此这是内存泄漏的常见原因。

function outer () {
  const arr = []
  return function inner (num) {
    arr.push(num)
  }
}

const fn = outer()

for (let i = 0; i < 100000000; i++) {
  fn(i)
}

arr 永远不会返回,垃圾收集器也无法到达,通过重复调用内部函数显着增加其大小,从而导致内存泄漏。闭包是不可避免的,因此请确保使用或返回外部作用域中的变量。

作为解决方案,尝试在使用后使这些变量无效,或者添加 use strict 以启用更严格的 JavaScript 模式,以防止意外的全局变量。

定时器和回调函数

setTimeout 函数在给定时间过去后执行,而 setInterval 在给定时间间隔内重复执行。这些被遗忘的计时器是内存泄漏的最常见原因。

function createRandomNumber () {
  const numbers = []
  return function () {
    numbers.push(Math.random())
  }
}

setInterval(createRandomNumber(), 2000)

createRandomNumber返回一个将随机数附加到外部范围数字数组的函数。通过在此函数上使用 setInterval 设置时间间隔,它会定期调用指定的时间间隔,并导致数字数组变得巨大。

要解决此问题,最佳实践需要在 setTimeoutsetInterval 调用内提供引用。然后,进行显式调用以清除计时器。

事件侦听器

当事件侦听器附加到 DOM 元素时,它将保持活动状态。即使从 DOM 中删除事件侦听器,它也会继续存在,这会阻止垃圾收集器释放内存。

let parent = document.getElementById('#parent')
let child = document.getElementById('#child')
parent.addEventListener('click', function () {
  child.remove()
})

由于事件侦听器始终处于活动状态并包含子元素引用,即使从 DOM 中删除了子元素,当父元素点击时,子元素变量仍然保留内存。原因是垃圾收集器无法释放子对象并且将继续使用内存。

当不再需要事件侦听器时,应该始终通过生成它的引用并将其提供给 removeEventListener 方法来取消注册它。

function removeChild () {
  child.remove()
}
parent.addEventListener('click', removeChild)
parent.removeEventListener('click', removeChild)

DOM引用

超出 DOM 引用表示已从 DOM 中删除但在内存中仍然可用的节点。垃圾收集器无法释放这些 DOM 对象,因为它们被称为对象图内存。

const elements = []
const element = document.getElementById('button')

elements.push(element);

function removeAllElements() {
  elements.forEach((item) => {
    document.body.removeChild(document.getElementById(item.id))
  })
}

removeAllElements() 方法从 body 节点移除指定的 button 元素。虽然这些 DOM 元素的确从文档中移除了。但是这些 DOM 节点仍然被 elements 这个数组所引用,由于这个原因,垃圾收集器无法释放子对象,并且会继续消耗内存。

循环引用

前面的章节中提到对象的循环引用问题,回收器的标记和清除算法似乎告诉我们这个问题已经解决了。但是,如果循环引用中的对象是 DOM 节点,则垃圾收集器无法释放它们。

控制台输出

我们通常会通过控制台输出来调试代码,但是控制台输出会保留在内存中,直到控制台被关闭。

识别内存泄漏

调试内存问题确实是一项艰巨的工作,但我们可以使用 Chrome DevTools 识别内存图和一些内存泄漏。作为开发人员,我们将重点关注日常的两个重要方面:

  1. 使用性能分析器可视化内存消耗
  2. 识别分离的 DOM 节点。

性能分析器

以下面的代码片段为例。有两个按钮:Print NumbersClear。单击Print Numbers按钮,通过创建段落节点并将一些大字符串推送到全局变量,从 110,000 的数字将附加到 DOM。Clear按钮将清除全局变量并覆盖文档正文,但不会删除单击Print Numbers时创建的节点。

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Memory leaks</title>
  </head>
  <body>
    <button id="print">Print Numbers</button>
    <button id="clear">Clear</button>
    <script>
      var longArray = []

      function print() {
        for (var i = 0; i < 10000; i++) {
          let paragraph = document.createElement('p')
          paragraph.innerHTML = i
          document.body.appendChild(paragraph)
        }
        longArray.push(new Array(1000000).join('y'))
      }

      document.getElementById('print').addEventListener('click', print)
      document.getElementById('clear').addEventListener('click', () => {
        window.longArray = null
        document.body.innerHTML = 'Cleared'
      });
    </script>
  </body>
</html>

performance.jpg

通过分析屏幕截图(上面的代码片段的性能时间线),我们可以看到,每次单击Print Numbers按钮时,蓝色的 JavaScript 堆都会出现峰值。这些峰值是很自然的,因为 JavaScript 正在创建 DOM 节点并将字符附加到全局数组。

每次单击Print Numbers按钮时,JavaScript 堆都会逐渐增加,并在单击Clear按钮后变得正常。在实际场景中,如果观察到内存持续激增,并且内存消耗没有减少,则可以认为存在内存泄漏。

另一方面,我们可以观察到节点数量持续增加,如绿色图所示,因为我们没有删除它们。

内存快照

当节点从 DOM 树中删除时,该节点被称为分离,但某些 JavaScript 代码仍然引用它。

让我们使用下面的代码片段检查分离的 DOM 节点。通过单击按钮,我们可以将列表元素附加到其父级并将父级分配给全局变量。简单来说,全局变量保存着 DOM 引用。

var detachedElement
function createList() {
  let ul = document.createElement('ul')
  for (let i = 0; i < 5; i++) {
    ul.appendChild(document.createElement('li'))
  }
  detachedElement = ul
}
document.getElementById('createList').addEventListener('click', createList)

memory.jpg

我们可以使用堆快照来检测分离的 DOM 节点。导航到 Chrome DevTools → 内存 → 堆快照 → 拍摄快照。单击 createList 按钮后,拍摄快照。可以通过在摘要部分过滤 Detached 来找到分离的 DOM 节点。我们使用 Chrome DevTools 探索了 废弃的 DOM 节点,可以尝试使用此方法来识别其他内存泄漏。

我们除了可以使用 Chrome DevTools 来分析内存泄漏,还可以借助一些测试工具来进行测试。比如web-vitals、PerfCascade、Codeceptjs、Nightwatch.js、Lighthouse、Puppeteer、TestCafe等等。另外,我们还可以使用ESLint之类的工具来预防在开发过程中写出不好的代码来。

权衡利弊

当用户访问网站或应用程序时,有两件事对用户来说很重要:

  • 应用程序需要多长时间才能打开。
  • 程序将消耗的内存空间量。

每个访问应用程序的用户都应该获得丰富的交互式体验。他们希望应用程序能够与系统上同时打开的其他应用程序一起执行多任务。这可以通过程序中适当的内存管理来完成。

如果JavaScript 应用程序遇到频繁崩溃、高延迟和性能差,潜在原因之一可能是内存泄漏。由于 JavaScript 引擎对自动内存分配的误解,内存管理经常被开发人员忽视,从而导致内存泄漏,最终导致性能不佳。

在多页应用中,一些细小的内存泄漏问题可能不会导致性能问题。因为这些泄漏的内存量通常很小,跳转到新的页面问题就消失了。但是,在单页应用中,这些细小的问题就会叠加,从而导致性能问题。比如某个组件写了一个未清除的定时器,那么不断访问这个组件就不断会产生新的定时器。

我们不能依赖垃圾收集器来管理内存,因为它是不可控的,所以只能控制我们的代码,养成良好的习惯,避免内存泄漏。

由于垃圾回收算法无法知道何时内存不再需要,因此 JavaScript 应用程序可能会使用比实际需要更多的内存。尽管对象被标记为垃圾,但是收集分配的内存以及何时收集是由垃圾回收器决定的。如果您需要使您的应用程序尽可能具有内存效率,则最好使用低级语言。但请记住,这也有其自身的权衡利弊。

为我们收集垃圾的算法通常定期运行以清除未使用的对象。但问题在于,我们作为开发人员不知道这会在什么时候发生。收集大量垃圾或经常收集垃圾可能会影响性能,因为这需要一定的计算能力来完成。不过,这对用户或开发人员来说是不会被察觉的。