第一次感觉到“循环引用”这个词在不同语言里是两副面孔,是在一次跨技术栈评审上。C++的同学指着调用链说:“这里环了,会泄漏。”旁边写着JavaScript/TypeScript的同学一脸不解:“环就环呗,垃圾回收不是会自动处理吗?”两边都觉得自己是对的,谁也说服不了谁。
其实他们都没有错,只是背后的内存管理模型完全不同。C++默认没有垃圾回收,靠引用计数或手工释放来管理生命周期;而JS/TS跑在带GC引擎的运行时里,标记可达性比计数强引用要粗暴得多,但对“环”的处理逻辑也截然不同。再加上鸿蒙生态里的ArkTS,它在语法上贴近TypeScript,却要跑到ArkTS运行时上,还要经常通过NAPI和C++层打交道,于是循环引用这个问题被混在一起之后,就成了一个非常容易踩坑的概念泥潭。
这篇内容我想把“循环引用”放到C++、JavaScript/TypeScript、ArkTS三种技术栈里逐一拆开,讲清楚它什么时候是内存杀手,什么时候只是纸老虎,什么时候又需要靠工具一层层挖出来。适合听过很多次“循环引用”但始终没系统捋过的同学,也适合正在做跨语言开发、想在写代码阶段就避开这类问题的工程师。
1. C++程序员的循环引用噩梦:引用计数为什么归不了零
1.1 一个最简单的shared_ptr循环示例
C++里最典型的循环引用场景,就是两个对象互相用shared_ptr持有对方。代码长这样:
cpp复制#include <iostream>
#include <memory>
struct Node {
std::shared_ptr<Node> next;
~Node() {
std::cout << "destroy node" << std::endl;
}
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // 注意:这里形成了环
return 0;
}
运行这个程序,你会看到控制台上一行“destroy node”都不会输出。两个Node对象在main结束后并没有被销毁,那段内存就静静躺在堆里,直到进程退出才由操作系统回收。
为什么?因为shared_ptr的析构规则是:只有当引用计数降到0,它才会删除所管理的对象。初始化时a和b的计数都是1,把a->next指向b之后,b的计数变成2;同理,a的计数也变成2。当main函数结束,局部变量a和b被销毁,各自析构一次,两个对象的引用计数都从2降到1,仍然不是0,所以不会走析构逻辑。
这里的本质原因是:每个对象都持有一个对对方的强引用,形成互相等待的僵局。用生活的话说,就像两个人互相欠钱,谁都想等对方先还钱再还对方,结果谁都不会先动,账永远清不了。
1.2 破环的三种武器:weak_ptr、裸指针与所有权重构
C++社区解决循环引用的经典思路,不是“加强回收”,而是“从结构上打掉环”。最直接的办法是引入weak_ptr。它不参与对象的强引用计数,只提供一种“借用”访问能力:
cpp复制struct Node {
std::weak_ptr<Node> next;
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a;
auto sp = a->next.lock(); // 使用时必须 lock()
if (sp) {
// 对象还活着,安全访问
}
return 0;
}
weak_ptr的生命周期语义很明确:它不会让对象“活得更久”。使用前必须调用lock()尝试提升为shared_ptr,如果对象已经被释放,提升会失败,返回空指针。这个机制避免了悬垂引用,也让原本的双向强持有变成了单向强持有加单向弱引用,环自然就断了。
第二种方式是使用裸指针。很多人一听裸指针就皱眉,但在所有权边界清晰的情况下,它恰恰是开销最低的方案。比如父对象用unique_ptr独占持有子对象,子对象只需要保存一个指向父对象的原始指针用来回调。只要父对象的生命周期严格长于子对象,这种设计就是安全的。这里的关键是:裸指针不参与任何生命周期计数,它只是一个“观察者”,不能决定对象何时死亡。
第三种方式是最根本的:重新设计对象图,让所有权关系不再成环。比如一个树形结构,父节点持有子节点的unique_ptr,子节点需要反查父节点时,优先通过一个独立的中间对象或者接口来访问,而不是让子节点反过来强持有父节点。我的习惯是:尽量让代码里的所有权关系构成一个有向无环图,环只能出现在“调用关系”层面,不能出现在“所有权”层面。
1.3 C++里为什么循环引用是“头等大事”
在其他语言里循环引用可能只是一个运行时优化问题,但在C++里它被提升到了非常高的优先级,原因在于C++没有天然的追踪式GC,它的资源释放依赖RAII和确定性析构。RAII是好东西,它让文件、锁、网络连接都能在作用域退出时自动释放,但一旦对象因为引用计数无法归零而永不析构,那么RAII管理的就不只是内存,还有文件描述符、数据库连接、互斥锁等一切资源,这些全都会被一起“冻住”。
再加上C++里大量使用指针和引用,对象之间的图形结构往往比高级语言更复杂,一个环藏在某个回调里,可能在代码评审阶段根本发现不了。所以调试C++的循环引用,通常要借助工具。我常用
