1. 运行时的职责
一句话总结: 运行时不只执行代码,还负责栈帧布局、堆分配、垃圾回收、异常展开与线程调度,是语言语义落地的支撑层。
编译产物不是孤立的机器码,它依赖运行时(runtime)提供的服务。运行时的边界因语言而异:C 的运行时几乎只有启动代码与 libc,Java 的运行时则是庞大的 JVM——堆管理、GC、字节码解释、JIT、类加载都在其中。语言设计者在「静态化多少」与「运行时留多少」之间取舍,直接决定内存模型与性能特征。
运行时服务全景:
┌─ 程序启动/退出, 全局构造析构
├─ 栈: 帧建立/销毁, 调用约定
├─ 堆: malloc/free 或 GC 分配/回收
├─ 异常: 展开栈帧, 匹配 handler
├─ 反射/内省: 类型信息查询
└─ 并发: 线程调度, 同步原语
| 语言 | 内存管理 | 运行时规模 |
|---|---|---|
| C/C++ | 手动 free | 极小 |
| Rust | 编译期所有权 + 运行时少 | 小 |
| Java/Go | GC | 大 |
| Python/Ruby | GC + 引用计数 | 较大 |
编译器后端生成的代码必须与运行时约定对接:栈帧布局要遵循 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 在内存问题上的不同答案。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。