“这里调用点必须已经证明旧局部值没有后续读取,栈上对象不会被目标继续引用。若实参指向当前帧中即将被覆盖的对象,单纯尾位置也不够;生命期分析要阻止这类复用,或改用足够长寿命的存储。”
形式陈述
先说明逃逸相对于谁
本页选一个函数调用帧作为分配区域。对象若可能在这次调用返回后仍被外部计算访问,就相对此帧逃逸。返回对象、写入全局、放进调用者仍可访问的容器,都是可能逃逸的路径。把一个对象传给同步、保证不保存参数的辅助函数,并不自动构成逃逸。
只研究正常返回、单线程、无地址伪造和无捕获调用栈的控制操作。源语言不允许观察栈地址与堆地址的数值差别;分配失败等资源观察另作约定。若语言能返回指向局部对象的裸地址却没有生命期限制,简单收回栈帧就不安全。
静态节点是分配站点,不是本次执行的具体对象。一个站点在循环或递归中可能产生很多实例;把它们合并会多报逃逸,算法必须宁可保守,也不能漏掉真实逃逸。调用上下文更精细时可以拆分节点,但本页不重新实现已有points-to 分析。
输入是一张可靠的可能引用图
设有限节点集合为 V,包括本函数分配站点及分析使用的外部对象摘要。弧 a→b 表示 a 的某个字段在某次允许执行中可能指向 b,允许环与自引用。图须覆盖所有真实字段边;一条静态路径的各边不必能在同一次执行同时发生,这种合并造成保守误报。
另给种子集合 Q:可能返回的对象;可能存入全局的对象;调用者拥有且返回后仍可能访问的参数容器;传给未知或可能保存参数的调用的对象。对未知调用,把被传对象及其可达内容视为可能被保存。已证明同步且不保留引用的 noescape 摘要才允许缩小这个集合,不能凭函数名或一次测试猜测。
例如 arg.saved = new Cell(0),即使函数返回整数 0,新 Cell 仍可能经调用者的 arg 被访问。将外部 arg 节点放进 Q,图中加入 arg→Cell,便能覆盖这条路径。只把“return 后的对象”作为种子会漏报。
一个独立的闭合算法
Esc = empty set
work = empty queue
for each q in Q:
if q not in Esc: add q to Esc; push q
while work is not empty:
a = pop work
for each possible field edge a -> b:
if b not in Esc:
add b to Esc
push b
输出 Esc 是包含 Q 且沿可能字段边闭合的最小集合。对本函数的分配站点 a,a∉Esc 给出“不沿已建模出口活过返回”的证据。图缺失一个可能字段存储、调用摘要低估保存行为,都会破坏这个结论;此算法的线性复杂度没有包含建立可靠图的成本。
设 |V|=v、可能边数为 e,种子表长 q。去重集合和邻接表使闭合阶段为 O(v+e+q) 时间、O(v) 附加空间。每个节点至多入队一次,所以环不阻碍终止。
证明很直接:外部返回后若真能访问一个本地对象,必从某个返回值、全局、外部容器或保存参数的调用出发,沿实际字段链取得它。种子与图的可靠性把这条具体链映到静态路径;按路径长度归纳,该对象的站点必在 Esc。因此不在 Esc 的站点不会被这些出口访问。这是充分条件,不是精确判定:在 Esc 里只说明“分析不能排除”。
直觉
栈分配之所以便宜,是因为函数返回时可以一次收回整帧;它安全的前提是,帧消失后再也没有代码会访问里面的对象。逃逸分析寻找这个前提能否成立。它并不问对象有没有被另一个变量引用,而是问引用能否活过所选择的分配区域。
例子与边界
make 返回时,哪些东西必须活下去
计数器的静态对象关系为 P→A,B、A→Ei、B→Ep、Ei→C、Ep→C,make 返回 P。另设函数内创建一个 scratch Cell,只在返回前读取它,没有放进任何外部对象。
从 Q={P} 开始,第 0 轮只有 P;第 1 轮加入 A、B;第 2 轮加入 Ei、Ep;第 3 轮加入 C,之后稳定。于是 P、A、B、Ei、Ep、C 都相对 make 的帧逃逸,scratch 不在集合里。两个环境和共享单元不能随 make 返回失效。
scratch 可以使用 make 帧内的独立存储;C 不行。若把 C 放在 make 的栈槽,返回后下一次调用复用该槽并写入 99,inc(3) 就可能读 99 再写 102,而非预期的 7。返回闭包本身放在堆上并不够,它所依赖的环境与可变单元也必须有足够长的生命期。
注意分析时刻不同:P、A 在 make 返回时确实逃逸;到了后来 inc 的安全点,main 已不再需要它们,它们可能成为运行时垃圾。相对某帧逃逸不等于永远存活,也不等于每次收集都必须从根可达。 静态分类不能取代运行时可达性。
不逃逸还不自动等于一个可复用槽
如果一个分配站点在循环内产生多个同时存活的对象,即使它们都不逃出函数,也不能让它们共用同一固定槽。可以证明每轮旧对象已死后重用,或使用函数内区域保存多个实例;否则仍可保守地选择堆分配。递归调用也需要各自帧中的不同对象身份。
栈空间预算、对齐、对象大小和控制流恢复都要满足 ABI。一个不逃逸的巨大数组可能不适合栈;把堆不足换成栈溢出并不自动保持资源行为。本文只证明正常资源条件下的生命期安全,不给性能保证。
栈对象若含堆指针,收集器还必须扫描其受管理字段。本文计数器核心模型统一使用堆基址指针;若实施栈对象优化,就需扩展根映射,把栈对象的堆指针字段登记为根,并保证回收器不会把栈对象基址误当移动堆对象。正文算法可决定候选,不能跳过这一步直接接原收集器。
另外,noescape 摘要必须说明“不保留到什么时候”。异步任务即使最终会释放参数,也可能在调用返回后使用它,不能套同步调用的“不逃逸”。外部函数、线程和回调的行为不明确时,保守标记为逃逸通常比冒险复用帧正确。
推论与应用
迁移练习
把 make 改成先在内部调用两个闭包,只返回 peek(0) 得到的整数,不把 P、A、B 或任何环境存到外面。在可靠调用摘要知道这些调用不保存引用的条件下,返回种子不再包含这些对象,整个组件都可成为帧内分配候选;是否消除对象或共用槽仍需另证。
再加 global_last = B。B 进入种子,Ep 与 C 随之逃逸;A、Ei 若没有其他出口不必因此也逃逸。虽然 Ei 和 Ep 指同一个 C,传播方向仍是“已逃逸容器 → 它能指向的对象”,不是从 C 反向把所有引用者拉进来。这能检验算法是否误用了无向连通分量。
参考资料
[1] Andrew Myers,Cornell CS 4120/5120,Compiling first-class functions,§5 “Escape analysis”,列出返回、全局、容器和调用四类逃逸渠道,并说明指针分析与跨过程信息的需要。本页在已给定可靠图的条件下展开闭合算法。
[2] Anders Møller 与 Michael I. Schwartzbach,Static Program Analysis,2026-09-30 版,§§11.1–11.3(分配站点与包含式分析)、§11.9 “Escape Analysis”,书页 170;具体 points-to 图构造复用Andersen 包含式分析,本页未把图闭合成本冒充完整指针分析复杂度。