引用计数的死穴
判断对象死活,最直觉的方案是引用计数:被引用一次计数加一,归零即死。Python 和 COM 都在用,但 JVM 不用——除了每次赋值都要维护计数器的开销,更致命的是循环引用:A 引用 B、B 引用 A,两个对象再无外人引用,计数却永远是 1,永远回收不掉。
Node a = new Node(); // a.count = 1
Node b = new Node(); // b.count = 1
a.next = b; b.next = a; // 互相引用,count 各自变成 2
a = null; b = null; // 外部引用断开,count 降回 1
// 引用计数视角:两个对象都还活着
// 实际情况:谁也访问不到它们了——内存泄漏可达性分析
JVM 的解法是换思路:不数引用,而是选定一批GC Roots,从它们出发沿引用链往下摸,摸得到的算活人,摸不到的判死刑。GC Roots 的花名册要记牢:栈帧局部变量表(正在执行的方法里握着的)、静态变量、常量引用、JNI 引用、活跃线程本身。排查内存泄漏时 MAT 里"path to GC Roots"就是在逆着这条链找"谁把对象拴住了"。
不可达也不等于立刻回收:对象还有一次自我拯救的机会——如果重写了 finalize() 且从未执行过,会被丢进 F-Queue 低优先级执行,执行时把自己重新挂到引用链上就复活了(机会只有一次)。这机制设计得鸡肋,JDK 9 起已标记废弃,知道即可,别指望它。
四种引用各守一岗
Object strong = new Object(); // 强:GC 饿死也不收
SoftReference<Object> soft = new SoftReference<>(new Object());
// 软:内存不足才收——适合图片缓存这类"有最好,没有也能活"
WeakReference<Object> weak = new WeakReference<>(new Object());
// 弱:下次 GC 必收——ThreadLocalMap 的 key 就是它
PhantomReference<Object> phantom = new PhantomReference<>(obj, queue);
// 虚:形同虚设,只为回收时收到通知——DirectByteBuffer 靠它清理堆外内存四档引用本质是给 GC 递了四张不同态度的纸条:强引用说"我在它就在",软引用说"实在缺内存再拿走",弱引用说"下一次 GC 随便处置",虚引用说"收走时吱一声就行"。日常代码 99% 用强引用,剩下 1% 的灵活性全靠后三种。
ThreadLocal 泄漏的真相
ThreadLocalMap 的 entry 里,key 是弱引用,value 是强引用。key 被 GC 后变 null,但 value 还被 entry 拴着——只要线程不死(线程池的线程恰恰长命百岁),这条 stale entry 就一直占着内存。JVM 自己会在 get/set 时顺手清理部分 stale entry,但清理时机不可靠,唯一的正解是 try/finally 里手动 remove()——用完即拆,别指望房子自己打扫。
根节点枚举为什么必须停顿
摸清 GC Roots 要求"引用关系静止不动",所以根节点枚举这一步必须 Stop The World——好在 HotSpot 用 OopMap 提前记录了哪里是引用,这一步快到微秒级;再配合安全点机制,只在"大家都停下来不添乱"的时刻做枚举。停顿能压多短,取决于后续标记、回收阶段有多少能并发——这正是下一篇各路回收算法与收集器表演的舞台。
评论 (0)