从零学 JVM(五):垃圾回收机制——对象存活判断与 GC Roots

一、为什么需要垃圾回收?

在 Java 中,我们通常通过 new 创建对象:

java 复制代码
User user = new User();

对象创建后,会占用 JVM 堆中的内存空间。

如果对象不再被使用,内存却一直无法释放,那么随着程序运行,对象会越来越多,最终可能导致堆内存不足,出现:

text 复制代码
java.lang.OutOfMemoryError: Java heap space

在 C/C++ 等语言中,开发者通常需要更直接地管理内存的申请与释放。而 Java 提供了自动垃圾回收机制(Garbage Collection,简称 GC),由 JVM 负责识别和回收不再需要保留的对象。

垃圾回收的核心任务:判断哪些对象仍然存活,并回收已经可以回收的对象所占用的内存。

不过要注意,GC 判断的是对象是否仍然能够从程序运行时的引用关系中到达,而不是判断业务上"以后还会不会用到它"。

二、垃圾回收主要回收哪些区域?

结合前面学习过的 JVM 运行时数据区,可以先区分一下:

内存区域 是否涉及 GC 说明
Java 堆 是,主要区域 对象实例通常在堆中分配,是垃圾回收重点
方法区 / 元空间 是 可以回收无用的类元数据、常量等,条件比较苛刻
Java 虚拟机栈 通常不依赖 GC 回收栈帧 方法执行结束后,栈帧随线程执行过程自动出栈
程序计数器 通常不需要 GC 线程私有,生命周期随线程
本地方法栈 通常不依赖 Java 堆 GC 由本地方法执行和线程栈生命周期管理

本篇主要讨论 Java 堆中的对象如何判断是否可以回收。

三、如何判断一个对象已经成为垃圾?

这是 JVM 垃圾回收最基础、也最重要的问题。

假设我们创建了一个对象:

java 复制代码
User user = new User();

当变量 user 不再引用这个对象时,它是否就一定成为垃圾了?

不一定。

因为可能还有其他对象引用它。比如:

java 复制代码
User user1 = new User();
User user2 = user1;

user1 = null;

虽然 user1 已经不再引用对象,但 user2 仍然引用它,所以这个对象仍然存活。

那么 JVM 到底如何判断对象是否可以回收?

常见的思路有两种:

  1. 引用计数法(Reference Counting)
  2. 可达性分析算法(Reachability Analysis)

1. 引用计数法

引用计数法的思路比较直观:

为每个对象维护一个引用计数器,每当有一个地方引用它时,计数器加 1;引用失效时,计数器减 1。当计数器为 0 时,就认为对象可以被回收。

例如:

java 复制代码
User user1 = new User();
User user2 = user1;

此时同一个对象被两个变量引用,可以简单理解为:

text 复制代码
对象 User
引用计数:2

当执行:

java 复制代码
user1 = null;

引用计数减为 1。

再执行:

java 复制代码
user2 = null;

引用计数变为 0,对象就可以被回收。

看起来很简单,但它有一个致命问题:无法正确处理循环引用。

2. 引用计数法的问题:循环引用

例如:

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

class B {
    A a;
}

public class Main {
    public static void main(String[] args) {
        A a = new A();
        B b = new B();

        a.b = b;
        b.a = a;

        a = null;
        b = null;
    }
}

执行完之后:

  • A 对象引用 B 对象。
  • B 对象引用 A 对象。
  • 外部变量 a 和 b 已经不再引用它们。

从业务角度看,这两个对象已经没有用了。

但是引用计数法中:

  • A 的引用计数仍然是 1,因为 B 还引用 A。
  • B 的引用计数仍然是 1,因为 A 还引用 B。

两个对象的计数都不为 0,因此仅靠引用计数无法判断它们已经没有外部引用。

这就是引用计数法的主要缺陷。

需要注意:引用计数法并不是所有垃圾回收系统都不用,但它不是 HotSpot JVM 判断 Java 对象是否存活的主要算法。

四、可达性分析算法(Reachability Analysis)

Java 虚拟机主要通过可达性分析算法来判断对象是否存活。

它的核心思想是:

从一组称为 GC Roots 的对象出发,向下搜索对象之间的引用关系。如果某个对象无法通过任何引用链从 GC Roots 到达,那么它就被认为是不可达对象,可以被判定为可回收对象。

先理解三个概念:

  • GC Roots:垃圾收集的起点。
  • 引用链:对象之间通过引用形成的路径。
  • 可达:从 GC Roots 出发,沿着引用关系能够找到这个对象。

例如图中:

  • GC Roots 能够通过引用链找到 A。
  • A 又引用了 B。
  • 所以 A 和 B 都是可达对象。
  • C 和 D 虽然互相引用,但没有任何 GC Roots 能够到达它们,因此它们是不可达对象。

这也解释了为什么可达性分析能够解决循环引用问题。

即使两个对象互相引用,只要从 GC Roots 出发无法到达它们,就不会因为这层循环引用而一直被保留。

五、什么是 GC Roots?

GC Roots 可以理解为 JVM 进行可达性分析时的起点。

那么哪些对象可以作为 GC Roots 呢?

常见的来源包括:

1. 虚拟机栈中引用的对象

例如:

java 复制代码
public void test() {
    User user = new User();
    // user 局部变量引用的对象
}

当前活动线程的栈帧中,局部变量表所引用的对象,在符合 JVM 根集合规则时可以作为 GC Roots。

2. 类的静态字段引用的对象

例如:

java 复制代码
public class Cache {
    public static User user = new User();
}

被存活类的静态字段引用的对象,可以通过静态字段形成从 GC Roots 出发的引用链。

3. 本地方法中的 JNI 引用

Java 程序可以通过 JNI 调用本地代码。

本地方法中的 JNI 局部引用和全局引用所指向的对象,也可能成为 GC Roots。

4. JVM 内部引用的对象

例如:

  • 活动线程。
  • 系统类加载器及 JVM 内部维护的相关对象。
  • 被同步锁持有等原因需要保持存活的对象。

具体的根集合由 JVM 实现维护,不同 JVM 和不同收集器可能存在实现细节差异。

这里要注意:不要简单地认为常量池里的所有内容都是 GC Roots。是否构成根,要看具体引用关系和 JVM 的根集合规则。

六、对象不可达,就会立即被回收吗?

不一定。

这是一个很容易被忽略的细节。

当对象从 GC Roots 出发已经不可达时,说明它具备了被回收的条件,但不代表 JVM 必须马上回收它。

实际什么时候回收,取决于垃圾收集器的运行时机和回收策略。

另外,Java 还存在一个特殊机制:finalize()。

1. finalize() 方法

如果一个对象重写了 finalize(),并且它此前还没有执行过该方法,那么在对象被判定为不可达时,JVM 可能安排它执行 finalize()。

例如:

java 复制代码
public class User {

    @Override
    protected void finalize() throws Throwable {
        System.out.println("对象即将被回收");
    }
}

在 finalize() 中,对象甚至可以重新建立外部引用,让自己重新变成可达对象,这种情况称为对象复活。

但要注意:

  1. finalize() 最多由 JVM 自动调用一次。
  2. 是否及时执行不能保证。
  3. 如果对象在 finalize() 中重新建立了引用,它可能逃过本轮回收。
  4. finalize() 已被弃用,并且在较新的 JDK 中已标记为计划移除,不应该在新代码中使用。

因此,实际开发中不要依赖 finalize() 管理资源或控制对象生命周期。

七、Java 的四种引用类型

前面我们提到,对象是否存活,和它是否还存在引用关系密切相关。

但 Java 并不是只有普通引用。

Java 提供了四种引用类型:

  1. 强引用(Strong Reference)
  2. 软引用(Soft Reference)
  3. 弱引用(Weak Reference)
  4. 虚引用(Phantom Reference)

它们的区别主要在于:GC 面对不同强度的引用时,是否可以清理对应对象。

1. 强引用(Strong Reference)

这是最常见的引用方式。

例如:

java 复制代码
User user = new User();

变量 user 对对象形成强引用。

只要对象仍然通过强引用链从 GC Roots 可达,垃圾收集器就不会回收它。

日常开发中绝大多数对象都是通过强引用持有的。

2. 软引用(Soft Reference)

软引用的特点是:

当内存比较充足时,GC 通常会保留软引用对象;当内存紧张时,GC 可以清除软引用。

示例:

java 复制代码
User user = new User();

SoftReference<User> softReference =
        new SoftReference<>(user);

软引用曾经常用于内存敏感型缓存。

不过要注意,软引用并不适合用来精确控制缓存容量和过期时间,因为何时清理由 JVM 的内存压力和实现策略决定。

3. 弱引用(Weak Reference)

弱引用比软引用更容易被回收。

如果一个对象只有弱引用关联,在 GC 处理弱引用时,对象就可以被清除。

示例:

java 复制代码
User user = new User();

WeakReference<User> weakReference =
        new WeakReference<>(user);

弱引用常见的应用场景之一是 WeakHashMap。

需要注意:弱引用并不意味着对象创建后立刻消失,而是在垃圾收集器处理弱引用时可以被清除。

4. 虚引用(Phantom Reference)

虚引用是四种引用中最特殊的一种。

它有两个重要特点:

  • 无法通过 get() 获取对象,调用始终返回 null。
  • 通常配合 ReferenceQueue 使用,在对象达到虚可达状态并被清除后,可以通过队列获知相关事件。

示例:

java 复制代码
ReferenceQueue<User> queue =
        new ReferenceQueue<>();

User user = new User();

PhantomReference<User> phantomReference =
        new PhantomReference<>(user, queue);

虚引用主要用于对象回收过程中的通知和资源清理协调。

实际开发中,可以了解 Cleaner 等更现代的资源清理机制,不建议自己依赖 finalize()。

八、有了 GC,为什么还会出现内存泄漏?

很多人会认为:

Java 有垃圾回收器,所以不会发生内存泄漏。

这是错误的。

GC 判断对象是否可以回收,依据的是对象是否可达,而不是业务是否还需要它。

例如:

java 复制代码
public class Cache {

    private static final Map<String, Object> CACHE =
            new HashMap<>();

    public static void add(String key, Object value) {
        CACHE.put(key, value);
    }
}

假设我们不断向 CACHE 中添加对象,但从来不删除。

即使业务已经不再使用这些对象,它们仍然被静态 Map 引用。

从 GC 的角度看:

text 复制代码
GC Roots
   |
   v
静态字段 CACHE
   |
   v
Map
   |
   v
缓存对象

对象仍然可达,GC 就不会回收它们。

这就是为什么 Java 仍然会出现内存泄漏。

常见场景包括:

  • 静态集合持续保存不再使用的数据。
  • 监听器注册后没有及时注销。
  • ThreadLocal 使用不当,导致对象被线程长期持有。
  • 缓存缺少容量限制和过期淘汰策略。

因此:

有 GC 不代表不会内存泄漏。只要无用对象仍然被强引用链持有,GC 就无法回收它。

九、GC 是如何触发的?

GC 并不是只有在堆内存完全用完时才会触发。

不同垃圾收集器会根据当前内存状态、对象分配速度、回收成本等因素决定何时进行垃圾回收。

常见场景包括:

1. 对象分配空间不足

例如 Eden 区空间不足,分配新对象时可能触发 Young GC。

2. 达到收集器的启动阈值

例如 G1 会结合老年代占用、对象分配速度和并发标记所需时间等信息,决定何时启动并发标记。

3. 元空间达到相关阈值

元空间压力可能触发垃圾回收尝试,以便回收无用类的元数据。

4. 显式请求 GC

例如:

java 复制代码
System.gc();

这只是向 JVM 提出一次垃圾回收请求,并不保证一定立即执行,也不保证一定执行 Full GC。

可以通过:

text 复制代码
-XX:+DisableExplicitGC

配置忽略显式 GC 请求。

因此,不能简单地认为 GC 只有一个固定触发条件。具体行为要结合 JVM 版本、垃圾收集器和运行参数分析。

十、本篇总结

  1. Java GC 主要负责判断对象是否存活,并回收可以回收的对象所占用的内存。
  2. HotSpot JVM 主要使用可达性分析判断对象是否存活,而不是单纯使用引用计数法。
  3. 引用计数法无法解决循环引用问题。
  4. 可达性分析从 GC Roots 出发,通过对象引用链判断对象是否可达。
  5. 常见 GC Roots 包括活动线程栈中的引用、存活类的静态字段引用、JNI 引用和 JVM 内部引用等。
  6. 对象不可达意味着具备被回收的条件,不代表立即回收。
  7. Java 有强引用、软引用、弱引用和虚引用四种引用类型。
  8. finalize() 不可靠且已被弃用,不应该用于资源管理。
  9. Java 仍然可能发生内存泄漏,因为无用对象也可能被静态集合、ThreadLocal 等强引用长期持有。
  10. System.gc() 只是请求,不保证立即执行或一定触发 Full GC。

十一、本篇面试题与参考答案

1. 什么是 Java 垃圾回收?GC 主要解决什么问题?

参考答案:

垃圾回收是 JVM 自动管理对象内存的一种机制。它主要负责判断对象是否仍然存活,并回收已经不再需要保留的对象所占用的内存。

它减少了开发者手动管理对象内存的负担,但不能解决所有内存问题。例如静态集合长期持有无用对象时,对象仍然可达,GC 就不会回收它。

2. Java 如何判断一个对象是否可以被回收?

参考答案:

Java 主要通过可达性分析判断对象是否存活。

JVM 从 GC Roots 出发,沿着对象之间的引用关系向下搜索。如果一个对象无法从 GC Roots 到达,就会被判定为不可达对象,具备被回收的条件。

3. 什么是 GC Roots?常见的 GC Roots 有哪些?

参考答案:

GC Roots 是 JVM 进行可达性分析时的起点。常见来源包括活动线程栈帧中的局部变量引用、存活类的静态字段引用、JNI 局部和全局引用,以及 JVM 内部维护的相关引用。

JVM 从这些根节点出发,遍历对象引用关系,判断对象是否可达。

4. 引用计数法有什么缺点?Java 为什么主要使用可达性分析?

参考答案:

引用计数法通过维护对象的引用数量判断对象是否可以回收,但它无法处理循环引用。

例如 A 引用 B,B 又引用 A,即使外部已经没有任何引用,两个对象的引用计数仍然不为零。

可达性分析从 GC Roots 出发判断对象是否可达,即使对象之间存在循环引用,只要没有从 GC Roots 到达它们的引用链,就可以被判定为不可达。

5. 对象不可达就一定会立即被回收吗?

参考答案:

不一定。对象不可达只是说明它具备了被回收的条件,实际什么时候回收由垃圾收集器的运行时机和回收策略决定。

另外,如果对象重写了 finalize() 且此前没有执行过,JVM 可能安排它执行。对象甚至可能在其中重新建立引用,让自己复活。不过 finalize() 已被弃用,不应该依赖它管理对象生命周期。

6. Java 四种引用类型有什么区别?

参考答案:

强引用是最常见的引用,只要强引用链可达,对象就不会被回收。

软引用在内存紧张时可以被清除,适合了解内存敏感型缓存场景,但不适合精确控制缓存策略。

弱引用比软引用更容易被清除,当 GC 处理弱引用时,只有弱引用关联的对象可以被清除。

虚引用无法通过 get() 获取对象,通常配合 ReferenceQueue 使用,用于对象清理过程中的通知和协调。

7. 有了垃圾回收机制,为什么 Java 还会出现内存泄漏?

参考答案:

因为 GC 判断对象是否可以回收,依据的是对象是否从 GC Roots 可达,而不是业务是否还需要它。

例如静态 Map 长期保存不再使用的数据,这些对象仍然能从 GC Roots 到达,因此 GC 不会回收它们。随着对象不断增加,就可能导致堆内存不足。

8. System.gc() 一定会触发 Full GC 吗?

参考答案:

不一定。System.gc() 只是向 JVM 提出显式垃圾回收请求,具体是否执行、采用什么回收方式,取决于 JVM 和垃圾收集器配置。

例如使用 -XX:+DisableExplicitGC 可以忽略显式 GC 请求。因此不能认为调用 System.gc() 就一定会立即执行 Full GC。

9. GC 是在什么时候触发的?

参考答案:

GC 没有一个适用于所有收集器的固定触发条件。常见情况包括对象分配空间不足、收集器达到并发回收启动阈值、元空间达到相关阈值,以及程序显式请求 GC。

具体需要结合当前使用的垃圾收集器、JVM 参数和 GC 日志判断。

相关推荐
金銀銅鐵4 小时前
一个 4 个常量的枚举,Kotlin 在字节码里生成了什么
jvm·kotlin
2301_808414386 小时前
Linux线程的同步与互斥
jvm
曲径海盗1 天前
Try-Except怎么用,3个追问解答在自动化下的应用
java·jvm·oracle
寂寞的船4 天前
【Unity知识速递】第10期 Unity GC 零分配实战:从 UnityEngine.Pool 到手动 GC 控制的全链路优化
jvm·unity·c#·游戏引擎·lucene
金銀銅鐵6 天前
同一个 Hello World,Kotlin 和 Java 在字节码层面差在哪
jvm·后端·kotlin
yeccheng7 天前
MySQL 索引超详解:从底层 B+ 树到面试高频考点
java·开发语言·jvm·mysql·面试
On The Road :9 天前
深入理解 JVM 垃圾回收:GC 是什么、如何判定垃圾、四大回收算法全解析
jvm·算法
Yves..9 天前
JVM 学习笔记:内存区域、类加载机制与垃圾回收
jvm
akinggw10 天前
libagentcore - 一个基于c++的AI多agent系统升级到2.0版本
开发语言·jvm·c++