做一个静态代码分析工具,很多人第一反应是接 PMD、Checkstyle 或者直接上 SonarQube,但我今天要说的是另一条路:基于 JDK 自带的 Java Compiler API 去做。
这套技术栈的核心思路不复杂:既然 javac 本身就要把 Java 源码解析成带类型、带符号、带作用域的语法树,那我们直接把这个“编译前端”当作分析引擎来用。只要不调用最后的字节码生成阶段,它就是一个信息量非常完整的静态代码分析工具。
这篇文章是给 Java 开发、代码质量平台开发、以及想自己定制团队代码规范的人写的。我会从一个能跑通的最小示例开始,一路讲到 AST 遍历、语义分析、规则注册、命令行入口,最后把我实际使用中踩过的高频坑也整理出来。内容里不会依赖任何第三方语法解析库,核心就是 javax.tools 和 com.sun.source.* 里的几个类。
1. 先说清楚:Java Compiler API 为什么能当静态分析引擎用
1.1 静态代码分析与“把代码编译一遍”的差别
静态代码分析不等于编译报错。编译只关心“这行代码能不能通过类型检查”,而静态分析关心的是更细的工程问题:这个私方法是不是没人调用、这段 catch 块是不是把异常吞了、新代码里是不是又出现了团队禁止的 API 调用、某个关键的返回结果有没有被忽略。
传统做法是引入 PMD 或 Checkstyle,使用它们自己定义的 AST。这些工具有一个共性:它们为了不依赖完整编译环境,通常会自己写一份语法解析器,然后在这份“AST 副本”上做规则判断。好处是轻量、规则语言成熟,坏处是你做的每一条自定义规则,都要先理解它的 AST 模型,而且它的 AST 和真正的 javac AST 不一定完全一致。
Java Compiler API 的路线正好相反。它让工具直接复用 javac 的解析和语义分析能力。你在代码里写:
CompilationUnitTree,等价于一个.java文件的顶层结构;MethodTree、ClassTree、VariableTree,分别对应方法、类和字段;Trees、Elements、Types,则负责回答“这个节点的类型是什么”“这个变量指向哪个声明”。
也就是说,你得到的不是一份简化后的源码 AST,而是 javac 内部真实使用的那棵语法树。语法树里出现了什么、隐式类型转换是什么、重载方法到底选的是哪一个,这套 API 能直接告诉你。
1.2 这套路由适合谁
适合这类场景的团队和个人:
- 团队内部想禁止某些 API,但不想为了两条规则去维护一套 Checkstyle 插件体系;
- 想做一个轻量的代码提交前检查工具,不希望项目额外引入重量级代码质量平台;
- 想深入理解 Java 语法结构,把“读源码”这件事编程化、自动化;
- 需要在不生成
.class文件的前提下,对上万行 Java 源码做结构巡检。
不太适合直接硬扛的场景,是那种希望直接分析 Kotlin、Groovy 或混合语言仓库的项目。javac 只认 Java 源码,语言边界在那里就是边界,不用硬试。
提醒一句:
com.sun.source.*虽然听起来像内部包,但它从 JDK 6 开始就跟着javac走,JDK 9 模块化之后被归纳到jdk.compiler模块里,仍然是可用 API。它的稳定性比com.sun.tools.javac.*好很多,做工具时尽量只依赖前者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最小可运行链路:从 ToolProvider 到 JavacTask
2.1 一个需要放到 JDK 而不是 JRE 环境的前提
很多人第一次调用 ToolProvider.getSystemJavaCompiler() 得到 null,就是因为运行时环境是 JRE。从这个 API 设计能看出,它依赖的是 JDK 自带的编译器实现,而不是一套独立第三方编译器。
所以在主入口里先做一次检查是稳妥的:
java复制JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
if (compiler == null) {
throw new IllegalStateException("当前运行环境不包含编译器,请使用 JDK 而不是 JRE");
}
如果你用的是 IDE,请确认当前项目的 JDK 配置不是指向 JRE。Maven 项目里建议在 maven-compiler-plugin 上直接把 release 设成 17 或项目实际使用的版本,让整个构建链路在同一个 JDK 下跑。
2.2 加载源文件并执行编译前端
下面这段代码是整套工具的地基。它不生成任何 class 文件,只负责把源码加载进来、做语法和语义分析:
java复制import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.util.JavacTask;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class StaticAnalysisEntry {
public static void main(String[] args) throws Exception {
if (args.length == 0) {
System.err.println("usage: StaticAnalysisEntry <JavaSourceFile>");
return;
}
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
try (StandardJavaFileManager fileManager =
compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) {
Iterable<? extends JavaFileObject> javaFiles =
fileManager.getJavaFileObjects(args[0]);
// 第二个 null 是 Processor,我们用不到注解处理
// options 里加 -proc:none,避免触发不必要的注解处理器
JavacTask task = (JavacTask) compiler.getTask(
null,
fileManager,
diagnostics,
List.of("-proc:none"),
null,
javaFiles);
// 先 parse,再 analyze
// parse 结果给出了源码对应的语法树
Iterable<? extends CompilationUnitTree> units = task.parse();
// analyze 完成符号解析和类型归因
task.analyze();
for (CompilationUnitTree unit : units) {
String packageName = unit.getPackageName() == null
? ""
: unit.getPackageName().toString();
System.out.println("解析文件:" + unit.getSourceFile().getName());
System.out.println("包名:" + packageName);
System.out.println("import 数量:" + unit.getImports().size());
}
}
}
}
这段代码跑通后,你应该能在控制台看到被分析文件的包名和 import 数量。很多刚接触 Compiler API 的人这时候会犯一个错误:只调用 task.parse(),然后拿着 AST 去遍历。
问题在于,只 parse() 出来的树,基本是纯语法层面的结构。你只能看到“这里有个方法调用”,但看不到这个调用对应的实际类型是什么,也看不到它是不是某个被覆盖的父类方法。这些东西要等 analyze() 跑完之后才完整。
2.3 parse、analyze 和真正编译的关系
JavacTask 里其实暴露了编译过程中的几个阶段接口:
| 方法 | 对应编译阶段 | 分析工具里是否需要 |
|---|---|---|
parse() |
词法解析和语法分析 | 必须要,生成 CompilationUnitTree |
analyze() |
语义分析,包括符号解析、类型归因、语法糖处理 | 做语义型规则时必须 |
generate() |
生成字节码并写 .class |
通常不需要,这会污染输出目录 |
简单理解:编译错误也是检查结果。但如果你的目标只是静态分析,不要调用 generate()。调用它等于额外做了一次“真的把代码编出来”的操作,分析工具不需要这个副作用,纯属浪费 CPU 和磁盘 IO。
3. 把 CompilationUnitTree 当成源码地图:AST 遍历与规则扫描器
3.1 先认识这棵树上最常见的几种节点
CompilationUnitTree 是整个 .java 文件的根节点。树往下展开,常见的节点类型包括:
| Tree 类型 | 对应源码片段 |
|---|---|
ClassTree |
class、enum、interface 等类型声明 |
MethodTree |
构造器和方法 |
VariableTree |
字段、局部变量、catch 参数、for 循环变量等 |
MethodInvocationTree |
方法调用表达式,如 foo.bar() |
IfTree |
if 语句 |
TryTree / CatchTree |
try-catch 结构 |
ReturnTree |
return 语句 |
NewClassTree |
new 表达式 |
ExpressionStatementTree |
以分号结尾的表达式语句 |
BlockTree |
花括号包起来的语句块 |
Tree 是 com.sun.source.tree.Tree 接口,每种节点都有对应的 visitXxx 方法。如果你自己写一个 TreeVisitor 去实现这个接口,会非常痛苦,因为接口方法太多。普通工具代码里,更推荐继承 TreePathScanner,它已经帮你处理好了访问顺序和路径跟踪。
3.2 用 TreePathScanner 写第一条规则:发现 System.out.println
我先写一个非常简单但立刻有用的扫描器。规则目标:找出代码里直接调用 System.out.println 或 System.out.print 的位置。
java复制import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TreePathScanner;
public class SystemOutScanner extends TreePathScanner<Void, Void> {
private final CompilationUnitTree unit;
public SystemOutScanner(CompilationUnitTree unit) {
this.unit = unit;
}
@Override
public Void visitMethodInvocation(MethodInvocationTree node, Void unused) {
String methodSelect = node.getMethodSelect().toString();
if ("System.out.println".equals(methodSelect)
|| "System.out.print".equals(methodSelect)) {
long line = unit.getLineMap().getLineNumber(node.getStartPosition());
System.out.println(
unit.getSourceFile().getName() + ":" + line
+ " 使用 System.out 输出:" + node);
}
return super.visitMethodInvocation(node, unused);
}
}
使用方式是在拿到 CompilationUnitTree 后直接 scan:
java复制for (CompilationUnitTree unit : units) {
new SystemOutScanner(unit).scan(unit, null);
}
这里有两个细节值得解释。
第一个是字符串匹配 System.out.println。对当前场景来说够用,但不严谨。如果代码里写了 PrintStream out = System.out; out.println(...),你要在更深的层次识别出 out 的类型是 java.io.PrintStream,并且调用目标就是 System.out.println,这时就必须借助下一节说的语义信息。
第二个是行号获取。node.getStartPosition() 返回的是相对这个源文件的字符偏移量,不是行号。必须通过 unit.getLineMap() 转换成行号和列号。直接打印 node 对象没用,那是个内存里的 AST 引用,不要指望用户能通过它定位问题。
3.3 为什么 TreePathScanner 比 TreeVisitor 更适合写规则
TreeVisitor 接口的方法签名没有携带“当前路径”上下文。当 scanner 从 visitMethodInvocation 进入一个方法调用节点时,你可能想知道这个节点在哪个类、哪个方法里。用 TreeVisitor 你就得自己维护一个节点栈,或者从父节点一路传参,代码非常啰嗦。
TreePathScanner 内部维护了一个 TreePath,它表示从根节点到当前节点的一条路径。在规则代码里,你可以随时调用:
java复制TreePath path = getCurrentPath();
然后用路径上的父节点信息做结构判断,例如判断当前方法是不是某个类里的私有方法、是否位于 try 块范围内等。这种“带着地图巡逻”的能力,正是分析规则最需要的。
我建议一开始就统一用 TreePathScanner<Void, RuleContext> 这种签名来做规则,不要每个规则写一个独立的递归遍历函数。后面要加上下文对象时会省很多事。
4. 想追到方法的真实身份:Trees、Element 和 TypeMirror 的配合
4.1 Tree、Element、TypeMirror 三者分工
这是使用 Compiler API 做工具时最需要想清楚的一组概念。
Tree是源码里的语法节点,它对应的是“代码长什么样”;Element是程序中的符号定义,它对应的是“这个标识符指的是哪个声明”;TypeMirror是类型本身,对应的是“这段表达式的类型是什么”。
举一个生活中的类比:你和两个不同的人分别说“杭州”,两人都会知道这是一个地名,但你心里的具体地理位置可能不一样。字符串相等只能用来做初步判断,真正的判断要看“这个符号到底绑定到了哪里”。
Java 源码里同名方法可能来自不同类。如果只靠 .toString() 做规则判断,很容易被同名方法骗过去。正确做法是用 Trees.getElement() 去拿 Element,再判断这个 Element 的类型和名称。
4.2 在 analyze 之后查询语义信息
要拿到语义信息,第一步还是先让 JavacTask 执行 task.analyze()。分析之后,通过 Trees.instance(task) 拿到工具类:
java复制import com.sun.source.tree.NewClassTree;
import com.sun.source.util.TreePathScanner;
import com.sun.source.util.Trees;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.DeclaredType;
import javax.lang.model.type.TypeMirror;
public class DateInstantiationScanner extends TreePathScanner<Void, RuleContext> {
private final Trees trees;
public DateInstantiationScanner(RuleContext context) {
this.trees = Trees.instance(context.javacTask);
}
@Override
public Void visitNewClass(NewClassTree node, RuleContext context) {
TypeMirror type = trees.getTypeMirror(getCurrentPath());
if (type instanceof DeclaredType declaredType) {
Element element = declaredType.asElement();
if (element instanceof TypeElement typeElement
&& "java.util.Date".equals(typeElement.getQualifiedName().toString())) {
long line = context.unit.getLineMap().getLineNumber(node.getStartPosition());
context.report("NO_JAVA_UTIL_DATE",
"禁止直接 new java.util.Date,请使用 java.time 相关类,行号 " + line);
}
}
return super.visitNewClass(node, context);
}
}
这里的关键点是:即使业务代码中 import java.util.Date;,TypeMirror 拿到的依然是完整限定名 java.util.Date。因为 analyze() 阶段已经完成了 import 解析和符号绑定,代码里的 Date 被解析成了哪个类,由编译器自己告诉你,而不是靠你用字符串猜。
4.3 哪些规则必须走语义分析,哪些可以只走语法
在实践里,我通常把规则分成两类:
只用语法就能做的规则,适合快速扫描。例如:
- 判断是否存在空 catch 块;
- 判断方法参数数量是否超过某个值;
- 判断 try 块嵌套是否过深;
- 判断类文件里是否直接出现了某个禁止调用的方法名。
需要语义分析的规则,适合做更可靠的检查:
- 判断某次调用目标是否确实是一个被禁止的 API;
- 判断某个
@Override方法是否真的覆盖了父类方法; - 判断某个变量是不是从未被读取;
- 判断某个局部变量的类型是否为接口类型而不是具体实现类。
能够区分这两种规则,能帮你省下大量性能开销。纯语法规则不需要跑 analyze(),一个文件一颗语法树扫过去就行。但如果你把所有规则都丢到语义分析阶段,很多简单检查也要把引用的 jar 全部加载进去,分析速度会明显下降。
提示:把规则的“分析级别”做成配置项,只对需要语义的规则强制
task.analyze(),不要一刀切。
5. 规则管理的三种可落地姿势:插件类、配置项和命令行入口
5.1 RuleContext:把公用的东西统一传给规则
不要让每个规则自己去创建 JavacTask,也不要让每个规则都拿着 CompilationUnitTree 做复杂初始化。创建一个上下文对象,统一塞给所有规则,是最容易维护的做法。
下面是一个最小化的 RuleContext:
java复制import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.Trees;
import java.util.ArrayList;
import java.util.List;
public class RuleContext {
public final JavacTask javacTask;
public final CompilationUnitTree unit;
public final Trees trees;
private final List<RuleViolation> violations = new ArrayList<>();
public RuleContext(JavacTask javacTask, CompilationUnitTree unit) {
this.javacTask = javacTask;
this.unit = unit;
this.trees = Trees.instance(javacTask);
}
public void report(String ruleId, String message) {
violations.add(new RuleViolation(ruleId, unit.getSourceFile().getName(), message));
}
public List<RuleViolation> getViolations() {
return violations;
}
}
再定义一个规则接口:
java复制import com.sun.source.util.TreePathScanner;
public interface AnalyzerRule {
String id();
TreePathScanner<Void, RuleContext> createScanner();
}
具体规则只需要实现 AnalyzerRule 并返回一个 TreePathScanner 即可。之前那个 SystemOutScanner 就可以顺便改造成一个规则实现。
5.2 示例规则:检查空 catch 块
在真实项目里,空 catch 块是高频坏味道。很多人注释都不写,异常吞得无声无息。用语法树检查它非常简单,因为它本质是看 CatchTree 里的 BlockTree 有没有语句。
java复制import com.sun.source.tree.BlockTree;
import com.sun.source.tree.CatchTree;
import com.sun.source.util.TreePathScanner;
public class EmptyCatchRule implements AnalyzerRule {
@Override
public String id() {
return "EMPTY_CATCH";
}
@Override
public TreePathScanner<Void, RuleContext> createScanner() {
return new TreePathScanner<>() {
@Override
public Void visitCatch(CatchTree node, RuleContext context) {
BlockTree block = node.getBlock();
if (block.getStatements().isEmpty()) {
long line = context.unit.getLineMap().getLineNumber(node.getStartPosition());
context.report(id(), "第 " + line + " 行 catch 块为空:" + node.getParameter().getName());
}
return super.visitCatch(node, context);
}
};
}
}
这里要提醒一个容易误判的细节:源码中空 catch 块里如果写了普通注释,比如:
java复制catch (Exception e) {
// ignore
}
在 javac 的 AST 里,注释并不会成为 BlockTree 的子节点。也就是说,从纯语法树视角看,它依然是一个没有任何语句的 catch 块。如果你的规则想区分“有注释的空块”和“没注释的空块”,就不能只靠 AST,还需要在更底层的词法阶段拿注释内容。这是大多数刚上手 Compiler API 的人容易踩的认知坑,一定提前想清楚规则语义。
5.3 用配置文件和命令行入口控制开关
规则如果写死在代码里,想让不同项目跑不同检查就很不方便。我会用一个 JSON 配置文件控制规则开关和级别:
json复制{
"rules": {
"EMPTY_CATCH": {
"enabled": true,
"level": "WARNING"
},
"NO_SYSTEM_OUT": {
"enabled": false,
"level": "ERROR"
}
}
}
命令行入口可以这样设计:
bash复制java -Xmx1g -jar java-static-check.jar \
--source-file src/main/java/com/example/Foo.java \
--config rules.json \
--output text
当规则检查结束后,通过 RuleContext 里的 violation 列表输出诊断结果:
text复制src/main/java/com/example/Foo.java:101 [WARNING] EMPTY_CATCH: catch 块为空
如果后面要接 CI、接入 IDE 插件或者自动提交评论,输出 JSON 也很容易加。核心是让诊断格式和规则逻辑解耦,规则只负责报告“哪个节点、为什么违规”,具体怎么展示交给外层处理。
6. 实际使用中的三大类坑:模块访问、依赖缺失和进程资源
6.1 JDK 9 之后不要再硬编码访问 javac 内部类
网上老资料里经常出现 com.sun.tools.javac.main.JavaCompiler 这类代码。JDK 8 时期这么做问题不大,JDK 9 模块化之后,这些包没有全部公开导出,直接访问轻则启动报 IllegalAccessError,重则在下个 JDK 小版本升级时编译不过。
如果要用 Compiler API,优先只碰这些公开入口:
javax.tools.ToolProvider:获取编译器;javax.tools.StandardJavaFileManager:管理源文件;com.sun.source.util.JavacTask:编译任务的入口;com.sun.source.tree.*:语法树节点;com.sun.source.util.TreePathScanner、Trees、SourcePositions:遍历和工具类。
不要把 com.sun.tools.javac.* 当依赖引入项目。就算某些旧例子能跑,那是它们当时没有遇到模块限制,不代表这是可维护的用法。你的工具是要长期跑的,不是跑一遍就删。
6.2 sourcepath 和 classpath 不配齐,语义分析结果会失真
这是一个很容易让结果“看起来对,实际错”的坑。
如果你分析的是 Foo.java,但 Foo 引用了工程里另一个模块的 Bar 类,而你的工具没有把这个 Bar 放进 classpath,那么 analyze() 阶段会怎样?
它会报编译错误,而且很多语义信息会变成 errorType。此时你再去做规则判断,可能会得到错误结论。比如你原本想检查某个方法是否调用了 LogManager 相关的 API,一旦 LogManager 在 classpath 里找不到,类型就是错误的,规则判断就会静默跳过或者空指针。
在获取 JavacTask 时,把 sourcepath 和 classpath 都显式传进 options:
java复制JavacTask task = (JavacTask) compiler.getTask(
null,
fileManager,
diagnostics,
List.of(
"-proc:none",
"-classpath", System.getProperty("java.class.path"),
"-sourcepath", sourceRoot
),
null,
javaFiles);
如果工具要分析的是某个 Maven 多模块项目,最稳妥的接入方式是让 Maven 生成 classpath 文件,然后传给这个工具。不要依赖工具内部去猜依赖关系。
实测经验:如果只做纯语法规则,classpath 缺失的影响还不大;但只要规则里出现了一行
trees.getTypeMirror(),classpath 就必须完整。否则你会在看似正常的代码上拿到一堆空类型。
6.3 超大仓库扫描时的内存与并行策略
很多人第一次把这个工具接到大仓库,跑了几百个源文件之后遇到 OOM,然后开始怀疑 Compiler API 内存效率差。
这个问题要分开看。
JavacTask 在设计上是围绕“一次编译任务”工作的。你把一万个 .java 文件一次塞进去,它会同时维护一万个文件的符号表、AST 和诊断信息。对分析工具来说,这不是最优用法。
我的建议是控制单次任务的文件规模。通常一次分析一个模块,或者一次分析一个软件包目录。如果项目非常大,先按包拆成多个批处理任务,任务之间通过进程或 API 拼接结果。
另一个更有效的方法是利用多进程而不是同一个 JVM 里的多线程。StandardJavaFileManager 不是设计成无状态线程安全共享的,多个线程同时调用同一个 JavacTask 很容易踩并发问题。与其在单个 JVM 里硬怼线程池,不如在 CI 上把一个大的源码目录切片,启动多个分析进程,每个进程独立跑一组文件。这样内存可控,结果也更容易隔离。
如果你还是希望单 JVM 内并行,一个保险做法是每个线程创建自己独立的 JavaCompiler、StandardJavaFileManager 和 DiagnosticCollector,互不共享。但这个方法对堆内存要求更高,建议把 -Xmx 至少给到 1GB,再根据实际文件数调整。
6.4 诊断信息不等于异常信息,要区分普通编译错误和工具运行错误
还有一个小坑:DiagnosticCollector<JavaFileObject> 收集到的诊断,包括源码中的语法错误、类型错误等信息。这些诊断不是 Exception,不会打断你的 main 方法。
示例代码里如果被分析的 Java 文件本身无法通过编译,task.analyze() 未必会抛异常,但 diagnostics 列表里会有一堆 ERROR 级别的条目。如果你没有检查诊断列表就认为“分析成功”,就会把坏文件当成好文件。
处理方式是在 parse 和 analyze 之后,单独扫一遍诊断列表:
java复制for (Diagnostic<? extends JavaFileObject> d : diagnostics.getDiagnostics()) {
if (d.getKind() == Diagnostic.Kind.ERROR) {
System.err.println("[编译错误] " + d);
}
}
在规则设计上,我通常建议:如果文件存在编译错误,语法树形状不完整,这时只跑那些不依赖类型信息的简单语法规则,其余语义型规则直接跳过,避免在错误节点上误报。这是“宁可少查,不可乱报”的取舍。
最后一点个人经验
如果你真的打算把这个思路做成团队工具,我给的建议是:第一版不要追求把规则做成可热插拔的 jar,也不要一开始就上 SPI 加载机制。
先把简单主流程跑通,也就是从 ToolProvider 到 JavacTask,再到一个只有几条规则的小型扫描器。选定一个输出格式,接入一个真实模块,观察它在普通代码上的检查结果和误报率。误报清零之后,再逐步补充语义规则。
我自己用下来的体会是:Java Compiler API 的难点从来不在 API 调用,而在你脑中的 Java 语言模型是否准确。AST 和符号表只是把你的判断力呈现出来,规则定得好不好,最终还是取决于你是否理解 Java 的类型推断、方法重载和继承机制。
如果你要查的第一个规则是“禁止在业务代码里直接使用某个底层类”,建议先拿那个类的多种写法做测试,把直接 new、静态方法调用、通过继承类访问这三种情况都跑一遍。这能帮你快速理解语法树和语义分析的分工,也会比直接读一堆 API 文档有效得多。
