很多人学静态分析时都会卡在一个点上:明明把数据流分析那一套规则背得滚瓜烂熟,可一放到真实程序上,结果总是不对劲。原因往往只有一个——真实程序里到处是方法调用,而经典的过程内分析把每一次调用都当成一个黑盒。黑盒一多,精度就没了。我整理某高校软件分析课程07的过程间分析(Interprocedural Analysis)这一讲时,把这个问题彻底想通了:过程间分析不是一个新的分析算法,而是一层架在普通数据流分析之上的“管道系统”,让数据真正能在方法之间流动起来。这篇文章适合两类人看:一类是在补静态分析基础的学生,另一类是要在自己的工具链里实现过程间分析的开发者。我尽量把课程里那些抽象的规则翻译成能直接落地的工程思路,也会把我自己实现时踩过的坑一并交代清楚。1. 过程内分析的死角:一次方法调用就能让精度归零1.1 过程内分析怎么做,又败在哪里过程内分析的定义很朴素:一次只分析一个方法。分析方法内部的 CFG(控制流图),为每个程序点计算数据流值,遇到方法调用指令时,不去看被调用方法内部发生了什么,而是把它当成一个不透明操作。对常量传播来说,调用点的结果就是未知;对指针分析来说,调用点产生的引用就是新的未知对象。课程里有一个非常经典的例子,我稍微改一下结构:int bar(int x) { return x + 1; } void main() { int a = 1; int b = bar(a); int c = a + 1; int d = b + c; }只要算过一遍过程内常量传播,你就会发现一个反直觉的结果:b、d全被标记成 Not A Constant(NAC)。可是人一眼就看得出来,bar(a)一定等于 2,d一定等于 3。为什么分析器算不出来?因为过程内分析根本不会进入bar的内部,bar(a)的返回值对它来说就是一个未知数。如果只是常量传播这种场景,损失的精度还能忍。但当你做的是一整套污点分析、逃逸分析、或者别名分析的时候,方法调用处理不好,结果会差到完全不可用。比如a = obj.getX(),你如果不知道getX()内部到底返回了什么字段,后面的分析基本上只能靠猜。这一讲的整个出发点就在这里:必须让分析“看穿”方法调用。这是过程间分析要解决的核心问题。1.2 从黑盒到白盒:过程间分析到底在“间”什么过程间分析做的事情,粗看就是一句话:把所有的分析方法串成一个整体,让数据流沿着真实的调用路径流动。但“串起来”这三个字背后有三个具体工程问题:第一,调用目标是谁。a.foo()里的foo可能是多个子类里各自实现的版本,分析器必须知道这个调用点可能跳到哪些方法。第二,数据怎么传递。实参怎么变成形参、返回值怎么传回调用点、方法内部的局部变量污染会不会泄漏到调用方的其他路径上。第三,迭代怎么收敛。过程间分析的数据流图比单方法 CFG 大得多,不动点迭代需要一种更聪明的调度方式。课程里把这些问题拆成了两块:前面讲调用图构建,后面讲过程间数据流分析。调用图回答“可能调用谁”,数据流分析回答“数据怎么流”。这两块是顺序依赖的关系——调用图错了,后面的数据流分析再精确也没有意义。2. 调用图构建的基石:CHA 算法与它的取舍2.1 虚分派:调用图要解决的核心难题先看看调用图长什么样。调用图是个有向图,每个节点是一个方法,每条边是一个调用关系。一个调用点可能对应多条边,因为语言里有虚方法、接口方法,甚至函数指针。构建调用图的核心工作就是处理虚分派:一个调用表达式的静态类型是 A,但运行时指向的实际对象可能是 A 的任意一个子类。举一个最简单的多态例子:class A { void foo() { ... } } class B extends A { void foo() { ... } } void test(A x) { x.foo(); }x的静态类型是 A,但它可能实际指向B的实例。调用点x.foo()既可能调到A.foo(),也可能调到B.foo()。如果不把这两个目标都找出来,调用图就是不完整的,后续过程间分析会把某些路径整个漏掉。CHA(Class Hierarchy Analysis)解决的就是这个问题。它只根据类的继承层次来枚举可能的调用目标,不关心对象的实际创建位置。做法非常直接:从调用点的接收者静态类型出发,在这类型本身以及它的所有子类里查找所有 override 了该方法的方法定义,全部作为候选目标。2.2 CHA 的完整流程课程里给出的 CHA 流程可以拆成四步。我按自己的理解重新组织一下:第一步,解析调用指令。先识别调用点的调用类型是静态调用(static)、特殊调用(super)还是虚调用(virtual/interface)。静态调用和 super 调用可以直接确定目标方法,只有虚调用需要走类层次。第二步,确定接收者的声明类型。比如x.foo(),x的声明类型就是 A。第三步,遍历声明类型及其子类链。在 A 以及继承 A 的所有子类中搜索foo()的方法声明,只要某个子类 override 了foo(),就把这个方法加入候选集合。第四步,把候选方法映射成调用图中的边,然后对新增的方法继续做同样的处理,直到没有新方法被加入。核心逻辑用伪代码表达就是这样:resolve(call site & cs) { T = declared_type(cs.receiver) for each method m in hierarchy_of(T): if m.name == cs.method_name && m.signature == cs.signature: add_edge(cs, m) }看起来简单,但有一个很重要的点:CHA 对子类的搜索是“全量”的。也就是说它不会区分 A 的子类里哪些是实际被实例化过的。没有new B()的代码,B.foo()依然会被列为调用目标。这是 CHA 精度低的最根本来源,后面第 2.3 节细说。2.3 快但粗糙:CHA 精度与性能的权衡CHA 的优势是快。它不需要做任何程序分析,只需要类继承关系,所以构建调用图的时间非常短,在 IDE 里面做“查找所有实现”这种功能几乎是秒开。但它的代价就是候选方法集合偏大,引入大量实际不会发生的调用边。我们用一个直观的对比来说明不同算法之间的精度梯度:算法候选目标集来源精度构建成本CHA接收者静态类型的所有子类低极低RTA仅程序中出现过的类型中低指针分析接收者的可达对象集合高高CHA 到 RTA 的改进思路很直接:程序里new过的类型集合是有限的,把类层次搜索范围从“所有子类”缩小到“当前程序可达类型中被实例化的子类”,就能砍掉不少假调用边。课程后面讲指针分析时会更进一步,直接分析出某个变量可能指向哪些对象,把虚调用的分派精度提到最高。这里必须强调一个工程观点:不要一上来就上指针分析。调用图的构建成本会直接放大后面的过程间分析。实际项目里,先用 CHA 搭一个完整链路,确认数据流分析逻辑是对的,再逐步精化分派策略,是性价比最高的路线。我见过很多团队一上来就搞全程序指针分析,结果跑一个中大型项目要几分钟,连迭代调试都做不下去。3. 过程间数据流分析的桥梁设计:节点转移与边转移3.1 ICFG:给过程间分析铺路有了调用图,下一步就是把单方法的 CFG 拼成一张更大的图——过程间控制流图(Interprocedural Control Flow Graph, ICFG)。ICFG 的节点集合包含每个方法 CFG 里的所有节点,以及每个调用点对应的两个特殊节点:Call 节点和 Return Site 节点。Call 节点是调用指令所在的节点,Return Site 节点是调用指令执行完之后继续执行的节点,在字节码里通常就是下一条指令。为什么要把一个调用点拆成两个节点?因为调用前后这两个节点的数据流状态差异极大:Call 节点之前是调用方的状态,Return Site 节点之后是被调方法返回后的状态。混在一起的话,参数传递和返回值处理会纠缠不清。ICFG 的边也分三类。普通 CFG 边是方法内部的跳转;调用边是从 Call 节点指向被调方法入口节点的边;返回边从被调方法的出口节点指向 Return Site 节点。这三类边合起来,构成了数据在方法之间流动的通道。光有这三类边还不够,还有一个非常关键的设计:Call-to-Return 边。它从 Call 节点直接连到 Return Site 节点,不经过被调方法。这条边模拟的是“被调方法不影响某个变量”的路径。没有这条边,过程间分析的所有路径都必须经过被调方法内部,计算代价极大,而且会错误地把被调方法内部的变量修改传播到调用方的其他路径上。它是过程间数据流分析精度和性能的关键。3.2 call 与 return 节点的转移函数设计课程里把过程间分析的数据流规则设计成节点转移函数和边转移函数两部分。先看 Call 节点和 Return 节点的转移函数,我直接用常量传播域的变量值来描述。Call 节点要做两件事。第一件是处理参数的“向上传递”,把实参值传给被调方法的形参。比如int b = bar(a),这里a的值 1 会通过 Call 节点流入bar方法入口处的形参x。第二件是向下的局部处理,把返回值对应的变量旧值杀掉。还是这个例子,调用完成后b会被重新赋值,所以 Call 节点向下传递时要把b标记为未定义(UNDEF),防止旧值顺着别的路径流入 Return Site。Return 节点做的事情更纯粹:把被调方法出口处的返回值变量值,复制给调用点的 Return Site。比如bar内部有return x + 1,此时返回变量的值是 2,那么从 Return 节点流出时,2 要顺着返回边到达main方法里bar(a)的 Return Site 节点。这里有一个初学者经常混的概念:Call 节点的“向下传递”和 Return 边的“返回数据”是两回事。Call 节点的向下传递处理的是调用前可能残留的旧值,Return 边传递的才是真实返回值。两者方向不同,处理的数据也不同。3.3 call-to-return 边:切断被调方法对调用点无关变量的污染为什么单独把 Call-to-Return 边拎出来讲?因为它是过程间分析最容易漏掉、也最容易写错的一环。它的语义很特别:沿着这条边流动的数据,只包括那些保证不会被本次调用修改的变量。怎么定义“不会被修改”?最简单的方式是用预先算好的 mod 集合。所谓 mod 集合,就是某个方法内部可能修改的全局变量或者调用方可见变量的集合。如果被调方法的 mod 集合不包含某个变量,那么这个变量在 Call-to-Return 边上的值就是安全的,可以从 Call 节点直接抄到 Return Site。反过来说,如果被调方法会修改某个变量,那这条路就不能传这个变量的旧值,必须把对应变量杀掉。课程里给了一个非常典型的例子:void main() { int a = 1; int b = callee(a); // callee 内部会修改 a // 这里 a 不能被想当然地当成 1 }如果一开始不知道callee会修改a,Call-to-Return 边会把a = 1直接传到 Return Site,那分析结果就是错的。正确地做法是查询callee的 mod 集合,发现a在其中,则在 Call-to-Return 边传值时 kill 掉a。用一句话总结:Call-to-Return 边模拟的是“绕过被调方法”的路径,它必须依赖被调方法的副作用信息来保证精度。没有这一层处理,过程间分析的正确性无从谈起。3.4 套入常量传播域:完整的五要素课程最后把这套框架总结成了五个具体要素。道理不复杂,就是套用经典数据流分析框架:域、方向、转移函数、meet 操作、迭代入口。只是这次的作用对象变成了 ICFG。常量传播的域是每个变量的常量值状态,包括 UNDEF、常量、NAC。方向是前向分析,沿着 ICFG 的控制流方向传播。转移函数就是前面说的节点转移加边转移。meet 操作是:同值取该值;不同值取 NAC;任何值和 UNDEF 合并,结果取那个非 UNDEF 的值。迭代入口从main方法出发,或者更准确地说是从整个程序的入口集合出发。这个框架有一个特别优雅的地方:它只定义了三种路径上的数据流动方式,就把所有跨方法的数据传递都覆盖了。正常边管方法内部流动,调用边管参数传入,返回边管返回值传回,Call-to-Return 边管绕过被调方法的传播。四类元素合在一起,构成了一条完整的“过程间路径”的所有可能形态。4. 常量传播实战推演:从入口到不动点4.1 一个小程序的完整推演这一节我们把前面的规则全部串起来,手动推一遍常量传播。程序如下:int addOne(int x) { return x + 1; } int twice(int x) { return x * 2; } void main() { int a = 1; int b = addOne(a); int c = twice(b); int d = a + b + c; }从入口开始的数据流推演过程:main入口处,a = 1。到达第一个 Call 节点,addOne(a)。实参a的值为 1,通过调用边传到addOne的形参x。Call 节点向下同时 kill 掉b的旧值(此时b还未定义,所以不会有实际影响)。同时 Call-to-Return 边把a = 1原样传到 Return Site,因为我们知道addOne不会修改a。addOne方法内部执行return x + 1。此时x = 1,所以返回值状态是 2。这个 2 通过返回边传回main方法中对应的 Return Site 节点,b = 2。紧接着到达第二个 Call 节点,twice(b)。实参b的值为 2 传到形参x。twice内部执行x * 2,返回值为 4。传回后c = 4。最后执行int d = a + b + c,三个变量分别是 1、2、4,所以d = 7。最终结果:a = 1,b = 2,c = 4,d = 7,全部是常量。这个程序如果只用过程内分析,结果会是b = NAC,c = NAC,d = NAC,两者之间是完全不同的精度级别。4.2 过程内与过程间的精度对比把两种分析的结果放在一个表格里,差异一目了然:程序点过程内分析过程间分析int a = 1之后a = 1a = 1int b = addOne(a)之后b = NACb = 2int c = twice(b)之后c = NACc = 4int d = a + b + c之后d = NACd = 7可以看出,过程间分析并不是用什么高深的奇技淫巧,它只是让数据老老实实走了一遍真实的调用路径。所有看似神奇的精度提升,都来源于“方法之间能传数据”这件事本身。这里有一个很值得玩味的点:过程内分析在d上得到 NAC,不只是因为b未知,还因为它在b未知的情况下连a都是安全的却不敢确认,导致一步错步步错。过程间分析因为把b追出来了,整个链路的精度全部保住了。精度的损失是会累积的,这是我在实际做工具时体会最深的一句话。4.3 调用图的“外溢价值”过程间分析产出的调用图不是一次性消耗品。课程里没有展开,但这里值得多说一句:调用图本身就是一个非常有价值的程序分析产物。IDE 的“查找所有实现”、方法跳转、重构时的安全影响范围计算,都用的是类似 CHA 或更精化的调用图。做编译器优化的团队拿调用图做内联决策、死代码消除,也比直接分析字节码高效得多。所以哪怕你的分析任务非常简单,只要程序里有方法调用,构建一张可用的调用图都值得。它是一次性投资,后面的所有分析都能复用。我在实际工程里维护分析框架时,调用图都是单独模块单独缓存,因为几乎每一个下游分析都要它。5. 工程实现踩过的坑与几条实在经验5.1 worklist 调度里最容易被忽略的触发关系理论上讲,把 ICFG 建好、转移函数写好,直接套用传统的不动点迭代就行。但工程实现时有一个很隐蔽的性能陷阱:worklist 的触发关系。举个例子。addOne方法入口形参的值发生变化时,addOne内部所有后续节点的状态都可能发生连锁变化,这没错。但如果addOne的 Return 节点的返回值状态发生了变化,会发生什么?答案是:所有调用addOne的调用点对应的 Return Site 节点都必须重新入队处理。这一步如果漏了,分析结果几乎必然不对。另一个容易被忽略的点是 Call-to-Return 边的依赖关系。如果被调方法的 mod 集合是动态计算的,那 mod 集合变化时也要触发相关 Call-to-Return 边的重新传播。课程里通常会预设 mod 集合是固定的,但真实工程里你放上一个全程序副作用分析,这个集合本身就要迭代逼近。我在早期版本里没处理这种依赖,结果跑出来的常量传播值时对时错,最后定位到是 mod 集合更新后没有重新触发调用点,整整调了两天才发现。实现的建议是:把“节点状态变化”和“边状态变化”分别建模成两类 worklist 事件,并且确保返回值变化事件能够映射到所有调用点的 Return Site 上。最好在测试里覆盖“同一个方法被不同调用点调用”的场景,这是最容易暴露调度问题的用例。5.2 库方法、native 方法与手写模型真实程序不可能只分析你写的代码。一旦调用图里出现System.currentTimeMillis()、StringBuilder.append()这类方法,CHA 通常解析不出合理的目标,因为它们要么是 native 方法,要么在 JDK 的类库深处。业界通用做法是给库方法写模型:手工定义每个关键库方法的参数传递关系、返回值属性和副作用。比如Collections.emptyList()可以建模成“返回一个空的不可变 List 对象”,String.valueOf(int)可以建模成“返回一个新字符串对象”。这样下游分析不需要真的钻进 JDK 源码,也能获得足够精确的结果。写模型的时候注意一个原则:模型的精度要跟分析的精度对齐。如果你做的是常量传播,库方法模型只需要关心“是否返回常量”;如果你做的是指针分析,模型就得多写一步“返回对象是否逃逸”。模型不是越多越好,只有那些真的会被调用并影响分析结果的方法,才值得单独建模。5.3 精度与规模:从 CHA 走向更精细的分派最后说一个方向性的经验。CHA 的调用图精度低,这在小型 Demo 上不会造成明显问题,但一旦跑到中大型项目,接口方法有多实现的时候,候选目标集会膨胀得非常快。调用边一多,过程间分析需要更新状态的方法就多,分析时间成倍增长。我做过的实践里,最有效的优化路径不是直接换指针分析,而是先做一次 RTA 或者非常轻量的类型传播,把程序中所有实例化过的类型收集起来,然后让虚调用分派只在实例化类型集合里搜索。这个改进通常只需要额外维护一个“已创建类型集合”,复杂度极低,却能让调用边数量显著下降。如果这一步还不够,再去考虑真正的指针分析。课程的后续章节会讲指针分析如何为虚调用提供更精确的 dispatch 信息,那是一条更长的路。但从 CHA 到 RTA 再到指针分析,每一步都是精度与性能的权衡,不存在“越精确越好”这种说法。工程上永远是够用就好。我自己在这门课学到的最有价值的东西,不是那些规则本身,而是它不断提醒我:分析一个方法的时候,永远要意识到方法外部还存在一个更大的环境,调用的连接让每一个方法都不可能真正孤立。过程间分析就是在为这种不孤立搭建严谨的桥梁。后面的学习中,指针分析、逃逸分析、污点分析,全都踩在这座桥上,把这套基础砸实了,后面会顺畅很多。
很多人学静态分析时都会卡在一个点上:明明把数据流分析那一套规则背得滚瓜烂熟,可一放到真实程序上,结果总是不对劲。原因往往只有一个——真实程序里到处是方法调用,而经典的过程内分析把每一次调用都当成一个黑盒。黑盒一多,精度就没了。我整理某高校软件分析课程07的过程间分析(Interprocedural Analysis)这一讲时,把这个问题彻底想通了:过程间分析不是一个新的分析算法,而是一层架在普通数据流分析之上的“管道系统”,让数据真正能在方法之间流动起来。这篇文章适合两类人看:一类是在补静态分析基础的学生,另一类是要在自己的工具链里实现过程间分析的开发者。我尽量把课程里那些抽象的规则翻译成能直接落地的工程思路,也会把我自己实现时踩过的坑一并交代清楚。1. 过程内分析的死角:一次方法调用就能让精度归零1.1 过程内分析怎么做,又败在哪里过程内分析的定义很朴素:一次只分析一个方法。分析方法内部的 CFG(控制流图),为每个程序点计算数据流值,遇到方法调用指令时,不去看被调用方法内部发生了什么,而是把它当成一个不透明操作。对常量传播来说,调用点的结果就是未知;对指针分析来说,调用点产生的引用就是新的未知对象。课程里有一个非常经典的例子,我稍微改一下结构:int bar(int x) { return x + 1; } void main() { int a = 1; int b = bar(a); int c = a + 1; int d = b + c; }只要算过一遍过程内常量传播,你就会发现一个反直觉的结果:b、d全被标记成 Not A Constant(NAC)。可是人一眼就看得出来,bar(a)一定等于 2,d一定等于 3。为什么分析器算不出来?因为过程内分析根本不会进入bar的内部,bar(a)的返回值对它来说就是一个未知数。如果只是常量传播这种场景,损失的精度还能忍。但当你做的是一整套污点分析、逃逸分析、或者别名分析的时候,方法调用处理不好,结果会差到完全不可用。比如a = obj.getX(),你如果不知道getX()内部到底返回了什么字段,后面的分析基本上只能靠猜。这一讲的整个出发点就在这里:必须让分析“看穿”方法调用。这是过程间分析要解决的核心问题。1.2 从黑盒到白盒:过程间分析到底在“间”什么过程间分析做的事情,粗看就是一句话:把所有的分析方法串成一个整体,让数据流沿着真实的调用路径流动。但“串起来”这三个字背后有三个具体工程问题:第一,调用目标是谁。a.foo()里的foo可能是多个子类里各自实现的版本,分析器必须知道这个调用点可能跳到哪些方法。第二,数据怎么传递。实参怎么变成形参、返回值怎么传回调用点、方法内部的局部变量污染会不会泄漏到调用方的其他路径上。第三,迭代怎么收敛。过程间分析的数据流图比单方法 CFG 大得多,不动点迭代需要一种更聪明的调度方式。课程里把这些问题拆成了两块:前面讲调用图构建,后面讲过程间数据流分析。调用图回答“可能调用谁”,数据流分析回答“数据怎么流”。这两块是顺序依赖的关系——调用图错了,后面的数据流分析再精确也没有意义。2. 调用图构建的基石:CHA 算法与它的取舍2.1 虚分派:调用图要解决的核心难题先看看调用图长什么样。调用图是个有向图,每个节点是一个方法,每条边是一个调用关系。一个调用点可能对应多条边,因为语言里有虚方法、接口方法,甚至函数指针。构建调用图的核心工作就是处理虚分派:一个调用表达式的静态类型是 A,但运行时指向的实际对象可能是 A 的任意一个子类。举一个最简单的多态例子:class A { void foo() { ... } } class B extends A { void foo() { ... } } void test(A x) { x.foo(); }x的静态类型是 A,但它可能实际指向B的实例。调用点x.foo()既可能调到A.foo(),也可能调到B.foo()。如果不把这两个目标都找出来,调用图就是不完整的,后续过程间分析会把某些路径整个漏掉。CHA(Class Hierarchy Analysis)解决的就是这个问题。它只根据类的继承层次来枚举可能的调用目标,不关心对象的实际创建位置。做法非常直接:从调用点的接收者静态类型出发,在这类型本身以及它的所有子类里查找所有 override 了该方法的方法定义,全部作为候选目标。2.2 CHA 的完整流程课程里给出的 CHA 流程可以拆成四步。我按自己的理解重新组织一下:第一步,解析调用指令。先识别调用点的调用类型是静态调用(static)、特殊调用(super)还是虚调用(virtual/interface)。静态调用和 super 调用可以直接确定目标方法,只有虚调用需要走类层次。第二步,确定接收者的声明类型。比如x.foo(),x的声明类型就是 A。第三步,遍历声明类型及其子类链。在 A 以及继承 A 的所有子类中搜索foo()的方法声明,只要某个子类 override 了foo(),就把这个方法加入候选集合。第四步,把候选方法映射成调用图中的边,然后对新增的方法继续做同样的处理,直到没有新方法被加入。核心逻辑用伪代码表达就是这样:resolve(call site & cs) { T = declared_type(cs.receiver) for each method m in hierarchy_of(T): if m.name == cs.method_name && m.signature == cs.signature: add_edge(cs, m) }看起来简单,但有一个很重要的点:CHA 对子类的搜索是“全量”的。也就是说它不会区分 A 的子类里哪些是实际被实例化过的。没有new B()的代码,B.foo()依然会被列为调用目标。这是 CHA 精度低的最根本来源,后面第 2.3 节细说。2.3 快但粗糙:CHA 精度与性能的权衡CHA 的优势是快。它不需要做任何程序分析,只需要类继承关系,所以构建调用图的时间非常短,在 IDE 里面做“查找所有实现”这种功能几乎是秒开。但它的代价就是候选方法集合偏大,引入大量实际不会发生的调用边。我们用一个直观的对比来说明不同算法之间的精度梯度:算法候选目标集来源精度构建成本CHA接收者静态类型的所有子类低极低RTA仅程序中出现过的类型中低指针分析接收者的可达对象集合高高CHA 到 RTA 的改进思路很直接:程序里new过的类型集合是有限的,把类层次搜索范围从“所有子类”缩小到“当前程序可达类型中被实例化的子类”,就能砍掉不少假调用边。课程后面讲指针分析时会更进一步,直接分析出某个变量可能指向哪些对象,把虚调用的分派精度提到最高。这里必须强调一个工程观点:不要一上来就上指针分析。调用图的构建成本会直接放大后面的过程间分析。实际项目里,先用 CHA 搭一个完整链路,确认数据流分析逻辑是对的,再逐步精化分派策略,是性价比最高的路线。我见过很多团队一上来就搞全程序指针分析,结果跑一个中大型项目要几分钟,连迭代调试都做不下去。3. 过程间数据流分析的桥梁设计:节点转移与边转移3.1 ICFG:给过程间分析铺路有了调用图,下一步就是把单方法的 CFG 拼成一张更大的图——过程间控制流图(Interprocedural Control Flow Graph, ICFG)。ICFG 的节点集合包含每个方法 CFG 里的所有节点,以及每个调用点对应的两个特殊节点:Call 节点和 Return Site 节点。Call 节点是调用指令所在的节点,Return Site 节点是调用指令执行完之后继续执行的节点,在字节码里通常就是下一条指令。为什么要把一个调用点拆成两个节点?因为调用前后这两个节点的数据流状态差异极大:Call 节点之前是调用方的状态,Return Site 节点之后是被调方法返回后的状态。混在一起的话,参数传递和返回值处理会纠缠不清。ICFG 的边也分三类。普通 CFG 边是方法内部的跳转;调用边是从 Call 节点指向被调方法入口节点的边;返回边从被调方法的出口节点指向 Return Site 节点。这三类边合起来,构成了数据在方法之间流动的通道。光有这三类边还不够,还有一个非常关键的设计:Call-to-Return 边。它从 Call 节点直接连到 Return Site 节点,不经过被调方法。这条边模拟的是“被调方法不影响某个变量”的路径。没有这条边,过程间分析的所有路径都必须经过被调方法内部,计算代价极大,而且会错误地把被调方法内部的变量修改传播到调用方的其他路径上。它是过程间数据流分析精度和性能的关键。3.2 call 与 return 节点的转移函数设计课程里把过程间分析的数据流规则设计成节点转移函数和边转移函数两部分。先看 Call 节点和 Return 节点的转移函数,我直接用常量传播域的变量值来描述。Call 节点要做两件事。第一件是处理参数的“向上传递”,把实参值传给被调方法的形参。比如int b = bar(a),这里a的值 1 会通过 Call 节点流入bar方法入口处的形参x。第二件是向下的局部处理,把返回值对应的变量旧值杀掉。还是这个例子,调用完成后b会被重新赋值,所以 Call 节点向下传递时要把b标记为未定义(UNDEF),防止旧值顺着别的路径流入 Return Site。Return 节点做的事情更纯粹:把被调方法出口处的返回值变量值,复制给调用点的 Return Site。比如bar内部有return x + 1,此时返回变量的值是 2,那么从 Return 节点流出时,2 要顺着返回边到达main方法里bar(a)的 Return Site 节点。这里有一个初学者经常混的概念:Call 节点的“向下传递”和 Return 边的“返回数据”是两回事。Call 节点的向下传递处理的是调用前可能残留的旧值,Return 边传递的才是真实返回值。两者方向不同,处理的数据也不同。3.3 call-to-return 边:切断被调方法对调用点无关变量的污染为什么单独把 Call-to-Return 边拎出来讲?因为它是过程间分析最容易漏掉、也最容易写错的一环。它的语义很特别:沿着这条边流动的数据,只包括那些保证不会被本次调用修改的变量。怎么定义“不会被修改”?最简单的方式是用预先算好的 mod 集合。所谓 mod 集合,就是某个方法内部可能修改的全局变量或者调用方可见变量的集合。如果被调方法的 mod 集合不包含某个变量,那么这个变量在 Call-to-Return 边上的值就是安全的,可以从 Call 节点直接抄到 Return Site。反过来说,如果被调方法会修改某个变量,那这条路就不能传这个变量的旧值,必须把对应变量杀掉。课程里给了一个非常典型的例子:void main() { int a = 1; int b = callee(a); // callee 内部会修改 a // 这里 a 不能被想当然地当成 1 }如果一开始不知道callee会修改a,Call-to-Return 边会把a = 1直接传到 Return Site,那分析结果就是错的。正确地做法是查询callee的 mod 集合,发现a在其中,则在 Call-to-Return 边传值时 kill 掉a。用一句话总结:Call-to-Return 边模拟的是“绕过被调方法”的路径,它必须依赖被调方法的副作用信息来保证精度。没有这一层处理,过程间分析的正确性无从谈起。3.4 套入常量传播域:完整的五要素课程最后把这套框架总结成了五个具体要素。道理不复杂,就是套用经典数据流分析框架:域、方向、转移函数、meet 操作、迭代入口。只是这次的作用对象变成了 ICFG。常量传播的域是每个变量的常量值状态,包括 UNDEF、常量、NAC。方向是前向分析,沿着 ICFG 的控制流方向传播。转移函数就是前面说的节点转移加边转移。meet 操作是:同值取该值;不同值取 NAC;任何值和 UNDEF 合并,结果取那个非 UNDEF 的值。迭代入口从main方法出发,或者更准确地说是从整个程序的入口集合出发。这个框架有一个特别优雅的地方:它只定义了三种路径上的数据流动方式,就把所有跨方法的数据传递都覆盖了。正常边管方法内部流动,调用边管参数传入,返回边管返回值传回,Call-to-Return 边管绕过被调方法的传播。四类元素合在一起,构成了一条完整的“过程间路径”的所有可能形态。4. 常量传播实战推演:从入口到不动点4.1 一个小程序的完整推演这一节我们把前面的规则全部串起来,手动推一遍常量传播。程序如下:int addOne(int x) { return x + 1; } int twice(int x) { return x * 2; } void main() { int a = 1; int b = addOne(a); int c = twice(b); int d = a + b + c; }从入口开始的数据流推演过程:main入口处,a = 1。到达第一个 Call 节点,addOne(a)。实参a的值为 1,通过调用边传到addOne的形参x。Call 节点向下同时 kill 掉b的旧值(此时b还未定义,所以不会有实际影响)。同时 Call-to-Return 边把a = 1原样传到 Return Site,因为我们知道addOne不会修改a。addOne方法内部执行return x + 1。此时x = 1,所以返回值状态是 2。这个 2 通过返回边传回main方法中对应的 Return Site 节点,b = 2。紧接着到达第二个 Call 节点,twice(b)。实参b的值为 2 传到形参x。twice内部执行x * 2,返回值为 4。传回后c = 4。最后执行int d = a + b + c,三个变量分别是 1、2、4,所以d = 7。最终结果:a = 1,b = 2,c = 4,d = 7,全部是常量。这个程序如果只用过程内分析,结果会是b = NAC,c = NAC,d = NAC,两者之间是完全不同的精度级别。4.2 过程内与过程间的精度对比把两种分析的结果放在一个表格里,差异一目了然:程序点过程内分析过程间分析int a = 1之后a = 1a = 1int b = addOne(a)之后b = NACb = 2int c = twice(b)之后c = NACc = 4int d = a + b + c之后d = NACd = 7可以看出,过程间分析并不是用什么高深的奇技淫巧,它只是让数据老老实实走了一遍真实的调用路径。所有看似神奇的精度提升,都来源于“方法之间能传数据”这件事本身。这里有一个很值得玩味的点:过程内分析在d上得到 NAC,不只是因为b未知,还因为它在b未知的情况下连a都是安全的却不敢确认,导致一步错步步错。过程间分析因为把b追出来了,整个链路的精度全部保住了。精度的损失是会累积的,这是我在实际做工具时体会最深的一句话。4.3 调用图的“外溢价值”过程间分析产出的调用图不是一次性消耗品。课程里没有展开,但这里值得多说一句:调用图本身就是一个非常有价值的程序分析产物。IDE 的“查找所有实现”、方法跳转、重构时的安全影响范围计算,都用的是类似 CHA 或更精化的调用图。做编译器优化的团队拿调用图做内联决策、死代码消除,也比直接分析字节码高效得多。所以哪怕你的分析任务非常简单,只要程序里有方法调用,构建一张可用的调用图都值得。它是一次性投资,后面的所有分析都能复用。我在实际工程里维护分析框架时,调用图都是单独模块单独缓存,因为几乎每一个下游分析都要它。5. 工程实现踩过的坑与几条实在经验5.1 worklist 调度里最容易被忽略的触发关系理论上讲,把 ICFG 建好、转移函数写好,直接套用传统的不动点迭代就行。但工程实现时有一个很隐蔽的性能陷阱:worklist 的触发关系。举个例子。addOne方法入口形参的值发生变化时,addOne内部所有后续节点的状态都可能发生连锁变化,这没错。但如果addOne的 Return 节点的返回值状态发生了变化,会发生什么?答案是:所有调用addOne的调用点对应的 Return Site 节点都必须重新入队处理。这一步如果漏了,分析结果几乎必然不对。另一个容易被忽略的点是 Call-to-Return 边的依赖关系。如果被调方法的 mod 集合是动态计算的,那 mod 集合变化时也要触发相关 Call-to-Return 边的重新传播。课程里通常会预设 mod 集合是固定的,但真实工程里你放上一个全程序副作用分析,这个集合本身就要迭代逼近。我在早期版本里没处理这种依赖,结果跑出来的常量传播值时对时错,最后定位到是 mod 集合更新后没有重新触发调用点,整整调了两天才发现。实现的建议是:把“节点状态变化”和“边状态变化”分别建模成两类 worklist 事件,并且确保返回值变化事件能够映射到所有调用点的 Return Site 上。最好在测试里覆盖“同一个方法被不同调用点调用”的场景,这是最容易暴露调度问题的用例。5.2 库方法、native 方法与手写模型真实程序不可能只分析你写的代码。一旦调用图里出现System.currentTimeMillis()、StringBuilder.append()这类方法,CHA 通常解析不出合理的目标,因为它们要么是 native 方法,要么在 JDK 的类库深处。业界通用做法是给库方法写模型:手工定义每个关键库方法的参数传递关系、返回值属性和副作用。比如Collections.emptyList()可以建模成“返回一个空的不可变 List 对象”,String.valueOf(int)可以建模成“返回一个新字符串对象”。这样下游分析不需要真的钻进 JDK 源码,也能获得足够精确的结果。写模型的时候注意一个原则:模型的精度要跟分析的精度对齐。如果你做的是常量传播,库方法模型只需要关心“是否返回常量”;如果你做的是指针分析,模型就得多写一步“返回对象是否逃逸”。模型不是越多越好,只有那些真的会被调用并影响分析结果的方法,才值得单独建模。5.3 精度与规模:从 CHA 走向更精细的分派最后说一个方向性的经验。CHA 的调用图精度低,这在小型 Demo 上不会造成明显问题,但一旦跑到中大型项目,接口方法有多实现的时候,候选目标集会膨胀得非常快。调用边一多,过程间分析需要更新状态的方法就多,分析时间成倍增长。我做过的实践里,最有效的优化路径不是直接换指针分析,而是先做一次 RTA 或者非常轻量的类型传播,把程序中所有实例化过的类型收集起来,然后让虚调用分派只在实例化类型集合里搜索。这个改进通常只需要额外维护一个“已创建类型集合”,复杂度极低,却能让调用边数量显著下降。如果这一步还不够,再去考虑真正的指针分析。课程的后续章节会讲指针分析如何为虚调用提供更精确的 dispatch 信息,那是一条更长的路。但从 CHA 到 RTA 再到指针分析,每一步都是精度与性能的权衡,不存在“越精确越好”这种说法。工程上永远是够用就好。我自己在这门课学到的最有价值的东西,不是那些规则本身,而是它不断提醒我:分析一个方法的时候,永远要意识到方法外部还存在一个更大的环境,调用的连接让每一个方法都不可能真正孤立。过程间分析就是在为这种不孤立搭建严谨的桥梁。后面的学习中,指针分析、逃逸分析、污点分析,全都踩在这座桥上,把这套基础砸实了,后面会顺畅很多。