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'ay'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

  1. MIR 写明 let _6: Tracked
  2. drop(_6) 表示按类型 Tracked 的规则析构这个值
  3. Tracked 实现了 Drop,规则里包含:先调用 <Tracked as Drop>::drop(&mut value)
  4. 该 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);展开后才是析构字段 vecVec::dropRawVec::dropdeallocate(见下一节源码)23

  Tracked String
MIR 表面 drop(_6) / drop(_4) 同样是 drop(local)
如何选中具体函数 _6: Trackedimpl 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

最终跑的二进制由这些确定步骤继续编下去,默认没有 tracing GC、也没有为 Drop / 作用域再挂一层隐藏运行时——除非你自己用 dyn Trait 等显式动态机制。

不宜说成「所有代码编译时展开为 MIR 就完事」

  1. MIR 是中间表示,不是最终产物。常见路径是源码 → HIR → MIR → LLVM IR → 机器码;MIR 上做借用检查等,之后还要继续降级和优化4
  2. 零成本抽象经过 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 的生命周期也绑进返回值,上面的写法就通不过——标注错了,合法代码会被误杀

两个参数共用 'alongest 与 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)。调用方在持有返回值期间,xy 都必须还有效

下面这段 main 会编译失败(E0597s 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 可能来自 xy 返回值存活 ≤ min(x, y)

写 API 时先问:调用者拿到返回引用后,最少需要谁还活着? 能分开就写 'a, 'b;返回值真可能来自多个参数,再共用 'a

多数时候不用手写 'a:lifetime elision

日常代码里,签名上的 'a 往往可以省略。官方书把编译器内置的几条模式叫做 lifetime elision rules:它们不是给程序员背的「编码规范」,而是编译器在特定、无歧义的情况下自动补全标注;补不全就报错,不会猜9

三条规则的要点是:

  1. 每个引用参数先各自获得一个生命周期参数(两个参数会先被看成 'a'b)。
  2. 若只有一个输入生命周期,它会赋给所有输出生命周期——所以 fn first(s: &str) -> &str 不必写成 fn first<'a>(s: &'a str) -> &'a str
  3. 若是方法且参数里有 &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

三条基石

  1. 每个值同一时刻有一个所有者(持有该值的绑定;所有权可 move)。
  2. 同一时刻:要么一个 &mut T,要么任意多个 &T——可变与共享不可并存。
  3. 引用必须始终有效:借用不能活过被借用数据的生命周期(不能悬垂)。

前两条管「谁能读、谁能写」;第三条正是前文 longest / Tracked 例子里 does not live long enough 在拦的事8

共享与独占

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,仍然要和这套分析一起看。

检查器说「不」时常见出路

图、链表等结构有时确实需要更灵活的别名。常见策略:

  1. Clone:各持一份所有权(简单,可能费内存)。
  2. Rc + RefCell(单线程)/ Arc + Mutex(多线程):把「共享 / 独占」推迟到运行时再检查;违反时 RefCellpanic,而不是静默 UB14
  3. 用索引代替引用:如 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

「所有者离开作用域就释放」分两层,不宜混成一句「运行时自动 deallocate1020

  1. 编译器在作用域结束处插入 drop glue:按规则调用 Drop::drop、并析构字段。业务代码里通常看不到 deallocate
  2. deallocateDrop 实现里的普通调用——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>,
}

Veclibrary/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::dropVec 上——只负责元素析构,注释写明缓冲由 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::droplibrary/alloc/src/raw_vec/mod.rs3

// 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,靠字段 VecRawVec
只含 i32bool 不用;出作用域只是栈槽作废,没有堆可释
自定义类型里有 String / Vec / Box 通常也不用;编译器按字段生成 drop glue,依次析构字段
自己握着裸指针、mmap、文件句柄等 才需要手写 Drop(或用已有 RAII 类型包起来)

MIR 里的 drop(_6)不是「只有实现了 Drop 的类型才会出现」:

所以 drop(inner: String) 会走进字段析构 → Vec::dropRawVec::dropdeallocate。一句话:不是每个类型都必须 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 可见) 展开为 _printdrop(_N) 等明确步骤,再经 LLVM 降为机器码 运行时「魔法」支撑抽象
泛型单态化等 优化后常可内联到接近手写 装箱 + 虚分发另算,那是别的税

「零」不是绝对零指令,而是相对手写、没有系统性的抽象税BoxVec 分配、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 帧 + 操作数栈」模型,但每个线程同样有调用栈。差别在默认布局与释放时机:许多值直接放在栈上;需要堆时用 StringBox 等,由所有者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、不会逻辑泄漏」。


小结

后续如果继续写 Rust 内存模型,可以单独聊 impl 块里的生命周期、以及和 Pin 的关系。

References

  1. 查看 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 MIRhttps://rustc-dev-guide.rust-lang.org/mir/index.html。  2 3

  2. 同上 commit 树内 library/alloc/src/vec/mod.rs:布局 L438–L441;Drop for Vec L4248–L4257(drop_in_place 元素;注释 RawVec handles deallocation)。  2 3

  3. 同上 commit 树内 library/alloc/src/raw_vec/mod.rsDrop for RawVec L420–L426(inner.deallocate,不 drop 内容)。  2

  4. 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

  5. Bjarne Stroustrup,Foundations of C++(ETAPS 2012):zero-overhead principle——What you don’t use, you don’t pay forWhat you do use, you couldn’t hand code any better。PDF:https://www.stroustrup.com/ETAPS12-corrected.pdf。概述亦见 cppreference Zero-overhead principlehttps://en.cppreference.com/w/cpp/language/Zero-overhead_principle  2

  6. Rust 标准库文档,HashMap::get:返回值生命周期绑定在 &self 上,与 key 参数的生命周期分离。参见:https://doc.rust-lang.org/std/collections/struct.HashMap.html#method.get 

  7. 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

  8. Rustc 错误索引,E0597this value lives only at certain times during execution):https://doc.rust-lang.org/error_codes/E0597.html  2

  9. The Rust Programming Language,第 10 章 Lifetime Elision——三条省略规则;歧义时不猜测而是报错:https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#lifetime-elision  2

  10. 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

  11. The RustonomiconOwnership and Lifetimes——为何在拒绝 GC 的前提下仍要拦住悬垂引用:https://doc.rust-lang.org/nomicon/ownership.html  2

  12. The Rust Programming Language,第 4 章 References and Borrowing——借用规则(共享 xor 独占、引用必须有效)与编译期防止数据竞争:https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html  2

  13. RFC 2094 Non-lexical lifetimes(NLL):借用可在最后一次使用后结束,而不必拖到词法作用域结尾:https://rust-lang.github.io/rfcs/2094-nll.html 

  14. 标准库 RefCell——运行时借用检查;违反共享/独占规则时 panic,而非编译期静默放行:https://doc.rust-lang.org/std/cell/struct.RefCell.html 

  15. 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

  16. 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 

  17. 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 

  18. 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

  19. rust-lang/rust f53b654a888Auto merge of #155018…)树内 library/alloc/src/string.rsString { vec: Vec<u8> } impl Drop for String。  2

  20. The Rust Programming Language,第 15 章 Drop——作用域结束时自动调用 drop(RAII):https://doc.rust-lang.org/book/ch15-03-drop.html  2 3 4

  21. C++ ↔ Rust 对照:Brown University C++ to Rust PhrasebookDestructors and resource cleanupDrop 与 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 

  22. 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 

  23. 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 

  24. OpenJDK Wiki,EscapeAnalysis(HotSpot C2):基于 Choi et al. 的 flow-insensitive 算法;消除 scalar replaceable 分配与相关锁;把未全局逃逸对象的堆分配替换为栈分配:https://wiki.openjdk.org/display/HotSpot/EscapeAnalysis 

  25. 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 

  26. 本博客《栈为什么比堆快》从内核视角梳理栈与堆的分配路径;本文侧重引用能活多久、谁对谁负责。 

  27. The RustonomiconLeaking——Rust 的安全承诺不以「绝不泄漏」为前提;mem::forget 等可在 safe 代码中阻止析构运行:https://doc.rust-lang.org/nomicon/leaking.html