垃圾回收与C++:引用计数、标记清除、智能指针模拟GC、RCU机制
说到垃圾回收,很多C++程序员第一反应是:「我们不需要那玩意儿」。嗯,这话对了一半。C++确实没有内置的垃圾回收器,但内存管理这件事,谁也逃不掉。我做了十几年C++开发,见过太多因为内存泄漏、悬空指针搞到通宵查bug的案例。说白了,垃圾回收不是「要不要」的问题,而是「怎么在C++里优雅地实现」的问题。
今天我们就来聊聊C++世界里几种主流的「垃圾回收」思路。注意,我这里加了引号——因为C++的GC,跟Java、Go那种全自动的不太一样。我们更倾向于说「内存管理策略」。
引用计数:最朴素的自动回收
引用计数的思路很简单:每个对象身上挂一个计数器,记录有多少人正在使用它。当计数器归零,就说明没人需要它了,直接释放。
我在项目中遇到过这样一个场景:一个网络库里的连接对象,被多个模块同时引用。如果手动管理生命周期,很容易出现「A模块以为B模块还在用,B模块以为A模块会释放」的尴尬局面。用引用计数就清爽多了。
核心思想:谁持有对象,谁就增加引用计数。谁释放引用,谁就减少计数。计数归零,对象自杀。
但引用计数有个臭名昭著的毛病——循环引用。A引用B,B引用A,两个计数永远不为零,谁也释放不了。这就像两个人互相抓着对方衣领,谁也走不了。
// 循环引用示例
struct Node {
std::shared_ptr<Node> next;
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // 完蛋,谁也释放不了
注意:循环引用是引用计数的死穴。如果你用shared_ptr,一定要警惕环形结构。我建议在可能形成环的场景下,考虑用weak_ptr打破循环。
标记清除:从根出发的全局扫描
标记清除是另一种思路。它不依赖计数器,而是从一组「根对象」出发,遍历所有可达的对象,打上标记。遍历结束后,没被标记的对象就是垃圾,直接清理。
这种方式的优点很明显:能处理循环引用。缺点也很明显:需要暂停程序(STW,Stop The World),而且会产生内存碎片。
我记得有一次给一个嵌入式系统做优化,用的就是标记清除的思路。系统内存只有64MB,引用计数的开销太大,而且循环引用问题频发。换成标记清除后,虽然每次GC会有几十毫秒的暂停,但整体稳定性提升了一大截。
我的建议:标记清除适合对实时性要求不高的场景。如果你做的是游戏引擎或者音视频处理,几十毫秒的暂停可能无法接受。这时候可以考虑增量式GC或者并发GC。
智能指针模拟GC:shared_ptr + weak_ptr 的组合拳
C++11引入的智能指针,其实已经给了我们一套轻量级的GC方案。shared_ptr负责引用计数,weak_ptr负责打破循环。两者配合,能覆盖大部分内存管理场景。
说白了,这就是C++版的「引用计数+弱引用」GC。虽然没有标记清除那么彻底,但胜在轻量、无侵入、零额外开销(除了引用计数的原子操作)。
// 用weak_ptr打破循环
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 弱引用,不增加计数
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->prev = a; // 不会造成循环引用
我在实际项目中,90%的内存管理问题都是用shared_ptr + weak_ptr解决的。剩下的10%,要么是性能敏感区(需要手撸内存池),要么是特殊场景(比如RCU)。
RCU机制:读多写少场景的终极方案
RCU(Read-Copy-Update)是Linux内核里用的一种同步机制,后来被很多高性能系统借鉴。它的核心思想是:读操作不加锁,写操作通过「复制-更新-延迟回收」的方式完成。
你想想看,在数据库缓存、路由表、配置管理等场景下,读操作占了99%,写操作极少。如果用传统的读写锁,每次读都要加锁,性能损失很大。RCU就解决了这个问题——读操作完全无锁,写操作通过替换指针的方式更新数据,旧数据等所有读者都离开后再回收。
RCU的关键:写操作不直接修改原数据,而是创建一个副本,修改副本,然后用原子操作替换指针。旧数据要等到所有正在读它的线程都结束后才能释放。
我曾经在一个高并发网关项目里用过RCU。那个网关每秒要处理几十万次路由查询,路由表更新却只有几次。用RCU后,读性能几乎达到了理论极限,写操作虽然复杂一点,但完全不影响读路径。
// RCU简化示例
std::atomic<Config*> g_config;
// 读操作(无锁)
Config* cfg = g_config.load(std::memory_order_acquire);
use_config(cfg); // 使用期间,保证不会被释放
// 写操作
Config* new_cfg = new Config(*old_cfg); // 复制
new_cfg->update(...); // 修改
g_config.store(new_cfg, std::memory_order_release); // 原子替换
// 等待所有读者离开后,再delete old_cfg
注意:RCU的实现需要操作系统的支持(比如Linux的rcu_read_lock/rcu_read_unlock),或者自己实现「宽限期检测」。不要轻易在生产环境手写RCU,除非你非常清楚自己在做什么。
四种方案的对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 引用计数 | 实现简单,实时性好 | 循环引用,原子操作开销 | 对象生命周期明确,无环结构 |
| 标记清除 | 能处理循环引用,无碎片问题 | STW暂停,实现复杂 | 内存充足,对暂停不敏感 |
| 智能指针GC | 零额外开销,标准库支持 | 循环引用需手动处理 | 大多数C++项目 |
| RCU | 读操作无锁,性能极高 | 写操作复杂,需要宽限期 | 读多写少的高并发场景 |
知识体系图
下面这张图展示了本章涉及的核心概念和它们之间的关系。你可以把它当作一张「内存管理策略地图」。
这张图里,四种策略各有各的领地。引用计数和智能指针GC适合大多数日常开发,标记清除适合需要彻底回收的场景,RCU则是高并发读多写少场景的杀手锏。没有银弹,只有合适的选择。
我的经验:别一开始就想用最复杂的方案。先问自己三个问题:1)有没有循环引用?2)读多还是写多?3)对暂停敏感吗?答案出来了,方案也就定了。
好了,这一章的内容就到这里。四种策略,各有千秋。下一章我们会深入智能指针的实现细节,看看shared_ptr的引用计数到底是怎么工作的,以及如何避免常见的陷阱。