做一个静态代码分析工具,很多人第一反应是接 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) c
