在 JVM 的内存管理中,"判定对象是否存活" 是 GC 的核心前提 ------ 如果把 GC 比作 JVM 的 "垃圾清洁工",那可达性分析算法就是 "清洁工的判定标准",引用类型就是 "给对象贴的不同标签":有的对象(强引用)再占内存也不能清,有的对象(软引用)内存不够时再清,有的对象(弱引用)一打扫就清。我早年做电商缓存系统时,因不懂软引用,用强引用存储商品图片缓存,导致堆内存持续上涨触发 OOM;后来改用软引用,内存不足时 GC 自动回收缓存,系统稳定性大幅提升。读懂可达性分析和引用类型,是从 "被动应对 GC" 到 "主动利用 GC" 的关键,也是写出低内存泄漏代码的核心。

一、可达性分析算法
JVM 判定对象是否存活,不靠 "对象是否被使用" 这种模糊的标准,而是靠可达性分析算法------ 这是一种 "根溯源" 的判定逻辑,也是现代 JVM(HotSpot、J9 等)的标配算法。
1. 核心逻辑
我常把可达性分析比作 "树的枝干":
- GC Roots(GC 根节点):是树的 "主根",绝对不会被 GC 回收;
- 对象引用链:是从主根延伸出的 "枝干",对象是 "叶子";
- 判定规则:如果一个对象能通过任意引用链连接到 GC Roots,就是 "可达对象"(存活);如果没有任何引用链连接到 GC Roots,就是 "不可达对象"(标记为可回收)。
简单说:可达 = 存活,不可达 = 可回收(注意:不可达不代表立刻回收,还需经过 "标记 - 清除" 等 GC 流程)。
2. GC Roots 的具体类型
GC Roots 不是 "随便的对象",必须是 JVM 认定的 "绝对存活" 的对象,主要包含以下 4 类(新手记这 4 类就够):
| GC Roots 类型 | 典型例子 | 实战说明 |
|---|---|---|
| 虚拟机栈中引用的对象 | 方法的局部变量(如User user = new User()) |
线程正在执行的方法中的对象,绝对存活 |
| 静态属性引用的对象 | public static User INSTANCE = new User() |
类的静态变量,类加载后一直存活 |
| 常量引用的对象 | public static final User CONST = new User() |
运行时常量池中的常量对象 |
| 本地方法栈中 Native 方法引用的对象 | JNI 调用的 C++ 方法中的对象 | 如调用System.currentTimeMillis()的底层对象 |
3. 实战场景
很多内存泄漏的本质,是对象 "看似无用,实则可达"(伪不可达):比如一个订单对象,业务逻辑已经处理完,但还被某个线程的局部变量(虚拟机栈)引用,GC 判定它 "可达",不会回收,最终堆积导致堆 OOM。
java
import java.util.ArrayList;
import java.util.List;
// 内存泄漏:无用对象仍被GC Roots(静态集合)引用,判定为可达,无法回收
public class GCRootsLeakDemo {
// 静态集合(GC Roots):持有大量无用User对象的引用
private static final List<User> USER_LIST = new ArrayList<>();
static class User {
private String id;
public User(String id) { this.id = id; }
}
public static void main(String[] args) {
// 循环添加100万个User对象到静态集合
for (int i = 0; i < 1000000; i++) {
USER_LIST.add(new User("user-" + i));
}
// 业务逻辑处理完,理论上这些User对象已无用,但仍被USER_LIST引用
// GC Roots(静态属性)→ USER_LIST → User对象,引用链可达,无法回收
System.out.println("添加完成,User对象数量:" + USER_LIST.size());
// 即使手动置空局部变量,静态集合的引用仍存在
User temp = null;
}
}
这个例子中,User 对象明明已经无用,但因被静态集合(GC Roots)引用,可达性分析判定为 "可达",GC 不会回收,最终导致堆内存溢出。我早年做用户会话系统时,就是因为用静态 Map 存储会话对象却不清理,触发了这种泄漏 ------ 解决方法是:业务完成后,手动从静态集合中移除对象(USER_LIST.clear()),切断引用链,让对象变为 "不可达"。
二、引用类型
JDK 1.2 后,Java 将引用分为 4 种类型,核心目的是让程序员能主动控制对象的回收时机------ 不同引用类型的对象,即使都 "可达",GC 的回收策略也完全不同。这是新手和资深开发者的核心区别:新手只会用强引用,资深开发者会根据场景选择合适的引用类型,最大化利用内存。
1. 强引用(Strong Reference)
这是最常见的引用类型,也是默认的引用方式 ------ 只要对象被强引用关联,GC 就绝对不会回收它,哪怕内存不足抛出 OOM,也不会回收强引用对象。
核心特点:
- 语法:
User user = new User()(普通的对象赋值); - GC 策略:永不回收,内存不足时抛 OOM;
- 适用场景:业务核心对象(如订单、用户信息),必须保证存活的对象。
实战踩坑:强引用导致内存泄漏
新手最易踩的坑:用强引用存储缓存对象,即使缓存过期,GC 也无法回收,最终导致 OOM。
java
// 错误:用强引用存储图片缓存,过期后仍无法回收
private Map<String, byte[]> imageCache = new HashMap<>();
// 存储缓存(强引用)
public void putImage(String key, byte[] data) {
imageCache.put(key, data);
}
// 即使业务上标记过期,强引用仍存在,GC无法回收
public void expireImage(String key) {
// 仅标记过期,未移除引用,对象仍可达
expiredKeys.add(key);
}
解决方法:要么手动移除引用(imageCache.remove(key)),要么改用软引用 / 弱引用。
2. 软引用(Soft Reference)
软引用是 "弹性引用"------ 如果对象只有软引用关联,内存充足时 GC 不回收,内存不足时 GC 会主动回收。这是做 "内存敏感型缓存" 的最佳选择(如图片缓存、临时数据缓存)。
核心特点:
- 语法:需借助
java.lang.ref.SoftReference类; - GC 策略:内存充足→不回收,内存不足→回收;
- 适用场景:缓存对象(允许内存不足时丢失,不影响核心业务)。
软引用实现图片缓存(避免 OOM)
import java.lang.ref.SoftReference;
import java.util.HashMap;
import java.util.Map;
// 正确:用软引用存储图片缓存,内存不足时自动回收
public class SoftReferenceCache {
// 缓存容器:key=图片ID,value=软引用(指向图片字节数组)
private Map<String, SoftReference<byte[]>> imageCache = new HashMap<>();
// 存储缓存:封装为软引用
public void putImage(String imageId, byte[] imageData) {
imageCache.put(imageId, new SoftReference<>(imageData));
}
// 获取缓存:检查软引用是否有效(对象未被回收)
public byte[] getImage(String imageId) {
SoftReference<byte[]> softRef = imageCache.get(imageId);
if (softRef != null) {
return softRef.get(); // 返回对象,若已回收则返回null
}
return null; // 缓存已被回收,需重新加载
}
}
这个例子中,当堆内存不足时,GC 会自动回收软引用指向的图片字节数组,避免 OOM;内存充足时,缓存又能正常使用。我做电商 APP 的商品图片缓存时,就用这种方式,既保证了图片加载速度,又避免了内存溢出。
3. 弱引用(Weak Reference)
弱引用是 "临时引用"------ 如果对象只有弱引用关联,只要 GC 触发(不管内存是否充足),就会立刻回收该对象。弱引用的生命周期比软引用更短,适合存储 "临时使用、随时可回收" 的对象。
核心特点:
- 语法:需借助
java.lang.ref.WeakReference类; - GC 策略:只要 GC 扫描到,就回收(无论内存是否充足);
- 适用场景:临时数据(如 ThreadLocal 的 key、缓存的临时中间结果)。
弱引用实现 ThreadLocal 的 key(核心源码逻辑)
ThreadLocal 能实现 "线程私有变量",底层就是用弱引用存储 key------ 避免 ThreadLocal 对象被回收后,key 仍强引用导致 Entry 泄漏:
java
// ThreadLocal的核心源码(简化)
public class ThreadLocal<T> {
// ThreadLocal的key是弱引用
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
// key是ThreadLocal对象,用弱引用封装
Entry(ThreadLocal<?> k, Object v) {
super(k); // 弱引用指向ThreadLocal
value = v;
}
}
}
}
如果 key 用强引用,当 ThreadLocal 对象被threadLocal = null置空后,key 仍强引用 ThreadLocal,导致 Entry 无法回收 ------ 这就是 ThreadLocal 内存泄漏的核心原因(即使 key 是弱引用,value 仍需手动移除)。
弱引用存储临时数据
java
import java.lang.ref.WeakReference;
// 弱引用存储临时数据,GC触发即回收
public class WeakReferenceDemo {
public static void main(String[] args) {
// 步骤1:创建对象,用弱引用关联
Object tempData = new Object();
WeakReference<Object> weakRef = new WeakReference<>(tempData);
// 步骤2:切断强引用,仅保留弱引用
tempData = null;
// 步骤3:触发GC(手动调用,仅用于测试)
System.gc();
System.runFinalization();
// 步骤4:检查弱引用是否已回收(返回null)
System.out.println("弱引用对象是否回收:" + (weakRef.get() == null)); // true
}
}
运行结果为true,说明 GC 触发后,弱引用关联的对象被立刻回收 ------ 这是弱引用的核心特性。
4. 虚引用(Phantom Reference)
虚引用是 "最弱的引用"------ 它的唯一作用是跟踪对象的回收状态 ,无法通过虚引用获取对象实例,也无法阻止对象被回收。虚引用必须和ReferenceQueue配合使用,当对象被 GC 回收时,虚引用会被加入队列,程序员可通过队列感知 "对象已回收",做一些清理工作(如释放直接内存)。
核心特点:
- 语法:需借助
java.lang.ref.PhantomReference类,且必须指定ReferenceQueue; - GC 策略:随时回收,无法通过
get()获取对象(get()永远返回 null); - 适用场景:跟踪对象回收、释放堆外内存(如 NIO 的直接内存)。
虚引用跟踪对象回收,释放直接内存
java
import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
// 虚引用跟踪直接内存对象的回收,手动释放堆外内存
public class PhantomReferenceDemo {
// 引用队列:存储被回收的虚引用
private static final ReferenceQueue<ByteBuffer> QUEUE = new ReferenceQueue<>();
public static void main(String[] args) throws InterruptedException {
// 步骤1:分配直接内存(堆外内存)
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024)
.order(ByteOrder.nativeOrder());
// 步骤2:创建虚引用,关联直接内存对象和队列
PhantomReference<ByteBuffer> phantomRef = new PhantomReference<>(directBuffer, QUEUE);
// 步骤3:切断强引用,仅保留虚引用
directBuffer = null;
// 步骤4:触发GC,回收对象
System.gc();
System.runFinalization();
// 步骤5:从队列中获取虚引用,感知对象已回收,释放直接内存
Reference<? extends ByteBuffer> ref = QUEUE.remove();
if (ref != null) {
System.out.println("对象已被GC回收,开始释放直接内存");
// 此处可调用底层方法释放直接内存(简化示例,仅打印)
ref.clear();
}
}
}
虚引用的核心价值不是 "使用对象",而是 "感知对象的回收时机"------NIO 的直接内存回收、自定义类加载器的资源清理,都会用到虚引用。
5. 四种引用类型对比表(核心总结)
| 引用类型 | 语法特征 | GC 回收策略 | 核心场景 | 实战避坑点 |
|---|---|---|---|---|
| 强引用 | 普通赋值(obj = new Obj) |
永不回收,内存不足抛 OOM | 核心业务对象 | 避免用于缓存,易导致泄漏 |
| 软引用 | SoftReference |
内存充足不回收,不足时回收 | 内存敏感型缓存(图片) | 需检查get()是否为 null |
| 弱引用 | WeakReference |
GC 触发即回收(无论内存是否充足) | 临时数据、ThreadLocal key | 不能存储核心数据,易丢失 |
| 虚引用 | PhantomReference |
随时回收,get()永远返回 null |
跟踪回收、释放堆外内存 | 必须配合 ReferenceQueue 使用 |
三、引用类型的选择原则
二十余年的实战经验,我总结出选择引用类型的 3 个核心原则:
- 核心数据用强引用:订单、用户、支付等核心业务对象,必须用强引用,保证绝对存活;
- 缓存数据用软引用:图片、临时报表等非核心缓存,用软引用,内存不足时自动回收,避免 OOM;
- 临时数据用弱引用:ThreadLocal key、中间计算结果等,用弱引用,GC 触发即回收,减少内存占用;
- 堆外内存用虚引用:NIO 直接内存、JNI 对象,用虚引用跟踪回收,手动释放堆外资源。
最后小结
核心回顾
- 可达性分析算法:以 GC Roots 为起点,引用链可达的对象存活,不可达的标记为可回收;内存泄漏的核心是 "无用对象仍可达";
- 引用类型决定 GC 策略:强引用永不回收,软引用内存不足时回收,弱引用 GC 触发即回收,虚引用仅跟踪回收;
- 实战选择:核心对象用强引用,缓存用软引用,临时数据用弱引用,堆外内存用虚引用。