Soot调用图与ICFG构建:Java静态分析原理与避坑 简介SootTest 是一份基于 Soot 框架的 Java 静态分析示例工程面向希望理解调用图Call Graph与过程间控制流程图ICFG生成机制的学习者和开发者可用于代码结构分析、编译优化入门及实验教学场景。压缩包共 23 个文件约 12.27MB以 class、java 源码、dot 图文件、依赖 jar、png 结果图以及 Eclipse 的 classpath/project 配置为主兼顾可运行代码、依赖与可视化输出。已有 1443 人学习或下载。工程围绕 Soot 的输入加载、Scene 场景构建、UnitGraph 与过程间控制流图创建、CallGraph 生成等关键环节给出可参考实现并附调用图 dot 输出和结果示意图便于读者对照理解方法间调用关系与跨方法控制流。对需要快速搭建 Soot 实验环境、验证分析思路或进行二次开发的读者这份工程可作为可直接拆解的模板与起点。1. 一段 Java 代码里藏着的两张图SootTest 到底在补哪块缺失javac 编译出来的 class 文件里没有“调用图”这种东西也没有“控制流图”。字节码里只有一条invokevirtual #12和常量池里一个方法符号引用到底会落到哪个实现编译器一个字都没说。SootTest 这类工程做的事就是把这个洞补上先把字节码重建成三地址形式的 Jimple再在 Jimple 上长出两张图——一张回答“谁可能调到谁”也就是调用图一张回答“从这一句能不能走到下一句、跨过方法边界之后又能走到哪”也就是过程间控制流图ICFG。后者是前者的下游ICFG 的所有跨过程边都由调用图决定调用图多一条边ICFG 就可能多一大片可达区域。搞懂这两张图是按什么顺序长出来的基本就搞懂了 Java 静态分析的地基也才能判断一个静态检查工具报出的告警到底是真问题还是图建歪了。2. 调用图一次 invokevirtual 为什么会被解析成一堆边2.1 顶点和边在 Soot 里是什么对象调用图的顶点是SootMethod不是类也不是签名串。这一点值得先说清楚同一个方法签名可能对应多个SootMethod实例不同类加载器、不同泛型擦除后的 bridge 方法所以拿String当顶点做去重很容易在一开始就出错。真正稳定的是SootMethod对象本身的引用以及它背后SootClass加签名的组合。边是soot.jimple.toolkits.callgraph.Edge四个字段最关键src()是调用方方法tgt()是被调方法srcStmt()是那条invoke语句本身kind()说明这条边是怎么来的。CallGraph cg Scene.v().getCallGraph(); // edgesOutOf 只给出边不保证顺序别拿它当拓扑序用 for (IteratorEdge it cg.edgesOutOf(method); it.hasNext(); ) { Edge e it.next(); SootMethod tgt e.tgt(); Unit site e.srcStmt(); // 可能为 nullPARAM/CLINIT 这类伪边就没有调用点 Edge.Kind kind e.kind(); // CALL / PARAM / CLINIT / THREAD / PRIVILEGED / FINALIZE if (kind ! Edge.Kind.CALL) continue; // 做路径分析时通常只保留 CALL }kind()是这套设计里最容易被忽略、但信息量最大的字段。CALL表示真实的调用关系PARAM是 Spark 在指向分析期间为了传递实参顺手加的伪边CLINIT指向静态初始化块THREAD用来表达Thread.start()到run()的语义。写遍历逻辑时如果不过滤kind你会看到方法自己调自己、或者一个方法调到了某个static {}块上排查半天还以为是 Soot 算错了。CallGraph内部维护了多组索引按 src 方法、按 tgt 方法、按调用点所以edgesOutOf、edgesInto、edgesOutOf(Unit)都是常数级查找。这也是它敢在分析全程被反复查询的原因代价是每条边加进去时要同步更新好几张哈希表。2.2 CHA为什么它只会多不会少类层次分析CHA是 Soot 默认的调用图算法逻辑朴素到近乎粗暴对invokevirtual取 receiver 的声明类型及其所有子类型里签名匹配的方法全部连边对invokeinterface取所有实现了该接口的类型对invokespecial和invokestatic目标唯一直接连。用一段代码把骨架写出来比看文字直观得多// 极度简化的 CHA 骨架只表达思想不是 Soot 内部真实实现 void resolve(Stmt callSite, SootMethod container) { InvokeExpr ie callSite.getInvokeExpr(); SootMethod declared ie.getMethod(); // 常量池里那个符号引用 SootClass declClass declared.getDeclaringClass(); if (ie instanceof StaticInvokeExpr || ie instanceof SpecialInvokeExpr) { addEdge(container, declared, callSite); // 精确唯一解 return; } // virtual / interface声明类型 全部子类型 for (SootClass c : hierarchy.getAllSubclassesOf(declClass)) { SootMethod impl c.getMethodUnsafe(declared.makeRef().getSubSignature()); if (impl ! null impl.isConcrete()) { addEdge(container, impl, callSite); // 用 makeRef().getSubSignature() 做重写匹配 } } }关键在最后那个循环。getAllSubclassesOf是传递闭包拿到的是整个类层次里所有后代跟运行时到底有没有实例化毫无关系。所以一个声明为List的字段调addCHA 会把ArrayList、LinkedList、Vector、CopyOnWriteArrayList甚至连带 JDK 内部一些不对外暴露的实现类全部连上哪怕这个字段在整个程序里从头到尾只被赋过一个ArrayList。这个“一定要多算”的保守性恰恰是它的价值只要类路径完整CHA 保证不漏边。反过来一旦漏了基本就是类路径缺类导致的跟你配置的算法无关。2.3 RTA 与 VTA用“实际被 new 过”剪掉不可能快速类型分析RTA在 CHA 上加了一个约束只有当某条边指向的具体类型在程序里被实例化过这条边才算数。所谓被实例化就是某个可达方法体里出现过new这个类型的字节码或者出现过X.class、数组创建之类的隐式实例化。这里藏着一个不动点问题也是整个调用图构建机制的核心。你想知道哪些类型被实例化必须知道哪些方法是可达的你想知道哪些方法可达又必须先有调用图。两者互为前提。Soot 的解法是把它做成 on-the-fly 的维护一个“待处理方法”队列从入口点出发每处理一个方法体就扫描其中的new指令更新实例化类型集合同时扫描其中的调用点按当前集合解析目标把新目标推进队列直到队列为空。// on-the-fly 的核心循环形态Soot 内部由 CallGraphBuilder 承担 DequeSootMethod worklist new ArrayDeque(Scene.v().getEntryPoints()); SetSootMethod reachable new HashSet(); SetSootClass instantiated new HashSet(); while (!worklist.isEmpty()) { SootMethod m worklist.poll(); if (!reachable.add(m)) continue; // 已经处理过跳过防止递归死循环 Body body m.retrieveActiveBody(); for (Unit u : body.getUnits()) { if (u instanceof NewInst) instantiated.add(((NewInst) u).getBaseType().getSootClass()); for (SootMethod callee : resolveByRTA(u, instantiated)) { if (!reachable.contains(callee)) worklist.add(callee); } } }resolveByRTA和 CHA 的区别只在过滤条件候选目标类型的SootClass不在instantiated里就丢掉。但注意这个过滤是单调的——实例化集合只增不减可达方法集合也只增不减所以迭代一定能停。如果哪天你把逻辑改成“发现不可达就把已连的边删掉”收敛性就没了我见过有人这么改过跑大项目直接卡死。VTAVariable Type Analysis再往前走一步不按类型算按变量算。一个 receiver 变量在程序里只可能被赋过哪几种类型传播一遍之后只保留这几种类型对应的实现。同样是子集约束传播粒度从“类”细到“变量”精度上去了内存也跟着上去。算法精度来源相对代价是否上下文敏感CHA只用类层次最低否RTA叠加实例化类型集合低否VTA叠加变量级类型传播中否SPARKinsens字段级指向集合高否SPARK1-call-site叠加调用点上下文很高是2.4 SPARK点到集合与不动点SPARK 走的是另一条路线先不急着解析调用点而是把整个程序抽象成一张指针赋值图PAG。节点分两类一类是变量节点局部变量、字段、数组元素、静态字段一类是分配点节点每个new。边表达约束比如x y就是y的指向集合必须包含于x的指向集合x.f y则是要把y里的内容流进x可能指向的所有对象的f字段。然后就是一个朴素到极致但正确性没得挑的传播过程每条边对应一种“把集合 A 加进集合 B”的操作用 worklist 反复跑直到没有任何集合变大为止。收敛之后每个变量都有了具体的指向集合这时候再看某个调用点的 receiver 可能指向哪些分配点直接查这些分配点所属的类型就知道该连哪些边。它的设计取舍非常明确——慢但精确。指向集合是天然的流不敏感上下文无关近似字段敏感但数组不敏感这些都在源码里写死了没有配置项。Spark 提供可调的是上下文敏感度cg.spark的cs选项insens是默认的上下文无关1-call-site按调用点区分1-obj按 receiver 对象区分。切到1-call-site以后同一个方法会因为不同调用点被复制出多份抽象版本指向集合不再混淆精度肉眼可见地涨代价也很直接方法体多的大项目上内存翻几倍是常态我见过一个中等规模的工程从 insens 切到1-call-site后堆内存直接涨了三倍还多。3. ICFG过程内 CFG 与调用边怎么缝成一张跨过程图3.1 UnitGraph 家族Complete / Exceptional / Brief 的取舍过程内控制流图的接口是soot.toolkits.graph.UnitGraph四个方法撑起全部语义getHeads()返回入口节点列表getTails()返回出口节点列表getSuccsOf(Unit)和getPredsOf(Unit)给出前后驱。注意getHeads()返回的是List而不是单个节点因为一个方法体在异常分析下可能有多个入口每个 trap 的 handler 也算一个外来的进入点。三个实现类的差别全在“异常边建不建、建多少”这一件事上BriefUnitGraph只建顺序边和分支边异常路径一概不管。跑得最快适合你已经确定方法体里没有 try-catch、不关心异常流的场景。CompleteUnitGraph建异常边但策略是保守的任何可能抛异常的语句都连到所有可能接住它的 trap。宁可多连不漏。ExceptionalUnitGraph建异常边但先用异常分析ThrowAnalysis算出这条语句实际可能抛出的异常类型集合只连到类型匹配的 trap。最精确也最慢。Body body method.retrieveActiveBody(); // 三种构造选哪个直接决定后面 ICFG 的边数 UnitGraph g1 new BriefUnitGraph(body); UnitGraph g2 new CompleteUnitGraph(body); // 第三个参数是异常分析策略Pedantic 最保守UnitThrowAnalysis 会读方法体里显式 throw 的类型 UnitGraph g3 new ExceptionalUnitGraph(body, new soot.toolkits.scalar.UnitThrowAnalysis(Scene.v()));ExceptionalUnitGraph的性能坑很实在它内部会为每个Unit计算一次异常集合而这个计算不缓存的话就是 O(n·m)。做全程序 ICFG 时务必按方法缓存住UnitGraph实例否则同一个被十个地方调用的方法会被重建十次 CFG白白浪费。3.2 跨过程边从哪来调用、返回、异常Soot 新版本里没有开箱即用、维护良好的 ICFG 实现老版本有过interproc包后来基本不维护了所以绝大多数工程都是自己包一层。常见的实现思路是把顶点定义成二元组(SootMethod, Unit)然后按三类边拼第一类是过程内边直接问UnitGraph.getSuccsOf。第二类是调用边从调用点指向被调方法的所有 head。第三类是返回边从被调方法的所有 tail 指回调用点之后的那条语句。用代码表达前两类public final class Icfg { public static final class Node { final SootMethod method; final Unit unit; // equals/hashCode 必须同时比较 method 和 unit // 只比 Unit 会把不同方法里长得一样的 Stmt 混成一个节点 } private final CallGraph cg; private final MapSootMethod, UnitGraph cache new HashMap(); private UnitGraph cfgOf(SootMethod m) { // computeIfAbsent 是关键同一方法被多调用点复用时只建一次 CFG return cache.computeIfAbsent(m, k - new ExceptionalUnitGraph(k.retrieveActiveBody())); } public ListNode succsOf(Node n) { ListNode out new ArrayList(); UnitGraph g cfgOf(n.method); // 边类型 1过程内含分支与异常 trap for (Unit s : g.getSuccsOf(n.unit)) out.add(new Node(n.method, s)); // 边类型 2调用边call site - callee 的每个 head if (n.unit instanceof Stmt ((Stmt) n.unit).containsInvokeExpr()) { for (IteratorEdge it cg.edgesOutOf(n.unit); it.hasNext(); ) { SootMethod callee it.next().tgt(); if (!callee.isConcrete()) continue; // native / abstract 没有 body跳过 for (Unit head : cfgOf(callee).getHeads()) out.add(new Node(callee, head)); } } return out; } }参数和逻辑说明一下。cfgOf里retrieveActiveBody()在方法还没被加载 body 时会触发一次解析如果这个方法来自一个没进 classpath 的类这里直接抛异常——这是全程序分析最常见的崩溃点。isConcrete()的判断不能省native方法和接口里的抽象方法都没有 body不判就会在retrieveActiveBody上抛RuntimeException。getHeads()返回List不能当单值用取get(0)前必须判空空方法体的 head 列表是空的。第三类返回边在这段代码里没写因为写不出来——succsOf只知道当前节点不知道“我是从哪个调用点进来的”自然无法决定返回到哪里。这恰恰说明了 ICFG 的核心困难所在。3.3 一个最小 ICFG 的完整遍历有了succsOf遍历就是一个带visited集合的 BFS。但这里有个顺序问题很容易搞错调用点本身既是过程内的普通节点又是跨过程边的起点所以它的后继有两个来源先是过程内的下一条语句再是被调方法的每个 head必须都返回。如果只返回后者程序里所有调用点之后的代码都不可达如果只返回前者ICFG 就退化成了若干个互不相连的过程内 CFG跨过程信息流全部丢失。图遍历本身没什么好说的有价值的是遍历过程中的一个约定出口节点tail在过程内通常没有后继但在 ICFG 里它们必须能回到调用方。所以真正可用的 ICFG 遍历不能是纯 BFS得维护一个“调用栈”结构弹出栈顶时才知道该返回到哪个调用点。这也是 IFDS、IDE 这类框架为什么要把“过程间的边”显式建模成 push/pop 的原因它们不是图算法的偏好是被调用栈这个语义逼出来的。3.4 上下文敏感 ICFG 的困境如果不带调用栈直接把所有调用点和所有返回边拼在一起会发生什么同一个方法的 body 会在 ICFG 里被多个调用点共享从调用点 A 进入方法 M从 M 出去时可能返回到调用点 B 的下一句。这种“交叉返回”在静态分析里有个专门的名字叫虚假调用路径它会导致数据在完全无关的两处代码之间流动。解决方案大致分两档。简单的一档是复制 body每个调用点各建一份该方法的 CFG 副本节点从(method, unit)变成(method, unit, context)交叉返回自然消失。代价是图的规模按调用深度指数膨胀递归方法直接爆。另一档是把它当成下推系统处理节点不做复制但边分 push 和 pop 两类可达性判定用 pushdown reachability 算法复杂度上一个量级但不会爆。真实工程里还有一档折中只用调用点做有限上下文比如最近一层调用点既挡掉大部分交叉返回又控制住规模。Soot 自己在调用图这一层提供了cs选项但 ICFG 这一层没有任何现成开关需要自己决定。这个不对称常常被忽略——很多人以为调了cg.spark cs:1-call-siteICFG 就自动变上下文敏感了实际上 ICFG 的边还是靠你手写的edgesOutOf拼出来的一点没变。4. 把这套流程跑通SootTest 里的调用链4.1 初始化顺序为什么不能乱Soot 有一堆全局单例G、Scene、Options顺序错一步结果就完全不同而且往往不报错只是给你一张空图。我踩过的坑里一半以上出在这一步。正确的顺序是G.reset()清空全局状态 → 配置Options类路径、处理目录、全程序模式→Scene.v().loadNecessaryClasses()加载必要的类 →setEntryPoints()指定入口 →PackManager.v().runPacks()跑分析流水线。G.reset(); // 不重置第二次调用会拿到上一次的 Scene Options opts Options.v(); opts.set_process_dir(Collections.singletonList(target/classes)); opts.set_prepend_classpath(true); // 自动补上 JRE 的类省得手动拼 rt.jar opts.set_whole_program(true); // 必须开否则 cg pack 根本不跑 opts.set_keep_line_number(true); // 输出里带行号方便对源码 // 换算法只改这两行默认是 CHA这里切 SPARK opts.setPhaseOption(cg.spark, enabled:true); opts.setPhaseOption(cg.spark, on-fly-cg:true); Scene.v().loadNecessaryClasses(); // 入口点必须在 loadNecessaryClasses 之后设否则 Scene 里还没有这个类 SootClass app Scene.v().forceResolve(demo.Target, SootClass.SIGNATURES); Scene.v().setEntryPoints(Collections.singletonList(app.getMethodByName(main))); PackManager.v().runPacks();参数逐个说。set_process_dir收的是目录列表Soot 会去里面扫.class不能直接塞 jar 包路径jar 要用set_soot_classpath。set_prepend_classpath(true)等价于命令行-pp作用是让 Soot 自己去推断运行时的类库省掉手写java.lang.Object所在 jar 的麻烦。set_whole_program(true)对应-w开了以后才会执行cg这个 pack不开的话Scene.v().getCallGraph()返回的是一个只有入口点、没有边的空壳。setEntryPoints是整个流程里最关键的一步也是最容易被当成走过场的一步。全程序分析只从入口点出发做可达性推导任何从入口点不可达的方法都不会被扫描进而里面的new不会被计入实例化集合进而会导致更多方法不可达。一个真实的例子把入口点设成某个工具类而不是main结果主程序里所有类都没被扫到调用图里孤零零几个节点还以为是算法配错了。4.2 生成调用图并导出runPacks()之后Scene.v().getCallGraph()就是成品。注意这个CallGraph是增量的——如果后续还有自定义的 whole-program transformer 在跑它会随着新发现的可达方法继续长。所以想在跑完之后拿一份稳定快照得在runPacks()返回之后再读。CallGraph cg Scene.v().getCallGraph(); try (PrintWriter pw new PrintWriter(cg.dot)) { pw.println(digraph cg {); pw.println( node [shapebox, fontsize10];); for (IteratorEdge it cg.listener(); it.hasNext(); ) { Edge e it.next(); if (e.kind() ! Edge.Kind.CALL) continue; // 滤掉 PARAM/CLINIT 等伪边否则图会脏得没法看 if (e.srcStmt() null) continue; // 没有调用点的边画出来也没意义 pw.printf( \%s\ - \%s\;%n, e.src().getSignature(), e.tgt().getSignature()); } pw.println(}); }cg.listener()返回全部边的迭代器是 Soot 里唯一一个不按方法索引、直接吐所有边的入口。导 dot 之前一定要过滤kind否则一张中等规模程序的图里会混进大量PARAM边看着像是每个方法都在调 JDK 里的一大堆东西实际上是 Spark 传参时留下的中间产物。4.3 在调用图上建 ICFG 并遍历把 3.2 的Icfg和这里的调用图接起来就是一个完整的跨过程遍历。起点通常取main方法的所有 head。Icfg icfg new Icfg(cg); DequeIcfg.Node work new ArrayDeque(); SetIcfg.Node seen new HashSet(); SootMethod main Scene.v().getMainMethod(); for (Unit h : icfg.cfgOf(main).getHeads()) work.add(new Icfg.Node(main, h)); while (!work.isEmpty()) { Icfg.Node n work.poll(); if (!seen.add(n)) continue; // 递归方法会绕回来不加这个集合直接死循环 for (Icfg.Node s : icfg.succsOf(n)) { // 在这里做数据流合并比如把 n 的活跃变量并进 s 的 in 集合 work.add(s); } }这段代码的形态和所有数据流分析的框架初始化都一样worklist 加上 visited 集合后者不是优化是正确性的一部分。Java 里方法互相递归太常见了toString调StringBuilder.append再回调toString不加集合的话队列永远清不空。真正要做数据流传播还得把“从 successor 拿回 in 集合、和当前 out 集合比、变了就重新入队”的逻辑补上。这套单调框架能不能收敛取决于你用的传递函数是不是单调的——活跃变量、可达定义这些都是但一旦你自己写了带“删除”语义的传递函数收敛性就没了保障。5. 避坑调用图漏边、ICFG 断链的五个现场5.1 调用图这一侧的漏边现象调用图里只有入口点一个节点或者只有几十条边明显对不上代码规模。原因几乎总是set_whole_program(true)没开或者开了但没设入口点。Soot 在全程序模式下的可达性推导完全由入口点驱动没设入口点它就当成空程序处理cgpack 空转一圈返回一张空图而且不抛异常。解决办法是在loadNecessaryClasses()之后、runPacks()之前显式setEntryPoints并且打印一下Scene.v().getEntryPoints().size()确认非零。现象图里出现大量名字带phantom的节点或者边指向了明显不存在的类。原因是类路径不全。Soot 遇到找不到的类不会报错而是造一个SootClass标记成 phantom方法签名照记body 是空的。指向 phantom 的边看起来正常但往下遍历时retrieveActiveBody()直接抛异常。解决办法是把编译产物依赖的 jar 全部通过set_soot_classpath加进去并且定期检查Scene.v().getPhantomClasses()是不是空。有 phantom 就说明分析结果不可信别急着往下看。现象lambda 表达式里的代码、Thread的run方法体、通过反射调用的方法全都不在调用图里。前两个是invokedynamic和运行时语义的问题lambda 编译成invokedynamic加引导方法Soot 默认把它当成一条指向LambdaMetafactory的普通调用lambda body 对应的合成方法根本没有调用边Thread.start()到run()之间没有字节码层面的调用关系纯静态分析看不到。反射更彻底方法名在运行时才确定除非上运行时日志。解决办法分场景lambda 可以在分析前做一次预处理把invokedynamic降级成显式的合成类Thread和回调类可以把run方法手工加进入口点列表反射则依赖 Soot 的反射日志机制跑一遍测试把日志喂回去。现象桥接方法、泛型擦除后的方法在图上看起来重复同一个调用点连着两条语义相同的边。这是 Java 泛型擦除的副产物编译器会为协变返回类型生成 bridge 方法签名不同但实现就是super调用。调用图里会看到foo(Object)和foo(String)两个节点互相连着。解决办法是在建图之后做一次去重把只包含单条super调用的 bridge 方法合并到目标方法或者在遍历时对isBridge()的方法直接跳转。5.2 ICFG 这一侧的断链现象ICFG 遍历到某个调用点就停了后面的代码一个都访问不到。原因是在succsOf里把调用点的后继只有被调方法的 head漏掉了过程内的下一条语句。这在写代码时特别容易发生因为直觉上“调用点嘛当然要跳进被调方法”。但 ICFG 是保可达性的一条语句既可能进入被调方法也可能正常往下执行被调方法正常返回之后两条边都得有。解决办法是把过程内后继和跨过程后继都放进同一个返回列表让调用方自己决定走哪条。现象try-catch 块里的变量活跃分析结果明显不对catch 分支的变量永远被判定为未定义。用了BriefUnitGraph建 CFG异常边一条没建从 try 块到 catch handler 之间根本没有路径。这个错误特别隐蔽因为图看起来是连通的只是少了异常那条路。解决办法是换成ExceptionalUnitGraph或者至少CompleteUnitGraph如果已经有性能瓶颈就按方法粒度做判断——确实不含 try 的方法用 Brief含的用 Exceptional不要全局一刀切。现象分析跑着跑着卡死或者栈溢出。ICFG 里递归是常态任何不带 visited 集合的遍历都会死循环。另一个更隐蔽的原因是ExceptionalUnitGraph构造时抛了异常被吞掉返回了一个空图然后遍历在空图里转悠。解决办法是遍历一律带Set并且在cfgOf里对getHeads().isEmpty()的情况打日志空 head 列表的方法要么是空方法体要么是构造异常被吞了两种情况都该看见。6. 设计取舍精度、上下文与“宁多勿少”从 CHA 到 SPARK 再到各种上下文敏感变体这条谱系上的每一个点都在做同一件事用更多的时间与内存换取更少的虚假边。这里没有最优解只有匹配场景的解。做代码规模统计、找未被调用的方法这类粗粒度任务CHA 完全够用跑得快还不容易因为类路径缺失翻车做污点追踪、空指针检测这类需要精确边关系的分析就必须往 SPARK 甚至上下文敏感走否则一堆虚假边带来的误报会淹没真问题。ICFG 这一层的取舍更尖锐。上下文敏感能消掉交叉返回但代价是图规模爆炸或者需要引入下推系统这样的重型机制上下文无关便宜但任何跨过程的数据流结论都要打个折扣。我后来形成的习惯是先用最保守的配置CHA、CompleteUnitGraph、上下文无关跑一遍把可达范围和图规模摸清楚如果规模在可控区间内再逐级提升精度每次提升都对比一下边数变化。如果精度提上去边数几乎不变说明程序里根本没有多态白花的代价如果边数腰斩说明之前那一版分析结论基本不可信。验证方法其实很简单手写一个测试类把多态调用、接口调用、递归、try-catch、lambda 全塞进去事先数清楚应该有多少条边再拿 Soot 的输出对。这个手算的基准比任何文档都可靠。我最早做这类分析时图省事跳过了这一步结果在一份真实工程上排查了两天“为什么调用图里有条边指向一个我从没调用过的类”最后发现是 JDK 内部某个类实现了同一个接口而我的入口点设错了导致实例化集合偏大。从那以后我每次建图都强制先跑一遍这个手算基准图对不上就不往下做分析——图是地基地基歪了上面盖什么都白搭。希望帮到你。本文还有配套的精品资源点击获取