使用 Web Inspector 进行内存调试
Web Inspector 现在包含两个新的时间线,用于调试网页的内存使用情况。第一个是高级内存时间线,旨在帮助开发人员更好地了解其网页的内存特性、识别峰值并检测总体内存增长。第二个是详细的 JavaScript 分配时间线,允许开发人员记录、比较和分析 JavaScript 堆的快照;这对于查找和解决 JavaScript 内存增长和泄漏问题非常有用。
内存时间线
网页的形态和大小各不相同。有些可能是带有大量图像和演示动画的静态页面,这些页面可能会导致内存峰值,从而在内存受限的平台上导致突然终止。或者它们可能是长期运行的交互式 JavaScript 应用程序,这些应用程序一开始占用内存很少,但随着时间的推移会积累内存,并在长期使用后变慢。高级内存时间线有助于对内存使用方式进行分类,并识别可能需要进一步调查的内容。
内存时间线显示了所检查页面的总内存占用。它将总内存分为四类:
- JavaScript – JavaScript 堆大小。这包括 JavaScript 对象、字符串、函数以及与这些对象相关的相应引擎数据。此部分的内存大小只会因垃圾回收而减小。
-
图像 – 解码图像数据。通常这与视口中可见的图像相对应。
-
图层 – 图形图层数据。这包括 WebKit 的瓦片网格、使用合成图层的页面内容以及引擎可能作为实现细节创建的任何其他图层。
-
页面 – 所有其他内存。这包括与 DOM、样式、渲染数据、内存缓存、系统分配等相关的引擎内存。
我们认为这些类别很好地概述了网页中使用的绝大多数内存。在某些页面中,图层和图像数据将是最大的类别,但在其他页面中,JavaScript 堆可能更大。有了这个细分,您可以开始调查峰值和增长。
在调查内存峰值时,最大比较内存图表会很有用。在顶部选择一个特定的时间范围后,您可以看到所选结束时的总内存使用量与录制期间观察到的内存峰值相比如何。当应用程序收到内存压力事件时,时间线还将包含标记。
一旦您有了细分,您就知道了从哪里入手进行缩减。对于大型图像数据,检查您的图像资源。要调试图层数据,请使用 Web Inspector 启用图层边框,以突出显示页面上使用合成图层的可见内容。为了检查 JavaScript 堆,我们有了新的 JavaScript 分配时间线。在我们查看这个新时间线之前,让我们回顾一下对 JavaScript 对象生命周期的理解。
JavaScript 泄漏
JavaScript 是一种垃圾回收语言。当对象被创建和修改时,引擎会自动为对象分配任何必要的内存。一旦一个对象不再被引用,引擎就可以回收(“收集”)为该对象分配的内存。
为了确定一个对象是否应该保持活动或被回收,引擎必须检查该对象是否可以从一组根对象访问。网页中的 window
对象是一个根对象。引擎可能拥有自己的其他根对象的内部列表。JavaScriptCore 包含一个保守型垃圾收集器,因此它将堆栈上指向堆分配对象的任何地址视为根。
通过跟踪从这些根对象到其他对象的引用,并递归地跟踪它们引用的对象,引擎可以标记所有可访问(“活动”)且应该保持活动状态的对象。最后,堆中所有未标记的对象都是不可访问(“死”)的,可以被回收。
随着新对象的创建和引用,JavaScript 应用程序的内存会增长。当不再需要的对象仍然被引用时,就会发生内存泄漏,导致它们的内存无法释放。在 JavaScript 中,如果应用程序逻辑未能清除对不再需要的对象的引用,可能会无意中发生这种情况。
许多泄漏一旦指出可能就会显而易见。例如,在这个代码片段中,一个全局变量持有 NodeList 并将保持活动状态
function addClickHandlers() {
paragraphs = document.querySelectorAll("p");
for (let p of paragraphs)
p.addEventListener("click", () => console.log("clicked"));
}
addClickHandlers();
函数返回后,paragraphs
中的 NodeList 就不再需要了,但由于它意外地创建了一个全局变量 window.paragraphs
,导致它发生了泄漏。像这里意外创建全局变量这样的简单错误,可以通过使用严格模式 JavaScript 来捕获。然而,相同的模式可能不太明显
class ElementDebugger {
constructor() { this.enabled = false; }
enable() { this.enabled = true; }
disable() { this.enabled = false; }
addElements(selector) {
this.elements = document.querySelectorAll(selector);
for (let elem of this.elements) {
elem.addEventListener("click", (event) => {
console.log("clicked", elem);
if (this.enabled)
debugger;
});
}
}
}
let paragraphDebugger = new ElementDebugger();
paragraphDebugger.addElements("p");
paragraphDebugger.enable();
在这个例子中,我们希望 paragraphDebugger
全局对象保持活动状态,以便我们可以在需要时启用或禁用它。然而,elements
NodeList 可能会无意中被保留。为了避免这里的泄漏,我们可以使用 let elements
为列表创建一个局部变量,或者在确定不再需要时显式清除引用,使用 this.elements = null
或 this.elements = undefined
。
delete
运算符,但这会带来其自身的性能损失。对于对象的命名属性,应避免使用 delete
,而应将属性设置为 null 或 undefined。上面的示例包含了对对象的显式直接引用(变量和对象属性)。然而,闭包引用的数据并不显式,很容易遇到对象不必要地被闭包捕获并导致内存增长的情况
class MessageList {
constructor() { this.messages = []; }
addMessage(xhr) {
this.messages.push({
text() { return xhr.responseText; }
});
}
}
window.messageList = new MessageList();
// Add messages from completed XHRs.
messageList.addMessage(xhr1);
messageList.addMessage(xhr2);
在这个例子中,泄漏并不那么明显。在 addMessage
中,我们将一个对象添加到我们的消息列表中。每条消息都有一个 text
方法,用于获取该消息的文本。然而,我们将此方法创建为一个闭包 function() { return xhr.responseText; }
。此函数捕获了 xhr
,因此完整的 XMLHttpRequest
对象被此闭包保留,尽管我们只需要其数据的一小部分。这是不必要的浪费。
更糟糕的是,这个 XMLHttpRequest
可以拥有它保留的事件监听器,而这些事件监听器也可能是闭包,它们会保留更多的对象!所有这些,当我们只需要保留文本时。为了避免在这个例子中保留 XMLHttpRequest
,我们可以避免在我们的闭包中捕获它,而是只保留我们需要的数据
addMessage(xhr) {
let messageText = xhr.responseText;
this.messages.push({
text() { return messageText; }
});
}
对于许多网页来说,小的内存增长并不是问题。页面会使用一点额外的内存,但当用户导航时,它会被清理掉。内存增长对于长期运行的 JavaScript 应用程序来说是一个更大的问题。随着时间的推移,小到中等的内存泄漏会累积,应用程序的性能可能会开始下降。最终,内存占用可能会达到内存受限设备的极限并导致崩溃。
JavaScript 分配时间线
JavaScript 分配时间线收集 JavaScript 堆的快照,然后可以对其进行分析。时间线在录制开始时、录制期间以及录制结束时都会拍摄快照。您还可以使用时间线导航栏中的按钮或在代码中调用 console.takeHeapSnapshot(<label>)
。
堆快照执行完整的垃圾回收并构建一个由节点(活动 JavaScript 对象)和有向边(节点之间的引用)组成的图。节点数据包含有关对象的一些基本信息:唯一标识符、类型和大小。边数据使我们以后能够准确地知道这个对象是如何保持活性的,因此我们为边记录一个名称,这在显示此路径时会很有用。例如,如果边是一个对象属性,我们将记录属性名称;如果它是一个捕获的闭包变量,我们将记录变量的名称。
快照本身不会保留任何 JavaScript 对象。这对于检测泄漏很重要;您希望允许对象被收集,以便以后可以识别未被收集的泄漏对象。
当您深入研究单个快照时,我们提供了几种不同的视图,让您可以探索和检查。有对象图视图,它允许您从一组根对象(即 Window 对象)探索堆。然后是实例视图,它按类对对象进行分组。由于我们连接到实时页面,如果某个特定对象仍然存在,我们可以提供该对象的预览,您甚至可以将值记录到 Web Inspector 的控制台并直接与该对象交互。已收集的对象会从实例视图的顶层移除。
实例视图是您花费大部分时间的地方,因为它让您无论对象路径有多深或多复杂,都能快速访问任何对象。它的分类也使得识别潜在问题变得容易。例如,如果您注意到存在多个 XMLHttpRequest
或 Promise
实例,但您不期望存在任何此类对象,您可以立即调查它们。此视图也适合按大小排序,使您能够快速关注快照中最大的对象,这在发生一组泄漏对象时(其中较大的对象通常是泄漏的根本原因)可以节省分析时间。
当展开一个实例时,您会看到它引用的其他对象。显式引用,例如属性名或数组索引,将具有名称。隐式或内部引用,例如保留在封闭作用域中定义的变量的闭包,将没有名称。
每个实例都有两个大小:自身大小和保留大小。自身大小仅是单个实例的大小。这通常非常小,足以容纳对象的状态。对于字符串和表示编译代码的某些系统对象,它可能更大。保留大小是对象的大小加上它所支配的所有节点的大小(即此特定对象单独保持活动状态的对象)。理解保留大小的一个简单方法是,如果该对象现在被删除,保留大小就是将被回收的内存量。Mozilla 开发者网络 (MDN) 提供了关于 JavaScript 中支配者的精彩描述。
创建一些对象后,像这样
class Person {
constructor(name) {
this.name = name;
}
}
class Group {
constructor(...members) {
this.members = members;
}
}
let shared = new Person("Shared");
let p1 = new Person("Person 1");
let p2 = new Person("Person 2");
let p3 = new Person("Person 3");
p1.parent = p2.parent = p3.parent = shared;
let group = new Group(p1, p2, p3);
我们可以找到 group
对象实例,展开它并查看它直接支配的对象(members
数组),如果我们继续展开,可以看到它支配的其他对象(p1
、p2
、p3
、shared
),这些对象最终构成了它的总保留大小。
内存工具最强大的方面之一是能够确定特定对象的路径,这样您就可以推断是什么让它保持活动状态。当您将鼠标悬停在实例的唯一标识符上时,您会看到一个弹出窗口,显示从根到该实例的最短路径。如果您怀疑某个对象应该已经消失了,那么这条路径对于理解为什么该对象仍然存在将是无价的。
您可以单击唯一标识符将实时值记录到控制台,以便直接与它交互。此外,对于函数,您可以单击“goto”箭头直接跳转到函数声明。
检测 JavaScript 泄漏
堆快照比较是检测泄漏和意外内存增长的有效技术。该技术通常被称为分代分析。分析单个堆快照以查找泄漏会非常耗时,而且在包含大量对象的页面上,小泄漏很难发现。这就是比较的优势所在,它让您可以专注于仅在两个时间点之间创建的对象。
分代分析在比较预期内存中性或内存增长最小的操作前后两个快照时效果最佳。例如,显示和隐藏页面的某个部分、创建和删除评论、切换偏好设置。您不会期望这些操作导致大量内存增长。但如果您重复执行它们并且确实发生了增长,那么比较操作前后的快照将揭示尚未被回收的已创建对象,这些对象可能是泄漏。
简而言之,步骤如下:
- 使您的 Web 应用程序进入稳定状态。
- 开始录制 JavaScript 分配时间线。
- 执行预期内存中性的操作。每次重复都拍摄一个快照。
- 停止录制。
最好重复操作多次,最终得到多个快照。通常,应用程序在第一次执行操作时会填充缓存,或者同样可能的是,JavaScript 引擎本身可能会在早期创建自己的内部对象。如果您执行操作五次,但内存只在第一次增加,那么可能没有问题;但如果您每次都看到稳定的增加,那么您很可能发现了泄漏。这种分析方式与 console.takeHeapSnapshot()
配合使用效果很好,因为它使得精确控制前后时间点变得容易。
要比较两个快照,请从快照列表开始。单击“比较”按钮,选择一个基线快照(之前)和一个比较快照(之后),您将获得熟悉的实例视图进行比较。比较仅显示在该时间范围内创建且仍然存活的对象。
结合上面的例子,很容易看出多个 XMLHttpRequest
对象被保留,可以看到是闭包使它们保持活动状态,跳转到捕获它们的函数,然后解决问题。
实现细节
暴露 JavaScriptCore 堆中的所有对象会揭示堆中内部的、引擎分配的对象。这些对象显示为没有预览的节点,名称如 Structure
和 FunctionExecutable
。我们认为包含这些对象是有用的,可以准确地显示它们如何对暴露给页面的实际对象的保留大小做出贡献。然而,请记住,它们的名称甚至它们的存在完全是内部实现细节,可能会发生变化。因此,实例视图从顶级类别中过滤掉了这些对象,让您可以只关注您可控制的对象。

在 JavaScriptCore 中,数字和布尔值等原始值不作为堆对象分配。因此,它们不会出现在任何快照中。相反,它们作为编码值存储在 JavaScript 对象中。另一方面,字符串原始值是作为堆对象分配的,并将出现在快照中。您始终可以将实时值记录到控制台并查看其所有属性和值。
我们付出了巨大努力,将快照的内存和性能成本降到最低。毕竟,如果您正在调试内存问题,您不会希望内存工具引入更多的内存压力。但是,您应该意识到同时调试内存和性能的准确性不如单独测量它们。Web Inspector 具有让您打开和关闭各个时间线的能力,以获得尽可能最准确的记录。
与其他可能昂贵的 console
API 一样,除非 Web Inspector 打开,否则 console.takeHeapSnapshot
不起作用。话虽如此,在生产环境中避免包含不必要的调试代码始终是最佳实践。
反馈
您可以在最新的 Safari 技术预览版中试用新的内存时间线。告诉我们它们对您有什么帮助。通过 Twitter(@webkit, @JosephPecoraro)或提交错误发送反馈。