热部署时旧classloader无法回收的主因是残留线程持有其引用;须显式设置并重置contextclassloader、主动停止线程、谨慎使用threadlocal、监控卸载后回收状态。
热部署时旧 classloader 无法回收,最常见也最隐蔽的原因之一就是线程未主动终止——只要有一个线程还在运行,且其上下文类加载器(contextclassloader)或栈帧中持有了旧 classloader 的引用,整个加载器及其加载的所有类就都无法被 gc 回收。
识别并清理残留线程
自定义 ClassLoader 加载的业务代码若启动了后台线程(如定时任务、监听器、Netty EventLoopGroup、Spring 的 TaskExecutor),这些线程默认会继承当前线程的 ContextClassLoader。一旦模块卸载,线程仍在跑,就等于“钉住”了旧加载器。
- 模块启动时,显式设置线程的 ContextClassLoader:
thread.setContextClassLoader(currentModuleClassLoader) - 模块卸载前,必须主动停止所有相关线程:调用
ScheduledExecutorService.shutdownNow()、EventLoopGroup.shutdownGracefully()等,并等待终止完成(如awaitTermination(10, TimeUnit.SECONDS)) - 对 ThreadLocal 使用要格外谨慎:避免在业务类中静态持有
ThreadLocal<somebean></somebean>;若必须使用,卸载时调用threadLocal.remove(),最好配合ThreadLocal.withInitial()+ 弱引用包装
重置线程上下文类加载器
主线程(如 Tomcat 的 ContainerBackgroundProcessor)、异步回调线程、第三方 SDK 内部线程等,可能在模块生命周期外保留旧 ContextClassLoader,造成隐式强引用。
- 模块加载完成时,用
Thread.currentThread().setContextClassLoader(currentLoader) - 模块卸载入口处,立即将当前线程的 ContextClassLoader 切回原始值(启动前保存一份原始引用)
- 对线程池(如
ThreadPoolTaskExecutor),需在销毁前统一执行setThreadFactory()替换为不绑定 ClassLoader 的工厂,或覆写beforeExecute()动态切换上下文
监控与兜底手段
仅靠人工清理容易遗漏,建议加入运行时验证机制。
- 卸载后触发一次
System.gc()(仅作提示,不保证执行),再通过ManagementFactory.getMemoryMXBean().getObjectPendingFinalizationCount()或 JMX 查看是否有待终结对象 - 用
WeakReference<classloader></classloader>包装加载器实例,稍后尝试 get();若返回 null,说明已可回收;否则说明仍有强引用,可配合 MAT 分析堆转储定位线程栈 - 启用 JVM 参数
-XX:+TraceClassUnloading -verbose:class,观察日志中是否出现Unloading class xxx from ...,缺失即代表卸载失败
线程问题不是“有没有启动”,而是“有没有真正结束”。每次部署新版本前,把线程当作资源来管理——创建即登记,销毁必注销。











