Apache Commons JCI 1.1 热更新与监听进阶实战详解

基于 Apache Commons JCI 1.1 与 fam 模块构建目录级热更新体系,逐层实测 FilesystemAlterationMonitor 事件回调、CompilingListener 自动编译与 ReloadingClassLoader 类重载全链路,给出源码目录约定、轮询间隔、JVM 参数与生产化要点,全部已在 Ubuntu 22.04 实测。

Apache Commons JCIApache Commons JCI2026/10/15

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) + ReloadingListenerstore 内容变化后重置类视图,下次 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-ioJCI 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 后进程内不再产生事件)。生产化检查表:

  1. 进程退出前必须 stop(),否则轮询线程阻止 JVM 干净退出。
  2. 一个 monitor 可以挂多个目录监听器(addListener 逐个添加,每个目录一个监听器),不需要多个 monitor 实例。
  3. 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 详解篇矩阵)。

七、参考链接

  1. Apache Commons JCI 官方主页(fam 模块说明):https://commons.apache.org/proper/commons-jci/
  2. Maven 中央仓库 commons-jci-fam 坐标:https://repo1.maven.org/maven2/org/apache/commons/commons-jci-fam/
  3. Maven 中央仓库 commons-jci-core 坐标:https://repo1.maven.org/maven2/org/apache/commons/commons-jci-core/
  4. 核心类签名与适配器兼容矩阵:见本专栏《Apache Commons JCI 1.1 编译器适配与 API 详解》
  5. JDK ClassLoader.defineClass 语义(热重载底层):https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/ClassLoader.html