☰
基于Soot的Java调用图与ICFG构建及影响面分析
2026/10/29 14:42:04 网站建设 项目流程

简介:SootTest 是一份面向 Java 静态分析与代码优化学习者的 Soot 实战示例工程,围绕调用图(Call Graph)与过程间控制流程图(ICFG)的生成展开,适合具备一定 Java 基础、希望理解字节码级程序分析流程的开发者参考。压缩包共 23 个文件、约 12.27MB,包含 3 个 Java 源码、10 个编译后的 class、1 个 Soot 依赖 jar、2 个 dot 图输出以及 png 预览图,另有 Eclipse 工程配置文件,可直接导入并对照调试。项目中演示了加载输入、配置 Soot、构建 Scene、生成 UnitGraph 与 ICFG、通过 CallGraph 构建方法调用关系的完整链路,dot 文件与图片便于比对分析结果和图形结构。已有 1443 人学习,可作为 Soot API 入门模板与静态分析实验的参考底稿。

1. 从一份十几万行的 Java 老服务说起:谁在调这个方法

接手一个跑了七八年的 Java 服务,想动一个看起来人畜无害的工具方法,第一反应通常不是改代码,而是先问一句:这方法谁在调?如果只有三五个调用方,读代码就够了;如果有四十个,还散在接口、抽象类、反射工厂里,靠 IDE 的 Find Usages 就只能算安慰剂。这时候需要的是 Java 调用图:把方法当节点、调用关系当边,一次性摊开看。再往下一层,进入方法内部,把分支、循环、异常和跨方法的调用点串起来,就是过程间控制流图(ICFG)。Soot 这个 Java 字节码分析框架,恰好同时提供了两样东西的原料:CallGraph 负责前者,UnitGraph 加调用图拼出后者。这份资源值得看的地方,是它把「从编译产物目录到一张能渲染的图」这条链路打通了,不用你去读框架源码猜 API。

2. 调用图与 ICFG 的算法底盘:先选型,再动手

2.1 节点、边与四种精度档位

调用图的节点是 SootMethod,边是 Edge,一条边携带三个关键信息:调用者 src、被调用者 tgt、调用点 srcUnit,以及一个 Kind 枚举(STATIC、SPECIAL、VIRTUAL、INTERFACE、THREAD、FINALIZE 等)。很多人第一次跑完发现图里所有人都在调所有人,问题不在代码,在精度档位选错了。Soot 提供的不止一种建图算法,粗暴理解成四档:CHA 只看声明类型,最粗最快;RTA 在 CHA 基础上用「实例化类型」做一次快速收窄;SPARK 做全程序指向分析,精度最高也最慢;VTA 介于中间,对容器和泛型有一定处理但不做完整字段敏感。

CHA 的过度连边不是 bug。写List list = ...; list.add(x),声明类型是 List,CHA 会把所有实现了 List 的类的 add 方法都连上,哪怕运行时只可能是 ArrayList。做快速摸底、看包依赖轮廓,CHA 够用;要做影响面分析、判断「改这个方法会不会波及线上」,就必须上 SPARK,否则你的结论会被一堆不可能的边稀释掉。

算法精度相对耗时适用场景
CHA低,按声明类型连边最快摸底、包级依赖、超大工程
RTA中低,按实例化类型收窄快中等规模、只要方向不要细节
SPARK高,指向分析慢,内存敏感影响面分析、变更评审
VTA中高,近似 SPARK中泛型容器多的业务代码

2.2 ICFG 为什么在 Soot 里不是现成的一张图

过程内控制流图 Soot 是直接给的:ExceptionalUnitGraph基于 Jimple 三地址码构造,节点是 Unit,边分普通边和异常边。但过程间控制流图在 Soot 里没有一个「一键导出」的官方入口。原因是 ICFG 的定义本身有分歧:调用点的后继到底连到被调方法的入口,还是连到返回点?返回之后又接回哪里?异常从被调方法抛出来算谁的边?不同论文的处理方式不同,框架如果硬编码一种,反而限制使用。

Soot 在 IDE 框架里提供了一个JimpleBasedInterproceduralCFG,实现了InterproceduralCFG<Unit, SootMethod>接口,getSuccsOf、getPredsOf、getCalleesOfCallAt、getReturnSitesOfCallAt这几个方法都有。拿它做数据流分析足够,但它是个黑匣子:内部自己构建调用图,你想换算法、想加过滤、想把结果导成 DOT,都不好插手。所以常见的工程做法是两条腿走路——先用现成类验证语义理解对不对,再手工用ExceptionalUnitGraph加CallGraph拼一份可控版本,导出的图节点带行号,能直接对着源码看。

2.3 拼装逻辑:三张图、两类接缝

手工拼 ICFG,本质是把三样东西缝起来。第一样是每个方法自己的ExceptionalUnitGraph,提供过程内的普通边和异常边。第二样是调用图,提供「哪个 Unit 调了哪个方法」。第三样是方法的入口和出口集合:入口是body.getUnits().getFirst(),出口是UnitGraph.getTails(),注意 tails 可能不止一个,多个 return 语句、throw 语句都会产生多个出口。

接缝有两类。向前接缝:调用点 unit 到被调方法入口。向后接缝:被调方法的每个出口回到调用点在调用者中的后继。异常路径是第三类接缝,容易被漏掉:被调方法抛出的异常,会跳到调用者在ExceptionalUnitGraph里对应的异常处理器。如果不处理,做可达性分析时异常分支会断掉,结论就会给出「这段代码不可达」的假阳性。业务代码里 try-catch 密度不低,这条边不能省。

还有两个边界要注意。一是库方法:应用类调java.util.ArrayList.add,调用图里 tgt 可能是 phantom 方法,没有 body,也就没有入口和出口。合理处理是把它当「原子操作」,调用点直接连到调用者的后继,不展开。二是多态调用点:一个 invokevirtual 可能对应多条出边,接缝要按边逐条接,不能按调用点只接一条。

2.4 环境与一个最小可跑骨架

依赖就三样:JDK、Soot、Graphviz。JDK 这块有讲究,Soot 4.x 对高版本字节码的支持是逐步加进来的,处理 Java 17 编译产物时容易在解析阶段翻车。稳妥做法是把被测代码编译到 Java 8 字节码(-source 8 -target 8),运行分析的 JVM 用 8 或 11 都行。Graphviz 用来把 DOT 渲染成 SVG 或 PNG,装完确认dot -V能出版本号。

<properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <!-- 4.x 系列,具体小版本以仓库里实际能拉到的为准 --> <soot.version>4.4.1</soot.version> </properties> <dependencies> <dependency> <groupId>org.soot-oss</groupId> <artifactId>soot</artifactId> <version>${soot.version}</version> </dependency> </dependencies>

这段依赖只引入核心包,不引 Android 相关模块。如果你的被测代码里有大量第三方 jar,不需要把它们逐个声明成依赖,只要在运行时把完整 classpath 传进去就行——Soot 读的是字节码,不关心 Maven 的依赖树长什么样。把soot.version单独提出来是为了后面换版本时只改一处,SPARK 的行为在不同小版本之间确实有过调整,遇到「同一份代码两次跑出来边数不一样」,先怀疑版本漂移。

3. 把调用图落成 DOT:五个参数与一次遍历

3.1 三行样本类,故意造出虚调用与接口调用

调参之前先造一个能暴露问题的样本。太干净的代码跑出来的图是一根直线,看不出精度差异。样本里放一个接口、两个实现、一个静态方法、一个构造器调用,再塞一个 try-catch,基本把常见边类型覆盖全了。

public interface Handler { void handle(String s); } public class AHandler implements Handler { public void handle(String s) { Helper.log(s); } // 静态调用 } public class BHandler implements Handler { public void handle(String s) { new Helper().run(s); } // 构造器 + 实例调用 } public class Helper { static void log(String s) { System.out.println(s); } void run(String s) { log(s); } }

编译后放在target/classes,用这个目录当分析入口。之所以不用整个 jar,是因为目录形式能保证类文件路径和包名严格对应,Soot 在process_dir模式下不容易漏类。用 jar 也可以,但要把 jar 同时放进process_dir和soot_classpath,漏一个就会出现类找不到却又不报错的诡异情况。

3.2 Soot 初始化:五个必设参数

这一段是整个流程里最容易出错的地方,血泪经验基本都堆在这里。Soot 有一套全局单例状态(G、Scene、Options),跑第二次之前必须G.reset(),否则上一轮的类会残留,图里会凭空多出上一次分析的边。

import soot.*; import soot.options.Options; import java.io.File; import java.util.Collections; public final class SootBoot { public static void boot(String appDir) { G.reset(); // 清全局状态,多次调用必需 Options o = Options.v(); o.set_prepend_classpath(true); // 自动补 JRE 类路径 o.set_whole_program(true); // 开启全程序模式,cg 包才会跑 o.set_allow_phantom_refs(true); // 缺失类降级为占位符而不是抛异常 o.set_output_format(Options.output_format_none);// 只分析,不产出 .class o.set_process_dir(Collections.singletonList(appDir)); o.set_keep_line_number(true); // 保留行号,DOT 里能标位置 o.set_soot_classpath(appDir + File.pathSeparator + System.getProperty("java.class.path")); o.setPhaseOption("cg.spark", "on"); // 显式启用建图算法 o.setPhaseOption("cg.spark", "verbose:true"); // 打印建图统计,方便对数 Scene.v().loadNecessaryClasses(); PackManager.v().runPacks(); // 这一步之后 CallGraph 才有内容 } }

逐个说。set_prepend_classpath(true)把当前 JVM 的类路径拼到 Soot 的搜索路径前面,省去手动指定rt.jar或jmods的麻烦,代价是分析时会带上一些你并不关心的 JDK 类。set_whole_program(true)决定 cg 包是否运行,不开这个,Scene.v().getCallGraph()会返回空图而不是报错,属于最容易让人误判「Soot 坏了」的沉默失败。set_allow_phantom_refs(true)是后悔药:业务代码依赖了什么你没放进去的 jar,Soot 只记录下来、不中断,图的完整性受损但流程能跑完。set_output_format(Options.output_format_none)关掉字节码输出,全程序模式下如果不关,Soot 会往sootOutput目录写一堆改写过的 class,既慢又容易和原始产物混淆。

verbose:true的作用被低估了。它会在建图结束时打印节点数、边数、各类边的分布,做前后对比时这行输出就是基线。如果你的改动让边数从 3000 涨到 12000,不用看图也知道精度档位变了。

3.3 遍历 CallGraph 写 DOT

拿到CallGraph之后不要急着全量导出。一个中等规模的业务系统,全量边数轻松上六位数,Graphviz 会直接卡死。先按「src 和 tgt 都是应用类」过滤,图能小一到两个数量级,而做业务影响分析时真正关心的也恰好是这部分。

import soot.jimple.toolkits.callgraph.*; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; public final class CgDump { static int idOf(Map<SootMethod, Integer> id, SootMethod m) { Integer v = id.get(m); if (v == null) { v = id.size(); id.put(m, v); } // 别写进 computeIfAbsent 的 lambda return v; } public static void dump(CallGraph cg, String out) throws IOException { Map<SootMethod, Integer> id = new LinkedHashMap<>(); StringBuilder sb = new StringBuilder("digraph CG {\n node [shape=box,fontsize=10];\n"); int kept = 0, skipped = 0; for (Iterator<Edge> it = cg.iterator(); it.hasNext(); ) { Edge e = it.next(); SootMethod s = e.src(), t = e.tgt(); if (s == null || t == null) { skipped++; continue; } // 入口边、隐式边 if (!isApp(s) || !isApp(t)) { skipped++; continue; } // 只留应用类 int a = idOf(id, s), b = idOf(id, t); sb.append(" n").append(a).append(" [label=\"") .append(esc(shortSig(s))).append("\"];\n"); sb.append(" n").append(b).append(" [label=\"") .append(esc(shortSig(t))).append("\"];\n"); String lbl = e.srcUnit() == null ? "" : " [label=\"" + esc(shortUnit(e.srcUnit())) + "\"]"; sb.append(" n").append(a).append(" -> n").append(b).append(lbl).append(";\n"); kept++; } sb.append("}\n"); Files.write(Paths.get(out), sb.toString().getBytes(StandardCharsets.UTF_8)); System.out.println("edges kept=" + kept + ", skipped=" + skipped); } static boolean isApp(SootMethod m) { return m.getDeclaringClass().isApplicationClass(); } static String shortSig(SootMethod m) { return m.getDeclaringClass().getShortName() + "." + m.getName(); } static String shortUnit(Unit u) { String s = u.toString().replace("\"", "'"); return s.length() > 60 ? s.substring(0, 60) + "..." : s; } static String esc(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); } }

几个细节值得停一下。idOf单独抽成方法而不是塞进computeIfAbsent,是因为在映射函数里修改同一个 Map 属于未定义行为,JDK 8 到 17 之间某些实现会抛ConcurrentModificationException,这种偶发异常排查起来非常费劲。esc处理双引号和反斜杠,方法签名里的泛型、内部类名经常带特殊字符,不转义 DOT 会直接解析失败。shortUnit截断到 60 个字符,是为了让边的标签可读——Jimple 语句动辄上百字符,全塞进图里除了让渲染变慢没有任何好处。

skipped计数不要删。它对应的是被过滤掉的边,数值异常大通常说明两件事之一:应用类判定没生效(比如被测代码被当成了库),或者分析范围混进了大量第三方包。这个数字和verbose:true打印的总边数一对,就能定位问题。

3.4 渲染与读图:从入度看扇入黑洞

# 小图用 dot,层次布局,边上的调用点看得清 dot -Tsvg callgraph.dot -o callgraph.svg # 大图换 sfdp,力导向,先把重叠压掉再说 sfdp -Tpng -Goverlap=false -Gsize=30,30 callgraph.dot -o callgraph.png # 只看某个类相关的子图,先过滤再渲染,比渲染完再找快得多 grep -E 'Helper|Handler' callgraph.dot > sub.dot && dot -Tsvg sub.dot -o sub.svg

dot的层次布局在节点超过两三百之后会挤成一团,sfdp更适合看全局结构但边的标签基本没法读。实用套路是两级:先用sfdp看整体轮廓,找到可疑的密集区域,再用 grep 把相关节点抽出来用dot精读。

读图先看入度。入度特别高的节点是扇入黑洞,通常是工具类、日志封装、全局配置读取——这类方法一改就是全站回归,图上会非常直观。再看 Kind 为 INTERFACE 的边,它们代表多态调用点。如果样本里Handler.handle只连到了一个实现,说明精度档位没生效,可能cg.spark没启用成功,也可能是实现类没被加载进 Scene。最后看有没有孤立节点:有节点没边,八成是loadNecessaryClasses没把它所在的类拉进来。

4. 手工拼 ICFG:调用边、返回边与异常边怎么接

4.1 先用现成实现试水,确认语义理解

JimpleBasedInterproceduralCFG适合当参照物。用它跑一遍,把某个调用点的后继打出来,对照源码看是不是符合预期,再决定手工版本要怎么接。

import soot.jimple.toolkits.ide.icfg.JimpleBasedInterproceduralCFG; JimpleBasedInterproceduralCFG icfg = new JimpleBasedInterproceduralCFG(); SootMethod target = Scene.v().getMethod("<Helper: void run(java.lang.String)>"); Body body = target.retrieveActiveBody(); for (Unit u : body.getUnits()) { System.out.println("UNIT " + u); for (Unit p : icfg.getPredsOf(u)) System.out.println(" <- " + p); for (Unit s : icfg.getSuccsOf(u)) System.out.println(" -> " + s); }

retrieveActiveBody()会触发 Jimple 生成,如果类还没被加载会抛异常,所以必须在runPacks()之后调用。这个类内部依赖Scene的全局状态和调用图,构造顺序错了拿到的结果会不完整。它的价值在于给你一个「标准答案」,尤其是getReturnSitesOfCallAt的返回集合——多个调用点共用一个方法的场景下,返回点可能不止一个,这一点手工实现时很容易漏。

4.2 手工拼接:把三类接缝显式写出来

手工版的核心是把每个方法的ExceptionalUnitGraph缓存起来,然后对每个调用点做双向连接。缓存的必要性在于图对象构造有成本,几万个方法重复构造会直接拖垮性能。

import soot.toolkits.graph.ExceptionalUnitGraph; import soot.jimple.*; import java.util.*; public final class IcfgBuilder { final Map<SootMethod, ExceptionalUnitGraph> graphs = new HashMap<>(); final CallGraph cg; public IcfgBuilder(CallGraph cg) { this.cg = cg; } ExceptionalUnitGraph graphOf(SootMethod m) { return graphs.computeIfAbsent(m, k -> new ExceptionalUnitGraph(k.retrieveActiveBody())); } /** 返回 unit 的过程间后继:过程内后继 + 被调方法入口 */ public List<Unit> succs(SootMethod owner, Unit u) { List<Unit> out = new ArrayList<>(graphOf(owner).getSuccsOf(u)); if (u instanceof Stmt && ((Stmt) u).containsInvokeExpr()) { for (Iterator<Edge> it = cg.edgesOutOf(u); it.hasNext(); ) { SootMethod callee = it.next().tgt(); if (callee == null || !callee.isConcrete()) continue; // 库方法不展开 out.add(callee.retrieveActiveBody().getUnits().getFirst()); } } return out; } /** 返回 unit 的过程间前驱:过程内前驱 + 各调用者的调用点 + 各被调方法的出口 */ public List<Unit> preds(SootMethod owner, Unit u) { List<Unit> out = new ArrayList<>(graphOf(owner).getPredsOf(u)); Body body = owner.retrieveActiveBody(); if (u == body.getUnits().getFirst()) { // 方法入口,接调用点 for (Iterator<Edge> it = cg.edgesInto(owner); it.hasNext(); ) { Unit callSite = it.next().srcUnit(); if (callSite != null) out.add(callSite); } } for (Unit tail : graphOf(owner).getTails()) { // 出口,接返回点 // 只有在 tail 会返回到调用者时才接,throw 出口不接返回边 if (tail instanceof ReturnStmt || tail instanceof ReturnVoidStmt) { out.addAll(returnSitesOf(owner)); } } return out; } Set<Unit> returnSitesOf(SootMethod callee) { Set<Unit> sites = new HashSet<>(); for (Iterator<Edge> it = cg.edgesInto(callee); it.hasNext(); ) { Edge e = it.next(); Unit callSite = e.srcUnit(); if (callSite == null) continue; SootMethod caller = e.src(); for (Unit s : graphOf(caller).getSuccsOf(callSite)) sites.add(s); } return sites; } }

succs里对containsInvokeExpr的判断是入口。Jimple 把每个方法调用都变成一条InvokeStmt或赋值语句里的InvokeExpr,前者是独立语句,后者藏在右值里,containsInvokeExpr()两种都能覆盖。edgesOutOf(u)按调用点取边,而不是按方法取边——同一个方法里可能有好几个调用点,按方法取会把它们混在一起。

preds里对尾节点的处理要区分ReturnStmt和ThrowStmt。return 出口接返回边,throw 出口不接,这是 ICFG 定义里比较明确的一条:异常传播走的是异常边,不是返回边。如果无差别地把所有 tail 都接上返回点,做后向分析时会算出「异常抛出后还能正常返回」的荒谬结论。

returnSitesOf是第三个接缝,也是最容易写错的地方。被调方法的返回点不是调用点本身,而是调用点在调用者里的后继。如果调用点所在语句后面还有分支,返回点可能有多个,必须全取。

4.3 导出 DOT 与三项自检指标

图拼完必须验证,否则后面所有分析结论都建立在沙地上。三个指标足够用。

// 指标 1:节点总数应等于所有被分析方法 unit 数之和 int nodes = 0; for (SootClass c : Scene.v().getApplicationClasses()) for (SootMethod m : c.getMethods()) if (m.isConcrete()) nodes += m.retrieveActiveBody().getUnits().size(); // 指标 2:过程间边数应等于「调用点数 * 2」减去无法展开的库调用 int interEdges = 0; for (Iterator<Edge> it = cg.iterator(); it.hasNext(); ) { Edge e = it.next(); if (e.srcUnit() != null && e.tgt() != null && e.tgt().isConcrete()) interEdges++; } // 指标 3:从 main 出发 BFS,可达节点占比低于 30% 就要查是不是入口选错了
指标期望值偏离时的第一嫌疑
节点总数等于应用类 concrete 方法的 unit 之和类未被loadNecessaryClasses加载
过程间边数调用点数 × 2 减去库调用调用图精度档位或 phantom 处理
可达节点占比从入口出发 60% 以上(业务代码)入口方法选错,或异常边断裂

导出 DOT 时节点 label 用unit.toString()加行号,别用 hash。行号靠set_keep_line_number(true)保留,能让你在图上直接定位到源码第几行,排查「这条边到底从哪来的」时省掉大量时间。

# ICFG 图通常比调用图更大,先按方法过滤再渲染 awk '/digraph/{print} /Helper_run/{print}' icfg.dot > icfg_helper.dot dot -Tsvg icfg_helper.dot -o icfg_helper.svg

5. 避坑与排查:五条血泪记录

5.1 图是空的,但什么都没报错

现象:Scene.v().getCallGraph()返回的对象非 null,迭代一遍零条边,程序正常退出。

原因:cg包默认不启用,set_whole_program(true)只是打开全程序模式的门,具体算法还得用setPhaseOption("cg.spark","on")显式点上。两者缺一,runPacks()会安静地跳过建图阶段。

解决:初始化里同时检查这两行,并且把cg.spark的verbose打开。有输出说明算法跑了,没输出说明压根没进这个 phase。顺带一个自查习惯:runPacks()之后立刻打印Scene.v().getApplicationClasses().size(),如果是 0,说明process_dir指向的目录里没有 class 文件,问题在路径不在算法。

5.2 This operation requires resolving level

现象:遍历过程中抛RuntimeException,提示某个操作需要某个 resolving level,通常是HIERARCHY或SIGNATURES。

原因:Soot 对类的加载分等级,loadNecessaryClasses只保证基本可用,某些操作(比如查某个类是否实现某接口)要求更高的解析等级。业务代码里用了大量第三方注解和接口时尤其容易触发。

解决:两个方向。一是提前把关键类拉高等级:Scene.v().addBasicClass("com.foo.Bar", SootClass.HIERARCHY),在loadNecessaryClasses之前调用。二是打开set_allow_phantom_refs(true),让缺失的类降级成占位符,代价是相关的边会丢。做过一次统计,在一个依赖三十多个第三方包的项目里,开 phantom 之后边数少了约 8%,做方向性判断可以接受,做精确影响面就必须把依赖补全。

5.3 Unsupported class file major version

现象:解析阶段直接抛异常,提示 class file 版本不支持,数字对应 Java 17 或更高。

原因:Soot 4.x 对高版本字节码的支持是渐进的,用高版本 JDK 编译出来的产物,框架内部的 ASM 解析器可能还没跟上。

解决:把被测代码编译到 Java 8 字节码,Maven 里配-source 8 -target 8。注意这里改的是被测代码的编译目标,不是运行分析程序的 JVM 版本。还有一个隐蔽情况:被测代码本身是 Java 8,但 classpath 里混进了高版本编译的第三方 jar,Soot 在解析依赖时同样会翻车。排查办法是把soot_classpath缩小到最小可用集合,逐个加回去定位。

5.4 SPARK 跑爆内存

现象:runPacks()卡在 cg 阶段,堆内存一路上涨,最后 OOM 或者 GC 抖动到几乎不推进。

原因:SPARK 做指向分析,状态空间和方法数、字段数、分配点数量都相关,规模上去之后内存需求是超线性的。全量加载 JDK 类会显著放大这个问题。

解决:四招按代价从低到高。第一,加堆:-Xmx8g,先确认是不是纯粹内存不够。第二,用set_excludeList或包名前缀过滤,把java.*、javax.*、sun.*排掉,只留应用包和必要的框架包。第三,降级到 RTA 做粗筛,用 RTA 的结果圈定可疑方法集合,再对这个小集合跑 SPARK。第四,分模块跑:把系统按包切成几块,分别建图再合并,代价是跨模块的边需要手工补。实际项目里第二招加第三招组合最有效,一次能把内存需求压掉一个数量级。

5.5 ICFG 后继为空,可达性分析全是假阳性

现象:手工拼的 ICFG 里,某些调用点的getSuccsOf返回空列表,或者从 main 出发 BFS 只覆盖了 20% 的节点。

原因:三类。一是调用点调的是库方法,isConcrete()为 false,代码里continue跳过了,又没有补「直接连到后继」的兜底逻辑。二是异常边没接,try 块里的调用点在有异常处理器时,ExceptionalUnitGraph的后继是处理器而不是下一句,如果自己重写了后继逻辑而漏掉异常边,链路就断了。三是多态调用点只接了一条边,edgesOutOf(unit)本身返回的是迭代器,用if判断一次就 break 的话,剩下的边全丢。

解决:库方法兜底一定要写——if (!callee.isConcrete()) { out.addAll(graphOf(owner).getSuccsOf(u)); },把它当原子操作。多态调用点统一用while (it.hasNext())循环。异常边直接在ExceptionalUnitGraph的结果上做增量,不要另起炉灶重算。改完之后用 4.3 的三个指标复查,可达节点占比回到 60% 以上再往下做分析。

6. 进阶:用反向可达把调用图变成影响面清单

调用图最有价值的用法不是画出来看,是回答「改这个方法,谁受影响」。正向可达回答「它会调到谁」,反向可达回答「谁会调到它」,后者才是变更评审要的答案。

public static Set<SootMethod> reverseReach(CallGraph cg, SootMethod seed) { Deque<SootMethod> stack = new ArrayDeque<>(); Set<SootMethod> seen = new HashSet<>(); stack.push(seed); seen.add(seed); while (!stack.isEmpty()) { SootMethod cur = stack.pop(); for (Iterator<Edge> it = cg.edgesInto(cur); it.hasNext(); ) { SootMethod caller = it.next().src(); if (caller != null && seen.add(caller)) stack.push(caller); } } return seen; }

edgesInto拿到的是所有指向该方法的边,src()为 null 的边要跳过,那是对应入口或线程启动的隐式边,没有实际调用者。seen.add的返回值直接当去重判断用,省掉一次contains查询,在大图上这个细节能省下可观的时间。

结果要分层看,别一股脑当平铺集合用。我一般按距离分三档:距离 1 是直接调用者,改动必须同步回归;距离 2 到 3 是间接影响,重点看有没有跨模块;距离超过 3 的多半是框架回调、事件总线、定时任务这类弱耦合路径,噪声大,人工过一遍比全量回归划算。

距离典型形态处理方式
1直接方法调用逐个检查签名兼容性
2–3跨类、跨模块调用链确认接口契约未变
>3框架回调、事件分发人工抽样,不盲目回归

有一件事必须提前说清楚:反射、动态代理、SPI 加载这三类调用,静态调用图天生看不见。业务代码里如果有Class.forName加getMethod的组合,或者大量 Spring 的@Autowired按类型注入,图上会缺边。我的处理办法是维护一份手工补充清单,把已知的反射入口写成配置,跑完反向可达之后人工合并进去。这份清单不长,一个中等项目也就十几条,但它能把「图上没边所以安全」这种错误结论挡在门外。

还有一个参数值得调:cg.spark的on-fly-cg。设成 true 时边是在查询过程中惰性生成的,内存占用低但重复查询会慢;设成 false 则一次性算完,内存高但后续遍历快。做单次反向可达用默认值就行,要做几十次不同种子的查询,关掉惰性反而更划算。

从那以后我每次跑影响面分析,都强制走一遍「指标自检 → 反向可达 → 反射清单合并」这三步,哪怕项目很小也不省。省掉自检那一步的代价,往往是在评审会上被人问一句「这个边为什么没有」然后当场哑火。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询