信号量本身不依赖类加载器,但嵌入热更新模块时,其阻塞线程的TCCL会强引用旧ClassLoader,阻碍元空间卸载;需shutdown线程池、避免静态持有、隔离类加载边界并验证卸载。

信号量本身不直接依赖类加载器,但若它被嵌入在热更新模块中(比如作为插件内部状态组件、或由动态加载类构造并持有),其挂起线程的上下文可能隐式绑定旧 ClassLoader,进而阻碍类元数据卸载。真正的问题不是“信号量挂起”,而是挂起期间线程未清理、静态引用未释放、或 TCCL 未重置,导致旧 ClassLoader 无法被 GC 回收——元数据就一直留在元空间里。
确保挂起线程不拖住旧类加载器
当信号量(如 Semaphore 或自定义同步器)处于 acquire() 阻塞状态时,调用线程会进入 WAITING 状态,但该线程的 Thread Context ClassLoader(TCCL)仍指向旧 ClassLoader。只要线程存活,TCCL 就构成一条强引用链。
- 热更新前,对所有可能持有信号量的线程池执行 shutdownNow(),并等待 awaitTermination() 完成
- 避免在动态加载类中启动长期存活的守护线程(如定时轮询、后台监听),这类线程默认继承当前 TCCL,且不易被感知
- 若必须保留阻塞逻辑,改用 Phaser 或 CountDownLatch 并配合显式生命周期管理:在模块卸载时调用 forceRelease() 类似逻辑(需自行封装)
切断信号量关联对象的静态/长生命周期引用
信号量常被用作共享资源控制器,容易被存入 static 缓存、Spring Bean 单例、或日志/监控代理中——这些位置一旦持有由旧 ClassLoader 加载的类实例,就会间接持住整个 ClassLoader。
- 检查所有 static final Semaphore 声明,确认其创建不在动态模块内;如确需动态初始化,改用实例变量 + 显式 close() 清理
- 避免将信号量与业务类实例(如 PluginImpl)强绑定,尤其不要在信号量 permit 释放回调中引用该实例的方法或字段
- 使用 WeakReference 包装信号量关联的上下文对象(如持有 PluginClassLoader 的元信息),防止 GC 时因引用残留而失败
隔离信号量的类加载边界
如果信号量逻辑本身也被热更新(比如自定义 AbstractQueuedSynchronizer 子类),必须确保它不跨 ClassLoader 边界被复用,否则新旧版本类型冲突会导致 NoClassDefFoundError 或静默行为异常。
- 信号量相关类(包括其内部 helper、listener、wrapper)应全部打包进插件 JAR,不依赖外部 classpath 提供
- 禁止在插件代码中硬编码引用系统类加载器可见的同步工具类(如 com.yourcompany.util.SemaphoreWrapper),否则会隐式委托加载,破坏隔离
- 加载后仅通过接口(如 ISemaphoreControl)交互,接口定义放在基础模块(由 AppClassLoader 加载),实现类由 URLClassLoader 加载
运行时验证元数据是否真正释放
不能只看信号量是否“释放了 permit”,要确认旧 ClassLoader 是否已从内存中消失。
- 启用 -XX:+TraceClassUnloading,观察热更新后是否有 “unloading class xxx” 日志;若无,说明 ClassLoader 仍存活
- 触发一次 Full GC 后,用 jcmd <pid> VM.native_memory summary 查看 metaspace 使用量是否回落
- 抓取堆转储(jmap -dump:format=b,file=heap.hprof <pid>),在 MAT 中筛选 java.util.concurrent.Semaphore 实例,再反查其 GC Roots,确认是否还连着某个 WebAppClassLoader 或 RestartClassLoader

















