技术演进中的开发沉思-328 JVM:垃圾回收(上)

在 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 个核心原则:

  1. 核心数据用强引用:订单、用户、支付等核心业务对象,必须用强引用,保证绝对存活;
  2. 缓存数据用软引用:图片、临时报表等非核心缓存,用软引用,内存不足时自动回收,避免 OOM;
  3. 临时数据用弱引用:ThreadLocal key、中间计算结果等,用弱引用,GC 触发即回收,减少内存占用;
  4. 堆外内存用虚引用:NIO 直接内存、JNI 对象,用虚引用跟踪回收,手动释放堆外资源。

最后小结

核心回顾

  1. 可达性分析算法:以 GC Roots 为起点,引用链可达的对象存活,不可达的标记为可回收;内存泄漏的核心是 "无用对象仍可达";
  2. 引用类型决定 GC 策略:强引用永不回收,软引用内存不足时回收,弱引用 GC 触发即回收,虚引用仅跟踪回收;
  3. 实战选择:核心对象用强引用,缓存用软引用,临时数据用弱引用,堆外内存用虚引用。
相关推荐
qq_397562311 小时前
Qt_工程执行逻辑_窗口逻辑
开发语言·qt
hoiii1871 小时前
基于MATLAB的Kriging代理模型实现与优化
开发语言·matlab
火云洞红孩儿1 小时前
2026年,用PyMe可视化编程重塑Python学习
开发语言·python·学习
椰羊~王小美1 小时前
前后端 格式化货币的方法
java·前端
heartbeat..2 小时前
数据库性能优化:优化的时机(表结构+SQL语句+系统配置与硬件)
java·数据库·mysql·性能优化
带刺的坐椅2 小时前
开发 Java MCP 就像写 Controller 一样简单,还支持 Java 8
java·llm·solon·mcp·skills
2501_944521592 小时前
Flutter for OpenHarmony 微动漫App实战:标签筛选功能实现
android·开发语言·前端·javascript·flutter
小唐同学爱学习2 小时前
缓存与数据库一致性问题
java·数据库·spring boot·缓存