柚木

Chrome 66 使用 DevTools 跟踪 JS 对象和 DOM 对象的引用

概要

在 Chrome 66 中调试内存泄漏变得容易得多。Chrome 的 DevTools 的快照(snapshot)现在可以跟踪 C++ DOM 对象,并显示 JavaScript 引用的所有可访问的 DOM 对象。这个特性是 V8 垃圾收集器的新 C++ DOM 跟踪机制之一。

背景

在垃圾收集系统中,如果对象已经不再使用,但是却无意中被其它对象引用,这个对象将不会被释放,就会发生内存泄漏。网页中的内存泄漏通常涉及 JavaScript 对象与 DOM 元素之间的交互。

以下 toy example 显示了开发者忘记注销事件侦听器时发生的内存泄漏。事件侦听器引用的对象都不能被垃圾收集。特别是 iframe 窗口与事件监听器一起使用时造成了内存泄漏。

// Main window: 
const iframe = document.createElement('iframe');
iframe.src = 'iframe.html';
document.body.appendChild(iframe);
iframe.addEventListener('load', function() {
  const local_variable = iframe.contentWindow;
  function leakingListener() {
    // Do something with `local_variable`. if (local_variable) {}
  }
  document.body.addEventListener('my-debug-event', leakingListener);
  document.body.removeChild(iframe);
  // BUG: 忘记取消 `leakingListener`.
});

渗漏的 iframe 窗口还会保留其所有 JavaScript 对象。

// iframe.html: 
class Leak {};
window.global_variable = new Leak();

了解保留路径(retaining path)是查找内存泄漏根本原因的重要方式。保留路径是防止垃圾收集泄漏对象的一系列对象组成的链。这个链从全局对象(例如主窗口)的根对象开始。链条在泄漏的对象处结束。链中的每个中间对象都直接引用链中的下一个对象。例如,在 iframe 中 Leak 对象的保留路径如下所示:

图1:通过 iframe 和事件监听器泄漏的对象的保留路径。

请注意,保留路径跨越 JavaScript / DOM 边界(分别以绿色/红色突出显示)两次。JavaScript 对象存在于 V8 堆中,而 DOM 对象是 Chrome 中的 C++ 对象。

DevTools 堆快照

我们可以通过在 DevTools 中获取堆快照来检查任何对象的保留路径。堆快照精确地捕获 V8 堆上的所有对象。以前,它只有关于 C++ DOM 对象的大致信息。例如,Chrome 65 为 toy example 的Leak对象显示了一个不完整的保留路径:

图2:Chrome 65 保留路径

只有第一行是精确的:Leak对象确实存储在global_variableiframe 的 Window 对象中。后面的几行信息近似于真实的保留路径,并且难以调试内存泄漏。

从 Chrome 66 开始,DevTools 通过对 C++ DOM 对象进行跟踪,并精确捕获它们之间的对象和引用。这基于之前的为跨组件垃圾收集引入的强大的 C++对象跟踪机制。因此,DevTools 中的保留路径现在实际上是这样的:

图3:Chrome 66 保留路径

这里有个简短的视频例子,可以自己打开 toy example 操作一下:

引擎之下:跨组件跟踪

DOM 对象由 Blink(Chrome 的渲染引擎)管理,该引擎负责将 DOM 转换为屏幕上的实际文本和图像。Blink 及其 DOM 实现是使用 C++ 编写的,这意味着 DOM 不能直接暴露给 JavaScript。相反,DOM 中的对象分为两部分:可用于 JavaScript 的 V8 包装对象和表示 DOM 中节点的 C++ 对象。这些对象彼此之间相互引用。确定跨多个组件(如 Blink 和 V8)的对象是否存活和对象所有权很困难,因为所有相关方都需要就哪些对象仍然活着以及哪些对象可以回收达成一致。

在 Chrome 56 及更早版本(即 2017 年 3 月)之前,Chrome 使用了一种称为**对象分组(object grouping)**的机制以确定对象的是否存活。对象根据文档的 containment 进行分组。只要某个对象通过其他保留路径保持活动状态,那么包含此对象的组就会保持活动状态。这在 DOM 节点的上下文中是有意义的,DOM 节点总是引用它们包含的文档,形成所谓的 DOM 树。但是,这种抽象移除了所有实际保留路径,使得难以调试,如图2所示。对于不适合此场景的对象,例如用作事件监听器的 JavaScript 闭包,此方法也变得很麻烦并导致各种错误,JavaScript 包装对象过早地被收集,导致它们被空的 JS 包装所取代,这些包装会失去所有的属性。

从 Chrome 57 开始,这种方法被跨组件跟踪所取代,它是通过跟踪从 JavaScript 到 DOM 的 C++实现来确定对象是否存活的机制。我们在 C++方面实施了具有写屏障的增量跟踪,以避免我们在以前的博客文章中讨论过的任何“stop-the-world”跟踪。跨组件跟踪不仅可以提供更好的延迟(latency),还可以更好地分析跨组件边界的对象是否存活,并修复多个用于导致泄漏的场景。最重要的是,它允许 DevTools 提供实际表示 DOM 的快照,如图3所示。

参考链接