逃逸分析与栈上分配

逃逸分析决定一个对象能否留在栈上、锁能否被消除。本文从逃逸状态的格与传播规则讲起,用连接图刻画方法内的指向关系,讲解过程间摘要与参数、返回值逃逸的推断,再落到标量替换、栈上分配与锁消除的实现细节,最后讨论精度与编译时间之间的权衡取舍与常见排错手段。

1. 逃逸分析要回答什么

一句话总结: 逃逸分析判断一个新建对象的作用域是否超出当前方法或当前线程,只要答案是「没有」,分配、同步、甚至整个对象都能被优化掉。

在托管语言里,new 是最常见的操作,也是最贵的操作之一:它要申请堆内存、触发 GC 压力、带来写屏障。但大量对象的生命周期其实短得可怜——它们在方法内创建、方法内用完、方法返回即死。如果能证明某个对象从未逃出当前方法,编译器就能做三件大事:

  • 栈上分配:把对象放进当前栈帧,方法返回时随栈自动回收,完全不惊动 GC;
  • 标量替换:干脆不分配对象,把它的字段拆成几个独立标量,直接放进寄存器;
  • 锁消除:对象只被当前线程看到,那么对它加的锁永远无竞争,monitorenter/monitorexit 可以整对删除。
public int distance() {
    Point p = new Point(3, 4);   // p 从未离开 distance()
    return (int) Math.sqrt(p.x * p.x + p.y * p.y);
}

这段代码里 Point 实例完全可以不存在。标量替换后,p.x、p.y 变成两个局部标量,分配消失了,随之消失的还有 Point 的构造调用与 GC 压力。关键在于证明:没有任何路径能让这个对象被方法外的代码看见。

逃逸分析是典型的 may-escape 分析:它要证明的是「绝不逃逸」,所以任何不确定的路径都必须保守地判定为逃逸。这决定了它的精度与代价之间的张力,也是后面所有取舍的根源。

2. 逃逸状态的格

一句话总结: 逃逸状态构成一个三层格 NoEscape ⊑ ArgEscape ⊑ GlobalEscape,合并取上界,越靠下能做的优化越多。

把逃逸程度建模成一个有限的格,是让分析可计算的第一步:

from enum import IntEnum

class Escape(IntEnum):
    NO_ESCAPE     = 0   # 只在当前方法内可见
    ARG_ESCAPE    = 1   # 逃到被调用者,但被调用者可能不长期持有
    GLOBAL_ESCAPE = 2   # 逃到堆、静态字段、其他线程

def join(a, b):
    """格的合并:取上界(更保守的一方)"""
    return Escape(max(a, b))
状态含义允许的优化
NO_ESCAPE完全局部栈上分配、标量替换、锁消除
ARG_ESCAPE作为实参传出去部分标量替换(若被调用者摘要证明不存储)
GLOBAL_ESCAPE存入堆/全局/跨线程只能保留普通堆分配

状态的提升由几类「逃逸源」触发,判定规则很直观:

  • 赋给静态字段或已知的全局容器 → 直接 GLOBAL_ESCAPE;
  • 作为返回值返回 → GLOBAL_ESCAPE(调用者可能任意保存它);
  • 作为实参传给未知方法(虚调用、反射、native) → ARG_ESCAPE;
  • 存入另一个对象的字段 → 逃逸程度等于那个容器对象的逃逸程度。

最后一条是递归的,也是分析需要迭代到不动点的原因:a.f = b 时 b 的逃逸状态取决于 a 的状态,而 a 的状态又可能取决于别的赋值。

3. 连接图与逃逸传播

一句话总结: 把「变量、分配点、字段」建成图的节点,「赋值、存字段、传参」建成边,再从逃逸源反向传播逃逸状态,直到不动点。

实现逃逸分析的标准做法是连接图(connection graph)。节点分三类:局部变量、对象分配点、以及被引用对象的字段;边表示「指向」关系。分析分两步走:先标记所有必然逃逸的节点(静态字段、返回值、未知调用的实参),再沿边反向传播。

def build_connection_graph(method):
    """简化版连接图: 节点为变量/分配点, 边为指向关系"""
    nodes = set()
    points_to = defaultdict(set)      # 节点 -> 它可能指向的分配点集合

    for stmt in method.stmts:
        if stmt.op == "NEW":
            nodes.add(stmt.dst)
        elif stmt.op == "MOV":
            nodes.add(stmt.dst)
            points_to[stmt.dst] |= points_to.get(stmt.src, set())
        elif stmt.op == "STORE_FIELD":
            # base.f = value: value 逃逸程度受 base 约束
            nodes.add(stmt.base)
            points_to[stmt.value] |= points_to.get(stmt.base, set())
    return nodes, points_to

传播过程就是一个 worklist:初始把「逃逸源」节点的状态设为 GLOBAL_ESCAPE,然后反复扫描,凡是「指向一个逃逸节点」的节点,其逃逸状态至少与该节点相同。

def propagate_escape(nodes, points_to, initial):
    state = {n: Escape.NO_ESCAPE for n in nodes}
    for n in initial:                     # 逃逸源
        state[n] = Escape.GLOBAL_ESCAPE

    work = list(initial)
    while work:
        cur = work.pop()
        for src, targets in points_to.items():
            if cur in targets:
                new = join(state[src], state[cur])
                if new != state[src]:
                    state[src] = new
                    work.append(src)
    return state

这段代码的复杂度在最坏情况下是 O(N²),实践中连接图通常很小(一个方法几十个节点),所以代价可控。但真正的难点不在图本身,而在于跨方法的边界——一个对象传给了另一个方法,它到底逃不逃逸,取决于那个方法做了什么。

4. 过程间分析:摘要

一句话总结: 为每个方法预先计算「参数逃逸摘要」——第 i 个参数是否会被存储、是否会被返回、是否与第 j 个参数建立连接——调用点据此推断实参的逃逸状态。

只做方法内分析是不够的,因为绝大多数对象都是通过方法调用传递的。解决办法是为每个方法算一份逃逸摘要:

class EscapeSummary:
    def __init__(self, n_params):
        # 参数 i 是否会逃逸到全局(被存储 / 返回 / 传给未知代码)
        self.param_escapes = [False] * n_params
        # 参数 i 是否被存储进「已逃逸」的容器,即真的逃逸
        self.param_stored  = [False] * n_params
        # 参数 i 是否可能返回给调用者
        self.param_returned = [False] * n_params
        # 参数 i 与参数 j 是否可能指向同一对象(连接关系)
        self.param_connected = set()
        # 返回值是否可能是某个参数(用于链式调用)
        self.ret_is_param = None

摘要的计算方式取决于调用图是否可静态解析:

调用形式可解析性处理方式
静态方法 / 私有方法 / final 方法唯一目标直接用目标的摘要
虚方法,类型已知(去虚化后)单目标内联后分析
虚方法,多目标多目标取所有目标摘要的并(保守)
函数指针 / 反射 / native不可解析一律视为 GLOBAL_ESCAPE

递归调用需要特殊处理:先假设递归方法的所有参数都逃逸(最保守),再迭代放宽,直到不动点。这一步必须设迭代上限,否则编译时间会失控。摘要的精度直接决定优化效果——比如 List.add(e) 若被保守地认为「存储了 e」,那么几乎所有放进集合的对象都会被判为逃逸。

def apply_summary(summary, arg_escape_states):
    """在调用点根据摘要推断实参的逃逸状态"""
    result = []
    for i, st in enumerate(arg_escape_states):
        if summary.param_stored[i]:
            result.append(Escape.GLOBAL_ESCAPE)   # 被存起来了,必然逃逸
        elif summary.param_escapes[i] or summary.param_returned[i]:
            result.append(Escape.ARG_ESCAPE)      # 传出去了,但也许不长期持有
        else:
            result.append(Escape.NO_ESCAPE)       # 摘要证明它被完整消费
    return result

摘要可以缓存并按方法签名复用,这也是「过程间分析不必然昂贵」的原因:只要摘要的粒度选得对,大部分成本能在编译期摊薄。

5. 标量替换

一句话总结: 标量替换把不逃逸对象的字段提升为独立的标量值,让它们像普通局部变量一样参与寄存器分配与优化,从而彻底消除对象。

标量替换(scalar replacement,在 LLVM 里常叫 SROA)是逃逸分析最有价值的产出。它做的事是把 p.x、p.y 从「通过指针访问内存」变成「两个 SSA 值」。

标量替换前 (IR):                  标量替换后:
  p  = NEW Point                     px.1 = 3
  p.x = 3                            py.1 = 4
  p.y = 4                            t1   = px.1 * px.1
  t1 = p.x * p.x                     t2   = py.1 * py.1
  t2 = p.y * p.y                     t3   = t1 + t2
  t3 = t1 + t2                       ret  t3
  ret t3

替换之后,px.1、py.1 就是普通标量,可以进寄存器、可以常量折叠、可以被 DCE 清理。上例中它们都是常量,整个计算会在编译期折叠成 5。

标量替换有两个硬性前提:

  1. 字段访问必须静态可解析。如果对象被反射访问、或字段通过动态派发访问,编译器无法枚举所有字段,只能放弃。
  2. 对象不能被取地址后传给未知代码。哪怕只是一次 System.identityHashCode(p),都会让对象「可被外部观察」,替换即失效。
def can_scalar_replace(alloc, escape_state, access_sites):
    if escape_state != Escape.NO_ESCAPE:
        return False
    fields = {f for (op, f) in access_sites}
    for op, _ in access_sites:
        if op not in ("LOAD_FIELD", "STORE_FIELD"):
            return False          # 取地址、比较身份、反射等一律拒绝
    return len(fields) > 0        # 至少有一个字段被访问才值得替换
优化前提收益失败后果
标量替换不逃逸 + 字段静态可解析消除分配与内存访问退回普通堆分配
栈上分配不逃逸 + 大小固定免 GC,栈回收堆分配
锁消除不逃逸出线程删除同步指令保留锁

6. 栈上分配与锁消除

一句话总结: 不逃逸的对象可以直接在当前栈帧上分配,随栈回收;只被单线程看到的对象,其锁永远无竞争,可以整对删除。

栈上分配比标量替换更保守一些:对象仍然以「对象」的形态存在,只是内存来自栈帧而非堆。它的优势是能处理字段访问不完全静态可解析的情况(比如对象被传给一个已知不存储它的方法)。前提是对象大小在编译期固定,且不包含跨帧引用。HotSpot 的 C2 编译器其实很少真正做栈上分配——因为一旦对象需要「地址」,栈上分配会引入更复杂的语义(栈帧移动、GC 扫描栈),C2 更倾向于标量替换。

锁消除的收益往往比分配消除更直观。同步块在字节码里是 monitorenter/monitorexit 对,每次进出都要操作对象头的标记字,还可能触发偏向锁撤销。如果对象不逃逸出线程,这把锁永远不会有第二个竞争者:

public String concat(String a, String b) {
    StringBuffer sb = new StringBuffer();   // 局部对象,不逃逸
    sb.append(a);
    sb.append(b);
    return sb.toString();
}

StringBuffer 的方法全是 synchronized 的,但如果 sb 不逃逸,C2 会把这一串 monitorenter/monitorexit 全部删掉。这就是为什么 StringBuffer 在单线程场景下并不比 StringBuilder 慢多少——前提是 JIT 真的做了逃逸分析。

需要注意的是,锁消除对「锁对象本身逃逸」的判断比对象分配更敏感:只要锁对象的引用被写入任何可能被其他线程读到的位置(哪怕只是赋值给一个 volatile 局部),锁消除就必须放弃。

7. 精度与编译时间权衡

一句话总结: 提高精度的手段(上下文敏感、字段敏感、路径敏感)都成倍增加分析成本,生产编译器通常选择「上下文不敏感 + 字段敏感 + 迭代上限」的组合。

逃逸分析的精度有三个正交的旋钮:

旋钮低精度做法高精度做法成本
上下文敏感度上下文不敏感(按方法合并)k-CFA / 调用串指数级增长
字段敏感度整个对象一个状态逐字段状态图规模 × 字段数
流敏感度流不敏感沿 CFG 逐点需迭代数据流

字段敏感度是最划算的一档:把「对象逃逸」细化成「字段逃逸」,可以让 p.x 被替换而 p.y 因为被存进列表而保留。代价是连接图节点数乘以平均字段数,通常还在可接受范围。

上下文敏感度则是最贵的:如果对每个调用串单独分析,递归 + 多态会把状态数炸掉。生产编译器(HotSpot C2、GraalVM 的社区版 EA)默认用上下文不敏感的分析,只靠内联来「局部地」恢复上下文信息——这又是一个「内联是万能的」例证。

MAX_ITERATIONS = 8      # 逃逸分析的迭代上限,超限则整体退化到最保守
MAX_GRAPH_NODES = 20000 # 连接图规模上限,超限放弃本次 EA

def run_escape_analysis(method, cfg):
    graph = build_connection_graph(method)
    if len(graph.nodes) > MAX_GRAPH_NODES:
        return None                      # 放弃,回退到堆分配
    for it in range(MAX_ITERATIONS):
        changed = propagate_once(graph)
        if not changed:
            return finalize(graph)
    return conservative_result(graph)    # 未收敛: 一律判为 GLOBAL_ESCAPE

8. 工程实践与排错

一句话总结: HotSpot 的 EA 默认开启但栈上分配极少落地,Go 的 EA 在编译期决定堆栈归属,两者都用「为什么没优化」的诊断开关帮助定位。

不同运行时的实现差异很大:

  • HotSpot C2:-XX:+DoEscapeAnalysis(默认开)。它做的是上下文不敏感的字段敏感分析,产出主要用于标量替换与锁消除,真正「栈上分配」的对象很少。可以用 -XX:+PrintEscapeAnalysis 观察每个分配点的判定结果。
  • Go:逃逸分析在编译期完成,直接决定对象分配在栈还是堆。用 go build -gcflags='-m' 打印每个变量「escapes to heap」的原因;-gcflags='-m -m' 打印更详细的两级原因。
  • GraalVM:有一套更激进的部分逃逸分析(partial escape analysis),能在分支上做「只在某些路径上替换」的优化,把对象分配推迟到真正需要它的分支。

排错时最常见的三类原因:

# Go: 查看为什么某个变量逃逸到堆
go build -gcflags='-m' ./...
# 输出示例:
#   ./main.go:12:6: moved to heap: buf        ← 被返回或存入全局
#   ./main.go:15:14: leaking param: p         ← 参数泄漏
#   ./main.go:20:2: buf escapes to heap       ← 传给未知函数
  1. 对象被取身份:任何 == 身份比较、hashCode()、wait/notify 都会让对象「可被观察」,判定为逃逸。
  2. 传给未知方法:接口调用、反射、fmt.Println(obj) 这类接受 interface{} 的调用,因为方法目标不可解析,参数被判逃逸。
  3. 被闭包捕获且闭包逃逸:闭包本身逃逸时,其捕获的所有变量一并逃逸。
// 反例: 返回指针 -> 逃逸到堆
func newBuf() *bytes.Buffer {
    b := new(bytes.Buffer)   // escapes to heap
    return b
}

// 正例: 就地使用 -> 留在栈上
func useBuf() int {
    var b bytes.Buffer       // 不逃逸
    b.WriteString("hello")
    return b.Len()
}

值得注意的是,Go 里「逃逸到堆」不等于「性能差」:堆分配本身很便宜,真正的代价是 GC 压力。所以在热路径上优化逃逸才有意义,把冷路径上的对象强行留在栈上通常得不偿失。

9. 总结

概念要点
分析目标证明对象不逃出方法/线程,属 may-escape 分析
逃逸格NoEscape ⊑ ArgEscape ⊑ GlobalEscape,合并取上界
逃逸源静态字段、返回值、未知调用实参、逃逸容器的字段
连接图变量/分配点/字段为节点,指向关系为边,反向传播
过程间摘要参数存储/返回/连接,按方法签名缓存复用
递归处理先保守假设全逃逸,迭代放宽至不动点
标量替换字段提升为标量,要求字段访问静态可解析
栈上分配栈帧内分配,随栈回收,要求大小固定
锁消除不逃逸出线程则锁无竞争,成对删除同步指令
精度旋钮上下文敏感、字段敏感、流敏感,成本依次递增
工程默认上下文不敏感 + 字段敏感 + 迭代上限
排错手段HotSpot PrintEscapeAnalysis、Go -gcflags=-m

逃逸分析是「把表示层的信息换成优化收益」的典型案例:它不改变程序语义,只是把一个保守的运行时假设(对象一定在堆上、锁一定有竞争)在有证据时替换成更便宜的实现。理解它的关键在于三点——逃逸格如何定义保守方向、连接图如何把方法内的指向关系显式化、以及过程间摘要如何在不炸掉编译时间的前提下恢复跨方法精度。掌握这三点,再看任何一门托管语言的分配行为,都能判断出「这个对象到底会落在哪里」。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「compiler」更多文章

  1. 浮点语义与快速数学优化
  2. 查询式编译器与增量类型检查:Salsa 架构
  3. Sanitizer 与编译期安全加固:ASan、TSan 与 CFI