核心问题是原生线程未终止导致类加载器被钉住、元数据无法卸载;需通过jmap/jcmd验证泄漏,重置tccl、合理配置线程池,并确保g1gc启用类卸载、清除class强引用及模块显式生命周期管理。

核心问题不在“加载模块”,而在“线程没死、类加载器钉住、元数据卸不掉”。原生线程(如自定义线程、线程池中的 Worker 线程)若持有上下文类加载器(TCCL)或强引用了动态加载的 Class/ClassLoader,就会阻止整个类加载器树被回收,导致它加载的所有动态类(包括代理类、反射存根、脚本类等)长期驻留 Metaspace。
确认是否是原生线程拖住类加载器
先验证再动手:
- 执行 jmap -clstats
:重点看 sun.misc.Launcher$AppClassLoader或自定义类加载器实例数。若数量稳定但单个加载了数千类,说明不是泄漏;若出现大量jdk.internal.loader.ClassLoaders$AppClassLoader@xxx或匿名类加载器且持续新增,大概率是线程反复创建新加载器 - 执行 jcmd
VM.native_memory summary scale=MB :查Class模块占用是否超 300MB,同时Internal或Thread模块也异常偏高 → 暗示线程与类加载器耦合过深 - 加参数启动:-XX:+TraceClassLoading -XX:+TraceClassUnloading(仅测试环境),观察日志中
Loaded频繁但Unloaded几乎为 0,且每批加载都紧随线程创建动作(如日志里出现Thread-123启动后立刻加载ModuleA$$EnhancerByCGLIB$$)
切断线程与类加载器的隐式绑定
原生线程默认继承父线程 TCCL,而模块加载常通过 Thread.currentThread().getContextClassLoader() 获取当前环境加载器。一旦线程复用(如线程池),这个 TCCL 就可能长期指向已废弃的模块类加载器。
- 在线程任务执行前显式重置 TCCL:
Thread.currentThread().setContextClassLoader(null)或切换为系统类加载器ClassLoader.getSystemClassLoader() - 若必须传递模块类加载器,改用局部变量传参,避免设为线程上下文;模块卸载时,确保所有相关线程已退出或完成 reset
- 检查线程池配置:禁用
allowCoreThreadTimeOut = true以外的长期存活策略;对按需加载模块的场景,优先使用短生命周期的Executors.newCachedThreadPool()并配合shutdownNow(),而非固定大小线程池
让动态类可卸载的硬性前提
即使线程清理干净,类仍卸不掉,往往卡在 JVM 卸载条件未满足。必须同步保障以下三点:
- GC 回收器支持类卸载:生产强制使用 -XX:+UseG1GC(JDK8u40+ 默认开启类卸载),禁用 Parallel GC(不支持卸载);CMS 需额外配 -XX:+CMSClassUnloadingEnabled
- 启用类卸载开关:G1 下 -XX:+ClassUnloading 默认开启,但建议显式加上;若用旧版 JDK,务必确认该参数生效
-
清除所有 Class 强引用:检查模块代码中是否将
Class对象缓存在静态 Map、ConcurrentHashMap 或 Guava Cache 中;如有,改用WeakReference<class></class>包装,或在模块卸载回调中主动remove
模块加载/卸载流程加固建议
不要依赖“自动清理”,要设计显式生命周期:
- 每个动态模块使用独立的
URLClassLoader实例,并在模块 stop() 时调用close()(JDK7+)或reset()(需自行实现资源清理) - 模块内避免使用
Class.forName("xxx", true, cl)的主动初始化,改用cl.loadClass("xxx")延迟初始化,减少无谓元数据生成 - 对高频反射调用(如模块间字段赋值),禁用膨胀:-Dsun.reflect.noInflation=true,避免每次触发都生成新的
GeneratedMethodAccessor











