高频八股 · JVM(8)

高频八股 · JVM(8):一个对象是怎么被判"死刑"的?

一、引用计数 vs 可达性分析

1.1 引用计数

思路 :给每个对象配一个计数器,有多少引用指向我,计数就是几,归零即垃圾。

优点 :实现简单,实时性好,归零立刻能收。

致命缺点 :循环引用。

java 复制代码
Node a = new Node();
Node b = new Node();
a.next = b;    // b 的计数 = 1
b.next = a;    // a 的计数 = 1
a = null;
b = null;      // 两个对象计数都还是 1,永远收不掉

外面已经访问不到它们了,但计数不归零,永远泄漏。所以 HotSpot 没用它------用引用计数的是 Python、ObjC(ARC 是编译器插桩,不是运行时维护)。

1.2 可达性分析

思路 :从一组叫 GC Roots 的根对象出发,顺着引用链遍历。能走到的就是活的,走不到的就是垃圾。

复制代码
GC Roots ──→ A ──→ B ──→ C    活着
                └─→ D

        E ──→ F                谁都走不到 → 垃圾

天然免疫循环引用------判断依据是"从根能不能走到",不是"有几个人指着我"。

对比项 引用计数 可达性分析
依据 有多少引用指向我 从根能否走到我
循环引用 无法回收 不受影响
时机 实时 需要 GC 周期
谁在用 Python、ObjC HotSpot

1.3 为什么必须 STW

遍历引用图时,如果用户线程还在改引用,就可能把刚挂到根上的对象误判为死 ,直接误杀。所以遍历期间必须冻结所有用户线程,这个暂停就是 STW。后面所有回收器的演进,本质上都在压短这段时间。

二、GC Roots 有哪几类

根的本质不是"重要",而是"它的存活不由 GC 决定"------它在栈上或类级别,GC 管不着,只能当起点。

根类型 具体是什么 生命周期由谁决定
虚拟机栈 正在执行的方法的局部变量、参数 方法是否返回
静态变量 static 字段指向的对象 类是否卸载
常量 static final、字符串常量池的引用 类是否卸载
JNI 引用 native 方法持有的 Java 对象 native 调用是否结束

补充清单(《深入理解 Java 虚拟机》里的,答题时能加分):系统类加载器加载的 Class 对象 、常驻异常对象 (如 NPE、OOM 预创建)、被同步锁持有的对象 、JMXBean / JVMTI 回调。

实用判断法 :问一句"是谁在引用它"------引用者是栈上的变量,它就是根;是堆里的对象,就继续往上追。

三、不可达 ≠ 立刻死:两次标记

对象被判不可达后,还要过一次审判:

复制代码
不可达 → 第一次标记
          ↓
    有 finalize() 且从未执行过?
    ├─ 否 → 直接回收
    └─ 是 → 进 F-Queue,执行 finalize()
              ├─ 重新连上引用链 → 逃脱
              └─ 没连上 → 第二次标记 → 回收

三个要点:只有重写了 finalize() 且没执行过的对象才有资格进 F-Queue ;它只会被执行一次;执行线程优先级很低,不保证及时。

java 复制代码
public class SaveMe {
    static SaveMe SAVE_ME;

    @Override
    protected void finalize() {
        SAVE_ME = this;          // 重新挂回引用链
    }

    public static void main(String[] args) throws Exception {
        SAVE_ME = new SaveMe();
        SAVE_ME = null;
        System.gc(); Thread.sleep(500);
        System.out.println(SAVE_ME == null ? "死了" : "活了");   // 活了

        SAVE_ME = null;
        System.gc(); Thread.sleep(500);
        System.out.println(SAVE_ME == null ? "死了" : "活了");   // 死了
    }
}

第一次活,第二次死 ------因为 finalize() 只执行一次,第二次直接跳过 F-Queue。

3.1 finalize 为什么被废弃

三个理由:执行时机不确定 、单线程执行拖慢 GC 、异常被吞掉。

  • JDK 9 标记 @Deprecated;JDK 18 标记为 deprecated for removal(JEP 421)。
  • 替代方案:关资源用 try-with-resources ,回收时清理本地资源用 java.lang.ref.Cleaner------它本质就是"虚引用 + 引用队列"的封装。

四、四种引用:GC 认不认这条边

引用 就是内存里存放对象地址的那几个字节,出现在栈帧槽位或对象字段里。软、弱、虚引用则是堆里的包装对象 (Reference 子类),内部 referent 字段指向目标,JVM 对这个字段特殊处理。

类型 get() GC 是否认可这条边 回收时机
强引用 对象 认可 不可达后的下一次 GC
SoftReference 对象或 null 内存够时认可,不够时不认可 内存不足时的 GC
WeakReference 对象或 null 从不认可 下一次 GC
PhantomReference 永远 null 从不认可,回收后入队 下一次 GC

四种引用的真正区别,在于 GC 认不认这条引用边。

4.1 强引用:宁可 OOM 也不收

java 复制代码
Object o = new Object();

只要这条链还在,内存不够了也宁可抛 OOM 。所谓"内存泄漏",本质就是临时对象被一条强引用链悄悄拴住了 ------静态集合、缓存 Map、没 remove() 的 ThreadLocal,都是这个套路。

4.2 软引用:内存不够才收

java 复制代码
SoftReference<byte[]> ref = new SoftReference<>(new byte[10 * 1024 * 1024]);
byte[] data = ref.get();   // 内存够 → 拿到;不够 → null

设计意图是做缓存。但实际项目别这么写:

  1. 回收策略不受你控制 。HotSpot 按"空闲堆 × -XX:SoftRefLRUPolicyMSPerMB"(默认 1000,即每 MB 空闲堆换 1 秒)算存活时间,一次 Full GC 后空闲堆变小,缓存会成片消失;
  2. 你想按 LRU 淘汰热点,它按内存水位一把清空。

业务缓存老实用 Caffeine / Guava(强引用 + 容量淘汰)。

4.3 弱引用:下次 GC 必收

不管内存够不够,只要 GC 就回收。

最经典的应用是 ThreadLocal 的 Entry:

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);       // key 弱引用
        value = v;      // value 强引用
    }
}

为什么用弱引用 :ThreadLocal 对象通常被线程的 ThreadLocalMap 持有,如果 key 是强引用,线程不死它就永远不回收。

但弱引用带来新问题 :key 被回收后变成 null,value 还是强引用 ,这条 Entry 变成 key=null 的脏 Entry,访问不到却占着内存。

完整因果链:key 弱引用被回收 → 留下 null key 的 Entry → value 强引用无法释放 → 泄漏。

所以 ThreadLocal 用完必须 remove();源码里 set/get/remove 会顺手清一部分,但不保证清干净。

其他用法:WeakHashMap、Guava 的 weakKeys()、各种监听器注册表。

4.4 虚引用:只为"收尸通知"

三个特点:get() 永远返回 null ;必须配合 ReferenceQueue ;唯一作用是对象被回收时收到通知。

java 复制代码
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> ref = new PhantomReference<>(new Object(), queue);
System.gc();
queue.remove(1000);      // 收到通知
ref.get();               // 永远 null

典型场景:DirectByteBuffer 回收堆外内存。

allocateDirect() 的内存不在堆里,GC 管不着。JDK 的做法是挂一个 Cleaner(PhantomReference 的子类),等 DirectByteBuffer 对象被回收时虚引用入队,Cleaner 线程收到通知,调用 Unsafe.freeMemory() 把堆外内存还回去。

这也解释了"堆内存很健康,但进程 RSS 一直涨"------堆外内存的释放依赖 GC 触发,对象不收,堆外就不还。

五、两个容易答错的细节

1. "不可达"和"被回收"不是同一时刻。 判断不可达只在 GC 跑可达性分析时才做,所以从"没人引用"到"真正回收"中间至少隔着一次 GC;软/弱/虚引用全都是"下次 GC 才回收"。

2. 引用队列是"被动通知",get() 是"主动查询"。 对象被回收时,JVM 把 Reference 对象本身 塞进队列。虚引用 get() 永远 null,只能靠队列;WeakHashMap 也是靠队列知道"哪条 Entry 该删了"。

所以"弱引用会自动清理"这话不准确------弱引用只负责让 key 变 null,清理动作是 WeakHashMap 自己做的。这是个很好的加分点。

六、高频面试题速答

Q1:怎么判断对象是垃圾?

引用计数(归零即垃圾)和可达性分析(从 GC Roots 走不到即垃圾)。HotSpot 用后者,因为前者解决不了循环引用。

Q2:GC Roots 有哪几类?

虚拟机栈中的局部变量和参数、静态变量、常量、JNI 引用。扩展还有系统类加载器的 Class 对象、常驻异常对象、同步锁持有对象等。

Q3:可达性分析为什么要 STW?

遍历期间用户线程改引用,可能把活对象误判为死对象而误杀,所以必须冻结引用图。

Q4:不可达一定被回收吗?

不一定。重写了 finalize() 且没执行过的会进 F-Queue,能重新连上引用链就活下来;否则直接回收。

Q5:finalize 能救几次?

一次。它只会被执行一次,第二次不可达就直接回收。

Q6:四种引用区别?

强引用永不回收(宁可 OOM);软引用内存不足时回收;弱引用下次 GC 必回收;虚引用只用于回收通知,get() 永远 null。

Q7:ThreadLocal 为什么内存泄漏?

Entry 的 key 是弱引用、value 是强引用。key 被回收后变 null,value 仍被强引用,形成访问不到也释放不了的脏 Entry。用完要 remove()。

Q8:虚引用能做什么?

只做"回收通知"。典型是 DirectByteBuffer 通过 Cleaner 释放堆外内存。

七、一句话总结

HotSpot 用可达性分析判死,从 GC Roots 走不到就是垃圾;但"不可达"只是"可以死了",还要过 finalize 这一关,而且只有一次机会。四种引用的区别,本质是 GC 认不认这条引用边------强引用永不放手,软引用看内存脸色,弱引用下次 GC 就走,虚引用只负责发讣告。

下一期讲回收的"手法":三种回收算法怎么取舍、8:1:1 到底是怎么来的 、跨代引用怎么用卡表 + 写屏障解决,顺带把三色标记的两个经典问题讲清楚。


(本系列持续更新中,共 10 期,敬请关注~)

相关推荐
养军博客1 小时前
spirngSecurity 鉴权教程02
java·springsecurity
CoderYanger1 小时前
Java EE 进阶:5. Spring IoC&DI(第二弹):Spring IoC
java·开发语言·spring boot·spring·面试·职场和发展·java-ee
Rain的Java大神之路2 小时前
面试被LeetCode 27(移除元素)问懵?别再用两层 for 循环了:一道“简单题”吃透双指针原地删除,附面试加分话术 + 11 个避坑清单
后端·leetcode·面试
悟天特斯2 小时前
安防一体化:从“各管各的“到“全局联动“
java·大数据·网络·人工智能·物联网
70后写java的老码农2 小时前
Spring Boot 3.x + ShardingSphere 5.x + MyBatis-Plus 3.x:订单分库分表实战(含两个踩坑记录)
java
霸道流氓气质2 小时前
AI 翻译插件:翻译记忆级联复用、术语混合检索与结构化质量门禁的 Vue3 + Spring AI 生产级实战
java·人工智能·spring
斯维赤3 小时前
OCR到底是接入第三方API 还是自己封装?
java·ocr
利威尔xu3 小时前
Go IM 源码实战(11):token 只是个 HASH,多端互踢的顺序错了就踢错人
后端·微服务·面试·架构·golang
码智社3 小时前
Redis 哨兵模式详解:从原理到生产实践
java·redis