1. 原文

Tracing from JS to the DOM and back again

2. 摘要翻译

Summary

在Chrome 66里,查找内存泄露问题越来越简单了。Chrome的开发者工具现在可以跟踪并快照出C++ DOM对象,并将所有可达的DOM对象以及它们的引用显示出来。这个功能是V8垃圾回收系统中的新C++追踪系统带来的新特性之一。

Background

一处内存泄露发生在某个未被使用的对象,因某些从其他对象而来的未被意识到的引用而导致。在WEB页面内的内存泄露经常发生在JS对象和DOM对象之间的交互中。

下面的玩具代码范例显示了一个处内存泄露,程序员忘记了注销一个事件监听器。当某个对象被事件监听器引用的时候,将会无法被垃圾回收。在例子中,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: forgot to unregister `leakingListener`.
});

内存泄露的iframe窗口也仍旧保持JS对象存活。

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

retaining paths的概念对于查找内存泄露来说非常重要。retaining paths是指一连串链起来的阻碍到泄露对象内存回收的对象。这条对象链起始于一个根节点对象,例如主窗口的global对象,结束于泄露的对象。每一个对象链中的中间对象都有一个直接引用链接到链上的下一个对象。举例来说,iframe中的泄露对象的retaining path看上去应该类似于:

注意这里的retaining path穿越了JS和DOM的边界(以绿色和红色高亮表示,分别)两次。JS对象存活在V8堆内存中,而在Chrome中DOM对象则是C++对象。

DevTools heap snapshot

我们可以通过在开发者工具中打一个堆快照来获得任何对象的retaining path。堆快照之前只能获取V8堆内存的所有对象。直到最近的版本之前,它都只能获得粗略的C++ DOM对象信息。举例来说,Chrome 65为泄露对象显示了一个不完整的retaining path:

只有第一行是精确的:泄露对象确实是存储在iframe的window对象的global_variable里的。剩余的多行只有粗略的retaining path,使得查找内存泄露点非常困难。

而Chrome 66版本的开发者工具可以在JS对象和C++ DOM对象之间精确追踪、捕获引用信息。这是基于随早前被引入的跨组件垃圾回收机制一起被引入的强大C++对象追踪机制而来的功能。作为结果,开发者工机中显示的retaining path现在可以说非常精确了:

Under the hood: cross-component tracing

DOM对象是由Blink —— Chrome的渲染引擎维护的,它负责将DOM转换成页面上真正显示的文本和图片。Blink已经它的转换功能由C++编写,这意味着DOM不能直接暴露给JS。取而代之,DOM对象拥有两个部分:一个V8 wrapper对象在JS中可以访问,另一个C++对象则在DOM中作为节点可以访问。这两个对象都有引用可以直接访问对方。跨不同组件,例如Blink和V8,来确定对象的存活和所有权是很困难的,因为所有涉及的部分都必须在哪些对象仍旧存活而哪些可以被回收一事上达成一致。

在Chrome 65和更早的版本上(例如2017年3月之前),Chrome使用一套被称为object grouping的机制来确定存活。对象经由文档的嵌套关系来进行分组。只要其中的任何一个对象经由retaining path保持存活,则这个对象组包括其中所有的对象都会保持存活。因为DOM节点经常会被他们的父容器引用,形成所谓的DOM trees,这种做法是合理的。然而,这种抽象实现方式删除了所有真实的retaining path,导致图2中的Debug非常困难。而某些对象的使用方式虽然并未被包含在这个范例中,例如JS中被用作事件监听器的闭包,仍旧会因为这种设计方式而变得非常难以处理,并且导致多个JS wrapper对象被过早回收的Bug(这些对象被替换成空的JS wrapper,丢失了所有的属性)。

从Chrome 57开始,这种实现方式被跨组件追踪机制替代,这套机制藉由追踪从JS对象到C++实现的DOM对象来确定对象的存活。我们渐进地在C++侧实现这套追踪机制,并引入了写屏障来避免在之前的博文中谈论过的stop-the-world问题。跨组件追踪机制不仅仅提供了更好的延迟性能,还能更好地跨组件确定对象的存活状态,并解决一系列之前容易导致内存泄露的应用场景。在此之上,它提供了开发者工具在获取快照的时候精确获得如图3中显示的那样的DOM对象的能力。

请尝试使用!我们很乐意听到你的反馈。

EOF