Apache Commons JCI 1.1 编译器适配与 API 详解
深入解析 Apache Commons JCI 1.1 的六模块结构与核心 API 签名,逐个剖析 eclipse、janino、javac、groovy 四大编译器适配的默认行为与双 JDK 实测表现,并给出 javac 适配器在 JDK 8 与 JDK 17 失败的完整根因证据链,全部基于 javap 与真实运行实测。
Apache Commons JCI 1.1 编译器适配与 API 详解
目标:讲清 Apache Commons JCI 1.1 的六模块结构、核心 API 签名(
javap逐一实测)与四大编译器适配的真实行为差异;重点给出 javac 适配器在 JDK 8 与 JDK 17 失败的完整根因证据链(字节码转换层不兼容 JDK 9+ class 文件格式)。所有类签名来自对中央仓库 jar 的javap反汇编,所有行为结论来自真实运行,运行环境为 Ubuntu 22.04.5、x86_64。
一、环境信息
- 操作系统:Ubuntu 22.04.5 LTS(x86_64)
- JDK:OpenJDK 17.0.20.1(对照环境 OpenJDK 1.8.0_504,Temurin 发行版)
- Maven:3.6.3;组件获取自 Maven 中央仓库(坐标与版本以 metadata 实查为准)
- 分析对象 jar:commons-jci-core 1.1、commons-jci-eclipse 1.1、commons-jci-janino 1.1、commons-jci-javac 1.0、commons-jci-groovy 1.1、commons-jci-fam 1.1
- 实测工程目录:
/opt/jci-demo
版本基线确认(Maven Central metadata 实查回显,curl -s https://repo1.maven.org/maven2/org/apache/commons/commons-jci-core/maven-metadata.xml):
<latest>1.1</latest>
<release>1.1</release>
<lastUpdated>20131014122043</lastUpdated>
二、模块与依赖全景
JCI 采用「1 个核心 + 5 个适配/能力模块」的多模块结构。各模块职责、最新版本与传递依赖(依赖树实测)如下:
| Maven 模块 | 最新版 | 职责 | 传递依赖(实测) |
|---|---|---|---|
| commons-jci-core | 1.1 | 工厂、通用 API、内存读写器/存储器、类重载器 | commons-io 2.2、commons-logging 1.1.3 |
| commons-jci-eclipse | 1.1 | Eclipse JDT 编译器适配 | org.eclipse.jdt.core.compiler:ecj 4.2.2 |
| commons-jci-janino | 1.1 | Janino 编译器适配 | org.codehaus.janino:janino 2.6.1(再传 commons-compiler 2.6.1) |
| commons-jci-javac | 1.0 | javac 编译器适配 | org.vafer:dependency 0.2(再传 compile 作用域 junit 3.8.2 与 asm 2.2.x) |
| commons-jci-groovy | 1.1 | Groovy 编译器适配 | groovy-all 1.7.11 |
| commons-jci-fam | 1.1 | 文件变更监听(FilesystemAlterationMonitor) | 无(零传递依赖) |
core jar 的完整类清单(jar tf 实测,过滤内部类后的顶层类):
org/apache/commons/jci/ReloadingClassLoader.class
org/apache/commons/jci/compilers/AbstractJavaCompiler.class
org/apache/commons/jci/compilers/CompilationProblemHandler.class
org/apache/commons/jci/compilers/CompilationResult.class
org/apache/commons/jci/compilers/JavaCompiler.class
org/apache/commons/jci/compilers/JavaCompilerFactory.class
org/apache/commons/jci/compilers/JavaCompilerSettings.class
org/apache/commons/jci/listeners/AbstractCompilationListener.class
org/apache/commons/jci/listeners/CompilingListener.class
org/apache/commons/jci/listeners/ReloadingListener.class
org/apache/commons/jci/problems/CompilationProblem.class
org/apache/commons/jci/readers/FileResourceReader.class
org/apache/commons/jci/readers/MemoryResourceReader.class
org/apache/commons/jci/readers/ResourceReader.class
org/apache/commons/jci/stores/FileResourceStore.class
org/apache/commons/jci/stores/MemoryResourceStore.class
org/apache/commons/jci/stores/ResourceStore.class
org/apache/commons/jci/stores/ResourceStoreClassLoader.class
org/apache/commons/jci/stores/TransactionalResourceStore.class
💡 技术拓展:同一个 jar 里「监听器」出现在两个模块——core 的
listeners包面向编译场景(CompilingListener、ReloadingListener),fam 的monitor包提供底层轮询引擎,core 的监听器依赖 fam 的引擎工作。这就是依赖 JCI 热更新必须同时引 core 与 fam 的原因。本文实战组合为 core + eclipse + janino + fam(javac 与 groovy 模块按需且受 JDK 版本约束,见第五章与第六章)。
三、核心 API 详解
以下签名全部来自 javap -cp <jar> 对 1.1 真实 jar 的反汇编输出,逐条可复核。
3.1 JavaCompilerFactory:适配器工厂
public final class org.apache.commons.jci.compilers.JavaCompilerFactory {
public static org.apache.commons.jci.compilers.JavaCompilerFactory getInstance();
public org.apache.commons.jci.compilers.JavaCompiler createCompiler(java.lang.String);
}
createCompiler 按名字字符串创建适配器,实测四种名字与返回类一一对应(Probe.java 实测回显):
"eclipse" -> org.apache.commons.jci.compilers.EclipseJavaCompiler
"janino" -> org.apache.commons.jci.compilers.JaninoJavaCompiler
"javac" -> org.apache.commons.jci.compilers.JavacJavaCompiler
"groovy" -> org.apache.commons.jci.compilers.GroovyJavaCompiler
工厂是单例入口(getInstance()),创建时不检查后端 jar 是否在 classpath,后端缺失会在真正编译或类加载时才报错——因此「工厂创建成功」不等于「适配器可用」,必须以实际编译结果为准(javac 适配器在 JDK 17 上就是工厂创建成功、编译报错的典型案例,见第五章)。
3.2 JavaCompiler:统一编译接口
public interface org.apache.commons.jci.compilers.JavaCompiler {
public abstract void setCompilationProblemHandler(org.apache.commons.jci.compilers.CompilationProblemHandler);
public abstract org.apache.commons.jci.compilers.JavaCompilerSettings createDefaultSettings();
public abstract org.apache.commons.jci.compilers.CompilationResult compile(java.lang.String[], org.apache.commons.jci.readers.ResourceReader, org.apache.commons.jci.stores.ResourceStore);
public abstract org.apache.commons.jci.compilers.CompilationResult compile(java.lang.String[], org.apache.commons.jci.readers.ResourceReader, org.apache.commons.jci.stores.ResourceStore, java.lang.ClassLoader);
public abstract org.apache.commons.jci.compilers.CompilationResult compile(java.lang.String[], org.apache.commons.jci.readers.ResourceReader, org.apache.commons.jci.stores.ResourceStore, java.lang.ClassLoader, org.apache.commons.jci.compilers.JavaCompilerSettings);
}
三个 compile 重载是使用频度最高的 API,参数语义实测归纳:
- 第 1 参:源码资源名数组,如
new String[]{"Hello.java"},名字必须与放入 reader 的资源名逐字一致,否则编译器读不到源码(实测 0 错误但 store 里没有产物)。 - 第 2 参:
ResourceReader,从哪里读源码;MemoryResourceReader(内存)与FileResourceReader(磁盘目录)两个实现。 - 第 3 参:
ResourceStore,字节码写到哪里;MemoryResourceStore(内存)与FileResourceStore(磁盘目录)。 - 第 4 参:编译源码可见的
ClassLoader(决定它能引用哪些既有类);不传时适配器用自身加载器。 - 第 5 参:
JavaCompilerSettings,版本与诊断设置;不传时用适配器默认值(eclipse/janino/javac 实测默认 source=target=1.4)。
3.3 JavaCompilerSettings:版本与诊断设置
public abstract class org.apache.commons.jci.compilers.JavaCompilerSettings {
public java.lang.String getSourceVersion();
public java.lang.String getTargetVersion();
public java.lang.String getSourceEncoding();
public boolean isWarnings();
public boolean isDeprecations();
public boolean isDebug();
public boolean isVerbose();
public void setSourceVersion(java.lang.String);
public void setTargetVersion(java.lang.String);
public void setSourceEncoding(java.lang.String);
public void setWarnings(boolean);
public void setDeprecations(boolean);
public void setDebug(boolean);
public void setVerbose(boolean);
}
使用模式固定为「createDefaultSettings() 拿默认值 → 按需 setter → 传入 compile 第 5 参」。两个适配器对该类的态度实测差异极大:
- eclipse 适配(EclipseJavaCompiler):版本设置生效,1.4–1.7 可用、1.8 抛
RuntimeException: unknown version 1.8(Probe2 实测)。 - janino 适配(JaninoJavaCompiler):setter 被接受但行为不随版本变(janino 自身语法能力即上限)。
- 实测默认值:
default source=1.4 target=1.4(Probe 对全部四个适配器的统一回显)。
3.4 CompilationResult 与 CompilationProblem:诊断对象
public interface org.apache.commons.jci.compilers.CompilationResult {
public abstract org.apache.commons.jci.problems.CompilationProblem[] getErrors();
public abstract org.apache.commons.jci.problems.CompilationProblem[] getWarnings();
}
public interface org.apache.commons.jci.problems.CompilationProblem {
public abstract boolean isError();
public abstract java.lang.String getFileName();
public abstract int getStartLine();
public abstract int getStartColumn();
public abstract int getEndLine();
public abstract int getEndColumn();
public abstract java.lang.String getMessage();
}
关键行为:编译失败不抛异常,诊断全部装进 CompilationResult。用缺分号源码实测两个适配器的定位精度(ProbeErr.java,坏源码第 3 行 return 1 + "x" 缺分号):
[eclipse] file=Bad.java line=3 col=57 isError=true
message=Syntax error, insert ";" to complete BlockStatements
[janino] file=Bad.java line=5 col=1 isError=true
message=File Bad.java, Line 5, Column 1: Operator ";" expected
eclipse 适配(ecj)定位到第 3 行 token 末尾并给出修复建议;janino 把缺分号定位到第 5 行块结束处。诊断质量上 ecj 明显更优,这也是「多后端」架构下的一个共性结论:后端决定错误体验。
3.5 ResourceReader 与 ResourceStore:内存三件套
public interface org.apache.commons.jci.readers.ResourceReader {
public abstract boolean isAvailable(java.lang.String);
public abstract byte[] getBytes(java.lang.String);
public abstract java.util.Collection list();
}
public interface org.apache.commons.jci.stores.ResourceStore {
public abstract void write(java.lang.String, byte[]);
public abstract byte[] read(java.lang.String);
public abstract void remove(java.lang.String);
public abstract java.lang.String[] list();
}
MemoryResourceReader/MemoryResourceStore 是两个接口的纯内存实现,组合即「不落盘编译」:
MemoryResourceReader reader = new MemoryResourceReader();
reader.add("Hello.java", src.getBytes("UTF-8"));
MemoryResourceStore store = new MemoryResourceStore();
CompilationResult result = compiler.compile(
new String[]{"Hello.java"}, reader, store, clsLoader, settings);
byte[] bytecode = store.read("Hello.class");
字节码取出后的加载路径有两条:直接用 ClassLoader.defineClass 定义(上文 2.4 节闭环),或用 core 提供的 ResourceStoreClassLoader 按需从 store 取类(实测 jar 中存在,适配热更新场景,方法签名同标准 ClassLoader 约定)。需要事务语义(编译失败整批回滚)时用 TransactionalResourceStore 包装一层。
3.6 ReloadingClassLoader:类重载器
public class org.apache.commons.jci.ReloadingClassLoader extends ClassLoader {
public void addResourceStore(org.apache.commons.jci.stores.ResourceStore);
public void removeResourceStore(org.apache.commons.jci.stores.ResourceStore);
public void handleNotification(org.apache.commons.jci.listeners.ReloadNotificationListener);
public Class<?> loadClass(java.lang.String) throws ClassNotFoundException;
}
职责单一:把一个或多个 store 挂在 parent 之前,handleNotification 收到重载通知时重置 store 视图,后续 loadClass 即拿到新字节码。它实现 ReloadNotificationListener 接口,与 ReloadingListener.addReloadNotificationListener 对接成热更新链路(完整实战见本专栏热更新篇)。
四、四大编译器适配逐个详解
4.1 eclipse 适配(commons-jci-eclipse 1.1 + ecj 4.2.2)
实测行为汇总:
- 默认设置:
source=1.4 target=1.4;泛型语法必须显式设 ≥1.5,实测 1.6 下编译、加载、反射调用全部成功(errors=0 bytecode bytes=536 invoke say() = hello-2)。 - 版本区间:1.4–1.7 可用;
setSourceVersion("1.8")抛RuntimeException: unknown version 1.8(ecj 4.2.2 发布于 2012 年,支持到 JDK 7 语法级别)。 - 错误诊断:行号列号精确(3:57),消息含修复建议(3.4 节实测)。
- 兼容性:JDK 8 与 JDK 17 双环境实测结论相同,是四大适配器中唯一「无参数、零妥协」可用的,推荐作为默认选择。
4.2 janino 适配(commons-jci-janino 1.1 + janino 2.6.1)
实测行为汇总:
- 非泛型源码零配置可用:默认设置直接编译成功(
errors=0 bytecode bytes=371 invoke say() = hello-2)。 - 不支持泛型:泛型源码报
File Hello.java, Line 3, Column 26: Expression "java.util.List < String > list" is not an lvalue。 - 不感知
JavaCompilerSettings的版本字段:设 1.6 与默认结果相同。 - 优势在体积与速度(janino 2.6.1 + commons-compiler 合计约 0.6 MB,比 ecj 的 1.8 MB 小一个数量级),适合编译极小的表达式类;JDK 8 与 JDK 17 行为一致。
4.3 javac 适配(commons-jci-javac 1.0)
这是唯一「双 JDK 都不能开箱可用」的适配器,实测:
- JDK 17:
createCompiler("javac")工厂创建成功(返回JavacJavaCompiler),compile 返回errors=1,消息Error while executing the compiler: java.lang.reflect.InvocationTargetException,字节码为空(bytecode bytes=-1)。 - JDK 8:同样报
InvocationTargetException,即便tools.jar存在(ProbeJavac2 实测JavacClassLoader能从/opt/java/openjdk/lib/tools.jar加载com.sun.tools.javac.Main,随后 invoke 仍失败)。 - JDK 8 有限可用方案(实测复现):
java -noverify启动 + 磁盘上存在同名源文件占位(内容可为 0 字节)+ 内存源码注入,三条件齐备后编译与调用成功(实测errors=0 bytecode bytes=494 invoke say() = hello-2)。 - 根因证据链见第五章。
4.4 groovy 适配(commons-jci-groovy 1.1 + groovy-all 1.7.11)
实测行为汇总:
- JDK 8:开箱即用,编译 + 加载 + 调用成功(
invoke say() = hello-jci-2)。 - JDK 17:编译成功(0 错误、产物 6562 字节),但实例化时炸
InaccessibleObjectException: Unable to make protected void java.lang.Object.finalize() throws java.lang.Throwable accessible: module java.base does not "opens java.lang" to unnamed module;加--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED两个参数后编译与调用全部成功(实测回显invoke say() = hello-jci-2)。 - 语义注意:它把
.java资源按 Groovy 语法编译(产物含元类支撑所以 6562 字节,约为 ecj 产物的 12 倍),不能用纯 Java 视角评估其产物。
五、javac 适配器失败根因剖析(实测证据链)
这一章用三步实测把 javac 适配器在 JDK 8/17 的失败定位到字节码转换层,结论可复核。
5.1 第一步:适配器的调用机制(反汇编)
对 jci-javac 1.0 jar 反汇编(javap -c -p)与字符串常量提取(strings),确认其核心机制:
com/sun/tools/javac/Main # 反射目标类
"compile" # 反射方法名
(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method; # getMethod 调用
org/apache/commons/jci/compilers/JavacClassLoader # 自定义类加载器
org/apache/commons/jci/compilers/FileInputStreamProxy
org/apache/commons/jci/compilers/FileOutputStreamProxy
org/codehaus/asm/ClassReader # asm 2.2(2006 年)读改字节码
Could not find javac compiler class (should be in the tools.jar/classes.jar in your JRE/JDK).
即:JavacClassLoader 加载 com.sun.tools.javac.Main 时用 asm 2.2 把 javac 字节码里的 java.io.FileInputStream/FileOutputStream 引用改写为 JCI 的代理类(FileInputStreamProxy/FileOutputStreamProxy),从而把「javac 读磁盘源码、写字节码」重定向到内存 reader/store;随后反射调用 Main.compile(String[], PrintWriter)。这套机制以 2006 年的 asm 2.2 实现,而 asm 2.2 只认识 Java 6 及以前的 class 文件格式。
5.2 第二步:JDK 8 的失败形态——VerifyError
用与适配器完全一致的调用序列复现(ProbeJavac3.java:JavacClassLoader 加载 → getMethod("compile", String[].class, PrintWriter.class) → invoke),JDK 8(1.8.0_504)实测回显:
step1 load OK: class com.sun.tools.javac.Main
step2 getMethod OK: public static int com.sun.tools.javac.Main.compile(java.lang.String[],java.io.PrintWriter)
step3 invoke 失败,根因:
java.lang.VerifyError: Expecting a stackmap frame at branch target 48 in method
com.sun.tools.javac.main.Main.feMessage(Ljava/io/PrintWriter;)V
解读:asm 2.2 改写 major 52(Java 8)的字节码时丢改了 StackMapTable 帧信息,字节码校验器拒绝加载被改写后的 javac 实现类。-noverify 关闭校验后此类加载成功(这就是 JDK 8 有限可用方案的原理),但 javac 6+ 读源码走 NIO 文件管理器、不经 FileInputStream,内存注入只对「读类文件」生效,源码仍需磁盘上存在同名占位文件——JDK 8 上实测缺占位文件时报 javac: file not found: Hello.java。
5.3 第三步:JDK 17 的失败形态——ClassFormatError
同一脚本在 JDK 17(17.0.20.1)实测回显:
step1 load OK: class com.sun.tools.javac.Main
step2 getMethod OK: public static int com.sun.tools.javac.Main.compile(java.lang.String[],java.io.PrintWriter)
step3 invoke 失败,根因:
java.lang.ClassFormatError: Nest member class_info_index 129 has bad constant type
in class file com/sun/tools/javac/main/Main
解读:JDK 11+ 的 class 文件带 NestMembers 属性,asm 2.2 按旧版常量池假设解析直接报格式错误。-noverify 只关校验、不修格式,JDK 17 无任何 JVM 参数可救。两步对照即完成根因闭环:失败点不在 tools.jar 是否存在(JDK 8 中存在仍失败),而在字节码转换层与新版 class 文件格式不兼容。
5.4 结论与替代
| 环境 | javac 适配器 | 原因 | 可行替代 |
|---|---|---|---|
| JDK 8 + 默认参数 | 失败(VerifyError) | asm 2.2 损坏 StackMapTable | -noverify + 磁盘占位文件(有限可用) |
| JDK 17 | 失败(ClassFormatError) | asm 2.2 不识别 NestMembers 属性 | eclipse/janino 适配器,或 javax.tools |
六、双 JDK 兼容性矩阵与运维建议
综合第三、四章实测,JCI 1.1 在 JDK 8(Temurin 1.8.0_504)与 JDK 17(17.0.20.1)下的完整兼容性矩阵:
| 能力 | JDK 8 | JDK 17 | 说明 |
|---|---|---|---|
| JavaCompilerFactory / Settings / Result / Problem | 可用 | 可用 | 纯接口层,无环境依赖 |
| eclipse 适配(ecj 4.2.2) | 可用 | 可用 | 版本上限 1.7;1.8 报 unknown version |
| janino 适配(2.6.1) | 可用 | 可用 | 不支持泛型;不感知版本设置 |
| javac 适配(1.0) | 有限可用 | 不可用 | -noverify + 磁盘占位;17 报 ClassFormatError |
| groovy 适配(groovy-all 1.7.11) | 开箱即用 | 需参数 | 17 需 --add-opens java.base/java.lang 与 java.base/java.util 两个 opens |
| MemoryReader/Store、ReloadingClassLoader | 可用 | 可用 | 内存三件套与类重载,双 JDK 行为一致 |
| fam 监听 + CompilingListener 热更新 | 可用 | 可用 | 见本专栏热更新篇实测 |
运维建议三条:
- 新项目默认选 eclipse 适配,版本设置固定为
1.6/1.6(兼容性最好的折中,实测全绿),把 janino 留给无泛型小片段,groovy 留给脚本 DSL。 - JVM 参数:适配器运行期堆示例
-Xms256m -Xmx256m(写入启动脚本或 systemd 的Environment=JAVA_OPTS=-Xms256m -Xmx256m,由java $JAVA_OPTS -cp "classes:/opt/jci-lib/*" Demo引用);JDK 17 且用 groovy 适配时在同一个位置追加两个--add-opens。 - 不要引入 commons-jci-javac(JDK 9+ 不可用,且传递依赖把 junit 3.8.2、asm 2.2.x 以 compile 作用域拖进所有模块);javac 语义需求走
javax.tools。
七、常见问题(FAQ)
Q1:为什么 createCompiler("javac") 工厂创建成功、编译却报错?
工厂只做 new JavacJavaCompiler(),不探测后端环境;适配器真正加载 com.sun.tools.javac.Main 是在 compile 调用时(第五章证据链)。因此「能创建」只说明类在 classpath 上,不代表 JDK 兼容。判据始终以 CompilationResult.getErrors() 为准。
Q2:compile 传 new String[]{"Hello.java"} 但 store 里没有产物?
资源名与放入 reader 的键不一致,或源码内容为空(实测 reader 键名 Hello.java、compile 数组元素 hello.java 大小写不符时,返回 0 错误但 store.read("Hello.class") 为 null)。键名逐字一致是硬前提。
Q3:eclipse 适配设 1.8 为什么抛 RuntimeException: unknown version 1.8?
ecj 4.2.2 的版本上限(2012 年产物)。实测合法区间 1.4–1.7。需要 JDK 8+ 语法级别时,两条路:把动态编译的源码按 1.7 语法改写(JCI 场景动态类通常很小),或单独把 org.eclipse.jdt.core.compiler:ecj 升级到新版(JCI 的 eclipse 适配接口未变)。
Q4:janino 编译报 Expression "java.util.List < String > list" is not an lvalue 但源码是合法 Java?
泛型语法。janino 2.6.1 语法上限是 Java 1.4,List<String> 这类参数化类型会被它当作 小于号 表达式解析报错。改用裸类型加显式强转,或换 eclipse 适配。
Q5:JDK 17 上除了 javac 适配还有哪些「隐性失败」?
groovy 适配的实例化阶段(4.4 节:编译成功但 InaccessibleObjectException)与任何依赖反射 setAccessible 打开 JDK 内部类的老库。排查手段统一为加 --add-opens 逐包放开实测,本文已给出 java.lang 与 java.util 两个实测必需项。
八、参考链接
- Apache Commons JCI 官方主页:https://commons.apache.org/proper/commons-jci/
- Maven 中央仓库 commons-jci-javac 坐标:https://repo1.maven.org/maven2/org/apache/commons/commons-jci-javac/
- Maven 中央仓库 ecj 4.2.2 坐标:https://repo1.maven.org/maven2/org/eclipse/jdt/core/compiler/ecj/
- OpenJDK ClassFile 规范(NestMembers 属性):https://docs.oracle.com/javase/specs/jvms/se17/html/
- fam 监听与热更新实战:见本专栏《Apache Commons JCI 1.1 热更新与监听进阶实战详解》