「Java 进阶之路」系列 Day35
写在前面
前几篇一直在讲垃圾"怎么回收"(标记清除/整理/复制、分代收集、CMS/G1/ZGC),但更前置的问题其实是:JVM怎么判断一个对象是不是垃圾?很多人第一反应是"引用计数",但Java压根没用这个方案;还有人写缓存时纳闷——明明一个Map里还攥着对象的引用,为什么内存紧张时这些对象说没就没了。这篇把这两个问题一次讲清楚。
一、是什么:可达性分析怎么判断一个对象活着还是死了
为什么不用引用计数
最直观的判活方案是给每个对象挂一个计数器,被引用一次加一,引用失效减一,计数器归零就判定为垃圾——这就是引用计数法,简单直接,但有一个硬伤:处理不了循环引用。
如果A和B互相引用,即使外部所有代码都不再持有A、B的引用了,它们两个的计数器依然都是1,永远不会归零,会被误判为"还活着",造成内存泄漏。正是因为这个致命缺陷,主流JVM都没有采用引用计数法(Python等语言用了引用计数,但要靠额外的循环检测器来弥补这个漏洞)。
可达性分析:从"根"出发,摸得到就是活的
Java采用的方案是可达性分析(Reachability Analysis):选定一批特殊的对象作为GC Roots,从这些Roots出发,沿着引用链一路往下摸,凡是能够被摸到(可达)的对象,都判定为存活;摸不到的对象,才判定为垃圾。
上图里,A、B、C、D、E都能从GC Roots出发摸到,判定为存活;F虽然自己引用着E,但没有任何Roots能摸到F本身,F就是垃圾,即使F和E之间有引用关系也无济于事——这正好解决了引用计数法处理不了的循环引用问题:即使F和E互相引用,只要它们整体上摸不到GC Roots,照样会被正确回收。
常见能作为GC Roots的对象包括:各个线程当前调用栈里的局部变量、方法区里的静态变量、被JNI(Native方法)引用的对象、以及一些和类加载、类型相关的固定引用。
二、为什么明明有引用还是被回收:强引用之外还有三种引用
如果只有"有引用就存活、没引用就回收"这一条规则,会有一类场景很难处理:想让JVM在内存充裕时尽量保留一批对象(比如缓存),但内存紧张、快要OOM之前,又希望能优先把它们清掉,而不是眼睁睁看着系统因为死抱着缓存不放而崩溃。
Java用四种强度不同的引用类型来满足这类需求:
| 引用类型 | 什么时候被回收 | 典型场景 |
|---|---|---|
| 强引用(Strong Reference) | 只要还可达,永远不会被回收,哪怕内存溢出也不放 | 日常的new Object()赋值 |
| 软引用(SoftReference) | 内存足够时不回收;内存不足、快要OOM之前会被回收 | 内存敏感的缓存 |
| 弱引用(WeakReference) | 不管内存够不够,只要发生一次GC就会被回收 | ThreadLocal的Entry key |
| 虚引用(PhantomReference) | 形同虚设,随时可能被回收,拿不到引用的对象本身,只用来在对象被回收时收到一个通知 | 配合Cleaner做资源清理跟踪 |
回到开头的疑问:如果缓存的Map里存的是软引用而不是强引用,即使这个引用链条一直存在、对象在可达性分析里依然"可达",只要JVM快要内存不足了,垃圾回收器依然会主动把软引用指向的对象清掉,把内存腾出来——这不是可达性分析出了错,而是软引用本身在设计上就允许JVM在特定条件下"选择性地"把它们视为可以牺牲的对象。
三、怎么用:软引用做缓存,弱引用防内存泄漏
软引用:一个天然的、内存敏感的缓存实现
Map<String,SoftReference<byte[]>>cache=newHashMap<>();// 存入缓存cache.put("key1",newSoftReference<>(loadBigData()));// 取用时要判断是否已经被回收SoftReference<byte[]>ref=cache.get("key1");byte[]data=(ref!=null)?ref.get():null;if(data==null){data=loadBigData();// 已被回收,重新加载cache.put("key1",newSoftReference<>(data));}用软引用包一层,缓存就能自动获得"内存宽裕时尽量保留,内存紧张时自动让路"的能力,不需要手写LRU淘汰逻辑去猜测什么时候该清理——代价是每次取用都要判空重新加载,且软引用对象本身如果引用关系复杂,也会带来一定的额外管理开销,所以规模较小、访问频繁的缓存,用现成的Guava Cache/Caffeine这类工具会比自己手搓软引用更省心。
弱引用:ThreadLocal为什么用它做key
回到Day09讲过的ThreadLocal内存泄漏问题——ThreadLocalMap的Entry对key(也就是ThreadLocal实例本身)用的就是弱引用:只要一次GC发生,只要没有其他强引用指着这个ThreadLocal实例,它就会被回收,Entry里的key会自动变成null。这样设计是为了让"外部代码已经不再持有这个ThreadLocal引用"这件事,能尽快被JVM感知到,而不需要用户显式调用remove()才能让key被回收——但value依然是强引用,不会跟着自动清理,这才是ThreadLocal真正的内存泄漏风险点:key没了、value还在,占着位置出不去,所以remove()该调用还是要调用。
虚引用:连"拿到对象"这件事都不允许
虚引用是四种里最特殊的一种——通过PhantomReference.get()永远返回null,也就是说你压根不能靠虚引用去访问这个对象,它唯一的作用是配合一个引用队列(ReferenceQueue),在对象被垃圾回收器真正回收前的某个时机,往队列里塞一个通知。这给了开发者一个"对象即将/已经被回收"的钩子,常用来替代已经过时的finalize()方法,做一些堆外内存、文件句柄之类的资源清理收尾工作(JDK 9之后的java.lang.ref.Cleaner底层就是基于虚引用实现的)。
四、面试追问
Q1:为什么Java不用引用计数法判断对象是否存活?
引用计数法无法正确处理循环引用的场景:两个互相引用的对象,即使外部已经没有任何代码持有它们,各自的计数器依然不为零,永远不会被判定为垃圾,导致内存泄漏。Java采用的可达性分析是从GC Roots出发摸引用链,天然能正确处理循环引用——只要整体上摸不到GC Roots,即使对象之间互相引用也会被回收。
Q2:哪些对象可以作为GC Roots?
常见的包括:各线程当前调用栈里的局部变量、方法区里的静态变量、被JNI(Native方法)引用的对象,以及一些和类加载、类型信息相关的固定引用。可达性分析就是从这批对象出发,判断哪些对象能被引用链摸到。
Q3:软引用和弱引用的核心区别是什么?
软引用只有在内存不足、即将发生OutOfMemoryError之前才会被回收,内存充裕时会尽量保留,适合做内存敏感的缓存;弱引用则不管内存够不够,只要发生一次垃圾回收就会被清理,生存周期比软引用短得多,典型应用是ThreadLocalMap里Entry对key的引用。
Q4:ThreadLocal为什么会内存泄漏,跟弱引用有什么关系?
ThreadLocalMap的Entry对key(ThreadLocal实例)用的是弱引用,只要没有其他强引用,一次GC后key就会被回收变成null;但Entry对value的引用依然是强引用,不会自动清理。如果线程是线程池里的核心线程、长期存活,且用完ThreadLocal后没有调用remove(),这些key已经为null、但value还在的Entry会一直占用内存,无法被回收,这才是真正的内存泄漏点,不是弱引用本身导致的问题,而是value没有配套清理导致的。
Q5:虚引用有什么实际用途?
虚引用无法通过get()方法获取到对象本身(永远返回null),唯一作用是配合引用队列,在对象被垃圾回收前后收到一个通知。它常被用来替代已经过时、不推荐使用的finalize()方法,跟踪对象生命周期的结束时机,去做一些堆外内存释放、文件句柄关闭之类的资源清理工作,JDK 9之后推荐的java.lang.ref.Cleaner底层就是基于虚引用实现的。
下一篇预告
Day36 结合线上实战场景,讲讲OOM排查和连接池问题该怎么定位——从这几篇讲的GC原理落地到真实的故障排查思路上。