【JVM】四大引用类型分析

【JVM】四大引用类型分析

【一】引用分类

Java 从 JDK1.2 开始引入引用分级机制 ,目的是精细化控制对象回收时机,解决传统强引用无法灵活释放内存、缓存溢出、大对象内存泄漏等问题。

共分为 4 类,强度从高到低:强引用 > 软引用 > 弱引用 > 虚引用

【1】强引用 Strong Reference(默认引用)

(1)定义与作用

代码中最普通的对象赋值,只要存在强引用链,GC 永远不会回收该对象,OOM 也不会释放。

  • 作用:正常业务对象持有,保证核心业务对象存活;
  • 回收规则:只有所有强引用断开(置为 null、跳出作用域),GC 才会回收

(2)代码案例

java 复制代码
public class StrongRefDemo {
    public static void main(String[] args) {
        // 强引用:obj 持有对象
        Object obj = new byte[1024 * 1024 * 10];
        System.gc();
        // GC 后对象依旧存在,强引用不会被回收
        System.out.println(obj);

        // 断开强引用链
        obj = null;
        System.gc();
        // 无任何强引用,下次GC直接回收堆内存
    }
}

(3)强引用的场景

只要一条可达的强引用链指向堆对象,就是强引用,GC 不会回收,下面分大类列举所有开发中会遇到的强引用场景,并配示例。

1-普通局部变量引用(方法内)

方法中直接 new 赋值给变量,作用域内全程强引用。

java 复制代码
void test() {
    // str 是强引用
    String str = new String("demo");
    byte[] big = new byte[1024*1024];
}
  • 生命周期:方法执行期间有效;
  • 释放时机:方法执行完毕,局部变量栈帧销毁,引用消失;
  • 手动提前释放:str = null;
2-成员变量(实例变量)

对象内部属性持有另一个对象,只要实例本身可达,属性就是强引用。

java 复制代码
class User {
    // 实例成员,强引用
    List<Order> orderList = new ArrayList<>();
}

User u = new User(); // u可达 → orderList 强引用存活

释放条件:u = null,整个 User 对象不可达,内部成员引用才失效。

3-静态变量 /static 静态集合(最容易内存泄漏)

static 属于类,类加载后常驻方法区,只要类不卸载,引用永久有效。

java 复制代码
// 全局静态缓存,强引用永久持有
public static List<Object> CACHE = new ArrayList<>();
// 静态对象
public static BigData DATA = new BigData();

风险:放入大量大对象,程序运行期间永远不会回收,极易 OOM。

4-数组中存储对象

数组元素对内部对象都是强引用。

java 复制代码
Object[] arr = new Object[10];
arr[0] = new byte[1024*1024]; // 数组持有,强引用

只有数组本身失去所有强引用,内部元素才会被释放。

5-容器集合(ArrayList/HashMap/HashSet 等)

所有普通集合内部存储都是强引用,key、value 全部强持有。

java 复制代码
HashMap<String, Object> map = new HashMap<>();
map.put("k", new LargeImg()); // key、value 均为强引用

对比:WeakHashMap 只有 key 是弱引用,value 依旧是强引用。

6-ThreadLocal 存储的值(极易内存泄漏)

ThreadLocalMap 的 key 是弱引用,但 value 是强引用

java 复制代码
ThreadLocal<BigFile> tl = new ThreadLocal<>();
tl.set(new BigFile());

线程池场景下线程复用,不调用 tl.remove(),value 会一直强引用常驻堆。

7-方法参数、返回值传递

对象作为入参、返回值,调用栈持有强引用。

java 复制代码
// arg 是强引用
void func(Object arg) {}

Object getObj() {
    return new Object(); // 返回后接收变量持有强引用
}
8-内部类 / 匿名内部类 / Lambda 持有外部对象

非静态内部类会隐式持有外部类实例的强引用;Lambda 捕获外部变量也会生成强引用。

java 复制代码
class Outer {
    List list = new ArrayList();
    // 非静态内部类隐式持有 Outer.this 强引用
    class Inner {}
}

// Lambda 捕获外层变量,产生强引用
Runnable run = () -> System.out.println(list.size());

容易出现:外部类本应回收,但内部类 / Lambda 还在运行,导致外部类无法释放。

9-循环引用(A 持有 B,B 持有 A)

两个对象互相持有对方,属于双向强引用链。

现代 CMS/G1/ZGC 可达性分析可识别并回收,但仍会增加 GC 开销。

复制代码
class A { B b; }
class B { A a; }

A a = new A();
B b = new B();
a.b = b;
b.a = a;
a = null;
b = null;
// 无外部强引用,GC 可回收
10-本地变量缓存、临时引用赋值

多次赋值只要变量还在,就是强引用:

java 复制代码
Object o1 = new Object();
Object o2 = o1; // o2 也是同对象的强引用
11-JNI / 本地方法持有 Java 对象

native 代码通过 JNI 保存全局引用,会长期强持有 Java 堆对象,不主动释放会内存泄漏。

12-常量池、字符串常量引用
java 复制代码
// 常量池常驻,强引用永久存在
String s = "abc";

如果把常量字符串作为 WeakHashMap 的 key,常量池一直持有 key,key 永远不会被回收,弱引用失效。

(4)快速总结:哪些属于强引用

  1. 普通局部变量、实例成员变量
  2. static 静态变量、静态集合
  3. 数组、HashMap/ArrayList 等普通容器的 key/value
  4. ThreadLocal 的 value
  5. 内部类、匿名类、Lambda 捕获外部对象
  6. 方法参数、返回值接收对象
  7. 对象互相循环引用
  8. JNI 全局引用、字符串常量池对象

(5)开发注意事项

  1. 静态集合极易内存泄漏
    static List<Object> cache = new ArrayList<>() 全局静态集合持有对象,程序不退出永远不回收,大量缓存直接 OOM;
  2. 局部变量及时置空:方法内超大数组、大文件对象,使用完手动 xxx=null 缩短引用生命周期;
  3. 避免长生命周期对象持有短期大对象(比如全局缓存持有图片、文件字节数组);
  4. ThreadLocal 用完必须 remove(),否则线程复用导致强引用常驻堆。

【2】软引用 SoftReference(缓存专用)

(1)定义与作用

强度次于强引用,内存充足时 GC 不回收;内存不足、即将发生 OOM 前,JVM 会自动回收软引用对象

搭配 ReferenceQueue 可监听回收事件。

  • 核心场景:内存缓存(图片缓存、本地资源缓存、本地二级缓存);
  • 回收规则:堆空闲内存充足 → 保留;堆内存紧张 → 全部回收。

(2)代码案例

java 复制代码
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;

public class SoftRefDemo {
    public static void main(String[] args) {
        // 引用队列,对象被回收后会入队
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
        // 软引用包装大数组
        SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024 * 20], queue);

        System.out.println("内存充足,获取对象:" + softRef.get());
        // 疯狂分配内存,挤压堆空间触发软引用回收
        List<byte[]> list = new ArrayList<>();
        while (true) {
            list.add(new byte[1024 * 1024 * 10]);
        }
        // 内存耗尽前 softRef.get() 返回 null,对象已被回收
    }
}

(3)开发注意事项

  1. 做本地缓存优先用 SoftReference,替代单纯 HashMap,自动控内存;
  2. 必须配合 ReferenceQueue 清理失效软引用,否则 Reference 对象本身堆积内存泄漏;
  3. 高并发缓存场景建议封装工具类,定期清理队列中已回收的软引用 Key;
  4. 不能用于必须常驻的业务数据(内存紧张会丢失缓存,业务需做好缓存击穿兜底);
  5. JVM 参数可调整软引用回收策略:-XX:SoftRefLRUPolicyMSPerMB,控制空闲内存保留时长。

【3】弱引用 WeakReference(临时关联、无强制存活)

(1)定义与作用

强度低于软引用,只要发生 GC,无论内存是否充足,直接回收弱引用对象

  • 核心场景:WeakHashMap(底层全是弱引用)、临时监听、非强制缓存、关联元数据;
  • 典型使用:ThreadLocalMap、缓存元信息、避免强引用循环泄漏。

(2)代码案例

java 复制代码
import java.lang.ref.WeakReference;

public class WeakRefDemo {
    public static void main(String[] args) {
        WeakReference<Object> weakRef = new WeakReference<>(new Object());
        System.out.println("GC前:" + weakRef.get());

        System.gc(); // 主动触发GC
        System.out.println("GC后:" + weakRef.get()); // null,对象已回收
    }
}
经典场景:WeakHashMap
java 复制代码
// key 是弱引用,key无外部强引用时自动清除Entry
WeakHashMap<String, Object> weakMap = new WeakHashMap<>();
String key = new String("cache-key");
weakMap.put(key, new byte[1024 * 1024]);
key = null; // 断开强引用
System.gc();
// map 自动清除该键值对,不会常驻内存

(3)开发注意事项

  1. WeakHashMap Key 必须是包装对象,不能是常量字符串(字符串常量池存在强引用,不会回收);
  2. 弱引用对象回收不可控,不能存储需要稳定读取的数据;
  3. ThreadLocal 底层使用弱引用 key,若线程不清理 value 仍会发生内存泄漏(value 是强引用);
  4. 大量临时元数据、一次性缓存优先弱引用,减少堆常驻对象。

【4】虚引用 PhantomReference(最弱,仅用于堆外内存回收)

(1)定义与作用

强度最低,无法通过 get () 获取原始对象 ,唯一作用:对象被 GC 回收时,收到回收通知,用于资源清理。

必须绑定 ReferenceQueue,无队列则无任何意义。

  • 核心场景:堆外内存(NIO DirectBuffer)释放、文件句柄、Native 资源、自定义资源回收;
  • 回收规则:对象进入可达性分析不可达后,放入队列,开发者在队列中做资源释放。

(2)代码案例

java 复制代码
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomRefDemo {
    public static void main(String[] args) throws InterruptedException {
        ReferenceQueue<Object> queue = new ReferenceQueue<>();
        Object obj = new Object();
        PhantomReference<Object> phantom = new PhantomReference<>(obj, queue);

        System.out.println(phantom.get()); // 永远返回null,无法获取对象
        obj = null;
        System.gc();

        // 阻塞等待对象回收通知
        Reference<?> ref = queue.remove();
        System.out.println("对象已被GC,可以释放底层native资源");
    }
}

底层 NIO DirectByteBuffer 就是依靠虚引用监控堆外内存,对象回收时主动释放操作系统堆外内存,避免堆外内存溢出。

(3)开发注意事项

  1. 业务代码极少手动使用,JDK NIO、文件流底层封装;
  2. 虚引用不能持有业务对象,仅做回收钩子;
  3. 队列处理线程要异步、低延迟,避免阻塞 GC 回收链路;
  4. 禁止在虚引用回调中创建新强引用,会导致对象复活、永久无法回收。

【二】四种引用对比总表

表格

引用类型 回收时机 核心用途 get () 是否返回对象
强引用 无任何强引用链才回收 正常业务对象、核心数据 一定返回
软引用 内存不足 OOM 前回收 内存缓存、图片资源 内存充足返回,不足 null
弱引用 只要 GC 就回收 WeakHashMap、临时元数据 GC 后返回 null
虚引用 GC 标记后入队,无法获取对象 堆外 / Native 资源释放 永远 null

【三】通用开发规范与避坑总结

【1】内存缓存选型规范

  • 永久不能丢的数据:强引用 + 持久化;
  • 可丢失、内存友好缓存:SoftReference
  • 临时、无强依赖元数据:WeakReference
  • 堆外 / 本地文件 / 原生资源:依赖虚引用做后置清理。

【2】通用内存泄漏风险点

  1. 静态集合强持有大对象,无过期清理;
  2. ThreadLocal 使用后不 remove,线程池复用导致 value 常驻;
  3. 缓存未使用软 / 弱引用,无限膨胀 OOM;
  4. 堆外 DirectBuffer 未被正常回收,虚引用线程阻塞导致堆外溢出;
  5. 循环强引用(A 持有 B,B 持有 A),无外部引用时现代 GC 可回收,但老版本会泄漏;
  6. ReferenceQueue 不消费,大量 Soft/WeakReference 实例堆积占用堆。

【3】GC 与引用开发最佳实践

  1. 缓存工具类统一封装软引用,定时轮询 ReferenceQueue 清理失效引用;
  2. 大对象使用完毕手动置空,缩短强引用生命周期;
  3. 线程池、ThreadLocal 遵循用完即清原则;
  4. 堆外内存场景尽量使用池化,减少虚引用回收压力;
  5. 不依赖 System.gc() 强制回收,仅做调试,生产禁用;
  6. 弱引用 Key 避免常量池字符串、全局单例对象。

【4】问题延伸

  • WeakHashMap 为什么 Key 弱引用、Value 强引用?
    防止 value 反向强引用 key,导致 key 无法被回收;
  • 软引用和弱引用的使用场景区分:
    软引用适合用户可容忍缓存丢失的资源;弱引用适合生命周期跟随 key 的附属数据;
  • 虚引用为什么不能 get 对象?
    此时对象已完成标记清除,内存随时会被回收,不允许访问防止野指针。

【四】ThreadLocal的引用分析案例

【1】底层存储结构前置认知

(1)Thread 线程对象

每个 Thread 实例持有成员变量 threadLocals ,ThreadLocal 本身不存数据,数据存在当前线程 Thread 对象内部:

java 复制代码
// Thread 类源码
ThreadLocal.ThreadLocalMap threadLocals = null;
  • 生命周期:线程创建时初始化,线程销毁后整个 threadLocals 直接丢弃;
  • 线程池场景:线程长期存活,threadLocals 不会被销毁。

(2)ThreadLocalMap(自定义哈希表,非 HashMap)

ThreadLocalMap 是定制哈希表,内部存储数组 Entry[] table,核心存储单元是自定义 Entry

Entry 自定义实现:

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k); // key 交给父类 WeakReference 包装
        value = v; // value 直接强引用保存
    }
}

核心设计:

  1. Entry 的 key = WeakReference(弱引用)
  2. Entry 的 value = 普通强引用 Object

(3)外部业务变量(开发者定义的 ThreadLocal 变量)

java 复制代码
// 外部强引用:tl 是栈上局部变量 / static静态变量
ThreadLocal<User> tl = new ThreadLocal<>();
tl.set(new User());

(4)结构关系

thread------》ThreadLocalMap------》Entry数组------》key(是threadLocal的弱引用)、value(存入的Object对象new User())

【2】引用链路

(1)正常使用时:完整引用链路(分两条)

(1)链路 1:外部代码 → ThreadLocal 对象(Key 本体)【强引用链】

bash 复制代码
栈局部变量 tl(强引用) → ThreadLocal实例(key本体)

只要开发者没有执行 tl = null,这条强引用链一直存在。

(2)链路 2:Thread 线程 → ThreadLocalMap → Entry → Key+Value 混合引用链

bash 复制代码
Thread线程对象(强引用)
    ↓
threadLocals(ThreadLocalMap,强引用成员变量)
    ↓
Entry[] table 数组(强引用持有每一个Entry)
    ↓
Entry 对象
        ├─ 父类WeakReference<ThreadLocal> → 弱引用指向 ThreadLocal实例(Key)
        └─ 字段 value → 强引用指向 业务数据对象(Value)

(3)合并完整可达链(正常场景)

bash 复制代码
线程Thread → ThreadLocalMap → Entry
    弱引用 → ThreadLocal(Key) ← 外部变量tl(强引用)
    强引用 → 业务对象User(Value)

此时:

  • Key(ThreadLocal)同时存在外部强引用 + Entry 内弱引用,GC 绝对不会回收;
  • Value 只有一条强引用链:Thread -> Map -> Entry -> value,线程存活则 Value 永远存活。

(2)断开外部 ThreadLocal 引用:tl = null 后的引用链路

执行代码:tl = null;

此时外部栈强引用链断裂,只剩下 Entry 内部的弱引用指向 ThreadLocal Key:

bash 复制代码
Thread → ThreadLocalMap → Entry
    ├─ 弱引用 → ThreadLocal(Key) 【无任何强引用了】
    └─ 强引用 → User(Value)
1. GC 发生后的变化

因为 Key(ThreadLocal)只剩弱引用,GC 会直接回收 ThreadLocal 实例;

此时 entry.get() 返回 null,该 Entry 变成空 key 残留 Entry

bash 复制代码
Thread → ThreadLocalMap → Entry
    ├─ 弱引用:目标已被回收,get()=null
    └─ 强引用 → User(Value) 依然存在!
2. 两种分支情况

(1)分支 A:线程后续继续调用 get/set/rehash

ThreadLocalMap 在读写时会执行 expungeStaleEntry() 探测清理:

发现 entry.get() == null,手动执行:

java 复制代码
entry.key = null;
entry.value = null;

断开 Value 的强引用,业务对象 User 失去引用链,下一次 GC 回收。

(2)分支 B:线程池线程长期不再操作该 ThreadLocalMap(最容易泄漏)

线程长期存活,不再执行任何 get/set,不会触发自动清理;

残留 Entry 永久存在,Value 的强引用链永远无法断开:

bash 复制代码
Thread -> ThreadLocalMap -> Entry -> value(强引用) -> User对象

User 对象无法被 GC,产生内存泄漏

(3)执行 threadLocal.remove () 后的引用链路(彻底释放)

remove() 会直接定位当前 ThreadLocal 对应的 Entry,做两步清空:

  1. Entry 的弱引用 key 置空;
  2. Entry 的 value 字段置空;

引用链完全断裂:

复制代码
Thread -> ThreadLocalMap -> Entry(key=null,value=null)

Key、Value 都无任何引用,GC 可一次性回收,从根源杜绝泄漏。

【3】设计思路

(1)为什么 Key 要设计成弱引用?

(1)场景推演:没有弱引用会发生严重内存泄漏

假设 key 是强引用:

  1. 业务代码定义 ThreadLocal tl = new ThreadLocal<>();
  2. 线程调用 tl.set(obj)ThreadLocalMap.Entry 强持有 tl
  3. 业务代码断开外部引用:tl = null;
  4. 此时 Entry 内部还存在一条强引用链:Thread -> threadLocals -> Entry -> key(强引用) -> tl对象
  5. tl 永远无法被 GC,Entry 永久残留在线程 map 中,value 也跟着常驻堆

(2)弱引用的解决方案

key 被 WeakReference 包装:

  • 外部 tl = null 后,不存在任何强引用指向 ThreadLocal 实例
  • 下一次 GC 会直接回收 ThreadLocal 对象
  • ThreadLocalMap 扩容、set、get 操作扫描哈希槽时,会发现 entry.get() == null(key 已回收),自动清空整条 Entry(key+value),释放内存

(3)设计目的总结

让 ThreadLocal 对象本身能正常被垃圾回收,避免 ThreadLocal 实例永久驻留在线程的 Map 里,降低无手动清理时的内存泄漏概率。

(2)为什么 Value 必须是强引用,不能用弱引用?

很多人疑惑:既然 key 用弱引用,value 为什么不一起弱引用?

(1)业务逻辑层面:value 是我们要存储的数据

使用 ThreadLocal 的核心诉求:在线程生命周期内持有数据

如果 value 是弱引用:

  • 线程执行中途只要触发一次 GC,value 直接被回收
  • get() 突然返回 null,业务代码无感知,出现诡异空指针、上下文丢失,完全不符合线程隔离存储的设计目标。

(2)生命周期绑定逻辑

  • key(ThreadLocal):工具对象,用完可丢弃,允许 GC 回收
  • value(业务数据):线程执行期间必须稳定存在,需要强引用保活

(3)反向引用风险

如果 value 弱引用,同时 key 弱引用:

线程执行中 GC 随时清空 value,上下文直接丢失,违背 ThreadLocal 线程私有存储的定位。

(3)这套引用设计天生存在的缺陷:Value 内存泄漏

(1)完整泄漏链路(线程池场景最严重)

  1. 线程池核心线程长期复用,线程对象不会销毁
  2. ThreadLocal tl = new ThreadLocal<>(); tl.set(大对象);
  3. 业务代码执行完:tl = null;
  4. GC 回收 ThreadLocal(key 弱引用生效),Entry 变成 [key=null, value=大对象]
  5. 若该线程后续不再执行 get/set/remove,ThreadLocalMap 不会自动清理空 key 的 Entry
  6. 线程长期存活,Entry 常驻,value 强引用无法释放 → 内存泄漏

(2)触发条件

  1. 使用线程池(线程不销毁)
  2. ThreadLocal 实例外部引用置空,没有手动 remove()
  3. 该线程后续不再操作这个 ThreadLocalMap,自动清理机制无法触发

(4)ThreadLocalMap 自带的自动清理机制(兜底方案)

源码中 get() / set() / rehash() 方法都会执行探测清理 expungeStaleEntry

遍历哈希桶,遇到 entry.get() == null 的过期 Entry:

  1. 将 entry.key = null
  2. 将 entry.value = null(断开 value 强引用)
  3. 整个 Entry 置空,帮助 GC 回收

局限性:

只有访问 ThreadLocalMap 时才会清理;如果线程休眠、阻塞,长期不操作 map,过期 Entry 会持续堆积。

(5)官方推荐的兜底方案:手动 remove ()

无论强弱引用设计,规范写法:

java 复制代码
try {
    threadLocal.set(context);
    // 业务逻辑
} finally {
    threadLocal.remove(); // 主动删除当前Entry,彻底断开key、value引用
}

执行 remove 会直接把对应 Entry 的 key、value 置空,从根源杜绝泄漏,不依赖 GC 和自动清理逻辑。

【4】整套引用设计思路总结(分层梳理)

(1)设计目标

  1. 实现线程私有数据隔离;
  2. 允许 ThreadLocal 工具对象正常 GC,不常驻内存;
  3. 保证线程运行期间存储的业务数据不被 GC 随意回收;
  4. 内置自动清理逻辑作为兜底,缓解内存泄漏。

(2)分层设计取舍

  1. Key 使用弱引用
    解决 ThreadLocal 对象本身无法回收的问题,避免工具类对象永久占用 Entry;
  2. Value 使用强引用
    保障线程上下文数据稳定存活,防止 GC 随机清空业务数据;
  3. 内置过期 Entry 自动清理
    在读写 map 时主动清除 key 已回收的无效 Entry,释放 value 强引用;
  4. 暴露 remove API
    给开发者提供主动释放手段,解决线程池长期线程导致的堆积泄漏问题。

【5】开发对应注意事项(结合引用设计衍生)

  1. 线程池场景必须在 finally 执行 remove,不能依赖弱引用自动清理;
  2. 不要在线程中存放超大对象(大字节数组、大量缓存),泄漏后内存占用极高;
  3. 不要将 ThreadLocal 定义为局部临时变量且不 remove,极易产生过期 Entry;
  4. 若使用一次性短期线程(无线程池,执行完销毁),线程对象回收时整个 ThreadLocalMap 直接释放,泄漏风险极低;
  5. 弱引用仅解决 ThreadLocal 对象回收,完全解决不了 value 泄漏,不要误以为弱引用就能高枕无忧。
相关推荐
看-是灰机9 小时前
使用go语言实现对接
linux·开发语言·后端·docker·语言模型·golang·飞书
于慨9 小时前
安装java
java
夕除9 小时前
sign 是什么
java·前端
Penina10189 小时前
代码如何导入idea
java·intellij idea
.Hypocritical.9 小时前
Maven笔记——2
java·笔记·maven
huohuopro9 小时前
手写 Tomcat 步骤教程
java·网络
snow@li10 小时前
Java:Lombok 完整讲解
java
Shadow(⊙o⊙)10 小时前
高并发内存池:Part-1——定长内存池
java·前端·javascript
国服第二切图仔10 小时前
17-config命令 - 配置管理系统
java·前端·javascript