根本原因是JVM依据GC Roots可达性判定对象存活,而非代码中是否显式引用;GC Roots包括虚拟机栈局部变量、静态字段、常量池对象、JNI全局引用及活跃线程,只要存在任意引用链连通即不回收。

对象没被显式引用,却无法被 GC 回收,根本原因在于 JVM 并不看“你写没写引用”,而是严格依据 GC Roots 的可达性网络 判断存活。只要从 GC Roots 出发,存在任意一条引用链能触达该对象,它就不是垃圾——哪怕你在代码里早已把它设为 null 或退出了作用域。
一、真正决定生死的是 GC Roots 引用链,不是代码表象
GC 不分析你的源码逻辑,只做一件事:从一组硬性定义的“根对象”出发,沿着所有强引用逐层遍历。只要路径不断,对象就“活着”。这些 GC Roots 包括:
- 虚拟机栈中正在使用的局部变量(包括方法参数、临时变量)
- 方法区中类的静态字段所引用的对象
- 方法区中常量池里字符串常量、类引用等指向的对象
- 本地方法栈(JNI)中正在使用的全局引用(
jobject) - 活跃线程本身及其持有的锁、待处理的监控器等
注意:一个对象即使局部变量已消失、IDE 显示“无引用”,只要它被某个静态 Map 存着,或被线程池里的 ThreadLocal 持有,就仍通过 Roots 可达。
二、四大常见“隐形锚点”让对象意外存活
很多“以为已释放”的对象,其实被以下四类结构悄悄固定在内存中:
-
ThreadLocal 残留值:线程池复用下,
ThreadLocalMap的 entry.key 是弱引用,但 value 是强引用;若未调用remove(),业务对象会随线程长期驻留 -
静态集合缓存失控:如
private static Map<String, BigData> cache = new HashMap<>();,put 后不清理、无过期策略,对象永远可达 -
非静态内部类隐式持外层引用:成员内部类或匿名类编译后自带
this$0字段,若注册给 EventBus、定时器、监听器等长生命周期对象,整个外层实例被拖住 -
JNI 全局引用未释放:C/C++ 层用
NewGlobalRef()创建的引用,Java 层无自动管理机制,必须配对调用DeleteGlobalRef()
三、GC 不是“立刻响应”,回收还受分代与时机约束
即使对象真正不可达,也不代表马上消失:
- Minor GC 只清理年轻代,老年代中的不可达对象要等 Major GC 或 Full GC 才处理
- ZGC/Shenandoah 等并发收集器标记阶段与应用线程并行,对象可能在标记中途被新引用“复活”
- 对象需经历两次标记:第一次判定不可达,第二次检查是否重写了
finalize()(已废弃但逻辑仍存在),仅当两次都确认才进入回收队列 - 系统未触发 GC 或堆内存尚有余量时,JVM 可能延迟回收,
System.gc()仅是建议,不保证执行
四、验证可达性网络的实用方法
不能靠猜,要用工具看清真实引用链:
- 用
jmap -histo:live <pid>查看实时存活对象数量与大小 - 用
jmap -dump:format=b,file=heap.hprof <pid>生成堆快照,再用 Eclipse MAT 分析“支配树(Dominator Tree)”和“到 GC Roots 的最短路径” - 开启 GC 日志:
-Xlog:gc*,gc+ref=debug,观察对象是否出现在 “unloading classes” 或 “weak/soft reference processing” 阶段 - 结合
jstack <pid>检查线程状态,确认是否有线程正持有可疑对象(如 WAITING 状态的线程引用了缓存)

















