频繁Full GC主因是代码内存泄漏:静态集合无清理、ThreadLocal未remove、资源未关闭、监听器未注销。排查用MAT分析Dominator Tree和GC Roots,修复需加淘汰策略、finally清理、try-with-resources及主动解绑。

Full GC 频繁触发,往往不是 JVM 参数配得不够大,而是业务代码在“悄悄吃内存”。真正压垮老年代的,常是几行看似无害的代码——它们让对象长期驻留、无法回收,最终逼 JVM 不得不执行全局停顿。
静态集合无节制累积
静态容器(如 static Map、static List)是内存泄漏高发区。一旦被用作缓存但没设上限或淘汰策略,数据只增不减,全部进入老年代。
- 典型写法:
private static final Map<String, User> CACHE = new HashMap<>();,每次登录都 put 却从不 remove - 排查重点:MAT 中查看 Dominator Tree,若发现
java.util.HashMap或java.util.ArrayList占比异常高,且引用链指向某个 static 字段,基本可锁定 - 修复建议:改用
ConcurrentHashMap+ 定时清理,或直接换 Caffeine 等带 LRU/LFU 的本地缓存
ThreadLocal 使用后未清理
ThreadLocal 本身不泄漏,但线程复用场景下(如 Tomcat 线程池、自定义线程池),若 set 后没调用 remove(),其 value 就会随线程长期存活,且强引用阻止 GC。
- 常见于上下文传递:如用 ThreadLocal 存放用户 token、事务 ID、日志 MDC
- 排查线索:堆转储中搜索
java.lang.ThreadLocal$ThreadLocalMap,看 entry.value 是否大量堆积且类型集中 - 规范写法:务必在 finally 块或 filter/interceptor 结束处调用
threadLocal.remove()
资源未关闭导致关联对象滞留
IO 流、数据库连接、HTTP 客户端、Redis 连接等资源未 close,不仅占用句柄,更关键的是它们背后持有的缓冲区、连接池对象、监听器等,往往体积大、生命周期长,直接卡在老年代。
立即学习“Java免费学习笔记(深入)”;
- 典型反例:try-without-resources、忘记 close()、异常路径遗漏 close
- 排查方式:jstack 查看线程栈,确认是否有大量 WAITING 状态的连接等待;MAT 中检查
SunNioChannel、PooledConnection等类实例数是否持续增长 - 强制保障:优先使用 try-with-resources;Spring 管理的资源(如 JdbcTemplate)确保配置了正确的连接池 max-active/max-idle
监听器/回调注册后未注销
事件驱动架构中,监听器(如 Spring ApplicationListener、Guava EventBus、自定义观察者)一旦注册,就与发布者形成强引用链。若监听器对象本身持有大量数据或未及时解绑,整个引用链都会留在堆里。
- 高频场景:动态加载模块、插件化系统、WebSocket 连接管理中反复 addListener 却不 remove
- 排查技巧:MAT 的 “Path to GC Roots” 功能,选一个疑似泄漏对象,看它是否被某个 listener 实例间接持有
- 防御性设计:监听器实现 Disposable 接口,配合 @PreDestroy 或 shutdown hook 主动注销


















