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 JCIApache Commons JCI2026/10/15

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-core1.1工厂、通用 API、内存读写器/存储器、类重载器commons-io 2.2、commons-logging 1.1.3
commons-jci-eclipse1.1Eclipse JDT 编译器适配org.eclipse.jdt.core.compiler:ecj 4.2.2
commons-jci-janino1.1Janino 编译器适配org.codehaus.janino:janino 2.6.1(再传 commons-compiler 2.6.1)
commons-jci-javac1.0javac 编译器适配org.vafer:dependency 0.2(再传 compile 作用域 junit 3.8.2 与 asm 2.2.x)
commons-jci-groovy1.1Groovy 编译器适配groovy-all 1.7.11
commons-jci-fam1.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 8JDK 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 热更新可用可用见本专栏热更新篇实测

运维建议三条:

  1. 新项目默认选 eclipse 适配,版本设置固定为 1.6/1.6(兼容性最好的折中,实测全绿),把 janino 留给无泛型小片段,groovy 留给脚本 DSL。
  2. JVM 参数:适配器运行期堆示例 -Xms256m -Xmx256m(写入启动脚本或 systemd 的 Environment=JAVA_OPTS=-Xms256m -Xmx256m,由 java $JAVA_OPTS -cp "classes:/opt/jci-lib/*" Demo 引用);JDK 17 且用 groovy 适配时在同一个位置追加两个 --add-opens。
  3. 不要引入 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 两个实测必需项。

八、参考链接

  1. Apache Commons JCI 官方主页:https://commons.apache.org/proper/commons-jci/
  2. Maven 中央仓库 commons-jci-javac 坐标:https://repo1.maven.org/maven2/org/apache/commons/commons-jci-javac/
  3. Maven 中央仓库 ecj 4.2.2 坐标:https://repo1.maven.org/maven2/org/eclipse/jdt/core/compiler/ecj/
  4. OpenJDK ClassFile 规范(NestMembers 属性):https://docs.oracle.com/javase/specs/jvms/se17/html/
  5. fam 监听与热更新实战:见本专栏《Apache Commons JCI 1.1 热更新与监听进阶实战详解》