跳到内容

内存管理

深入探讨 WebKit 所使用的内存管理系统。

概览

在 WebKit 中,当一个对象被另一个对象拥有时,我们通常使用 std::unique_ptr 来表示该所有权。WebKit 在其他情况下使用两种主要的管理策略:垃圾回收引用计数

WebKit 中的引用计数

概览

大多数 WebCore 对象不由 JavaScriptCore 的垃圾回收器管理。相反,我们使用引用计数。我们有两种引用计数指针类型:RefPtrRef。RefPtr 旨在行为类似于 C++ 指针,而 Ref 旨在行为类似于 C++ 引用,这意味着前者可以设置为 nullptr,但后者不能。

Ref<A> a1; // This will result in compilation error.
RefPtr<A> a2; // This is okay.
Ref<A> a3 = A::create(); // This is okay.
a3->f(); // Calls f() on an instance of A.
A* a4 = a3.ptr();
a4 = a2.get();

与 C++ 的std::shared_ptr 不同,引用计数的实现是托管对象的一部分。对象要与 RefPtrRef 一起使用的要求如下:

  • 它实现了 ref()deref() 成员函数
  • 每次调用 ref()deref() 将增加和减少其内部引用计数器
  • ref() 的初始调用隐含在 new 中,即在对象被分配并调用构造函数之后;这意味着引用计数从 1 开始。
  • 当其内部引用计数器达到 0 时调用 deref() 时,“this”对象将被析构并删除。

有一个方便的超模板类 RefCounted<T>,它会自动为任何继承类 T 实现此行为。

如何使用 RefPtr 和 Ref

当通过 new 创建一个实现了 RefPtr 和 Ref 所需语义的对象时,我们必须立即使用 adoptRef 将其采纳Ref 类型,如下所示

class A : public RefCounted<A> {
public:
    int m_foo;

    int f() { return m_foo; }

    static Ref<A> create() { return adoptRef(*new A); }
private:
    A() = default;
};

这将在不调用新创建对象的 ref() 的情况下创建 Ref 实例,从而避免了不必要的从 0 到 1 的增量。WebKit 的编码约定是将构造函数设为私有,并添加一个静态 create 函数,该函数在采纳后返回引用计数对象的实例。

请注意,由于 C++11 中的拷贝省略,返回 RefPtr 或 Ref 是高效的,并且以下示例不会使用拷贝构造函数创建临时 Ref 对象)。

Ref<A> a = A::create();

将引用计数对象的所有权传递给函数时,使用右值引用和 WTFMove(等效于带有一些安全检查的 std::move),并且当调用者保证使对象保持活动状态时,使用常规引用,如下所示

class B {
public:
    void setA(Ref<A>&& a) { m_a = WTFMove(a); }
private:
    Ref<A> m_a;
};

...

void createA(B& b) {
    b.setA(A::create());
}

请注意,由于拷贝省略,A::create 上没有 WTFMove

转发 ref 和 deref

如上所述,使用 RefPtrRef 管理的对象不一定必须继承自 RefCounted。一种常见的替代方法是将 refderef 调用转发到拥有所有权的其他对象。例如,在以下示例中,Parent 类拥有 Child 类。当有人将 Child 存储在 RefRefPtr 中时,Parent 的引用计数会代表 Child 增加和减少。当指向任一对象的最后一个 RefRefPtr 消失时,ParentChild 都会被析构。

class Parent : RefCounted<Parent> {
public:
    static Ref<Parent> create() { return adoptRef(*new Parent); }

    Child& child() {
        if (!m_child)
            m_child = makeUnique<Child>(*this);
        return m_child
    }

private:
    std::unique_ptr<Child> m_child;    
};

class Child {
public:
    ref() { m_parent.ref(); }
    deref() { m_parent.deref(); }

private:
    Child(Parent& parent) : m_parent(parent) { }
    friend class Parent;

    Parent& m_parent;
}

引用循环

当对象 X 持有指向另一个对象 Y 的 RefRefPtr,而对象 Y 又通过 RefRefPtr 拥有 X 时,就会发生引用循环。例如,以下代码会导致一个简单的内存泄漏,因为 A 持有 B 的 Ref,而 B 又持有 A 的 Ref

class A : RefCounted<A> {
public:
    static Ref<A> create() { return adoptRef(*new A); }
    B& b() {
        if (!m_b)
            m_b = B::create(*this);
        return m_b.get();
    }
private:
    Ref<B> m_b;
};

class B : RefCounted<B> {
public:
    static Ref<B> create(A& a) { return adoptRef(*new B(a)); }

private:
    B(A& a) : m_a(a) { }
    Ref<A> m_a;
};

我们对 WebCore 中垃圾回收的对象需要特别小心,因为它们通常会在 C++ 代码中没有任何 RefRefPtr 的情况下使其他引用计数的 C++ 对象保持活动状态。因此,在 WebCore 代码中强引用 JS 值几乎总是错误的。

ProtectedThis 模式

因为 WebCore 中的许多对象都由树形数据结构管理,所以操作此类树形数据结构节点的函数最终可能会删除自身(this 对象)。这是非常不希望的,因为此类代码通常最终会出现使用后释放的错误。

为了防止这类错误,我们通常采用一种策略,即添加 RefRefPtr 类型的 protectedThis 局部变量,并如下所示存储 this 对象

ExceptionOr<void> ContainerNode::removeChild(Node& oldChild)
{
    // Check that this node is not "floating".
    // If it is, it can be deleted as a side effect of sending mutation events.
    ASSERT(refCount() || parentOrShadowHostNode());

    Ref<ContainerNode> protectedThis(*this);

    // NotFoundError: Raised if oldChild is not a child of this node.
    if (oldChild.parentNode() != this)
        return Exception { NotFoundError };

    if (!removeNodeWithScriptAssertion(oldChild, ChildChange::Source::API))
        return Exception { NotFoundError };

    rebuildSVGExtensionsElementsIfNecessary();
    dispatchSubtreeModifiedEvent();

    return { };
}

在这段代码中,移除 oldChild 的操作可以执行任意 JavaScript 并删除 this 对象。因此,如果我们没有 protectedThis,那么在 this 对象已经被释放后,rebuildSVGExtensionsElementsIfNecessarydispatchSubtreeModifiedEvent 可能会被调用。而 protectedThis 保证了该对象的引用计数至少为 1(因为 Ref 的构造函数将引用计数增加了 1)。

这种模式也可用于其他需要在代码块内防止被析构的对象。在以下代码中,childToRemove 是使用 C++ 引用传入的。因为此函数将从 this 容器节点中移除此子节点,所以它可能会在函数仍在运行时被析构。为了防止出现任何使用后释放的错误,此函数将其存储在 Ref(protectedChildToRemove)中,这保证了该对象在函数将控制权返回给调用者之前保持活动状态

ALWAYS_INLINE bool ContainerNode::removeNodeWithScriptAssertion(Node& childToRemove, ChildChangeSource source)
{
    Ref<Node> protectedChildToRemove(childToRemove);
    ASSERT_WITH_SECURITY_IMPLICATION(childToRemove.parentNode() == this);
    {
        ScriptDisallowedScope::InMainThread scriptDisallowedScope;
        ChildListMutationScope(*this).willRemoveChild(childToRemove);
    }
    ..

另请参阅Darin 的 RefPtr 基础知识以获取更多信息。

WebKit 中的弱指针

在某些情况下,需要表达两个对象之间的关系,而无需将其生命周期绑定在一起。在这些情况下,WeakPtr 很有用。与 std::weak_ptr 类似,此类创建对对象的非拥有引用。有许多遗留代码为此目的使用原始指针,但目前正在努力始终使用 WeakPtr 代替,因此请在您编写的新代码中这样做。

要创建对象的 WeakPtr,我们需要使其类继承自 CanMakeWeakPtr,如下所示

class A : CanMakeWeakPtr<A> { }

...

function foo(A& a) {
    WeakPtr<A> weakA = a;
}

当引用的对象被删除时,解引用 WeakPtr 将返回 nullptr。因为创建 WeakPtr 会分配一个额外的 WeakPtrImpl 对象,所以您仍然有责任在适当的时候处理 WeakPtr

WeakHashSet

虽然普通的 HashSet 不支持将 WeakPtr 作为其元素,但有一个专门的 WeakHashSet 类,它支持弱引用一组元素。因为当引用的对象被删除时,WeakHashSet 不会收到通知,所以 WeakHashSet 的用户/所有者仍有责任从集合中删除相关条目。否则,WeakHashSet 将一直持有 WeakPtrImpl,直到调用 computeSize 或发生重新哈希。

WeakHashMap

WeakHashSet 类似,WeakHashMap 是一个专门的类,用于将 WeakPtr 键映射到值。因为当引用的对象被删除时,WeakHashMap 不会收到通知,所以 WeakHashMap 的用户/所有者仍有责任从映射中删除相关条目。否则,WeakPtrImpl 及其值所使用的内存空间将不会被释放,直到下一次重新哈希或摊销清理周期到来(基于读写操作的总数)。

DOM 节点的引用计数

Node 是一个引用计数对象,但有点特殊。它有一个单独的布尔标志,指示它是否具有节点。只要 Node 对象的引用计数大于 0 或设置了此布尔标志,它就不会被删除。布尔标志有效地充当了从父 Node 到其每个 NodeRefPtr。我们这样做是因为 Node 只知道其第一个子节点最后一个子节点,并且每个兄弟节点都实现为双向链表,以实现兄弟节点的高效插入删除和遍历。

从概念上讲,每个 Node 都由其根节点和对其的外部引用保持活动状态,我们将根节点用作每个 Node 的 JS 包装器的不透明根。因此,只要节点本身或任何共享相同根节点的其他节点被垃圾回收器访问,每个 Node 的 JS 包装器就会保持活动状态。

另一方面,Node 不会通过引用计数或布尔标志使其父节点或任何包含 Shadow 的祖先 Node 保持活动状态,尽管 JavaScript API 要求如此。为了实现这种 DOM API 行为,如果尚未有 JS 包装器,WebKit 将为每个正在从其父节点中移除的 Node 创建一个 JS 包装器。作为根节点(新移除的子树)的 Node 是其 JS 包装器的不透明根,如果移除的子树中存在任何需要保持活动的 JS 包装器,垃圾回收器将访问此不透明根。实际上,如果新移除的子树包含任何带有活动 JS 包装器的节点,这会使新的根节点及其所有后代节点保持活动状态,从而保留了 API 契约。

重要的是要认识到,在 Node 子类或由 Node 直接拥有的对象中存储指向另一个 NodeRefRefPtr 可能会创建引用循环,或者一个永远不会被清除的引用。无法保证每个节点将来都会从Document断开连接,并且某些 Node 只要存在,就可能始终具有父节点或子节点。只有在时间上受限制的情况下,才允许在 Node 子类或其拥有的其他数据结构中存储指向另一个 NodeRefRefPtr。例如,可以将 RefRefPtr 存储在排队的事件循环任务中。在所有其他情况下,应使用 WeakPtr 引用另一个 Node,并且应使用 JS 包装器关系(例如不透明根)来保持 Node 对象之间的生命周期联系。

同样重要的是要指出,通过在排队的事件循环任务中存储 RefRefPtr 来保持 C++ Node 对象活动,并不能使其 JS 包装器保持活动,并可能导致概念上活动的对象的 JS 包装器被错误地垃圾回收。为了避免这个问题,请改用GCReachableRef,以在一段时间内临时持有对节点的强引用。例如,HTMLTextFormControlElement::scheduleSelectEvent() 使用 GCReachableRef 在事件循环任务中触发事件

void HTMLTextFormControlElement::scheduleSelectEvent()
{
    document().eventLoop().queueTask(TaskSource::UserInteraction, [protectedThis = GCReachableRef { *this }] {
        protectedThis->dispatchEvent(Event::create(eventNames().selectEvent, Event::CanBubble::Yes, Event::IsCancelable::No));
    });
}

另一种方法是,我们可以使其继承自活动 DOM 对象,并使用以下函数之一来排队任务或事件

Document 节点还有一个特殊之处,因为每个Node都可以通过ownerDocument 属性访问文档,无论该 Node 是否连接到文档。每个文档都有外部客户端使用的常规引用计数和引用节点计数。文档的引用节点计数是其 ownerDocument 是该文档的节点总数。只要文档的引用计数和节点引用计数大于 0,它就会被保持活动状态。此外,当常规引用计数变为 0 时,它会清除各种状态,包括对拥有节点的内部引用,以断开与它们的任何引用循环。从这个意义上说,文档是特殊的,它可以存储指向其他节点的 RefPtr。请注意,虽然引用节点计数作用类似于从每个 Node 到其所有者 DocumentRef,但存储指向相同文档或任何其他文档的 RefRefPtr 将创建引用循环,应避免这样做,除非像上面提到的那样,它在时间上是有限的。