Rust 的三个智能指针 Box、Rc、Arc,你真的选对了吗?
同样的代码,换个智能指针就从「编译通过」变成「内存泄漏」——这事我踩过,你最好别踩。
从一个场景说起
假设你在开发一个 Rust 服务,需要把一份应用配置共享给多个模块读取。你很自然地写了这样的代码:
struct AppConfig {
db_url: String,
max_connections: u32,
}
struct Logger {
config: AppConfig, // 每个模块自己持有一份
}
struct DbPool {
config: AppConfig, // 又是独立的一份拷贝
}
fn main() {
let cfg = AppConfig {
db_url: "postgres://localhost:5432/mydb".into(),
max_connections: 100,
};
let logger = Logger { config: cfg };
// 下面这行编译不过——cfg 的所有权已经移走了!
let db_pool = DbPool { config: cfg };
}
编译报错:use of moved value: 'cfg'。因为 AppConfig 没有实现 Copy,传给 Logger 之后所有权就转移了。
你心想:「那我把 cfg.clone() 传进去总行了吧?」
let logger = Logger { config: cfg.clone() };
let db_pool = DbPool { config: cfg.clone() };
能跑了——但每次克隆都复制一整份配置数据,如果配置结构体很大(比如包含几十个字段、配置文件路径、密钥证书),这开销就是白给的。更别说如果后面在循环里创建多个模块,反复克隆的性能损耗会直接反映在延迟上。
问题的本质是:你需要「多个地方共享同一份数据」,而不是「每人复制一份」。这时候,Rust 的三个智能指针就是为你准备的答案。
核心原理
智能指针到底是什么?
在 Rust 里,普通的引用 &T 只是「借」数据,不拥有所有权。而智能指针是「拥有数据 + 额外逻辑」的结构体——典型地,它们实现了 Deref 和 Drop 两个 trait:
Deref让你可以用*ptr解引用访问内部数据Drop让指针离开作用域时自动清理堆数据
你可以把智能指针理解成「自动管理内存的指针盒子」。
Box——最朴素的「堆盒子」
Box<T> 是 Rust 最基础的智能指针,做的事就一件:把值从栈搬到堆,栈上只留一个指针。
看内存布局就知道了:
栈上(8字节指针) 堆上
┌──────────────┐ ┌──────────────────┐
│ Box 指针 ——→ │ ————————→ │ 实际数据 (如 i32) │
└──────────────┘ └──────────────────┘
什么时候非用它不可?
场景一:递归类型。Rust 需要在编译时确定每个类型的大小,但像链表这样的递归类型会让大小「无限嵌套」:
// 编译错误:递归类型 `List` 没有确定的大小
enum List {
Cons(i32, List), // 每个 Cons 里又装一个 List,无限递归
Nil,
}
用 Box 把内部 List 变成指针,大小就固定了(8 字节):
enum List {
Cons(i32, Box<List>), // 现在 Cons 的大小是 i32(4) + 指针(8) = 12 字节(对齐后16)
Nil,
}
fn main() {
// 构建链表: 1 -> 2 -> 3
let list = List::Cons(
1,
Box::new(List::Cons(
2,
Box::new(List::Cons(3, Box::new(List::Nil))),
)),
);
// list 离开作用域时,Box 的 Drop 会递归释放所有节点
}
场景二:Trait 对象(动态分发)。你需要一个集合里存放「不同具体类型但实现了同一 Trait」的对象时:
trait Animal {
fn speak(&self) -> &'static str;
}
struct Dog { name: String }
struct Cat { name: String }
impl Animal for Dog {
fn speak(&self) -> &'static str { "汪汪" }
}
impl Animal for Cat {
fn speak(&self) -> &'static str { "喵喵" }
}
fn main() {
// Box<dyn Animal> 擦除了具体类型,统一按指针处理(大小固定)
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog { name: "旺财".into() }),
Box::new(Cat { name: "咪咪".into() }),
];
for animal in &animals {
println!("{}", animal.speak()); // 运行时动态分发
}
}
Box<dyn Animal> 是一个胖指针(16 字节),包含「数据指针」和「虚函数表指针」,实现运行时的多态。
场景三:大对象搬堆上。栈空间默认只有几 MB,如果一个结构体几百 KB,直接放栈上可能导致栈溢出。用 Box 把它扔到堆上:
struct BigConfig {
data: [u8; 1024 * 512], // 512KB 的大数据
}
fn main() {
// 栈上只放一个 8 字节的指针,512KB 数据在堆上
let config = Box::new(BigConfig {
data: [0u8; 1024 * 512],
});
// 函数传参时只复制指针,不用复制 512KB 数据
process_config(config);
}
fn process_config(_cfg: Box<BigConfig>) {}
Rc——单线程的「共享引用计数器」
Box 只解决「堆存储」,不解决「共享」。当需要多个所有者共享同一份堆数据时,就用 Rc<T>(Reference Counted)。
它的底层多了个引用计数字段:
Rc 的内存布局(伪代码):
┌──────────────────────────────────────┐
│ strong_count: usize // 强引用计数 │
│ weak_count: usize // 弱引用计数 │
│ data: T // 实际数据 │
└──────────────────────────────────────┘
核心规则:每次 Rc::clone() 让 strong_count +1;每次 drop 让 strong_count -1;降为 0 时释放数据。
⚠️ 注意:
Rc::clone()是浅拷贝——只增加引用计数,不复制堆数据。这是它和普通.clone()的本质区别。
看一个生产中的经典用法——共享配置:
use std::rc::Rc;
struct AppConfig {
name: String,
timeout: u64,
}
fn main() {
// 创建 Rc,strong_count 初始为 1
let config = Rc::new(AppConfig {
name: "MyApp".into(),
timeout: 30,
});
println!("初始引用数: {}", Rc::strong_count(&config)); // 1
// 模拟三个模块共享配置——clone 只增加计数,不复制数据
let module_a = Rc::clone(&config);
println!("A 模块共享后: {}", Rc::strong_count(&config)); // 2
let module_b = Rc::clone(&config);
println!("B 模块共享后: {}", Rc::strong_count(&config)); // 3
{
let module_c = Rc::clone(&config);
println!("C 模块共享后: {}", Rc::strong_count(&config)); // 4
} // module_c 离开作用域,count -1
println!("C 模块释放后: {}", Rc::strong_count(&config)); // 3
// 三个模块都能读取同一份配置
println!("A: {}", module_a.name);
println!("B: {}", module_b.timeout);
} // module_a, module_b, config 依次 drop,count 到 0 时释放堆上数据
运行输出:
初始引用数: 1
A 模块共享后: 2
B 模块共享后: 3
C 模块共享后: 4
C 模块释放后: 3
Arc——多线程的「原子引用计数器」
Rc 有个致命局限:不能跨线程。因为它的引用计数是普通 usize,多线程同时修改会导致数据竞争,计数错乱。如果你尝试把 Rc 传给 thread::spawn,编译器直接报错:
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new("不能过线程".to_string());
// 编译错误:`Rc<String>` 无法在线程间安全传递
thread::spawn(move || {
println!("{}", data);
});
}
这时候需要 Arc<T>——它和 Rc 几乎一样,唯一的区别是:引用计数用 AtomicUsize(原子类型),多线程下修改计数的操作是「不可中断的」,保证安全。
代价是:原子操作比普通内存操作慢一点(大约几个纳秒级别),所以单线程用 Rc,多线程用 Arc。
use std::sync::Arc;
use std::thread;
fn main() {
let config = Arc::new(vec!["线程安全", "共享", "数据"]);
let mut handles = vec![];
for i in 0..3 {
let arc_clone = Arc::clone(&config);
handles.push(thread::spawn(move || {
println!("线程 {} 读取: {:?}", i, arc_clone);
}));
}
for h in handles {
h.join().unwrap();
}
// 所有线程共享同一个 Vec,没有拷贝
}
深入细节
陷阱一:Rc 的循环引用导致内存泄漏
Rc 最大的坑是循环引用——A 引 B,B 引 A,彼此的 strong_count 都无法归零,堆数据永远不会释放。
看下面这段看似无害的代码:
use std::rc::Rc;
use std::cell::RefCell;
struct Node {
name: String,
children: RefCell<Vec<Rc<Node>>>, // 子节点
parent: RefCell<Option<Rc<Node>>>, // 父节点(强引用)
}
fn main() {
let parent = Rc::new(Node {
name: "父".into(),
children: RefCell::new(vec![]),
parent: RefCell::new(None),
});
let child = Rc::new(Node {
name: "子".into(),
children: RefCell::new(vec![]),
parent: RefCell::new(Some(Rc::clone(&parent))),
});
// 父节点也引用子节点——形成循环!
parent.children.borrow_mut().push(Rc::clone(&child));
println!("parent strong_count: {}", Rc::strong_count(&parent)); // 2(自身+child.parent)
println!("child strong_count: {}", Rc::strong_count(&child)); // 2(自身+parent.children[0])
} // 离开作用域后:parent 和 child 的 count 永远停在 1,内存泄漏!
解决方案:用 Weak<T> 打破循环。
Weak<T> 是 Rc 的弱引用——克隆时只增加 weak_count,不增加 strong_count,不阻止数据释放。获取弱引用指向的数据需要调用 .upgrade(),如果数据已被释放则返回 None。
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
name: String,
children: RefCell<Vec<Rc<Node>>>,
parent: RefCell<Option<Weak<Node>>>, // 父节点改为弱引用
}
fn main() {
let parent = Rc::new(Node {
name: "父".into(),
children: RefCell::new(vec![]),
parent: RefCell::new(None),
});
let child = Rc::new(Node {
name: "子".into(),
children: RefCell::new(vec![]),
parent: RefCell::new(Some(Rc::downgrade(&parent))), // Rc → Weak
});
parent.children.borrow_mut().push(Rc::clone(&child));
println!("parent strong_count: {}", Rc::strong_count(&parent)); // 1(仅自身)
println!("parent weak_count: {}", Rc::weak_count(&parent)); // 1(来自 child.parent)
// 通过 Weak 访问父节点
let child_parent = child.parent.borrow();
if let Some(weak_ref) = &*child_parent {
if let Some(p) = weak_ref.upgrade() {
println!("子节点的父节点是: {}", p.name);
}
}
} // 离开作用域:parent count→0 释放 → child 的 count→0 释放 → 无泄漏
设计原则:在树形结构中,「父→子」用强引用(Rc),「子→父」用弱引用(Weak)。类似地,双向链表、图结构中的反向引用都用 Weak。
陷阱二:Arc 只能共享不可变数据
Arc 保证的是「跨线程安全共享」,但如果你需要在多个线程中修改共享数据,光靠 Arc 不够——需要配合 Mutex 或 RwLock:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc<Mutex<T>>:共享 + 可变 + 线程安全
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = c.lock().unwrap(); // 加锁
*num += 1; // 修改(其他线程会阻塞在这)
println!("当前值: {}", *num);
})); // 离开作用域时自动解锁
}
for h in handles {
h.join().unwrap();
}
println!("最终结果: {}", *counter.lock().unwrap()); // 5
}
如果是「多读少写」的场景,用 Arc<RwLock<T>> 性能更好——允许多个线程同时读,写时独占。
对比表:一张图选对智能指针
| 维度 | Box<T> |
Rc<T> |
Arc<T> |
|---|---|---|---|
| 所有权模型 | 单一所有权 | 共享所有权 | 共享所有权 |
| 线程安全 | 单线程(不涉及) | ❌ 禁止跨线程 | ✅ 原子计数 |
| 计数方式 | 无 | usize(无原子开销) |
AtomicUsize(轻微开销) |
| 克隆行为 | 深拷贝(复制数据) | 浅拷贝(仅 +1 计数) | 浅拷贝(原子 +1 计数) |
| 主要用途 | 堆存储、递归类型、动态分发 | 单线程共享只读数据 | 多线程共享数据 |
| 可变性 | 默认不可变(Box::new(mut) 可解引用修改) |
需配合 RefCell |
需配合 Mutex/RwLock |
最佳实践
1. 能做「借用」就别上智能指针
很多人看到需要共享数据,第一反应就用 Rc。但很多时候,用普通的引用 &T 就够了:
// ❌ 过度设计:没必要用 Rc
fn process(config: Rc<AppConfig>) { ... }
// ✅ 更好:如果调用方能控制生命周期,用引用即可
fn process(config: &AppConfig) { ... }
智能指针是用来解决「所有权冲突」的,不是用来替代引用的。
2. 选择顺序:优先用最简单的
能用 &T(引用) → 不用智能指针
非要用智能指针 → 单线程用 Rc,多线程用 Arc
Rc 要可变 → Rc<RefCell<T>>
Arc 要可变 → Arc<Mutex<T>>
Arc 多读少写 → Arc<RwLock<T>>
3. 避免不必要的原子开销
// ❌ 单线程场景,不需要 Arc
let data: Arc<Vec<u8>> = Arc::new(vec![1, 2, 3]);
// ✅ 单线程用 Rc 就够了
let data: Rc<Vec<u8>> = Rc::new(vec![1, 2, 3]);
4. 用 Box<[T]> 而不是 Box<Vec<T>>
如果你需要一个固定长度的堆数组且不会扩容,用 Box<[T]> 而非 Box<Vec<T>>——少一层间接性,内存布局更紧凑:
// 更优:直接分配到堆上的固定数组
let data: Box<[u8]> = vec![1, 2, 3, 4, 5].into_boxed_slice();
// 避免:Vec 本身有堆分配,外面又套一层 Box
let data: Box<Vec<u8>> = Box::new(vec![1, 2, 3, 4, 5]);
总结
- Box 解决「堆存储」——递归类型、Trait 对象、大对象,记住「单一所有权 + 固定大小指针」就够了。
- Rc 解决「单线程共享」——靠引用计数实现多个所有者,但小心循环引用,反向引用用
Weak打破闭环。 - Arc 解决「多线程共享」——本质是 Rc 的原子版本,需要加
Mutex/RwLock才能实现可变共享,单线程场景别用它。
选智能指针没有银弹——理解每个指针「解决什么问题,有什么代价」,才能在真实项目中做出正确的权衡。