Rust 生命周期标注在解决什么:返回值依赖、min 约束与 Java 为何不用
最近在读 Rust 的生命周期(lifetime)章节,编译器报错时常常让人一头雾水:明明只是返回一个引用,为什么非要写 <'a>?写了一个 'a 还不够,有时还要 'a, 'b。下面用几个可编译的例子说明返回值依赖怎么写进签名,并和 Java 的 GC 做个对照。
返回值绑在哪个参数上
签名里的 <'a, 'b> 表示两个参数各有各的生命周期;返回值写 &'a str 还是 &'b str,就是在声明它跟哪一个参数走。
'a 和 'b 分开:返回值只绑在 x 上
函数始终返回第一个参数时:
fn always_first<'a, 'b>(x: &'a str, _y: &'b str) -> &'a str {
x
}
x 是 'a,y 是 'b,返回值是 &'a str——只依赖 x,和 y 无关。于是 y 可以在内层作用域里释放,只要 x 还在,返回值就合法:
fn main() {
let outer = String::from("I live long");
let result;
{
let inner = String::from("I die soon");
result = always_first(&outer, &inner);
} // inner 释放,无妨
println!("{}", result); // ✅ "I live long"
}
注释里写「inner 释放,无妨」——编译器确实会在内层 } 插入 drop glue。String 的析构不会打印,可以用打日志的 Tracked 看时机,再用 rustc --emit=mir 看插入的 drop(place);String 则继续落到 RawVec(见下文)1。
struct Tracked(&'static str);
impl Drop for Tracked {
fn drop(&mut self) {
println!("[Drop] {}", self.0);
}
}
fn demo_drop_order() {
let outer = Tracked("outer");
{
let inner = Tracked("inner");
let _ = (&outer, &inner);
println!("离开内层作用域前");
} // 这里 Drop inner
println!("内层已结束");
println!("离开 outer 作用域前");
} // 这里 Drop outer
rustc 编译运行后的输出是:
离开内层作用域前
[Drop] inner
内层已结束
离开 outer 作用域前
[Drop] outer
可以看到:内层 } 之后先出现 [Drop] inner,函数结束前再出现 [Drop] outer。下面用 rustc tracked.rs --emit=mir 看编译器插进去的代码,把「drop(_6) 怎么变成这行打印」拆开说明1。
MIR 里的 _6 是什么
MIR 一般不保留源码变量名,改用 _数字 这种局部槽。名字写在 debug 里,构造时也能对上:
scope 1 {
debug outer => _4;
...
scope 3 {
debug inner => _6;
}
}
bb2: {
_4 = Tracked(const "outer");
_6 = Tracked(const "inner");
...
}
如下所示:
| MIR | 源码 | 内容 |
|---|---|---|
_4 |
outer |
Tracked("outer") |
_6 |
inner |
Tracked("inner") |
所以后面的 drop(_6),意思就是:对 inner 做析构。
内层 } 之后先跑到哪
源码顺序是:先 println!("离开内层作用域前"),再出内层作用域。MIR 里对应:
bb3: {
_7 = _print(...); // 展开后的「离开内层作用域前」
}
bb4: {
drop(_6) -> [return: bb5, ...];
}
bb4 里还没有 [Drop] inner 这几个字。drop(_6) 是 MIR 的终结器(drop glue):对 _6 做完析构,再跳到 bb5。它本身不是一次普通的 Call println。
drop(_6) 为什么会调到你写的 Drop::drop
同一份 .mir 文件前面,有一份完整的函数,对应源码里的 impl Drop for Tracked:
fn <impl at tracked.rs:4:1: 4:22>::drop(_1: &mut Tracked) -> () {
...
}
drop(_6) 那一行不会写成:
_x = <Tracked as Drop>::drop(move _6) -> ...
人读的 dump 通常只留终结器 drop(_6)。它能对上上面那个 fn <impl …>::drop,靠的是类型,不是靠变量名叫 inner:
- MIR 写明
let _6: Tracked drop(_6)表示按类型Tracked的规则析构这个值Tracked实现了Drop,规则里包含:先调用<Tracked as Drop>::drop(&mut value)- 该 impl 编译后就是文件里的
fn <impl at tracked.rs:…>::drop
类型若换成 String,同一个 drop(_N) 会走字段析构和 Vec / RawVec,而不会进 Tracked 这份 <impl>::drop。
进入 Tracked::drop 之后,如何变成终端上的一行字
源码只有:
fn drop(&mut self) {
println!("[Drop] {}", self.0);
}
println! 是宏,进 MIR 时已经展开。在 <impl …>::drop 里大致是:
bb0: {
_5 = &((*_1).0: &str); // self.0;对 inner 来说是 "inner"
_7 = Argument::new_display::<&str>(...); // 给 "{}" 用
}
bb1: {
// promoted[0] = ["[Drop] ", "\n"]
_3 = Arguments::new_v1(...); // 拼成整句要输出的内容
}
bb2: {
_2 = _print(move _3); // 真正写到终端
}
bb3: {
return; // 回到 demo 函数的 bb5
}
可以看到:源码里的 println! 在 MIR 里没有这个名字,最后一步是 _print;"[Drop] " 和换行落在 promoted 常量里。
把内层结束这一段按时间顺序串起来:
1. 跑完「离开内层作用域前」(bb3 的 _print)
2. 到达内层 }
3. 执行 bb4: drop(_6) ← _6 即 inner
4. 进入 <impl>::drop,_1 指向 inner
5. 读 (*_1).0 → "inner"
6. 拼 Arguments → "[Drop] inner\n"
7. 调用 _print → 终端打印 [Drop] inner
8. <impl>::drop 返回
9. 从 bb4 转到 bb5,继续「内层已结束」
outer 同理,只是换成更后面的 bb10: drop(_4),再进同一个 <impl …>::drop,打印 [Drop] outer。
和 String / RawVec 差在哪
Tracked 没有堆缓冲,这条链停在用户 Drop::drop 和 _print,不会进 RawVec::drop。若 local 是 String,业务函数的 MIR 里同样只看到 drop(_N);展开后才是析构字段 vec → Vec::drop → RawVec::drop → deallocate(见下一节源码)23。
Tracked |
String |
|
|---|---|---|
| MIR 表面 | drop(_6) / drop(_4) |
同样是 drop(local) |
| 如何选中具体函数 | _6: Tracked → impl Drop for Tracked |
无 Drop for String,按字段走到 Vec / RawVec |
| 终端 / 堆 | _print 打日志 |
deallocate 还堆 |
前面的 always_first + String 例子单独跑时,内层结束后 result 仍是 I live long;对其 --emit=mir,业务函数里同样只有 drop(_N),不会出现一条裸的 deallocate 指令。
从这份 MIR 看「零成本抽象」
看完 drop(_6) 和 _print,很容易概括成一句:「高级写法都在编译期展开成 MIR,运行时没有 magic。」方向对,但有两点要收紧。
MIR 里能直接看到的(也就是零成本叙事里「编译期说清楚」的那一面)14:
println!不再是神秘宏,而是Arguments+_print- 作用域结束不是运行时偷偷扫一遍,而是明确的
drop(_6)/drop(_4) - 调哪个
Drop::drop,由 local 的类型在编译期定死
最终跑的二进制由这些确定步骤继续编下去,默认没有 tracing GC、也没有为 Drop / 作用域再挂一层隐藏运行时——除非你自己用 dyn Trait 等显式动态机制。
不宜说成「所有代码编译时展开为 MIR 就完事」:
- MIR 是中间表示,不是最终产物。常见路径是源码 → HIR → MIR → LLVM IR → 机器码;MIR 上做借用检查等,之后还要继续降级和优化4。
- 零成本抽象经过 MIR,但不终于 MIR。内联、循环展开、消死代码等很多发生在 LLVM 一侧;MIR 负责把抽象变成可检查、可继续编译的明确形态。
更稳的说法是:像 Drop、作用域、宏这类抽象,在编译期经 MIR 等多个阶段被确定性地展开和转换,最终变成直接的底层代码;运行时不靠额外的隐式机制去「维持」这些抽象——这和 Stroustrup 说的「不用的不付费、用了的接近手写」是同一路想法5。对照 Java:便利往往挂在 GC / 虚拟机运行时上;这里便利挂在编译期展开上。
若误写成 fn always_first<'a>(x: &'a str, y: &'a str) -> &'a str,编译器会认为返回值也可能指向 y,上面的 main 就通不过——两个参数共用 'a,约束会变严。
HashMap 查找
返回值来自 map,和临时 key 无关:
use std::collections::HashMap;
fn lookup<'a, 'b>(map: &'a HashMap<String, String>, key: &'b str) -> Option<&'a str> {
map.get(key).map(|s| s.as_str())
}
fn main() {
let map = HashMap::from([("name".to_string(), "Alice".to_string())]);
let value;
{
let key = String::from("name");
value = lookup(&map, &key);
} // key 释放
println!("{:?}", value); // ✅ Some("Alice")
}
标准库里的 HashMap::get 就是这种形状:返回的是 &V,生命周期跟 &self(map)走,和传入的 key: &Q 无关6:
pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
where
K: Borrow<Q>,
Q: Hash + Eq,
若误写成 fn lookup<'a>(map: &'a HashMap<...>, key: &'a str),编译器会把 key 的生命周期也绑进返回值,上面的写法就通不过——标注错了,合法代码会被误杀。
两个参数共用 'a:longest 与 min 约束
若返回值可能是 x,也可能是 y,两个引用要共用同一个 'a。官方书第十章的 longest 示例就是这种签名7:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
所有引用共用 'a。编译器不知道最后返回的是 x 还是 y,只能按最保守的规则——返回值存活 ≤ 两者里较短的那个,也就是 min(x, y)。调用方在持有返回值期间,x 和 y 都必须还有效。
下面这段 main 会编译失败(E0597:s does not live long enough)8:
fn main() {
let result;
{
let s = String::from("hello");
result = longest(&s, "world");
// "world" 是 &'static str,活得够久
// 但 result 仍可能指向 s
} // s 在这里 drop
println!("{}", result); // ❌ E0597
}
| 签名 | 返回值含义 | 调用方约束 |
|---|---|---|
fn f<'a, 'b>(x: &'a T, y: &'b T) -> &'a T |
明确来自 x |
返回值存活 ≤ x 的生命周期 |
fn f<'a>(x: &'a T, y: &'a T) -> &'a T |
可能来自 x 或 y |
返回值存活 ≤ min(x, y) |
写 API 时先问:调用者拿到返回引用后,最少需要谁还活着? 能分开就写 'a, 'b;返回值真可能来自多个参数,再共用 'a。
多数时候不用手写 'a:lifetime elision
日常代码里,签名上的 'a 往往可以省略。官方书把编译器内置的几条模式叫做 lifetime elision rules:它们不是给程序员背的「编码规范」,而是编译器在特定、无歧义的情况下自动补全标注;补不全就报错,不会猜9。
三条规则的要点是:
- 每个引用参数先各自获得一个生命周期参数(两个参数会先被看成
'a和'b)。 - 若只有一个输入生命周期,它会赋给所有输出生命周期——所以
fn first(s: &str) -> &str不必写成fn first<'a>(s: &'a str) -> &'a str。 - 若是方法且参数里有
&self/&mut self,输出生命周期默认跟self走。
longest 之所以必须手写 <'a>,正因为 elision 走完三条规则后,仍然不知道返回值该跟 x 还是 y79。结构体里存放引用(如后面的 Parser<'a>)则几乎总要在类型上写出生命周期——那是「值里藏着借用」,和函数签名省略是另一回事。
和 Java 对照:Java 方法签名里没有「返回的引用最多活多久」这一栏,因为堆对象靠 GC 按可达性续命;Rust 把这件事放进类型,多数情况由 elision 默默填好,只有有歧义时才要你写清楚。
Borrow Checker:没有 GC 时,规则谁来执行
签名上的 'a、&T / &mut T,要靠 Borrow Checker(借用检查器) 在编译期核对。它是一套静态分析:跟踪所有权与借用,违反规则就不能生成程序——这也是 Rust 在不要 tracing GC 的前提下,仍要拦住悬垂引用、非法别名的核心机制101112。
三条基石
- 每个值同一时刻有一个所有者(持有该值的绑定;所有权可 move)。
- 同一时刻:要么一个
&mut T,要么任意多个&T——可变与共享不可并存。 - 引用必须始终有效:借用不能活过被借用数据的生命周期(不能悬垂)。
前两条管「谁能读、谁能写」;第三条正是前文 longest / Tracked 例子里 does not live long enough 在拦的事8。
共享与独占
&T:可同时存在多个,约定只读。&mut T:独占;存在期间不允许再有指向同一数据的&T或另一个&mut T。
fn main() {
let mut data = 10;
let ref1 = &data;
let ref2 = &data; // ✅ 多个不可变借用
let ref_mut = &mut data; // ❌ 此时仍有 ref1 / ref2
println!("{ref1} {ref2}");
}
单线程里这也禁止「一边保留旧引用、一边通过可变借用改底层」这类别名错误;跨线程时,再叠加上 Send / Sync,才构成 Safe Rust 对数据竞争的防线12。它拦的是内存不安全的访问模式,不是业务逻辑错误,也不保证不 OOM(见后文「所有权不保证内存不会被用光」)。
悬垂引用:生命周期必须盖得住借用
fn main() {
let r;
{
let x = 5;
r = &x;
} // x drop
println!("{r}"); // ❌ r 指向已结束的数据
}
和 C++ 里指针仍能「碰巧打出旧值」不同:这里编译期直接拒绝。函数签名里的 'a,就是把「返回的借用最多能活多久」写成类型,好让检查器跨函数边界继续推7。
NLL:按最后一次使用收束借用
早期检查更贴近词法作用域(大括号);如今主流是 NLL(Non-Lexical Lifetimes):在控制流上证明某次借用之后再无使用,即可提前结束该借用,即使变量绑定还在作用域里13:
fn main() {
let mut data = 10;
let ref1 = &data;
// 此后不再使用 ref1
let ref_mut = &mut data; // ✅ NLL:ref1 的借用已结束
*ref_mut += 1;
}
Rust 2018 之后,这类写法才符合日常直觉;签名上的 lifetime 标注与 elision,仍然要和这套分析一起看。
检查器说「不」时常见出路
图、链表等结构有时确实需要更灵活的别名。常见策略:
Clone:各持一份所有权(简单,可能费内存)。Rc+RefCell(单线程)/Arc+Mutex(多线程):把「共享 / 独占」推迟到运行时再检查;违反时RefCell会panic,而不是静默 UB14。- 用索引代替引用:如
Vec下标、arena id,让类型里少挂生命周期。
绕规则进 unsafe 可以,但安全责任回到人手上——和本文 C++「默认不挡悬垂」不是同一档默认值。
一句话:Borrow Checker 是编译期的内存纪律;代价是要适应规则,收益是 Safe Rust 路径上默认不靠 GC 去「事后续命」。它不承诺无泄漏、无 OOM,也不等于「任意 unsafe 与全生态都已形式化盖章」(见 RustBelt 一节)。
Java 为什么不需要生命周期标注?
和「要不要写 lifetime」最直接相关的一点:Java 有 tracing GC,Rust 没有。 Java 靠运行时可达性续命;Rust 靠编译期所有权与借用规则保证内存安全。生命周期标注是借用规则的一部分,只在编译期存在,运行时没有「寿命监视器」710。
《The Rustonomicon》开篇把动机说得很直白:GC 让「指针指着的东西别提前消失」这件事变得轻松,但在需要手动管内存的场景里成本太高;Rust 的所有权系统就是为了在不要 GC的前提下,仍然拦住悬垂引用这类错误11。
常有人再补一句:「Java 有虚拟机,Rust 没有。」方向大致对——常见路径是 Java 跑在 JVM 上,Rust 经 LLVM 生成本地代码、没有同等托管堆运行时——但这不是 lifetime 问题的充要解释。反例很清楚:Go 也基本编译成本地代码,却仍有 GC,同样不靠生命周期标注;没有 VM 也可以像 C 一样手写 malloc/free,安全靠人而不是借用检查。JVM 带来字节码、JIT、类加载等一整套能力,和生态/部署关系很大;本文关心的轴心仍是内存管理模型(GC / 可达性 vs 所有权 / 借用),不是「有没有虚拟机」本身。
同一段逻辑,两种结局
Java 里可以这样写:
public static void main(String[] args) {
String result;
{
String s = new String("hello");
result = s;
} // s 变量出栈,堆上对象仍在
System.out.println(result); // ✅ hello
}
Rust 里若写成借用:
fn main() {
let result;
{
let s = String::from("hello");
result = &s;
} // s drop
println!("{}", result); // ❌ 悬垂引用
}
JVMS 规定:类实例与数组的内存从堆分配,堆由自动存储管理(garbage collector)回收,对象从不显式释放;每个线程另有私有的 JVM 栈,帧里放局部变量与运算中间结果15。因此上面 Java 例子里,出作用域的是栈上的局部变量 s,堆上的 String 只要还被 result 可达,就不会被回收。
更贴切的对照不是「Java 用 GC 管理所有权」,而是:
| Java | Rust | |
|---|---|---|
| 寿命判据 | 可达性(从 GC roots 能否摸到对象) | 所有权 / 借用(谁拥有、借用能否活过所有者) |
| 谁保证安全 | 运行时 tracing GC(现代 HotSpot 远不止经典 mark-and-sweep 一种) | 编译期借阅检查 + 所有者结束时的 Drop |
| 「所有权」一词 | 语言模型里几乎没有 Rust 那种唯一所有者 / move | 类型系统一等公民 |
可达性怎么算:tracing 谱系(不是逃逸分析)
「从 GC Roots 摸得到」在实现上通常叫 tracing(追踪):把堆看成有向图,从根出发遍历引用边,摸到的算活,其余可回收。这和前面的 逃逸分析不是一路——逃逸分析是 JIT 优化;可达性是运行时回收判据。
经典算法与文献可以按这条线读:
| 思路 | 在说什么 | 代表性出处 |
|---|---|---|
| Mark-sweep(标记–清除) | 从 base/root 沿指针链标记可达单元,再扫一遍把未标记的收回 free list | McCarthy 1960:Lisp 实现里的 reclamation(文中描述了标记可达再回收的过程)16 |
| 三色标记(tri-color) | 白 / 灰 / 黑刻画「尚未访问 / 已见但子节点未扫完 / 已扫完」;并发时用不变式(如黑不直接指向白)协调 mutator 与 collector | Dijkstra、Lamport 等,CACM 1978:on-the-fly GC17 |
| 复制 / 分代等变体 | 仍基于「从根追踪谁还活着」,换的是怎么搬对象、怎么缩小扫描范围 | 系统综述与手册见 Jones / Hosking / Moss18 |
HotSpot 的 Serial、Parallel、G1、ZGC 等,语义上都是 tracing GC 家族:并发标记、SATB、着色指针等是工程与算法演进,不是改成「用逃逸分析管生命周期」。引用计数是另一条主线(对象图有环时通常还要补 tracing 或等价手段);JVM 对象回收不靠「计数归零」当主机制。
想把谱系一次读完:The Garbage Collection Handbook 把 mark-sweep、复制式、分代、并发与实时收集的算法脉络收在一处18。
Rust 里 String 的缓冲在堆上,但所有权在栈上的 s;作用域结束时走 Drop 释放堆缓冲1019。&s 若活得比 s 更久,就是悬垂引用——借阅检查在编译期拒绝,而不是靠运行时 GC 把对象「留住」。
sequenceDiagram
participant Stack as 栈
participant Heap as 堆
participant GC as Java GC
Note over Stack,Heap: Java
Stack->>Heap: new String
Stack->>Stack: result = s
Stack->>Stack: s 出栈
Heap->>GC: 仍被 result 引用
Note over Stack,Heap: Rust
Stack->>Heap: String::from
Stack->>Stack: result = &s
Stack->>Stack: s drop,释放堆
Note over Stack: 编译拒绝悬垂引用
String 出作用域:编译器插 drop,标准库里 deallocate
「所有者离开作用域就释放」分两层,不宜混成一句「运行时自动 deallocate」1020:
- 编译器在作用域结束处插入 drop glue:按规则调用
Drop::drop、并析构字段。业务代码里通常看不到deallocate。 deallocate是Drop实现里的普通调用——drop glue 跑到那里,才把堆块还给分配器。没有 tracing GC 在背后扫一遍。
标准库里 String 没有自己的 impl Drop(在 library/alloc/src/string.rs 搜不到 impl Drop for String),只是包着 Vec<u8>19:
// library/alloc/src/string.rs:353-355
pub struct String {
vec: Vec<u8>,
}
Vec 在 library/alloc/src/vec/mod.rs 再拆成缓冲与长度2:
// library/alloc/src/vec/mod.rs:438-441
pub struct Vec<T, A: Allocator = Global> {
buf: RawVec<T, A>,
len: usize,
}
与上一节 MIR 对齐:业务函数里先出现 drop(s);String 自身无 Drop::drop,drop glue 析构字段后才进入标准库。路径是:
drop(s: String) // MIR:drop(_N),形态同 Tracked 的 drop(_6)
→(String 无 Drop::drop,只析构字段 vec)
→ Vec::drop(vec/mod.rs):先 drop_in_place 元素(对 u8 基本无事)
→ RawVec::drop(raw_vec/mod.rs):inner.deallocate → Allocator::deallocate
真正的 Drop::drop 在 Vec 上——只负责元素析构,注释写明缓冲由 RawVec 处理2:
// library/alloc/src/vec/mod.rs:4248-4257
unsafe impl<#[may_dangle] T, A: Allocator> Drop for Vec<T, A> {
fn drop(&mut self) {
unsafe {
// use drop for [T]
ptr::drop_in_place(ptr::slice_from_raw_parts_mut(self.as_mut_ptr(), self.len))
}
// RawVec handles deallocation
}
}
堆块归还在 RawVec::drop(library/alloc/src/raw_vec/mod.rs)3:
// library/alloc/src/raw_vec/mod.rs:420-426
unsafe impl<#[may_dangle] T, A: Allocator> Drop for RawVec<T, A> {
/// Frees the memory owned by the `RawVec` *without* trying to drop its contents.
fn drop(&mut self) {
// SAFETY: We are in a Drop impl, self.inner will not be used again.
unsafe { self.inner.deallocate(T::LAYOUT) }
}
}
因此:
| 问题 | 答案 |
|---|---|
deallocate 是 GC 自动扫出来的吗? |
不是 |
编译器会直接插入一条裸 deallocate 吗? |
一般不;插入的是 drop glue → Drop::drop |
| 堆何时释放? | RawVec::drop 里的 Allocator::deallocate 随 drop 执行 |
是不是每个类型都要自己写 impl Drop?
不是。 要释放堆或其它资源,所有权链上得有人在析构时去做;不一定是外层类型手写 Drop20。
| 情况 | 要不要自己写 Drop |
|---|---|
String |
不用。自身无 impl Drop,靠字段 Vec → RawVec |
只含 i32、bool 等 |
不用;出作用域只是栈槽作废,没有堆可释 |
自定义类型里有 String / Vec / Box |
通常也不用;编译器按字段生成 drop glue,依次析构字段 |
自己握着裸指针、mmap、文件句柄等 |
才需要手写 Drop(或用已有 RAII 类型包起来) |
MIR 里的 drop(_6) 也不是「只有实现了 Drop 的类型才会出现」:
- 类型实现了
Drop→ 先调Drop::drop,再 drop 字段 - 类型没实现
Drop、但字段需要析构 → 仍生成 drop glue,直接按字段析构
所以 drop(inner: String) 会走进字段析构 → Vec::drop → RawVec::drop → deallocate。一句话:不是每个类型都必须 impl Drop,而是拥有资源的那一层(或它的字段)要有析构逻辑;业务结构体往往靠组合 String / Vec / Box 就能自动释放。
借用检查保证「没有人在 drop 之后还拿着指向这块缓冲的引用」;析构路径保证「该释放时释放」。move 之后旧绑定不再 drop,避免 double-free。前面 always_first 例子里,MIR 的 drop(_6) / drop(_4) 就是这一层;deallocate 发生在 RawVec::drop 内部。
和 C++ RAII:释放同类,差别在借用检查
Drop 扮演的角色和 C++ 析构函数 / RAII 同类:作用域结束时清理资源,不必手写 free2021。差别不在「会不会自动释放」,而在释放之后还能不能继续用悬垂引用、move 之后旧变量还能不能碰。
释放本身:两边都是 RAII。
C++:
#include <string>
#include <iostream>
int main() {
{
std::string s = "hello";
std::cout << s << "\n";
} // ~basic_string:释放堆缓冲
}
Rust:
fn main() {
{
let s = String::from("hello");
println!("{s}");
} // drop glue → Vec → RawVec → deallocate
}
悬垂引用:C++ 能编过,Rust 在编译期拦住。
和上文 Tracked 同一套路:作用域结束会析构并打 [Drop];差别在「析构之后还能不能用指针」。C++ 默认不挡——指针可指向已销毁对象(未定义行为):
#include <iostream>
struct Tracked {
const char* name;
~Tracked() { std::cout << "[Drop] " << name << "\n"; }
};
int main() {
const Tracked* p;
{
Tracked inner{"inner"};
p = &inner;
std::cout << "离开内层作用域前\n";
} // ~Tracked → [Drop] inner;对象已销毁
std::cout << "内层已结束\n";
std::cout << p->name << "\n"; // 悬垂:能编译;常仍打出 inner,仍是 UB
}
一次常见运行结果(和前面 Rust Tracked 的 drop 顺序对齐;最后一行是悬垂读,不保证可复现):
离开内层作用域前
[Drop] inner
内层已结束
inner
「还能打出 inner」不代表安全:对象已析构,只是栈槽或静态字面量碰巧还没被盖掉——标准下是未定义行为,换编译器、开优化或再分配一次就可能崩、乱码或「看起来正常」。
同一逻辑的 Rust(仍用 Tracked):
struct Tracked(&'static str);
impl Drop for Tracked {
fn drop(&mut self) {
println!("[Drop] {}", self.0);
}
}
fn main() {
let p: &Tracked;
{
let inner = Tracked("inner");
p = &inner;
println!("离开内层作用域前");
} // Drop inner
println!("内层已结束");
println!("{}", p.0); // ❌ 编译错误:`inner` does not live long enough
}
同一结构在 Java 里故意对不上 C++ / Rust 的析构:语言没有在 } 处插入的 ~T() / Drop(不要用已过时的 finalize 去对标)15。下面第一段因此不会打印任何 [Drop]——不是样例写漏了,而是模型里就没有这一步。
final class Tracked {
final String name;
Tracked(String name) { this.name = name; }
// 没有析构函数;} 结束时编译器不会插入「销毁对象」的调用
}
public static void main(String[] args) {
Tracked p;
{
Tracked inner = new Tracked("inner");
p = inner;
System.out.println("离开内层作用域前");
} // 只有局部变量 inner 出栈;堆上对象仍被 p 引用,此处无析构
System.out.println("内层已结束");
System.out.println(p.name); // ✅ inner —— 可达,不是悬垂
}
运行结果稳定为(注意中间没有 [Drop]):
离开内层作用域前
内层已结束
inner
若只是想要「离开作用域时跑一段清理代码」,Java 用的是 try-with-resources / AutoCloseable——那是资源关闭回调,不是把对象从堆上拆掉;close() 之后只要还有引用,对象仍可达:
final class Tracked implements AutoCloseable {
final String name;
Tracked(String name) { this.name = name; }
@Override public void close() {
System.out.println("[close] " + name); // 确定性清理,≠ GC 回收
}
}
public static void main(String[] args) {
Tracked p;
try (Tracked inner = new Tracked("inner")) {
p = inner;
System.out.println("离开 try 前");
} // 编译器在此插入 inner.close()
System.out.println("try 已结束");
System.out.println(p.name); // ✅ 仍可达;close 没有「析构掉」对象
}
离开 try 前
[close] inner
try 已结束
inner
和上文「同一段逻辑,两种结局」同一条轴:Java 对象寿命靠可达性 / GC;C++/Rust 的 Tracked 在作用域结束时已经(或要)跑析构,再用指针/借用才变成 UB 或编译错误。[close] 可以对齐「作用域结束做事」,但对齐不了「对象生命周期在 } 结束」。
Move:Rust 让旧绑定失效;C++ 源对象仍可被「合法地」碰到。
#include <string>
#include <utility>
#include <iostream>
int main() {
std::string a = "hi";
std::string b = std::move(a);
// a 处于有效但未指定状态;析构仍会跑,但再用 a 不报编译错
std::cout << "a=[" << a << "] b=[" << b << "]\n";
}
fn main() {
let a = String::from("hi");
let b = a; // move
println!("{a}"); // ❌ 编译错误:value borrowed here after move
println!("{b}");
}
| C++ RAII / 析构 | Rust Drop + 借用检查 |
|
|---|---|---|
| 作用域结束释放堆 | 有(如 std::string) |
有(如 String) |
| 挡住「指向已析构对象的引用」 | 默认不挡(靠规范/工具/评审) | 编译期挡住 |
| move 后旧变量 | 有效但未指定,仍可能被使用 | 旧绑定失效,再用是编译错误 |
| 不安全边界 | 语言整体偏「默认可不安全」 | 绕过规则要进 unsafe |
一句话:C++ RAII 和 Rust Drop 在「谁负责释放」上同类;Rust 多出来的是所有权与借用检查——RAII 解决释放,借用检查解决别用悬垂指针。 同一套 Tracked 结构:Java 赋值后对象仍可达、没有确定性 [Drop];C++ 能悬垂编过;Rust 编译期拦住。和 Java 对照时比的是 GC;和 C++ 对照时,别误读成「Rust 发明了另一种释放」,而是「释放像 RAII,安全多挡一层」。
这套做法常被归到 零成本抽象(zero-cost / zero-overhead abstractions) 的叙事里。说法来自 C++ 传统,Stroustrup 概括为两层:不用的特性不付费;用了的应接近合理手写的效率5。前面 Tracked 的 MIR 已经展示过:宏和 drop(place) 在编译期变成明确指令,但 MIR 仍只是中间站(见「从这份 MIR 看零成本抽象」)。落到本文:
| 抽象 | 「零成本」在说什么 | 对照 |
|---|---|---|
| lifetime / 借用检查 | 检查在编译期完成;生成代码里引用就是指针 | Java:传递引用方便,成本在 tracing GC |
Drop / RAII |
作用域结束是普通析构调用(如 deallocate),没有 GC 线程 |
tracing GC 的标记、并发、暂停 |
| 宏 / 作用域(MIR 可见) | 展开为 _print、drop(_N) 等明确步骤,再经 LLVM 降为机器码 |
运行时「魔法」支撑抽象 |
| 泛型单态化等 | 优化后常可内联到接近手写 | 装箱 + 虚分发另算,那是别的税 |
「零」不是绝对零指令,而是相对手写、没有系统性的抽象税。Box、Vec 分配、dyn Trait 仍有真实成本——零成本说的是设计目标:代价透明、可优化到接近手写,而不是默认绑一个重型托管运行时统一收费。
一个粗浅的类比
Java 像酒店服务员:你下单(分配对象),服务员记录谁在用(GC 跟踪可达性),没人用了再收拾(回收)。Rust 像图书馆借书:借书(获取引用)必须在还书期限(生命周期)内读完,违反规则管理员当场拦住(编译错误);还书时管理员按登记清场(Drop / deallocate),不是事后全馆大扫除式的 tracing GC。前者开发轻松,但有 STW 和延迟尖刺;后者学习曲线陡,但没有 tracing GC 暂停。C++ 则更像「自己管图书室」:RAII 保证还书时书架归位,但默认不设「借条不能活过书」的编译门禁。
Java 也有栈上变量,和 Rust 差在哪?
容易误解成「Java 完全没有栈」,其实差别不在有没有栈,而在对象本身放哪、引用能活多久靠谁保证。
JVM 栈上究竟存什么
按 JVMS,每个线程有私有的 Java Virtual Machine Stack,里面压的是一串 帧(Frame):方法调用时创建,返回时销毁。一帧里主要有1522:
| 组成部分 | 内容 |
|---|---|
| 局部变量表 | 参数与局部变量:原始类型的值,或类型为 reference 的槽 |
| 操作数栈 | 计算表达式、传参、接返回值时用的临时槽(同样是值或 reference) |
| 其它帧数据 | 指向当前类运行时常量池的引用等(支持动态链接) |
规范还写明:因为程序从不直接捅 JVM 栈,帧在实现上甚至可以分配在堆上;对程序员模型来说,仍叫「栈上的帧」15。
常见写法对应到这张表:
void method() {
int x = 42; // 局部变量表里的原始值
String s = new String("hello"); // 局部变量表里是 reference;String 实例在堆
}
类实例与数组从堆分配,由 GC 回收,对象从不显式 free15。栈帧里的 s 出作用域,只是少了一个 GC root;堆上的对象只要还有别的引用可达,就不会被收。
Rust 没有 JVM 那种「bytecode 帧 + 操作数栈」模型,但每个线程同样有调用栈。差别在默认布局与释放时机:许多值直接放在栈上;需要堆时用 String、Box 等,由所有者在 drop 时释放。返回 String 是转移所有权,不是把栈上借用递出去:
let s = String::from("hello");
fn get() -> String { s } // move,不是借栈上引用出去
若返回 &str,就要靠 lifetime(或 elision)说明它跟谁走。Java 可以把堆引用存进字段、传来传去,靠可达性续命;Rust 若持有借用,类型系统会追问「借的那块数据谁还活着」。
逃逸分析:论文里的栈分配 vs HotSpot 的标量替换
Escape analysis(逃逸分析)问的是:一个对象会不会逃出创建它的方法,或逃出创建它的线程。Choi 等人 OOPSLA’99 的工作用 connection graph 做这类分析,并明确列出两类用途:(1) 不逃出方法则可以分配在该方法的 stack frame 上;(2) 不逃出线程则可消同步等。论文也提到:不逃逸时还可能弱化对象访问、甚至消除对象创建23。
这和「语言语义上对象在堆上」不矛盾:JVMS 的堆模型仍然成立;栈分配 / 消分配是编译器优化,成功了少占堆、少进 GC,失败了语义不变。
今天的 HotSpot C2 实现了该谱系的 flow-insensitive 逃逸分析,但落地方式与「把完整对象放到栈帧」不同。OpenJDK Wiki 写明:分析之后,C2 会消除 scalar replaceable 对象的分配及相关锁,也会为未全局逃逸对象做消锁;同时明确——C2 does NOT replace a heap allocation with a stack allocation24。更直白地说:
| 结果 | 含义 |
|---|---|
| 逃逸 / 无法标量替换 | 正常堆上分配,GC 管理 |
| NoEscape 且可标量替换 | 取消这次分配:字段变成局部变量/寄存器,往往既没有堆上的完整对象,也没有「栈上的完整 Java 对象」 |
| 真正的栈分配 | 栈帧里保留带对象头的完整布局——Choi 论文作为优化目标讨论过;C2 当前不做 |
因此「C2 不做真正的栈分配」不是「所有对象永远只能待在堆上」,而是:能优化时优先消掉对象(标量替换),而不是在栈上摆一个完整实例;优化不掉的,仍在堆上。口语说「JIT 把小对象放到栈上」,常常是对标量替换的不精确概括,不是语言保证,也不是 HotSpot 的字面行为。
Rust 侧没有与 HotSpot 一一对应的「逃逸分析 → 标量替换」故事:借用检查约束的是引用是否悬垂,不是 JIT 事后决定能不能少一次 malloc。未逃逸的临时值本来就常在栈上或被优化进寄存器;需要共享所有权时用 Rc/Arc 等,那是另一条显式路径。
结构体里的借用,以及 RustBelt 证明了什么
同一个「解析器」设计,两种写法:
public class Parser {
private String source;
public String slice() {
return source.substring(0, 10); // 新 String 在堆,GC 管
}
}
struct Parser<'a> {
source: &'a str,
}
impl<'a> Parser<'a> {
fn slice(&self) -> &'a str {
&self.source[..10]
}
}
// Parser 不能比 source 活得更久——编译期保证
Java 用运行时可达性(GC)换传递引用的便利;Rust 用生命周期把「引用最多活多久」写进类型。
RustBelt(POPL’18)用 Iris 分离逻辑,在精简模型 λRust 上形式化 ownership、borrowing 与 lifetime inclusion,并用 Coq 机器检查:核心规则健全,且某些带 unsafe 的库在满足验证条件时可安全扩展语言25。这支持一个判断——lifetime 纪律可以是语义上可检验的安全基础,而不只是教学约定。
它没有证明「现实中的 rustc + 全部标准库 + crates.io 永远内存安全」。证明范围是模型与部分核心故事;任意 unsafe、FFI、编译器 bug、历史上的 soundness issue,都不在「已证永不可能」之列。Safe Rust(不写 unsafe、并信任编译器与已审计的标准库)在工程上以内存安全为设计目标,和实践里 tracing GC 语言一样,仍要区分设计目标与全系统形式化盖章。
不是谁更「高级」,是两种内存管理哲学26。就「要不要写 'a」而言:有没有 tracing GC 是主因;有没有 JVM 是重要背景,但不是充要条件。
所有权不保证「内存不会被用光」
借用检查挡住的是悬垂引用、数据竞争这类内存不安全;它不保证程序不会把内存撑爆,也不把「绝不泄漏」写进安全承诺——mem::forget、引用计数环等都可以让资源迟迟不跑 Drop,而文档明确把泄漏视为 safe 范畴里仍可能发生的事27。
更日常的一种情况:集合还被你握在手里,只增不删——内存仍然可达、仍有所有者,所以根本不是「丢了指针的经典泄漏」,但长期运行一样会 OOM。HashMap 就是现成例子:
use std::collections::HashMap;
fn main() {
let mut cache: HashMap<u64, String> = HashMap::new();
let mut i = 0u64;
loop {
// 只 insert、从不 remove:所有条目一直被 cache 拥有
cache.insert(i, format!("payload-{i}"));
i = i.wrapping_add(1);
// 进程长期跑下去 → 堆持续涨 → 最终 OOM / 被系统杀掉
}
}
cache 离开作用域时,整张表还是会按 Drop 释放;问题在于这个作用域可以活过整个进程寿命。Java 里对全局 HashMap 只进不出,一样会把堆撑到 OutOfMemoryError——GC 回收的是不可达对象,不会替你删掉仍被引用的缓存项。
所以对照要收窄:Rust 相对 Java GC,换掉的是「悬垂 / 误用已释放」的默认故事,不是「写了所有权就不会 OOM、不会逻辑泄漏」。
小结
- 分离
'a, 'b:返回值只来自某一个参数 → 其它参数可以更早 drop(HashMap::get一类 API 即如此)。 - 共用
'a(如longest):返回值可能来自多个参数 → 约束取 min;也是 elision 补不全、必须手写的典型情况。 - Borrow Checker:编译期执行所有权 / 共享与独占 / 引用有效;NLL 按最后使用收束借用。卡住时常见出路是 Clone、
Rc+RefCell、或索引代替引用。 - 多数函数靠 lifetime elision 即可;标注只在编译期存在。零成本抽象:编译期经 MIR 等确定性展开,无默认 runtime magic;MIR 是中间表示,不是最终机器码。
- 主因:Java 有 tracing GC(可达性:mark-sweep → 三色 / 并发等谱系),Rust 没有,故用所有权 / 借用 /
Drop。有无 JVM 解释不了 Go(无 JVM、有 GC)这类反例。 - JVM 栈存的是帧;HotSpot 逃逸分析常做标量替换,不是 C2 里的真正栈分配。
- Rust:MIR
drop(_N)按 local 的类型选析构;Tracked进用户Drop::drop(println!展开为_print),String进Vec→RawVec::drop→deallocate。外层类型不必自己写impl Drop——字段析构即可。 - 与 C++:释放机制同属 RAII;本质差别是 Rust 编译期借用检查 / move 失效,C++ 默认不挡悬垂引用与误用已 move 对象。同一
Tracked结构在 Java 里靠可达性续命,不会在作用域结束时打确定性[Drop]。 - RustBelt 给 ownership/lifetime 打了形式化地基,不等于「全部 Rust 程序已证内存安全」。
- 所有权 / 借用检查不保证无泄漏、不保证不 OOM:如
HashMap只增不删会撑爆堆;泄漏本身也不在 Rust 的「memory safety」承诺里。 - 别用 Java 的直觉直接写 Rust 的返回引用——要么返回 owned 类型,要么把依赖关系写进签名。
后续如果继续写 Rust 内存模型,可以单独聊 impl 块里的生命周期、以及和 Pin 的关系。
References
-
查看 drop glue:
rustc tracked.rs --emit=mir生成.mir;在业务函数里搜终结器drop(,在同文件搜fn <impl …>::drop(即Drop::drop函数体)。drop(place)按类型选择析构方式,人读的 dump 通常不在drop(_6)同一 basic block 里再写显式Call <… as Drop>::drop。官方书见20;MIR 终结器说明见 rustc-dev-guide The MIR:https://rustc-dev-guide.rust-lang.org/mir/index.html。 ↩ ↩2 ↩3 -
同上 commit 树内
library/alloc/src/vec/mod.rs:布局 L438–L441;Drop for VecL4248–L4257(drop_in_place元素;注释 RawVec handles deallocation)。 ↩ ↩2 ↩3 -
同上 commit 树内
library/alloc/src/raw_vec/mod.rs:Drop for RawVecL420–L426(inner.deallocate,不 drop 内容)。 ↩ ↩2 -
rustc-dev-guide,The compiler / MIR 相关章节:编译管线经 HIR、MIR 等阶段,MIR 之后继续生成 LLVM IR 与机器码;MIR 是中间层而非最终输出。总览:https://rustc-dev-guide.rust-lang.org/overview.html;MIR:https://rustc-dev-guide.rust-lang.org/mir/index.html。 ↩ ↩2
-
Bjarne Stroustrup,Foundations of C++(ETAPS 2012):zero-overhead principle——What you don’t use, you don’t pay for;What you do use, you couldn’t hand code any better。PDF:https://www.stroustrup.com/ETAPS12-corrected.pdf。概述亦见 cppreference Zero-overhead principle:https://en.cppreference.com/w/cpp/language/Zero-overhead_principle ↩ ↩2
-
Rust 标准库文档,
HashMap::get:返回值生命周期绑定在&self上,与key参数的生命周期分离。参见:https://doc.rust-lang.org/std/collections/struct.HashMap.html#method.get ↩ -
The Rust Programming Language,第 10 章 Validating References with Lifetimes(含
longest示例与 lifetime 语法):https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html ↩ ↩2 ↩3 ↩4 -
Rustc 错误索引,
E0597(this value lives only at certain times during execution):https://doc.rust-lang.org/error_codes/E0597.html ↩ ↩2 -
The Rust Programming Language,第 10 章 Lifetime Elision——三条省略规则;歧义时不猜测而是报错:https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-elision ↩ ↩2
-
The Rust Programming Language,第 4 章 What is Ownership?——对比 GC 语言与所有权:
drop在作用域结束时由编译器插入,运行时不引入 tracing GC:https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html ↩ ↩2 ↩3 ↩4 -
The Rustonomicon,Ownership and Lifetimes——为何在拒绝 GC 的前提下仍要拦住悬垂引用:https://doc.rust-lang.org/nomicon/ownership.html ↩ ↩2
-
The Rust Programming Language,第 4 章 References and Borrowing——借用规则(共享 xor 独占、引用必须有效)与编译期防止数据竞争:https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html ↩ ↩2
-
RFC 2094 Non-lexical lifetimes(NLL):借用可在最后一次使用后结束,而不必拖到词法作用域结尾:https://rust-lang.github.io/rfcs/2094-nll.html ↩
-
标准库
RefCell——运行时借用检查;违反共享/独占规则时panic,而非编译期静默放行:https://doc.rust-lang.org/std/cell/struct.RefCell.html ↩ -
The Java Virtual Machine Specification, Java SE 21 Edition,§2.5.2 Java Virtual Machine Stacks 与 §2.5.3 Heap(堆由 garbage collector 回收,对象从不显式释放;栈存储 frames,帧在实现上可以堆分配):https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.5.3 ↩ ↩2 ↩3 ↩4 ↩5
-
John McCarthy. Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I. Communications of the ACM 3(4), 1960, pp. 184–195. 文中描述从 base registers 沿 car/cdr 链标记可达单元、再回收未标记存储(mark-sweep 谱系的早期表述)。DOI: 10.1145/367177.367199;作者页 PDF:https://www-formal.stanford.edu/jmc/recursive.pdf ↩
-
Edsger W. Dijkstra, Leslie Lamport, A. J. Martin, C. S. Scholten, E. F. M. Steffens. On-the-fly Garbage Collection: An Exercise in Cooperation. Communications of the ACM 21(11), 1978, pp. 966–975. 并发收集与三色标记思想的经典论文。DOI: 10.1145/359642.359655;Lamport 页 PDF:https://lamport.azurewebsites.net/pubs/garbage.pdf ↩
-
Richard Jones, Antony Hosking, Eliot Moss. The Garbage Collection Handbook: The Art of Automatic Memory Management(CRC Press;第 2 版 2023)。系统覆盖 mark-sweep、复制式、分代、并发与实时 GC 等算法;配套站点:https://gchandbook.org/ ↩ ↩2
-
rust-lang/rust
f53b654a888(Auto merge of #155018…)树内library/alloc/src/string.rs:String { vec: Vec<u8> },无impl Drop for String。 ↩ ↩2 -
The Rust Programming Language,第 15 章 Drop——作用域结束时自动调用
drop(RAII):https://doc.rust-lang.org/book/ch15-03-drop.html ↩ ↩2 ↩3 ↩4 -
C++ ↔ Rust 对照:Brown University C++ to Rust Phrasebook,Destructors and resource cleanup(
Drop与 C++ 析构在资源清理上角色同类):https://cel.cs.brown.edu/crp/idioms/destructors.html;Microsoft Rust for C/C++ Programmers 第 7 章对 RAII、move 后状态与借用检查亦有对照:https://microsoft.github.io/RustTraining/c-cpp-book/ch07-ownership-and-borrowing.html ↩ -
The Java Virtual Machine Specification, Java SE 21 Edition,§2.6 Frames(局部变量表、操作数栈、运行时常量池引用):https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.6 ↩
-
Jong-Deok Choi, Manish Gupta, Mauricio J. Serrano, Vugranam C. Sreedhar, Samuel P. Midkiff. Escape Analysis for Java. OOPSLA 1999. 摘要与引言将「不逃出方法 → 可栈分配」与「不逃出线程 → 可消同步」列为主要应用,并提到消除对象创建的可能。DOI: 10.1145/320384.320386 ↩
-
OpenJDK Wiki,EscapeAnalysis(HotSpot C2):基于 Choi et al. 的 flow-insensitive 算法;消除 scalar replaceable 分配与相关锁;不把未全局逃逸对象的堆分配替换为栈分配:https://wiki.openjdk.org/display/HotSpot/EscapeAnalysis ↩
-
Ralf Jung, Jacques-Henri Jourdan, Robbert Krebbers, Derek Dreyer. RustBelt: Securing the Foundations of the Rust Programming Language. PACMPL 2(POPL), 2018. 证明对象为 λRust 模型及满足验证条件的 unsafe 库扩展,而非完整 rustc 与全部生态。PDF:https://plv.mpi-sws.org/rustbelt/popl18/paper.pdf ↩
-
The Rustonomicon,Leaking——Rust 的安全承诺不以「绝不泄漏」为前提;
mem::forget等可在 safe 代码中阻止析构运行:https://doc.rust-lang.org/nomicon/leaking.html ↩