对象访问与引用类型:句柄/直接指针以及强、软、弱、虚引用(054)

JVM原理与调优约 13 分钟阅读7,158 字符
知识库文档 55 / 251

一、两个看似独立却紧密关联的话题

本篇涵盖两个主题:对象访问方式(句柄 vs 直接指针)和 Java 引用类型(强、软、弱、虚)。它们看似独立——一个讨论栈上的引用变量如何定位到堆上的对象,另一个讨论 GC 如何根据引用强度决定回收策略——但本质上都在回答同一个问题:"JVM 中的'引用'到底意味着什么?"

理解对象访问方式,能让你明白为什么 obj.getClass() 可以正常工作、为什么 GC 移动对象时不需要更新栈上的所有引用;理解引用类型,能让你在面试中自信地回答"如何防止 OOM""WeakHashMap 的原理是什么""虚引用有什么实际用途"等经典问题。

二、对象访问方式:句柄 vs 直接指针

在 Java 代码中,Object obj = new Object(); 这行代码中的 obj 是一个引用类型变量,存储在虚拟机栈的局部变量表中。但 obj 到底如何"指向"堆中的对象实例?JVM 规范并没有规定具体的实现方式,目前主流 JVM(以 HotSpot 为代表)有两种方案:

句柄访问(Handle Access)

在句柄访问方式中,栈上的引用变量指向一个句柄池(Handle Pool)中的句柄,句柄中包含了两个指针:一个指向堆中的对象实例数据,另一个指向方法区中的对象类型数据。布局如下:

code
栈(引用) → 句柄池(句柄) → 堆(对象实例数据)
                         → 方法区(对象类型数据)

优点:当 GC 移动对象时(比如标记-整理算法中的滑动整理),只需要修改句柄中的实例数据指针,栈上的引用变量不需要改变。这降低了 GC 移动对象时更新引用的开销。

缺点:每次访问对象都需要经过一次间接寻址——先访问句柄池,再通过句柄访问对象。这增加了访问开销,比直接指针多一次指针跳转。

直接指针访问(Direct Pointer Access)

在直接指针访问方式中,栈上的引用变量直接指向堆中的对象实例数据,而对象实例数据中包含了指向方法区对象类型数据的 Klass Pointer。这是 HotSpot 采用的方式。布局如下:

code
栈(引用) → 堆(对象实例数据) → 方法区(对象类型数据)

优点:访问对象时只需要一次指针跳转,速度更快。HotSpot 选择直接指针访问,正是因为对象访问是热路径上的高频操作,节省一次指针跳转带来的性能收益相当可观。

缺点:GC 移动对象时,必须更新所有指向该对象的引用(包括栈上的和堆中其他对象里的引用),比句柄访问方式需要更新更多的引用位置。

文章配图
文章配图

HotSpot 的选择

HotSpot 选择了直接指针访问,但通过OopMap(Ordinary Object Pointer Map)数据结构来弥补 GC 移动对象时需要更新引用的问题。OopMap 记录了栈帧中哪些位置是引用类型,GC 在移动对象时,通过 OopMap 精确定位需要更新的引用位置,避免遍历整个栈帧。这种"以空间换时间"的设计让 HotSpot 在对象访问性能和 GC 更新成本之间找到了平衡。

三、Java 的四种引用类型

Java 从 JDK 1.2 开始,将引用划分为四种强度递减的类型:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。这四种引用类型的核心区别在于 GC 对待它们的不同态度——引用强度越弱,GC 回收的意愿越强。

强引用(Strong Reference)

强引用是最常见的引用类型,也是默认的引用方式。Object obj = new Object() 中的 obj 就是一个强引用。只要强引用还存在,GC 永远不会回收被引用的对象——即使发生 OOM,JVM 宁愿抛出异常也不会回收强引用指向的对象。这是 Java 内存安全的基本保证:只要你还持有引用,对象就不会被 GC 回收。

软引用(Soft Reference)

软引用通过 java.lang.ref.SoftReference 类实现,用于描述"有用但非必需"的对象。当系统内存充足时,软引用指向的对象不会被回收;但当系统即将发生 OOM 时(即堆内存紧张到无法满足新的分配请求),GC 会回收所有软引用指向的对象。

软引用的典型应用场景是内存敏感的高速缓存(Memory-Sensitive Cache)。比如图片缓存:用 SoftReference<Bitmap> 保存图片数据,当内存充足时直接从缓存中获取,避免重复加载;当内存紧张时,GC 自动回收缓存中的图片,释放内存给更重要的对象。Guava 的 CacheBuilder.softValues() 就是基于这个机制。

弱引用(Weak Reference)

弱引用通过 java.lang.ref.WeakReference 类实现,强度比软引用更弱。当 GC 工作时,无论当前内存是否充足,只要发现了弱引用指向的对象,就会回收它。弱引用的生命周期只持续到下一次 GC 之前。

弱引用的典型应用场景是 **WeakHashMap**。WeakHashMap 中的 Key 使用弱引用持有——当某个 Key 不再被外部强引用时,下一次 GC 就会回收这个 Key,WeakHashMap 中对应的 Entry 也会被自动移除。这与传统的 HashMap 形成对比:HashMap 中强引用持有的 Key 即使外部不再使用,也不会被 GC 回收,可能导致内存泄漏。另一个经典场景是 ThreadLocal:ThreadLocalMap 中的 Entry 使用弱引用持有 ThreadLocal 对象,防止 ThreadLocal 对象在线程池环境中因为强引用无法回收而导致内存泄漏。

虚引用(Phantom Reference)

虚引用通过 java.lang.ref.PhantomReference 类实现,是所有引用类型中最弱的一种。与其他引用类型不同,虚引用完全不影响对象的生命周期,也无法通过虚引用来获取对象实例——PhantomReference.get() 方法始终返回 null。虚引用的唯一用途是:在对象被 GC 回收时,收到一个系统通知。

虚引用必须与引用队列(ReferenceQueue)配合使用。当 GC 确定一个对象需要被回收时,如果发现它还有一个虚引用指向它,就会在回收对象之前,将这个虚引用加入到与它关联的引用队列中。程序可以通过轮询引用队列来得知哪些对象即将被回收,从而执行一些清理操作。虚引用的典型应用场景是堆外内存的释放管理——比如 NIO 中的 DirectByteBuffer,它使用虚引用(Cleaner 继承自 PhantomReference)来跟踪 DirectByteBuffer 对象的生命周期,当对象被 GC 回收时自动释放其占用的堆外内存。

文章配图
文章配图

引用队列(ReferenceQueue)

引用队列是四种引用类型(除强引用外)的一个重要配套机制。当创建软引用、弱引用或虚引用时,可以关联一个 ReferenceQueue。当 GC 确定要回收这些引用指向的对象时,在回收对象之前(或之后,取决于引用类型),JVM 会将这个引用对象加入到关联的引用队列中。程序可以通过 ReferenceQueue.poll() 或 ReferenceQueue.remove() 方法来获取这些引用对象,从而知道哪些对象即将被回收,并执行相应的清理逻辑。

文章配图
文章配图

四、四种引用类型对比总结

引用类型 回收时机 是否可通过引用获取对象 典型用途
强引用 永不回收(只要引用存在) 是 默认的引用方式,普通对象引用
软引用 内存即将 OOM 时回收 是 内存敏感的高速缓存
弱引用 下一次 GC 时回收(无论内存是否充足) 是 WeakHashMap、ThreadLocal
虚引用 对象被 GC 回收时(用于通知) 否(get() 始终返回 null) 堆外内存释放管理(DirectByteBuffer)

文章配图
文章配图

五、面试追问链

从"JVM 中栈上的引用变量如何定位到堆中的对象"这个基础问题出发,逐层追问:句柄访问和直接指针访问的区别是什么各有什么优缺点、HotSpot 为什么选择直接指针访问、Java 有哪四种引用类型各自的回收时机是什么、软引用和弱引用的区别是什么什么场景下应该用软引用而不是弱引用、虚引用的 get() 方法为什么始终返回 null 虚引用的实际用途是什么、引用队列 ReferenceQueue 的作用是什么它和四种引用类型如何配合使用、WeakHashMap 的原理是什么为什么它的 Key 在 GC 后会自动消失、ThreadLocal 为什么使用弱引用如果改成强引用会有什么后果、DirectByteBuffer 如何利用虚引用管理堆外内存。

文章配图
文章配图

延伸阅读

  • java.lang.ref 包(SoftReference、WeakReference、PhantomReference、ReferenceQueue)的 JDK 源码,理解引用对象的内部实现
  • Guava CacheBuilder 的软引用/弱引用缓存策略,理解引用类型在实际框架中的应用
  • 「04 JVM 原理与调优」系列下一篇(055)将深入「类加载生命周期:加载、验证、准备、解析、初始化、卸载」

问答(不计入阅读时长)

Q1:HotSpot 为什么选择直接指针访问而不是句柄访问?这带来的最大代价是什么?HotSpot 又是如何弥补的?

A:HotSpot 选择直接指针访问的核心原因是性能——对象访问是 JVM 中最频繁的操作之一,直接指针只需要一次指针跳转(栈引用→对象),而句柄访问需要两次(栈引用→句柄→对象),在热路径上节省一次指针跳转带来的总体性能收益相当可观。最大的代价是 GC 移动对象时需要更新所有指向该对象的引用,包括栈上的和堆中其他对象里的引用。HotSpot 通过 OopMap(Ordinary Object Pointer Map)来弥补——OopMap 精确记录了每个栈帧中哪些位置是引用类型,GC 移动对象时只需更新 OopMap 标记的位置,不需要遍历整个栈帧。这本质上是"用空间换时间":用 OopMap 的存储开销换取对象访问的频率优势。

Q2:软引用和弱引用的核心区别是什么?如果让你设计一个图片缓存,你会用软引用还是弱引用?为什么?

A:核心区别在于 GC 回收的触发条件。软引用只在内存即将 OOM 时才回收,弱引用在下一次 GC 时无论内存是否充足都会回收。对于图片缓存,应该用软引用。因为缓存的目的是"空间换时间"——如果内存充足,缓存应该保留来提升性能;只有当内存紧张时,才应该牺牲缓存来保护核心业务。弱引用会导致每次 GC 都清空缓存,相当于缓存完全失效,失去了缓存的意义。Guava 的 CacheBuilder.softValues() 就是基于这个设计选择。但要注意:在高并发场景下,软引用可能导致频繁的 Full GC(因为 Full GC 是回收软引用的前提),此时可以考虑用弱引用配合显式的缓存大小控制(如 LRU 驱逐策略)。

Q3:虚引用的**get()**方法为什么始终返回**null**?如果虚引用不能获取对象,那它还有什么用?

A:get() 返回 null 是虚引用的设计选择——虚引用的目的不是"持有"对象,而是"跟踪"对象的回收。如果 get() 能返回对象,那虚引用本身就变成了一个强引用一样的存在,会阻止对象被 GC 回收,这就和虚引用"不影响对象生命周期"的定位矛盾了。虚引用的真正用途是:当 GC 确定一个对象需要被回收时,在回收之前将虚引用加入 ReferenceQueue,程序从队列中取出虚引用后,可以执行一些清理操作(如释放堆外内存)。典型例子是 NIO 的 DirectByteBuffer,它使用 Cleaner(继承自 PhantomReference)来跟踪对象,当 DirectByteBuffer 对象被 GC 回收时,Cleaner 自动释放 DirectByteBuffer 持有的堆外内存。

Q4:**WeakHashMap**中 Key 为什么用弱引用?如果改成强引用会有什么问题?

A:WeakHashMap 的 Key 使用弱引用,是为了实现"当 Key 不再被外部使用时,对应的 Entry 自动移除"的语义。如果改成强引用,就会出现经典的内存泄漏场景:Key 对象即使在外部已经不再使用(外部引用置为 null),WeakHashMap 内部的强引用仍然持有 Key,导致 Key 和关联的 Value 永远无法被 GC 回收。这在缓存场景中尤其危险——缓存中的过期数据会一直占用内存,直到 OOM。WeakHashMap 通过弱引用 + ReferenceQueue 实现自动清理:当 Key 被 GC 回收后,弱引用进入 ReferenceQueue,WeakHashMap 在每次操作时检查队列并移除对应的 Entry。

Q5:**ThreadLocal**的**ThreadLocalMap**中 Entry 为什么用弱引用持有**ThreadLocal**对象?如果改成强引用,在线程池环境中会有什么后果?

A:ThreadLocalMap 中的 Entry 使用弱引用持有 ThreadLocal 对象(Key),是为了防止在线程池环境中 ThreadLocal 对象无法被回收。在线程池中,线程是复用的,不会销毁。如果 Entry 对 ThreadLocal 是强引用,那么即使业务代码中 ThreadLocal 对象已经不再被引用(外部引用置为 null),线程的 ThreadLocalMap 中仍然持有这个 ThreadLocal 的强引用,导致 ThreadLocal 对象和它关联的 Value 都无法被 GC 回收,造成内存泄漏。使用弱引用后,当 ThreadLocal 对象的外部强引用消失,下一次 GC 就会回收它,ThreadLocalMap 的 Entry 在后续操作中会被清理。但要注意:弱引用只解决了 Key 的回收问题,Value 仍然可能泄漏——因为 Entry 中的 Value 是强引用,如果 Key 被回收后 Entry 没有被及时清理,Value 仍然无法回收。所以最佳实践是:使用完 ThreadLocal 后必须调用 remove() 方法。

Q6:引用队列(ReferenceQueue)和引用类型是如何配合工作的?软引用、弱引用、虚引用入队的时机有何不同?

A:引用队列的工作机制是:创建引用对象时关联一个 ReferenceQueue,当 GC 确定要回收引用指向的对象时,JVM 会自动将这个引用对象加入 ReferenceQueue。三种引用类型的入队时机不同:软引用和弱引用是在对象被回收之后入队(此时 get() 返回 null,但引用对象本身还在队列中);虚引用是在对象被回收之前入队(get() 始终返回 null,入队只表示对象即将被回收)。程序可以通过 poll()(非阻塞)或 remove()(阻塞)从队列中取出引用对象,然后执行清理操作。这个机制让程序能够"感知"到 GC 的回收行为,从而在不直接干预 GC 的前提下,对资源释放进行精细控制。

Q7:为什么说"强引用只要存在,GC 就永远不会回收对象"?如果 JVM 真的内存不足到无法分配,也不能回收强引用对象吗?

A:是的,即使堆内存不足到无法分配新对象,JVM 也不会回收强引用指向的对象。JVM 的 GC 只回收"不可达"对象——即从 GC Roots 出发无法通过引用链到达的对象。强引用指向的对象从 GC Roots 出发是可达的,因此永远不会被 GC 判定为垃圾。如果堆内存真正不足,JVM 会先尝试 GC 回收其他不可达对象,如果 GC 后仍然无法分配,就会抛出 OutOfMemoryError,而不是去回收强引用对象。这个设计保证了 Java 内存模型的可预测性——程序员可以确信:只要持有引用,对象就安全存活。但这也意味着:如果程序员不小心持有不再需要的对象引用(如静态集合中未清理的元素),就会导致内存泄漏。

Q8:如果让你设计一个需要跟踪大量对象回收事件并执行清理的框架,你会选择虚引用还是弱引用 + ReferenceQueue 的组合?为什么?

A:应该选择虚引用 + ReferenceQueue。原因是:弱引用在对象回收后入队,此时如果程序从队列中取出弱引用并尝试访问对象,get() 返回 null,但对象已经被回收了,无法再执行任何清理操作(比如释放对象持有的堆外内存需要知道内存地址,但这个地址已经随对象回收而失效)。虚引用在对象回收之前入队,虽然 get() 也返回 null,但此时对象尚未被真正回收,程序可以通过虚引用关联的额外信息(如 Cleaner 中保存的堆外内存地址)来执行清理操作。这是 NIO 的 DirectByteBuffer 选择 Cleaner(继承自 PhantomReference)而不是 WeakReference 的根本原因。弱引用 + ReferenceQueue 更适合"感知对象已被回收并移除缓存条目"这类不需要访问回收对象的场景。