本文目录#

引言#

Java 的垃圾收集机制并不能杜绝所有内存问题。业务代码持有不必要的引用、第三方库缓存实现不当、类加载器泄漏等,都可能导致堆内存或元空间持续膨胀。本文梳理实际排查流程,并总结稳定的排查工具链。

常见泄漏类型#

长生命周期集合#

  • 使用 static 集合缓存用户会话、配置,忘记清理;
  • ThreadLocal 未显式 remove,导致线程池中线程长期持有对象引用;
  • LRU 缓存实现未限制容量或缺少淘汰策略;

事件监听与回调#

  • Observer/Listener 模式未正确取消注册;
  • CompletableFuture 回调链持有外部对象;
  • RxJava、Project Reactor 中的 Flux 订阅未解除。

类加载器泄漏#

  • Web 容器热部署后,旧 ClassLoader 引用残留;
  • JDBC DriverManager 未卸载驱动;
  • java.util.logging 自定义 Handler 未关闭。

排查流程#

  1. 复现与量化:使用 Grafana 监控堆使用趋势,观察 Full GC 后是否回落;
  2. 获取堆转储jcmd <pid> GC.heap_dump filename-XX:+HeapDumpOnOutOfMemoryError
  3. 分析工具
    • Eclipse MAT:Leak Suspect、Dominators Tree;
    • VisualVM / JMC:实时监控线程与内存;
    • jmap -histo:查看对象实例统计;
  4. 定位 GC Roots:在 MAT 中选取可疑对象,查看保留路径(Path to GC Roots),识别是否被缓存或线程持有;
  5. 小心 false positive:软引用、弱引用要区分 GC 可达性。

案例分享#

监听器忘记注销#

电商风控系统上线后,发现 JVM 堆在 48 小时内从 4GB 涨到 12GB。堆转储显示 com.foo.security.AlertListener 实例 20 万个占用大量内存。原因是租户动态创建监听器但未移除,通过实现 AutoCloseable 并在租户过期时统一 close 解决。

ThreadLocal 泄漏#

支付业务中使用 ThreadLocal<SimpleDateFormat>,线程池复用导致对象无法回收。改为 DateTimeFormatter 或在 finallyremove(),外加 -XX:+DisableExplicitGC 避免业务错误调用 System.gc()

类加载器堆积#

Tomcat 热部署后 Full GC 无法回收旧 ClassLoader。在渲染模板中引用第三方库缓存的 Class<?> 静态字段,导致类加载器链条未断。解决方式:

  • 使用 WeakReference<Class<?>> 存储;
  • 注册 ServletContextListener,销毁时执行清理;
  • 对 JDBC 驱动调用 DriverManager.deregisterDriver

预防策略#

  • 使用 Caffeine 等具备到期策略的缓存,设置最大容量;
  • 通过 Metrics 记录对象池大小以及 ThreadLocal key 数量;
  • 对外部资源(Netty、Unsafe 分配的 off-heap)及时 release;
  • 线上启用 JFR 定期采样内存事件;
  • 在 CI 阶段对关键接口进行 jmap -histo diff,监控激增类型。

总结#

JVM 内存泄漏排查需要工具与流程配合:先量化、再 dump、再分析路径。通过建立“资源生命周期即代码”的意识,加上自动化监控,可以把泄漏风险控制在可观测范围内。

参考资料#


本作品系原创,采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,转载请注明出处。