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"
}

若误写成 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 无关1

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 示例就是这种签名2

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)3

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:它们不是给程序员背的「编码规范」,而是编译器在特定、无歧义的情况下自动补全标注;补不全就报错,不会猜4

三条规则的要点是:

  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 还是 y24。结构体里存放引用(如后面的 Parser<'a>)则几乎总要在类型上写出生命周期——那是「值里藏着借用」,和函数签名省略是另一回事。

和 Java 对照:Java 方法签名里没有「返回的引用最多活多久」这一栏,因为堆对象靠 GC 按可达性续命;Rust 把这件事放进类型,多数情况由 elision 默默填好,只有有歧义时才要你写清楚。


Java 为什么不需要生命周期标注?

和「要不要写 lifetime」最直接相关的一点:Java 有 tracing GC,Rust 没有。 Java 靠运行时可达性续命;Rust 靠编译期所有权与借用规则保证内存安全。生命周期标注是借用规则的一部分,只在编译期存在,运行时没有「寿命监视器」25

《The Rustonomicon》开篇把动机说得很直白:GC 让「指针指着的东西别提前消失」这件事变得轻松,但在需要手动管内存的场景里成本太高;Rust 的所有权系统就是为了在不要 GC的前提下,仍然拦住悬垂引用这类错误6

常有人再补一句:「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 栈,帧里放局部变量与运算中间结果7。因此上面 Java 例子里,出作用域的是栈上的局部变量 s,堆上的 String 只要还被 result 可达,就不会被回收。

更贴切的对照不是「Java 用 GC 管理所有权」,而是:

  Java Rust
寿命判据 可达性(从 GC roots 能否摸到对象) 所有权 / 借用(谁拥有、借用能否活过所有者)
谁保证安全 运行时 tracing GC(现代 HotSpot 远不止经典 mark-and-sweep 一种) 编译期借阅检查 + 所有者结束时的 Drop
「所有权」一词 语言模型里几乎没有 Rust 那种唯一所有者 / move 类型系统一等公民

Rust 里 String 的缓冲在堆上,但所有权在栈上的 s;作用域结束时走 Drop 释放堆缓冲58&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

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

  1. 编译器在作用域结束处插入 drop glue:按规则调用 Drop::drop、并析构字段。业务代码里通常看不到 deallocate
  2. deallocateDrop 实现里的普通调用——drop glue 跑到那里,才把堆块还给分配器。没有 tracing GC 在背后扫一遍。

标准库里 String 没有自己的 impl Drop,只是包着 Vec<u8>8

pub struct String {
    vec: Vec<u8>,
}

Vec 再拆成缓冲与长度10

pub struct Vec<T, A: Allocator = Global> {
    buf: RawVec<T, A>,
    len: usize,
}

作用域结束时的实际路径是:

s 离开作用域
  → drop glue:析构 String 的字段 vec
       → Vec::drop:先 drop_in_place 元素(对 u8 基本无事)
       →(随后)RawVec::drop:alloc.deallocate(ptr, layout)

Vec::drop 只负责元素析构,注释写明缓冲由 RawVec 处理10

unsafe impl<#[may_dangle] T, A: Allocator> Drop for Vec<T, A> {
    fn drop(&mut self) {
        unsafe {
            ptr::drop_in_place(ptr::slice_from_raw_parts_mut(self.as_mut_ptr(), self.len))
        }
        // RawVec handles deallocation
    }
}

RawVec::drop 才调用分配器11

unsafe impl<#[may_dangle] T, A: Allocator> Drop for RawVec<T, A> {
    fn drop(&mut self) {
        unsafe { self.inner.deallocate(T::LAYOUT) }
    }
}

因此:

问题 答案
deallocate 是 GC 自动扫出来的吗? 不是
编译器会直接插入一条裸 deallocate 吗? 一般不;插入的是 drop glue → Drop::drop
堆何时释放? RawVec::drop 里的 Allocator::deallocate 随 drop 执行

借用检查保证「没有人在 drop 之后还拿着指向这块缓冲的引用」;Drop 保证「该释放时释放」。move 之后旧绑定不再 drop,避免 double-free。

这套做法常被归到 零成本抽象(zero-cost / zero-overhead abstractions) 的叙事里。说法来自 C++ 传统,Stroustrup 概括为两层:不用的特性不付费;用了的应接近合理手写的效率12。落到本文:

抽象 「零成本」在说什么 对照
lifetime / 借用检查 检查在编译期完成;生成代码里引用就是指针 Java:传递引用方便,成本在 tracing GC
Drop / RAII 作用域结束是普通析构调用(如 deallocate),没有 GC 线程 tracing GC 的标记、并发、暂停
泛型单态化等 优化后常可内联到接近手写 装箱 + 虚分发另算,那是别的税

「零」不是绝对零指令,而是相对手写、没有系统性的抽象税BoxVec 分配、dyn Trait 仍有真实成本——零成本说的是设计目标:代价透明、可优化到接近手写,而不是默认绑一个重型托管运行时统一收费。

一个粗浅的类比

Java 像酒店服务员:你下单(分配对象),服务员记录谁在用(GC 跟踪可达性),没人用了再收拾(回收)。Rust 像图书馆借书:借书(获取引用)必须在还书期限(生命周期)内读完,违反规则管理员当场拦住(编译错误);还书时管理员按登记清场(Drop / deallocate),不是事后全馆大扫除式的 tracing GC。前者开发轻松,但有 STW 和延迟尖刺;后者学习曲线陡,但没有 tracing GC 暂停。

Java 也有栈上变量,和 Rust 差在哪?

容易误解成「Java 完全没有栈」,其实差别不在有没有栈,而在对象本身放哪、引用能活多久靠谁保证

JVM 栈上究竟存什么

按 JVMS,每个线程有私有的 Java Virtual Machine Stack,里面压的是一串 帧(Frame):方法调用时创建,返回时销毁。一帧里主要有713

组成部分 内容
局部变量表 参数与局部变量:原始类型的值,或类型为 reference 的槽
操作数栈 计算表达式、传参、接返回值时用的临时槽(同样是值或 reference
其它帧数据 指向当前类运行时常量池的引用等(支持动态链接)

规范还写明:因为程序从不直接捅 JVM 栈,帧在实现上甚至可以分配在堆上;对程序员模型来说,仍叫「栈上的帧」7

常见写法对应到这张表:

void method() {
    int x = 42;                        // 局部变量表里的原始值
    String s = new String("hello");    // 局部变量表里是 reference;String 实例在堆
}

类实例与数组从分配,由 GC 回收,对象从不显式 free7。栈帧里的 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) 不逃出线程则可消同步等。论文也提到:不逃逸时还可能弱化对象访问、甚至消除对象创建14

这和「语言语义上对象在堆上」不矛盾:JVMS 的堆模型仍然成立;栈分配 / 消分配是编译器优化,成功了少占堆、少进 GC,失败了语义不变。

今天的 HotSpot C2 实现了该谱系的 flow-insensitive 逃逸分析,但落地方式与「把完整对象放到栈帧」不同。OpenJDK Wiki 写明:分析之后,C2 会消除 scalar replaceable 对象的分配及相关锁,也会为未全局逃逸对象做消锁;同时明确——C2 does NOT replace a heap allocation with a stack allocation15。更直白地说:

结果 含义
逃逸 / 无法标量替换 正常堆上分配,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 的库在满足验证条件时可安全扩展语言16。这支持一个判断——lifetime 纪律可以是语义上可检验的安全基础,而不只是教学约定。

没有证明「现实中的 rustc + 全部标准库 + crates.io 永远内存安全」。证明范围是模型与部分核心故事;任意 unsafe、FFI、编译器 bug、历史上的 soundness issue,都不在「已证永不可能」之列。Safe Rust(不写 unsafe、并信任编译器与已审计的标准库)在工程上以内存安全为设计目标,和实践里 tracing GC 语言一样,仍要区分设计目标全系统形式化盖章

不是谁更「高级」,是两种内存管理哲学17。就「要不要写 'a」而言:有没有 tracing GC 是主因;有没有 JVM 是重要背景,但不是充要条件。


小结

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

References

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

  2. The Rust Programming Language,第 10 章 Validating References with Lifetimes(含 longest 示例与 lifetime 语法):https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html  2 3

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

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

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

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

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

  8. alloc::string::String 定义为 vec: Vec<u8>,自身无 impl Drop,释放委托给 Vec(rust-lang/rust library/alloc):https://doc.rust-lang.org/src/alloc/string.rs.html  2

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

  10. alloc::vec::Vec 的布局(buf: RawVec + len)与 Dropdrop_in_place 元素;缓冲由 RawVec 释放):https://doc.rust-lang.org/src/alloc/vec/mod.rs.html  2

  11. alloc::raw_vec::RawVecDrop:调用 inner.deallocate,不 drop 内容:https://doc.rust-lang.org/src/alloc/raw_vec/mod.rs.html 

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

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

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

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

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

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