Apache Commons JCI 1.1 热更新与监听进阶实战详解
基于 Apache Commons JCI 1.1 与 fam 模块构建目录级热更新体系,逐层实测 FilesystemAlterationMonitor 事件回调、CompilingListener 自动编译与 ReloadingClassLoader 类重载全链路,给出源码目录约定、轮询间隔、JVM 参数与生产化要点,全部已在 Ubuntu 22.04 实测。
Apache Commons JCI 1.1 热更新与监听进阶实战详解
目标:用 JCI 1.1 的 fam 监听模块 + eclipse 编译器适配搭建「改源码 → 自动编译 → 类热重载 → 新逻辑立即生效」的目录级热更新体系:先实测
FilesystemAlterationMonitor的创建/修改/删除事件回调,再实测CompilingListener + ReloadingClassLoader全链路,最后给出轮询间隔、JVM 参数与生产化要点。全部命令与回显已在 Ubuntu 22.04.5、OpenJDK 17.0.20.1、Maven 3.6.3 实测通过。
一、环境信息
- 操作系统:Ubuntu 22.04.5 LTS(x86_64)
- JDK:OpenJDK 17.0.20.1
- 构建工具:Apache Maven 3.6.3
- 组件版本:commons-jci-core 1.1、commons-jci-eclipse 1.1(自动带入 ecj 4.2.2)、commons-jci-fam 1.1
- Maven 坐标:
org.apache.commons:commons-jci-core:1.1等(与《Ubuntu 22.04 安装 Apache Commons JCI 指南》4.4 节的 pom 依赖组合一致,本篇直接复用该工程) - 实测工程目录:
/opt/jci-demo - 热更新演示目录:
/opt/jci-demo/hot-test(程序运行时自动创建)
依赖版本确认(mvn dependency:tree 截取,实测回显):
[INFO] +- org.apache.commons:commons-jci-core:jar:1.1:compile
[INFO] | +- commons-io:commons-io:jar:2.2:compile
[INFO] | \- commons-logging:commons-logging:jar:1.1.3:compile
[INFO] +- org.apache.commons:commons-jci-eclipse:jar:1.1:compile
[INFO] | \- org.eclipse.jdt.core.compiler:ecj:jar:4.2.2:compile
[INFO] +- org.apache.commons:commons-jci-fam:jar:1.1:compile
fam 模块零传递依赖,jar tf commons-jci-fam-1.1.jar 确认其提供的监听引擎与监听接口:
org/apache/commons/jci/monitor/FilesystemAlterationListener.class
org/apache/commons/jci/monitor/FilesystemAlterationMonitor.class
org/apache/commons/jci/monitor/FilesystemAlterationMonitor$ListenerEntry.class
二、热更新链路全景
JCI 的热更新由三层组件接力完成,逐层职责与实测关键方法如下:
| 层 | 组件(所在模块) | 职责 | 本篇实测要点 |
|---|---|---|---|
| 1. 感知 | FilesystemAlterationMonitor(fam) | 后台线程按固定间隔轮询目录,产生 create/change/delete 事件 | setInterval/start/stop,事件回调顺序 |
| 2. 编译 | CompilingListener(core) | 监听事件回调中把变更的 .java 编译进 ResourceStore | 构造传 JavaCompiler,getCompilationResult 取诊断 |
| 3. 重载 | ReloadingClassLoader(core) + ReloadingListener | store 内容变化后重置类视图,下次 loadClass 拿新字节码 | addResourceStore + addReloadNotificationListener 对接 |
与自研方案的差别在于:JCI 把「监听 → 编译 → 重载」的粘合代码封装成了两个现成监听器,业务侧只需组装三个对象。选型上本文沿用 eclipse 适配器(双 JDK 实测可用、错误定位精确,见本专栏 API 详解篇的兼容性矩阵),编译设置固定 1.6/1.6。
三、第一层:FilesystemAlterationMonitor 事件监听
3.1 API 签名(javap 实测)
public class org.apache.commons.jci.monitor.FilesystemAlterationMonitor {
public void addListener(java.io.File, org.apache.commons.jci.monitor.FilesystemAlterationListener);
public void removeListener(org.apache.commons.jci.monitor.FilesystemAlterationListener);
public org.apache.commons.jci.monitor.FilesystemAlterationListener[] getListenersFor(java.io.File);
public void setInterval(long);
public void start();
public void stop();
}
FilesystemAlterationListener 是事件接口,常用回调为 onFileCreate(File)、onFileChange(File)、onFileDelete(File);AbstractFilesystemAlterationListener 提供全部空实现便于按需覆写。用法固定四步:setInterval(毫秒) → addListener(目录, 监听器) → start() → 用完 stop()。
3.2 事件回调实测
验证程序 ProbeHot.java 的 Part1(完整程序见 4.2 节,本节仅摘事件部分):
File evDir = new File(root, "events");
evDir.mkdirs();
FilesystemAlterationMonitor evMonitor = new FilesystemAlterationMonitor();
evMonitor.setInterval(300);
evMonitor.addListener(evDir, new AbstractFilesystemAlterationListener() {
public void onFileCreate(File file) { System.out.println(" EVENT create " + file.getName()); }
public void onFileChange(File file) { System.out.println(" EVENT change " + file.getName()); }
public void onFileDelete(File file) { System.out.println(" EVENT delete " + file.getName()); }
});
evMonitor.start();
File evFile = new File(evDir, "app.txt");
write(evFile, "one");
Thread.sleep(800);
write(evFile, "two");
Thread.sleep(800);
evFile.delete();
Thread.sleep(800);
evMonitor.stop();
运行后的实测回显:
== Part1 FilesystemAlterationMonitor 事件回调 ==
EVENT create app.txt
EVENT change app.txt
EVENT delete app.txt
monitor stopped
验证点:创建、覆写(内容变化即 change)、删除三类事件全部被回调捕获;轮询间隔 300 毫秒下每次操作后 sleep 800 毫秒保证事件被消费。间隔值取舍:300–500 毫秒适合开发调试,1–2 秒适合生产(降低空转 IO),实测该线程是纯轮询、空闲时不占 CPU 大头但会做目录扫描。
💡 技术拓展:文件监听技术路线对比——
路线 机制 依赖 备注 JCI fam 轮询目录快照 零额外依赖 本文所用,跨平台行为一致 WatchService(JDK 7+) 内核 inotify 零依赖 事件类型更细,需自处理溢出 Apache Commons IO FileAlterationMonitor 轮询目录快照 commons-io JCI fam 与其同源同风格 本文采用 JCI fam,理由是它与 JCI 的编译监听器(
CompilingListener)按约定接口直接对接,热更新链路无需粘合代码。
四、第二、三层:自动编译与类热重载全链路
4.1 链路组装原理
三行代码把三层组件接成闭环(组装顺序有讲究,逐条解释):
CompilingListener listener = new CompilingListener(compiler); // 1. 监听器持有编译器
ReloadingClassLoader loader = new ReloadingClassLoader(parent); // 2. 重载器持有父加载器
loader.addResourceStore(listener.getStore()); // 3. 重载器从监听器的 store 取类
listener.addReloadNotificationListener(loader); // 4. 编译完成后通知重载器
monitor.addListener(srcDir, listener); // 5. 监听器观察源码目录
运行时序:目录变更 → fam 回调 CompilingListener.onFileChange → listener 把变更源码编译进自己的 store(eclipse 适配器,用 3 行的同一个 store)→ 编译成功即发出重载通知 → ReloadingClassLoader 收到通知重置类视图 → 业务线程下一次 loadClass("Hello") 拿到新字节码。
4.2 完整实测程序
/opt/jci-demo/src/main/java/ProbeHot.java 全文(Part1 事件演示 + Part2 热更新闭环,可整体复制):
import org.apache.commons.jci.ReloadingClassLoader;
import org.apache.commons.jci.compilers.JavaCompiler;
import org.apache.commons.jci.compilers.JavaCompilerFactory;
import org.apache.commons.jci.listeners.AbstractFilesystemAlterationListener;
import org.apache.commons.jci.listeners.CompilingListener;
import org.apache.commons.jci.monitor.FilesystemAlterationMonitor;
import java.io.File;
import java.io.FileOutputStream;
import java.lang.reflect.Method;
public class ProbeHot {
static void write(File f, String content) throws Exception {
FileOutputStream out = new FileOutputStream(f);
out.write(content.getBytes("UTF-8"));
out.close();
}
static String helloSrc(String tag) {
return "public class Hello {\n"
+ " public String say() {\n"
+ " return \"reloaded-" + tag + "\";\n"
+ " }\n"
+ "}\n";
}
public static void main(String[] args) throws Exception {
File root = new File("/opt/jci-demo/hot-test");
deleteRecursive(root);
new File(root, "src").mkdirs();
System.out.println("== Part1 FilesystemAlterationMonitor 事件回调 ==");
File evDir = new File(root, "events");
evDir.mkdirs();
FilesystemAlterationMonitor evMonitor = new FilesystemAlterationMonitor();
evMonitor.setInterval(300);
evMonitor.addListener(evDir, new AbstractFilesystemAlterationListener() {
public void onFileCreate(File file) { System.out.println(" EVENT create " + file.getName()); }
public void onFileChange(File file) { System.out.println(" EVENT change " + file.getName()); }
public void onFileDelete(File file) { System.out.println(" EVENT delete " + file.getName()); }
});
evMonitor.start();
File evFile = new File(evDir, "app.txt");
write(evFile, "one");
Thread.sleep(800);
write(evFile, "two");
Thread.sleep(800);
evFile.delete();
Thread.sleep(800);
evMonitor.stop();
System.out.println(" monitor stopped");
System.out.println("== Part2 CompilingListener 自动编译热更新 ==");
JavaCompiler compiler = JavaCompilerFactory.getInstance().createCompiler("eclipse");
CompilingListener listener = new CompilingListener(compiler);
ReloadingClassLoader loader = new ReloadingClassLoader(ProbeHot.class.getClassLoader());
loader.addResourceStore(listener.getStore());
listener.addReloadNotificationListener(loader);
File srcDir = new File(root, "src");
FilesystemAlterationMonitor monitor = new FilesystemAlterationMonitor();
monitor.setInterval(300);
monitor.addListener(srcDir, listener);
monitor.start();
write(new File(srcDir, "Hello.java"), helloSrc("v1"));
Thread.sleep(1500);
Class<?> cls = loader.loadClass("Hello");
Method say = cls.getMethod("say");
System.out.println(" first load -> " + say.invoke(cls.getDeclaredConstructor().newInstance()));
write(new File(srcDir, "Hello.java"), helloSrc("v2"));
Thread.sleep(1500);
cls = loader.loadClass("Hello");
say = cls.getMethod("say");
System.out.println(" second load -> " + say.invoke(cls.getDeclaredConstructor().newInstance()));
monitor.stop();
System.out.println(" compilation errors=" + listener.getCompilationResult().getErrors().length);
System.out.println(" reload done");
}
static void deleteRecursive(File f) {
File[] children = f.listFiles();
if (children != null && children.length > 0) {
int i = 0;
do {
if (children[i].isDirectory()) { deleteRecursive(children[i]); } else { children[i].delete(); }
i++;
} while (i < children.length);
}
f.delete();
}
}
4.3 编译与运行实测
cd /opt/jci-demo
mvn -B -q clean compile
mvn -B -q dependency:copy-dependencies -DoutputDirectory=/opt/jci-demo/lib
java -Xms256m -Xmx256m -Dfile.encoding=UTF-8 -cp "target/classes:lib/*" ProbeHot
完整实测回显(与 3.2 节 Part1 部分拼合即程序全貌):
== Part1 FilesystemAlterationMonitor 事件回调 ==
EVENT create app.txt
EVENT change app.txt
EVENT delete app.txt
monitor stopped
== Part2 CompilingListener 自动编译热更新 ==
first load -> reloaded-v1
second load -> reloaded-v2
compilation errors=0
reload done
验证点:写入 helloSrc("v1") 源码后约 1.5 秒(300 毫秒轮询 + 编译耗时余量),loader.loadClass("Hello") 加载到首次编译的类并返回 reloaded-v1;把同一文件覆写为 helloSrc("v2") 后再次 loadClass 拿到同名类的新版本,返回 reloaded-v2——这就是「不重启进程、不改业务代码,改一个文件立刻生效」的热更新闭环。listener.getCompilationResult().getErrors().length 为 0 表示自动编译零错误。
4.4 JVM 参数位置与修改方法
上文 java 命令中 -Xms256m -Xmx256m 即堆参数(初始 256 MB、上限 256 MB,热更新场景建议 256–512 MB 区间)。位置固定在 java 命令之后、主类之前;需要调整时改这一处即可,例如放宽为 -Xms256m -Xmx512m。需要持久生效时写入启动脚本(JAVA_OPTS=-Xms256m -Xmx256m 后以 java $JAVA_OPTS -cp "target/classes:lib/*" ProbeHot 引用)或 systemd 单元文件的 Environment=JAVA_OPTS=-Xms256m -Xmx256m 一行。fam 轮询线程默认随 JVM 常驻,堆内长期持有已编译字节码,256 MB 起步可避免小内存机器上热更新类持续堆积导致 OOM。
五、生产化要点
5.1 线程与生命周期
每个 FilesystemAlterationMonitor 启动一个后台轮询线程,stop() 之后线程退出(实测 Part1 末尾 monitor stopped 后进程内不再产生事件)。生产化检查表:
- 进程退出前必须
stop(),否则轮询线程阻止 JVM 干净退出。 - 一个 monitor 可以挂多个目录监听器(
addListener逐个添加,每个目录一个监听器),不需要多个 monitor 实例。 setInterval必须在start()之前调用;启动后改间隔需 stop 再 start(实测行为)。
5.2 编译失败的处理
源码写坏时 CompilingListener 不会把错误传播成异常,错误沉淀在 listener.getCompilationResult()。实测给坏源码(缺分号)跑同一链路时,loadClass("Hello") 表现为「拿到旧类继续可用」(store 未被污染),同时 getCompilationResult().getErrors().length 为 1、消息含行号列号(Syntax error, insert ";" to complete BlockStatements 这类,与 API 详解篇 3.4 节一致)。生产接入方式:定时或在重载通知前检查 errors 并接入告警,而不是依赖异常。
5.3 与磁盘布局的约定
CompilingListener 观察的是「源码目录」,产物进入它内部的 store(内存),不会写回磁盘;需要产物落盘时改用 FileResourceStore 包装后 addResourceStore(实测 jar 内提供 FileResourceStore,路径即磁盘目录)。演示程序用 src 子目录承载源码只为语义清晰,监听根可以是任意目录;递归子目录变更同样会触发事件(fam 按目录树扫描,实测根目录下新建子目录并写入文件能收到对应回调)。
5.4 双 JDK 与适配器选型
热更新链路的编译环节由适配器决定兼容性:eclipse 适配(本篇实测)在 JDK 8 与 JDK 17 全部可用;javac 适配在 JDK 17 不可用(ClassFormatError,根因见 API 详解篇第五章),热更新场景禁止在 JDK 17 使用 javac 适配;groovy 适配在 JDK 17 需在启动命令追加 --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED(与 4.3 节堆参数写在同一个位置)。
六、常见问题(FAQ)
Q1:改了文件但事件没触发?
按序排查:监听器是否加在目录而不是文件上(addListener 第一参是 File 目录);monitor.start() 是否调用;操作与检查之间是否留出了至少一个轮询周期的间隔(本篇用 sleep 800/1500 毫秒);是否把监听器加到了已 stop 的 monitor 上。
Q2:热重载后拿到的还是旧类?
三点实测经验:类必须通过挂了 store 的 ReloadingClassLoader.loadClass 获取(用应用主加载器加载会命中旧字节码且被缓存);重载通知方向不能接反(必须是 listener.addReloadNotificationListener(loader),把 loader 当通知接收者);确认 getCompilationResult().getErrors() 为 0(编译失败时 store 保持旧内容,这是保护而非故障)。
Q3:轮询间隔设多小合适? 实测 300 毫秒下目录扫描稳定;生产建议 1000–2000 毫秒。间隔过小会放大目录扫描 IO(大目录树尤其明显),过大则重载延迟线性增加——本篇 4.3 节的 sleep 时长即按「间隔 300 毫秒 + 编译余量」设计。
Q4:能否同时监听多个目录、挂多个编译监听器?
可以。addListener 按目录逐个注册,getListenersFor(dir) 可查某目录当前监听器(实测方法存在);多模块多目录热更新即逐目录注册各自的 CompilingListener。注意每个编译监听器各自持有独立 store,挂到同一个 ReloadingClassLoader 时对每个 store 都要 addResourceStore。
Q5:JDK 8 上跑这套链路有什么差异? 无差异,本篇链路(eclipse 适配 + fam + 重载)在 JDK 8 与 JDK 17 实测行为一致;差异只出现在适配器边界(见 5.4 节与 API 详解篇矩阵)。
七、参考链接
- Apache Commons JCI 官方主页(fam 模块说明):https://commons.apache.org/proper/commons-jci/
- Maven 中央仓库 commons-jci-fam 坐标:https://repo1.maven.org/maven2/org/apache/commons/commons-jci-fam/
- Maven 中央仓库 commons-jci-core 坐标:https://repo1.maven.org/maven2/org/apache/commons/commons-jci-core/
- 核心类签名与适配器兼容矩阵:见本专栏《Apache Commons JCI 1.1 编译器适配与 API 详解》
- JDK ClassLoader.defineClass 语义(热重载底层):https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/ClassLoader.html