「运行时与内存管理」

系统讲解语言运行时的内存管理:栈帧布局与调用约定、堆分配与逃逸分析、标记清除与分代 GC、写屏障与回收根、引用计数与所有权模型,以及内存安全边界的工程权衡,附带可运行的 Python 示例与伪代码。

1. 运行时的职责

一句话总结: 运行时不只执行代码,还负责栈帧布局、堆分配、垃圾回收、异常展开与线程调度,是语言语义落地的支撑层。

编译产物不是孤立的机器码,它依赖运行时(runtime)提供的服务。运行时的边界因语言而异:C 的运行时几乎只有启动代码与 libc,Java 的运行时则是庞大的 JVM——堆管理、GC、字节码解释、JIT、类加载都在其中。语言设计者在「静态化多少」与「运行时留多少」之间取舍,直接决定内存模型与性能特征。

运行时服务全景:
  ┌─ 程序启动/退出, 全局构造析构
  ├─ 栈: 帧建立/销毁, 调用约定
  ├─ 堆: malloc/free 或 GC 分配/回收
  ├─ 异常: 展开栈帧, 匹配 handler
  ├─ 反射/内省: 类型信息查询
  └─ 并发: 线程调度, 同步原语
语言内存管理运行时规模
C/C++手动 free极小
Rust编译期所有权 + 运行时少小
Java/GoGC大
Python/RubyGC + 引用计数较大

编译器后端生成的代码必须与运行时约定对接:栈帧布局要遵循 ABI,堆分配要调用运行时分配函数,GC 对象要登记为根(roots)。理解运行时,本质是理解「编译器生成的代码」与「运行时提供的服务」之间的契约——栈、堆、异常、并发四条线都是这份契约的组成部分。

2. 栈帧与调用约定

一句话总结: 调用约定规定参数怎么传、返回值怎么回、栈帧怎么布局,ABI 化后跨语言互操作才成为可能。

每次函数调用在栈上建立一帧(frame),存放参数、局部变量、返回地址与保存的寄存器。栈帧布局由调用约定(calling convention)决定:SysV x86-64 约定前六个整数参数走寄存器(rdi、rsi、rdx、rcx、r8、r9),其余压栈;返回值放 rax;栈向下增长且保持 16 字节对齐。编译器按约定生成进入与退出序列(prologue/epilogue),栈帧的确定性让递归、异常展开与栈回溯(backtrace)都可预测。

x86-64 SysV 调用约定 (示意):
  调用方: 传参 → call f (压返回地址) → 恢复寄存器
  f:      push rbp → mov rbp, rsp     (prologue)
          ... 分配局部空间, 保存被调用者保存寄存器
          ... 函数体
          mov rsp, rbp → pop rbp → ret (epilogue)
# 概念: 用数据类模拟栈帧布局
@dataclass
class FrameLayout:
    name: str
    arg_regs: list[str]        # 参数寄存器分配
    stack_args: int            # 溢出到栈的参数个数
    locals: dict[str, int]     # 局部变量 -> 相对 rbp 偏移
    saved_regs: list[str]      # 需保存的被调用者保存寄存器
    alignment: int = 16

def emit_prologue(layout, asm):
    asm.append(f"push rbp")
    asm.append(f"mov rbp, rsp")
    for r in reversed(layout.saved_regs):
        asm.append(f"push {r}")
    # 为局部变量留空间, 保持 16 字节对齐
    frame_size = align(len(layout.locals) * 8, layout.alignment)
    asm.append(f"sub rsp, {frame_size}")
ABI 要素内容影响
参数传递寄存器/栈混合互操作与性能
返回值寄存器或隐藏指针大结构体返回
寄存器划分调用者/被调用者保存寄存器分配约束
对齐规则栈与结构体对齐平台强制

调用约定是编译器后端与链接器、调试器、操作系统共同遵守的契约。不同的约定服务于不同场景:fastcall 用更多寄存器、cdecl 全栈传参、stdcall 由被调用方清理栈。栈帧的另一个关键用途是异常展开(unwinding):C++ 与 Swift 的异常靠栈帧上的展开信息(.eh_frame)逐帧清理局部对象并查找 handler,这要求帧布局带有标准化的元数据,否则跨语言异常无法传播。

3. 堆与逃逸分析

一句话总结: 堆用于无法确定生命周期的对象,逃逸分析把「不逃逸」的对象降级到栈上分配,是零开销语言的关键优化。

栈上对象生命周期由作用域决定,但很多对象需要活得比创建它的函数更久——返回值、存入全局结构体、闭包捕获。这些对象必须放堆。编译器可用逃逸分析(escape analysis)判断一个对象是否逃逸:若对象完全不逃逸(只在本函数内使用),就把它降级为栈分配甚至标量替换(scalar replacement,拆成多个寄存器变量),省去堆分配与后续回收。Java、Go 的 JIT 都做这类优化。

def escape_analysis(ir):
    """粗略逃逸分析: 标记逃逸对象 (概念实现)。"""
    escapes = set()
    for instr in ir:
        if instr.op == "store_to_global" or instr.op == "return":
            escapes.add(instr.obj)
        if instr.op == "call":
            for arg in instr.args:
                if arg in ir.alloc_sites:
                    escapes.add(arg)      # 传给未知函数
    return {a for a in ir.alloc_sites if a not in escapes}

# 不逃逸: 完全在函数内使用 -> 栈分配
def make_point():
    p = Point(1, 2)      # 不逃逸
    return p.x + p.y     # 只有值返回, 对象本身不逃逸

# 逃逸: 返回对象本身 -> 必须堆分配
def make_point():
    p = Point(1, 2)
    return p             # 对象逃逸出函数
分配策略条件回收方式
栈分配不逃逸且生命周期确定帧销毁即回收
标量替换可拆分为若干字段直接寄存器/局部
堆分配逃逸或大对象GC / free
线程局部仅单线程使用线程结束回收

逃逸分析是「编译器替程序员做内存决策」的典范:源码写法不变,运行时自动把热点对象搬到栈上。代价是分析必须保守——传参给未知函数、取对象地址、存入全局都可能被判定逃逸。分析粒度越细收益越高,但分析时间也越长,因此 JIT 常用简化版本(只分析当前方法),AOT 才做过程间分析。对程序员而言,理解逃逸分析能解释为什么「返回一个小结构体」往往不比返回两个字段慢。

4. 标记清除 GC

一句话总结: 标记-清除 GC 从根集合出发可达性遍历标记存活对象,再扫描堆清除未标记者,是理解一切 GC 的起点。

垃圾回收(Garbage Collection)自动回收不再可达(unreachable)的对象。标记-清除(mark-sweep)是最经典的算法:从根集合(栈、寄存器、全局)出发,沿对象引用递归标记所有可达对象;标记完成后堆里未标记的就是垃圾,清除阶段回收其内存。标记用三色抽象(白=未访问、灰=待处理、黑=已处理)描述遍历进度,避免重复处理。

# 三色标记清除 (概念实现)
WHITE, GRAY, BLACK = 0, 1, 2

def mark_sweep(roots, heap):
    color = {obj: WHITE for obj in heap}
    stack = []
    for r in roots:                    # 根入灰
        color[r] = GRAY
        stack.append(r)
    while stack:
        obj = stack.pop()
        color[obj] = BLACK
        for ref in obj.refs:           # 标记子引用
            if color[ref] == WHITE:
                color[ref] = GRAY
                stack.append(ref)
    # 清除: 白对象是垃圾
    return [obj for obj in heap if color[obj] != WHITE]
阶段开销特点
标记跟随所有引用时间与存活对象成正比
清除扫描整堆可触发分配
根枚举栈/寄存器扫描保守式需寄存器扫描

标记-清除的缺点是暂停(stop-the-world):标记与清除期间程序必须挂起,暂停时长随存活对象与堆规模线性增长。且内存出现碎片——清除把空洞留给 freelist,大对象可能分配失败。工程上由此演化出压缩(compaction)、复制(copying)、分代(generational)等改进。理解标记-清除的三色标记是理解后续所有 GC 算法的共同语言。

5. 分代 GC 与写屏障

一句话总结: 大部分对象英年早逝,分代 GC 把堆分成年轻代与老年代分别回收,写屏障在跨代引用建立时登记候选,控制暂停时长。

经验法则「绝大多数对象很快死亡」(weak generational hypothesis)支撑了分代 GC:新生对象放年轻代(young generation),年轻代回收(minor GC)只扫年轻代所以又快又便宜;活过几轮的晋升到老年代(old generation),老年代回收(major GC)较少触发但更慢。跨代引用——老年代对象指向年轻代对象——必须在 minor GC 时被当作根,否则年轻代存活对象会被误杀。写屏障(write barrier)在每次「老→年」引用写入时登记该老对象,让 minor GC 不必扫整个老年代。

class GenerationalHeap:
    def __init__(self, promote_threshold=3):
        self.young = []        # 年轻代
        self.old = []          # 老年代
        self.remembered = set()  # 含老->年引用的老对象
        self.age = {}

    def write_barrier(self, from_obj, to_obj):
        # 老对象写入指向年轻对象的引用 -> 登记
        if from_obj in self.old and to_obj in self.young:
            self.remembered.add(from_obj)

    def minor_gc(self, roots):
        roots = list(roots) + list(self.remembered)
        live = mark(roots, self.young)
        for obj in live:
            self.age[obj] = self.age.get(obj, 0) + 1
            if self.age[obj] >= self.promote_threshold:
                self.old.append(obj)          # 晋升
        self.young = [o for o in self.young if o in live]
GC 策略暂停模式吞吐延迟
标记-清除STW 全堆中高且不稳
分代复制年轻代 STW高低(年轻代小)
并发标记标记与程序并发中稳定
ZGC/C4读屏障 + 染色指针高亚毫秒

分代 GC 的工程细节决定成败:晋升阈值要自适应(动态调节避免对象在年轻代反复搬运)、老年代要选择压缩策略控制碎片、并发 GC(G1、ZGC)把标记/回收与程序并发跑,用读屏障与染色指针实现亚毫秒级暂停。JVM 的 G1 甚至把堆切成 region 按需回收。分代与非分代(Lisp2 压缩、Shenandoah)之争本质是「对象年龄假设是否成立」与「暂停预算多紧」的权衡。

6. 引用计数与所有权

一句话总结: 引用计数每增加一个引用就 +1、释放就 -1,计数归零立即回收;Rust 的所有权则把释放决策提前到编译期,运行时开销为零。

引用计数(reference counting, RC)是一种即时 GC:每个对象带一个计数字段,指针赋值时 +1、销毁时 -1,归零立刻释放。它无暂停、确定性好,但有两个老大难:循环引用(A 引用 B、B 引用 A,各自计数永不归零)导致泄漏,以及每次赋值都改计数带来的原子/缓存开销。Swift 用 RC + 弱引用解环,Python 用 RC 加一个可选的循环检测器。

// Rust 所有权: 编译期决定释放点, 无运行时计数
fn main() {
    let s = String::from("hello");  // s 拥有堆字符串
    let t = &s;                     // 借用, 不改变所有权
    println!("{}", s);              // s 仍然有效
}   // 离开作用域, s 自动 drop, 堆内存即刻释放

// Box: 显式堆分配, 作用域结束自动回收
let boxed = Box::new(42);
let raw: *mut i32 = &*boxed as *const i32 as *mut i32;
drop(boxed);                        // 手动提前释放
# 概念: 带弱引用的引用计数 (示意)
class RefCounted:
    def __init__(self):
        self.strong = 1
        self.weak = 0
        self.cycle = False

    def retain(self): self.strong += 1
    def release(self):
        self.strong -= 1
        if self.strong == 0:
            self.free()
方案循环引用运行时开销暂停
引用计数需弱引用/检测器每次赋值无
所有权编译期禁止零无
追踪 GC自动处理无逐次开销有

Rust 的答案是把「谁拥有、何时释放」变成编译期类型规则:值有唯一所有者,借用(borrow)通过生命周期标注限制引用超期,Arc/Rc 才引入显式计数。这换来零运行时开销与确定性,代价是程序员学习所有权与借用检查。所有权模型还可与 GC 混用:Arc<Mutex<T>> 提供共享可变,跨线程安全由类型系统保证。内存管理的三大流派——追踪 GC、引用计数、所有权——最终都指向同一个问题:谁来决定对象何时死亡。

7. 内存安全边界

一句话总结: 内存安全是「不悬垂、不越界、不双释、不数据竞争」,安全语言靠类型与运行时护栏,unsafe 则把责任交还程序员。

内存安全(memory safety)指程序不会发生悬垂指针、缓冲区越界、重复释放与数据竞争。静态语言靠类型系统(Rust 所有权)、运行时靠 GC(悬垂不可能,因为对象只要被引用就不会回收)与边界检查(bounds check)来保证。C/C++ 没有这些护栏,问题集中爆发在 unsafe 或 FFI 边界。理解安全边界,就是理解「编译器与运行时替你兜住了什么,又在哪放手」。

# 运行时护栏示例: 越界检查是解释器的内置行为
def list_get(li, idx):
    if idx < 0 or idx >= len(li):
        raise IndexError(f"index {idx} out of range")
    return li[idx]

# 反例: 无检查的直接偏移访问 (C 风格, 不安全)
#   arr[i] 在 i 越界时读到相邻内存
// Rust 的安全边界: unsafe 是显式越过
fn dangerous(p: *mut i32) -> i32 {
    unsafe { *p }          // 程序员负责保证 p 有效
}
问题传统应对现代语言应对
悬垂指针小心使用GC / 所有权 + 生命周期
越界手动检查自动边界检查
双重释放约定RAII / 所有权唯一性
数据竞争锁纪律Send/Sync 类型约束

安全边界的工程现实是「尽可能安全、必要时 unsafe」:Rust 的 unsafe 块、Swift 的 Unmanaged、Java 的 sun.misc.Unsafe 都是逃生口。GC 语言里安全边界还包括内存排序与并发内存模型——volatile、fence、happens-before 关系。对语言实现者,安全边界直接定义运行时必须提供的检查点;对使用者,理解边界能解释为什么「安全语言在热路径上仍常有边界检查开销」以及如何用批量操作把检查摊薄。

8. 总结

一句话总结: 运行时内存管理在栈、堆、GC 策略与安全边界之间权衡,所有权模型把释放提前到编译期,是语言性能与安全的分水岭。

主题核心结论
运行时职责栈/堆/异常/并发,语言语义的支撑层
栈帧与 ABI调用约定决定参数、返回与帧布局
堆与逃逸逃逸对象才堆分配,不逃逸降级栈/标量
标记-清除根可达遍历标灰标黑,白对象即垃圾
分代 GC年轻代快速回收 + 写屏障登记跨代引用
引用计数/所有权RC 即时确定性,所有权编译期零开销
安全边界GC 与类型系统兜底,unsafe 交还责任

内存管理是语言运行时与编译器后端交汇最深的领域:后端决定栈帧与分配调用,运行时决定回收策略,两者共同决定程序能否安全、高效地运行。三大策略——追踪 GC、引用计数、所有权——各有适用场景,现代语言常在边界处混合使用。把栈帧布局、逃逸分析、三色标记与所有权规则吃透,就掌握了绝大多数语言运行时内存子系统的主干,也就能读懂 JVM、Go 与 Rust 在内存问题上的不同答案。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 「错误恢复与诊断」
  2. 「现代优化 Pass 管线」
  3. 「类型系统与类型推断」