消除 JavaScript 中的内存泄漏
如果您想知道为什么您的 JavaScript 应用程序会遭受严重的速度减慢、性能下降、高延迟或频繁崩溃,并且您为找出问题而做出的所有艰苦尝试都无济于事,那么您的代码很有可能受到“内存泄漏”的困扰。内存泄漏相当常见,因为由于对现代高级编程语言(如 JavaScript)中的自动内存分配和释放存在误解,内存管理常常被开发人员忽视。如果无法处理 JavaScript 内存泄漏,可能会严重影响应用程序的性能,并使其无法使用。互联网上充斥着永无止境的复杂术语,通常很难理解。因此,在本文中,我们将采取全面的方法来了解什么是 JavaScript 内存泄漏、其原因以及如何使用 chrome 开发人员工具轻松发现和诊断它们。
什么是 JavaScript 内存泄漏?
内存泄漏可以定义为应用程序不再使用或需要的一块内存,但由于某种原因没有返回给操作系统,而仍然被不必要地占用。在代码中创建对象和变量会消耗内存。JavaScript 足够智能,可以确定何时不再需要该变量,并将其清除以节省内存。当您不再需要某个对象,但 JS 运行时仍然认为您需要时,就会发生 JavaScript 内存泄漏。此外,请记住,JavaScript 内存泄漏不是由无效代码引起的,而是由代码中的逻辑缺陷引起的。它会减少可用于执行任务的内存量,从而导致应用程序性能下降,并最终导致崩溃或冻结。
在深入研究内存泄漏之前,必须对内存周期、内存管理系统和垃圾收集器算法有充分的了解。
什么是记忆循环?
“存储器”由一系列触发器组成,触发器是由4到6个晶体管组成的双态(0和1)电路。触发器存储一个比特后,会一直保留该比特,直到用相反的比特重写。因此,存储器只不过是一个可重新编程的比特阵列。程序中使用的每条数据都存储在存储器中。
内存周期是指内存单元从空闲/自由状态,经过使用(读取或写入)阶段,再返回空闲状态的完整事件序列。内存周期大致可以分为三个主要步骤:
-
内存分配:操作系统会在程序执行过程中根据需要分配内存。在 C 和 C++ 等低级语言中,此步骤由程序员处理,但在 JavaScript 等高级语言中,此操作由自动内存管理系统自行完成。以下是 JavaScript 内存分配的一些示例:
var n = 5; // 为数字分配内存 var s = 'Hello World'; // 为字符串分配内存 var obj = { // 为对象分配内存 答:100, b:“一些字符串”, c: 空, }; var arr = [100, "some string", null]; // 为数组分配内存 function foo(x, y) { // 为函数分配内存 返回 x * y; }
-
内存使用:程序对分配的内存执行读写操作。这可以是读取或写入变量、对象的值,甚至可以将参数传递给函数。
-
内存释放:当任务完成并且不再需要分配的内存时,它将被释放并可供新的分配。
内存循环的第三步才是最复杂的地方。这里最难的挑战是确定何时“分配的内存不再需要,应该被释放”。这时,内存管理系统及其垃圾收集器算法就能派上用场了。
内存管理系统——手动与自动
内存管理是指在程序执行过程中根据其请求为其分配内存块,并在不再需要时释放内存块以供重新分配的过程。不同的编程语言会根据其复杂程度使用不同的方法来处理内存管理。
- 像 Pascal、C 和 C++ 这样的低级语言,都有手动内存管理系统,程序员必须在需要时手动/显式地分配内存,并在程序使用后释放内存。例如,C 语言使用 malloc() 和 calloc() 来预留内存,使用 realloc() 将预留的内存块移动到其他分配空间,并使用 free() 将内存释放回系统。
- JavaScript 和 VB 等高级编程语言都拥有一套自动化系统,它会在每次创建实体(例如对象、数组、字符串或 DOM 元素)时分配内存,并在不再使用时自动释放内存,这个过程被称为垃圾回收。当程序仍在消耗内存时,就会发生内存泄漏,理想情况下,这些内存应该在指定任务完成后释放。但由于某种原因,垃圾回收器未能发挥其作用,导致程序拒绝释放内存,导致内存继续被消耗,而这部分内存本应被释放。
垃圾收集器
垃圾收集器执行查找程序不再使用的内存并将其释放回操作系统以供将来重新分配的过程。为了找到不再使用的内存,垃圾收集器依赖于算法。尽管垃圾收集方法非常有效,但 JavaScript 仍然有可能发生内存泄漏。此类泄漏的主要原因通常是“不必要的引用”。其主要原因是垃圾收集过程基于估计或推测,因为“某些内存是否需要释放”这一复杂问题无法通过算法在每次运行时都准确确定。
在进一步讨论之前,让我们先来看看两种最广泛使用的 GC 算法
正如我们之前讨论过的,任何垃圾收集算法都必须执行两个基本功能。首先,它必须能够检测所有不再使用的内存;其次,它必须释放/取消分配垃圾对象占用的空间,并在将来需要时使其可供重新分配。
最流行的两种算法是:
- 引用计数
- 标记与清除
引用计数算法
该算法依赖于“引用”的概念。它基于计算其他对象对某个对象的引用数量。每次创建对象或分配对该对象的引用时,其引用计数都会增加。在 JavaScript 中,每个对象都有一个对其原型的隐式引用和对其属性值的显式引用。
引用计数算法是最基本的垃圾收集算法,它将“一个对象不再需要”的定义简化为“一个对象没有其他对象引用它”。如果指向该对象的引用为零,则该对象被视为可回收对象,并且不再被使用。
<脚本> var o = { // 创建了两个对象。其中一个对象作为其属性被另一个对象引用。 a: { // 另一个通过分配给“o”变量来引用。 b: 2; // 显然,没有一个可以被垃圾回收 } }; var o2 = o; // 'o2' 变量是第二个引用该对象的变量 o = 1; // 现在,原来在 'o' 中的对象有一个由 'o2' 变量体现的唯一引用 var oa = o2.a; // 引用对象的“a”属性。该对象现在有 2 个引用:一个作为属性, // 另一个作为“oa”变量 o2 = 'yo'; // 原本位于 'o' 中的对象现在没有任何引用。它可以被垃圾回收了。 // 但是它的“a”属性仍然被“oa”变量引用,因此无法释放 oa = null; // o 中原有对象的 'a' 属性没有被引用。它可以被垃圾回收。 }; </script>
引用计数算法的缺点:
然而,引用计数算法在循环引用的情况下存在很大的局限性。循环引用是指两个对象通过相互引用而创建的情况。由于这两个对象的引用计数至少为 1(彼此至少被引用一次),因此即使它们不再使用,垃圾收集器算法也不会回收它们。
<脚本> 函数 foo() { var obj1 = {}; var obj2 = {}; obj1.x = obj2; // obj1 引用 obj2 obj2.x = obj1; // obj2 引用 obj1 返回 true; } foo(); </script>
标记-清除算法
与引用计数算法不同,标记-清除算法将“对象不再需要”的定义简化为“对象不可达”,而不是“未被引用”。
在 JavaScript 中,全局对象被称为“root”。
垃圾收集器首先会找到所有根对象,并将所有引用映射到这些全局对象上,并引用这些对象,依此类推。垃圾收集器使用此算法识别所有可访问的对象,并回收所有不可达的对象。
标记-清除算法分为两个阶段:
- 标记阶段:每次创建一个对象,它的标记位都会被设置为 0(false)。在标记阶段,每个“可达”对象的标记位都会被修改,并设置为 1(true)。
- 清除阶段在标记阶段之后,所有标记位仍设置为 0(false)的对象都是不可达对象,因此它们将被算法作为垃圾收集并从内存中释放。
所有对象的标记位最初都设置为 0(假), 所有可访问对象的标记位都更改为 1(真), 不可达对象将从内存中清除。
标记-清除算法的优点:
与引用计数算法不同,标记-清除算法处理的是循环。循环中的两个对象没有被从根可达的任何对象引用。它们会被垃圾收集器视为不可达对象,并被清除。
标记-清除算法的缺点
这种方法的主要缺点是垃圾收集器算法运行时程序执行会被暂停。
JavaScript 内存泄漏的原因
防止 JavaScript 内存泄漏的关键在于了解不需要的引用是如何产生的。根据这些不需要的引用的性质,我们可以将内存源分为 7 种类型:
- 未声明/意外的全局变量JavaScript 有两种作用域——局部作用域和全局作用域。作用域决定了变量、函数和对象在运行时的可见性。
- 局部作用域变量仅在其局部作用域(定义它们的地方)内可访问和可见。局部变量被称为具有“函数作用域”:它们只能在函数内部访问。
<脚本> // myFunction() 之外的变量 'a' 无法访问 函数 myFunction() { var a = "这是一个局部范围变量"; // 变量 'a' 只能在 myFunction() 内部访问 } </script>
-
另一方面,全局作用域变量可以被 JavaScript 文档中的所有脚本和函数访问。当你开始在文档中编写 JavaScript 代码时,你已经处于全局作用域中。与局部作用域不同,整个 JavaScript 文档中只有一个全局作用域。所有全局变量都属于 window 对象。
如果你为一个之前未声明的变量赋值,它将自动成为“全局变量”。<脚本> // 变量 'a' 可以全局访问 var a = "这是一个全局变量"; 函数 myFunction() { // 变量 a 也可以在 myFunction() 内部访问 } </script>
意外全局变量案例:
如果在未事先声明的情况下为变量赋值,则会创建一个“自动”或“意外”的全局变量。本例将声明一个全局变量 a,即使在函数内部赋值也是如此。
<脚本> // 变量 'a' 具有全局作用域 函数 myFunction() { a = "这是一个偶然的全局变量"; // 变量“a”是全局变量,因为它在没有事先声明的情况下就被赋值了 } </script>
解决方案:全局变量的定义不会被垃圾收集器清除。因此,谨慎使用全局变量至关重要,JavaScript 程序员的最佳实践是在使用后将其置空或重新赋值。上例中,函数调用后将全局变量 a 设置为 null。另一种方法是使用“严格”模式解析 JS 代码。这将防止意外创建未声明的全局变量。另一种方法是使用“let”而不是“var”来声明变量。let 具有块作用域。它的作用域仅限于一个块、一个语句或一个表达式。这与全局定义变量的 var 关键字不同。
- 闭包
闭包是函数及其声明所在词法环境的组合。闭包是一个内部(封闭)函数,可以访问外部(封闭)函数的变量(作用域)。即使在外部函数执行完毕后,内部函数仍可以访问外部函数的作用域。
如果在外部函数中声明的变量自动可供嵌套的内部函数使用,并且即使未在嵌套函数中使用/引用该变量也会继续驻留在内存中,则闭包中会发生内存泄漏。
<脚本> var newElem; 函数外部(){ var someText = new Array(1000000); var elem = newElem; 函数内部(){ 如果(elem)返回一些文本; } 返回函数(){}; } 设置间隔(函数(){ newElem = outer(); }, 5); </script>
在上面的例子中,函数 inner 从未被调用但保留了对 elem 的引用。但由于闭包中的所有内部函数共享相同的上下文,inner(第 7 行)与 outer 函数返回的 function(){}(第 12 行)共享相同的上下文。现在每 5 毫秒我们对 outer 进行一次函数调用并将其新值(每次调用后)赋给全局变量 newElem。只要引用指向此 function(){},共享作用域/上下文就会保留,并且 someText 也会保留,因为它是内部函数的一部分,即使 inner 函数从未被调用。每次调用 outer 时,我们都会将前一个 function(){} 保存在新函数的 elem 中。因此,必须再次保留之前的共享作用域/上下文。所以在第 n 次调用 outer 函数时,第 (n-1) 次调用 outer 的 someText 无法被垃圾回收。此过程一直持续,直到系统最终耗尽内存。
解决方案:本例中的问题是由于对 function(){} 的引用保持活动状态而发生的。如果实际调用了外部函数(例如在第 15 行调用外部函数,例如 newElem = outer();),则不会发生 JavaScript 内存泄漏。由闭包导致的小型 JavaScript 内存泄漏可能无需关注。然而,周期性泄漏会随着每次迭代而重复出现并不断增大,从而严重损害代码性能。
- 分离 DOM/DOM 引用外分离 DOM 或 DOM 引用外 DOM 指的是那些已从 DOM 中移除但仍通过 JavaScript 保留在内存中的节点。这意味着,只要在任何地方仍有对变量或对象的引用,即使该对象从 DOM 中移除,也不会被垃圾回收。
DOM 是一棵双向链接树,引用树中任何一个节点都会阻止整棵树被垃圾回收。举个例子,在 JavaScript 中创建一个 DOM 元素,然后在某个时刻删除了这个元素(或其父元素),但忘记删除持有该元素的变量。这会导致分离 DOM,它不仅持有对 DOM 元素的引用,还持有对整棵树的引用。
<脚本> var demo = document.createElement("p"); demo.id =“我的文本”; document.body.appendChild(演示); var lib = { 文本:document.getElementById('myText') }; 函数创建函数(){ lib.text.innerHTML = “你好,世界”; } 创建函数(); 函数 deleteFunction() { document.body.removeChild(document.getElementById('myText')); } 删除函数(); </script>
即使从 DOM 中删除了 #myText,全局 lib 对象中仍然保留着对 #myText 的引用。这就是为什么它无法被垃圾回收器释放,并会继续消耗内存。这是另一种内存泄漏的情况,必须通过调整代码来避免。
解决方案:作为 JavaScript 的最佳实践,一种常见的做法是将 var demo 放在监听器内部,使其成为局部变量。当 demo 被删除时,该对象的路径会被切断。垃圾回收器可以释放这部分内存。
- 计时器 JavaScript 中有两个计时事件,分别是 setTimeout 和 setInterval。setTimeout() 会在等待指定的毫秒数后执行一个函数,而 setInterval() 则会重复执行该函数。setTimeout() 和 setInterval() 都是 HTML DOM Window 对象的方法。JavaScript 计时器是内存泄漏最常见的原因,因为它们的使用非常普遍。
考虑以下涉及造成内存泄漏的计时器的 JavaScript 代码。
<脚本> for (var i = 0; i < 100000; i++) { var buggyObject = { 再次调用:函数(){ var ref = this; var val = setTimeout(function() { ref.callAgain(); }, 1000000); } } buggyObject.callAgain(); 错误对象 = 空; } </script>
定时器回调及其绑定对象 buggyObject 在超时之前不会被释放。在这种情况下,定时器会重置自身并永久运行,因此即使没有对原始对象的引用,其内存空间也永远不会被回收。
解决方案:为了避免这种情况,请遵循 JavaScript 最佳实践,在 setTimeout/setInterval 调用中提供引用,例如,函数需要执行并完成后才能被垃圾回收。一旦不再需要它们,请显式调用以删除它们。除了像 Internet Explorer 这样的老旧浏览器外,大多数现代浏览器(例如 Chrome 和 Firefox)都不会遇到此问题。此外,像 jQuery 这样的库也会在内部处理此问题,以确保不会产生 JavaScript 内存泄漏。
-
旧版浏览器和漏洞百出的扩展程序
旧版浏览器,尤其是 IE6-7,因垃圾收集器算法无法处理 DOM 对象和 JavaScript 对象之间的循环引用而导致内存泄漏,臭名昭著。有时,错误的浏览器扩展程序也可能造成内存泄漏。例如,Firefox 中的 FlashGot 扩展程序就曾造成内存泄漏。 -
事件监听器
addEventListener() 方法将事件处理程序附加到特定元素。您可以向单个元素添加多个事件处理程序。有时,如果 DOM 元素及其对应的事件监听器生命周期不同,可能会导致内存泄漏。 -
缓存:
大型表、数组和列表中重复使用的对象存储在缓存中。缓存大小如果无限增长,则会导致高内存消耗,因为无法被垃圾回收。为避免这种情况,请确保指定其大小的上限。
使用 Chrome DevTools 查找 JavaScript 内存泄漏
在本节中,我们将学习如何使用 Chrome DevTools 通过以下 3 个开发人员工具来识别代码中的 JavaScript 内存泄漏:
- 时间线视图
- 堆内存分析器
- 分配时间表(或分配分析器)
首先打开您选择的任何代码编辑器,使用下面的代码创建一个 HTML 文档,然后在 chrome 浏览器中打开它。
<html> <head> <!------ JQuery 3.3.1 ------> <script src="https://code.jquery.com/jquery-3.3.1.min.js"integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="crossorigin="anonymous"></script> </head> <主体> <button id="leak-button">开始</button> <button id="stop-button">停止</button> <脚本> var foo = []; 函数增长(){ foo.push(new Array(1000000).join('foo')); 如果(正在运行) 设置超时(增长,2000); } var 运行 = false; $('#leak-button').click(function () { 正在运行=真; 生长(); }); $('#stop-button').click(function () { 正在运行=假; }); </script> </body> </html>
点击“开始”按钮后,会调用 grow() 函数,该函数会追加一个长度为 1000000 个字符的字符串。变量 foo 是一个全局变量,由于 grow() 函数每秒都会递归调用它,因此不会被垃圾回收。点击“停止”按钮会将运行标志更改为 false,以停止递归函数调用。每次函数调用结束时,垃圾回收器都会释放内存,但变量 foo 不会被回收,从而导致内存泄漏。
- 时间线视图我们将使用的第一个用于识别内存泄漏的 Chrome 开发者工具是“时间线”。时间线集中概览代码活动,帮助您分析加载、脚本编写、渲染等方面的时间消耗。您可以使用时间线记录选项可视化内存泄漏,并比较垃圾回收前后的内存使用情况数据。
- 步骤 1:在 Chrome 浏览器中打开我们的 HTML 文档,然后按 Ctrl+Shift+I 打开开发者工具。
- 步骤2:点击“性能”选项卡,打开时间线概览窗口。按 Ctrl+E 或点击录制按钮开始时间线录制。打开您的网页,然后点击“开始按钮”。
- 步骤3:等待15秒,然后点击网页上的“停止按钮”。等待10秒,然后点击右侧的垃圾图标,手动触发垃圾收集器并停止录制。
如上图所示,内存使用量随时间推移而上升。每次出现峰值都表明 grow 函数被调用了。但函数执行结束后,垃圾收集器会清理除全局变量 foo 之外的大部分垃圾。内存使用量持续增加,即使程序结束后,内存使用量最终也没有降到初始状态。
- 堆内存分析器 “堆内存分析器”显示 JavaScript 对象及其相关 DOM 节点的内存分布情况。您可以使用它来截取堆快照、分析内存图表、比较快照数据并查找内存泄漏。
- 步骤 1:按 Ctrl+Shift+I 打开 Chrome Dev Tools 并点击内存面板。
- 第 2 步:选择“堆快照”选项并单击开始。
- 步骤3:点击网页上的“开始”按钮,然后选择内存面板左上角的“记录堆快照”按钮。等待10-15秒,然后点击网页上的“关闭”按钮。继续操作,并拍摄第二个堆快照。
- 步骤 4:从下拉菜单中选择“比较”选项(而不是“摘要”),并搜索分离的 DOM 元素。这将有助于识别 DOM 引用溢出的情况。在我们的示例中,没有这种情况(示例中的内存泄漏是由于全局变量造成的)。
- 分配时间线/分析器分配分析器将堆内存分析器的快照信息与时间线面板的增量跟踪相结合。该工具会在整个记录过程中定期(每 50 毫秒一次)拍摄堆快照,并在记录结束时拍摄最终快照。研究生成的图表,查找可疑的内存分配。
在新版 Chrome 中,“配置文件”选项卡已被移除。现在您可以在内存面板中找到分配分析器工具,而不是在配置文件面板中。
- 步骤 1:按 Ctrl+Shift+I 打开 Chrome Dev Tools 并点击内存面板。
- 第 2 步:选择“时间线上的分配仪器”选项并单击开始。
- 步骤 3:点击“记录”,等待分配分析器定期自动拍摄快照。分析生成的图表,查找可疑的内存分配。
通过修改代码消除内存泄漏
现在我们已经成功使用 chrome 开发工具来识别代码中的内存泄漏,我们需要调整代码来消除这种泄漏。
正如前面“内存泄漏的原因”部分所讨论的,我们看到全局变量永远不会被垃圾收集器处理,尤其是在函数递归调用它们时。我们有三种方法可以修改代码:
- 不再需要全局变量 foo 后,将其设置为 null。
- 使用“let”而不是“var”来声明变量 foo。与 var 不同,let 具有块级作用域。它会被垃圾回收。
-
将 foo 变量和 grow() 函数声明放入点击事件处理程序中。
<脚本> var 运行 = false; $('#leak-button').click(function () { /* 变量 foo 和 grow 函数现在在 click 事件处理程序中声明。它们不再具有全局作用域。它们现在具有局部作用域,因此不会导致内存泄漏*/ var foo = []; 函数增长(){ foo.push(new Array(1000000).join('foo')); 如果(正在运行) 设置超时(增长,2000); } 正在运行=真; 生长(); }); $('#stop-button').click(function () { 正在运行=假; }); </script>
结论
完全避免 JavaScript 内存泄漏几乎是不可能的,尤其是在大型应用程序中。轻微的泄漏不会对应用程序的性能产生任何显著的影响。此外,像 Chrome 和 Firefox 这样的现代浏览器配备了先进的垃圾收集器算法,可以很好地自动消除内存泄漏。但这并不意味着开发人员必须忽视高效的内存管理。良好的编码实践有助于从开发阶段就有效控制泄漏的可能性,从而避免日后出现问题。使用 Chrome 开发者工具尽可能多地识别 JavaScript 内存泄漏,以提供卓越的用户体验,避免任何卡顿或崩溃。
原文来源:LambdaTest 博客
文章来源:https://dev.to/lambdatest/eradicating-memory-leaks-in-javascript-1af9