跳转到内容

运行时、内存管理与垃圾回收

语言运行时把"内存管理"虚拟成"对象用不完就扔"(→ 从晶体管到应用:抽象层次)。本章讲栈与堆的分工、分配器的实现、引用计数与追踪式 GC 的谱系,以及 JIT 如何用运行时信息做静态编译做不到的优化。

  • 栈:函数调用自动分配/释放,LIFO,移动指针即可。快(一条指令),但生命周期绑定调用帧;
  • 堆:手动/自动管理,生命周期任意。贵(要找空闲块),但有灵活性。

栈帧与调用约定见指令集架构。逃逸分析(escape analysis)是两者的桥梁:编译器证明对象不逃出函数时,把它从堆降级到栈——Java 与 Go 的优化,"新对象不一定在堆上"。

malloc 的任务:维护空闲块,找到够大的、切出、返回指针。经典策略:

  • 空闲链表:空闲块串成链,first-fit / best-fit 分配;碎片化是主要问题;
  • bump 分配:只移动指针——极快,配合 GC 整理(compact)消除碎片;
  • 分区(slab/size class):按大小分类的池,每个大小一个池——避免碎片、缓存友好(现代分配器 jemalloc、tcmalloc 的做法)。

分配的真实成本不在分配本身,而在缓存与碎片——分配器的局部性设计(相邻分配的块在内存中也相邻)影响程序整体性能。

每个对象计数引用数,归零即释放:

  • 优点:确定性析构、开销摊在每次赋值、实现简单(Python 的对象模型、C++ shared_ptr);
  • 缺点:循环引用永不释放(需要额外的环检测或弱引用);每次赋值都要改计数(原子操作多线程下贵);缓存不友好(改计数写回内存)。

从根(栈、全局)出发,定期追踪可达对象,不可达即回收:

算法思路权衡
标记-清除标记可达,清除不可达简单;碎片化、全停顿
标记-整理标记后压缩,消除碎片无碎片;整理有成本
复制可达对象复制到新空间无碎片、分配快(bump);空间减半
分代新生代/老年代分别处理利用"多数对象早死";写屏障成本
并发/增量与程序交替运行低停顿;写屏障与复杂性

分代假设:大多数对象早死——新生代用复制算法(存活少、复制便宜),老年代低频整理。这是 JVM/C#/Go 的共同结构。跨代引用用写屏障(赋值时记录)追踪,并发 GC 以吞吐换低停顿:与程序并发标记(CMS、G1、ZGC、Shenandoah),停顿从秒级降到毫秒级,代价是写屏障与整理开销。

GC 的权衡是吞吐/延迟/内存开销的三角——没有全能算法:低停顿付出吞吐与屏障成本,高吞吐付出停顿。GC 调优的现实:停顿打进 p99 时,先看分代与堆大小,再考虑换算法或换语言(→ 性能工程与可观测性)。

JIT 编译器用运行时信息做静态编译做不到的优化:

  • 热点探测:只编译执行频繁的代码,冷代码留在解释器/基线编译;
  • 基于类型的投机优化:假设"这个调用点至今都是整数类型"而特化编译,假设错了就**去优化(deoptimize)**回解释器——投机是 JIT 的核心机制;
  • 内联缓存(inline cache):缓存"这个调用点见过的对象类型",动态语言的方法查找从哈希查询变成类型检查 + 直接调用;
  • 分层编译:解释器 → 基线编译 → 优化编译,逐层换编译时间与峰值性能。

JVM(HotSpot)、V8、CPython 3.13+(实验性 JIT)、Julia 都是这个模式。JIT 的内联缓存与去优化解释了动态语言"预热后快十倍"的现象。

  • 栈快而受限,堆灵活而贵;逃逸分析把不逃逸的堆对象降到栈
  • 分区分配器(size class)避免碎片且缓存友好;分配成本主要在局部性
  • 引用计数确定性好但循环引用与计数开销;追踪式 GC 无环问题但有停顿
  • 分代假设:大多数对象早死;并发 GC 以吞吐换低停顿,权衡是三角
  • JIT 用运行时信息做静态做不到的优化:投机 + 内联缓存 + 去优化