Java 面试笔记III · 04 / 14

Lesson 27 · JVM 原理与调优

GC Roots 详解:哪些对象不会被回收?

中级·⭐ 必问·#JVM·#GC·#核心

第 1 站

面试官:JVM 怎么判断一个对象是否可以回收?

"看有没有引用指向它。" 大部分候选人说到这里就停了。面试官想听的下一句是——从哪些引用开始找?

JVM 判断对象是否存活,使用可达性分析(Reachability Analysis):从一组固定的"根对象"出发,沿引用链向下遍历。能遍历到的对象"存活",遍历不到的就是"垃圾"。

这组根对象就叫做 GC Roots。它们不是特殊对象,而是 GC 可达性分析的起点集合。只有从 GC Roots 可达的对象才不会被回收。

关键概念

理解 GC Roots 是理解内存泄漏的基石——内存泄漏的本质就是:你以为对象应该被回收,但它仍然被某个 GC Root 引用链"拽着"。JVM 中共有 5 种对象可以作为 GC Roots。

第 2 站

5 种 GC Roots 一览

#GC Root 类型说明典型场景
1栈帧局部变量每个线程栈帧中的局部变量和参数方法内 new 出的对象
2静态变量类中 static 字段引用的对象单例模式、静态缓存
3常量池引用运行时常量池中的引用字符串常量池、Class 引用
4JNI 全局引用Native 代码通过 JNI 创建的全局引用C/C++ 库持有的 Java 对象
5JVM 内部引用JVM 自身维护的内部对象ClassLoader、异常对象、系统线程
GC Roots 可达性分析全景图 栈帧局部变量Stack Frames 静态变量Static Fields 常量池引用Constant Pool JNI 全局引用Global Refs JVM 内部引用Internal Refs A B C D E F G间接可达 H间接可达 I间接可达 X不可达 → 回收 Y不可达 → 回收 GC Root 存活(可达) 不可达(可回收)
图 1 — GC Roots 从五个维度出发,沿引用链标记存活对象;不在任何引用链上的对象将被回收
面试话术

JVM 使用可达性分析判断对象是否存活。GC Roots 是分析的起点,包括 5 种:栈帧局部变量、静态变量、常量池引用、JNI 全局引用和 JVM 内部引用。从这些根出发能到达的对象都不会被回收。

第 3 站

栈帧引用:方法执行中的局部变量

这是最常见的 GC Root 类型。线程执行方法时,会创建栈帧(Stack Frame)压入虚拟机栈。栈帧的局部变量表存放了所有局部变量和方法参数——它们引用的对象就是 GC Roots。只要方法还在执行(栈帧未弹出),这些对象就不会被回收。

方法返回后对象变为不可达
public class StackFrameDemo {
    public void processData() {
        User user = new User("Alice");                     // user 是 GC Root
        Map<String, Object> cache = new HashMap<>();
        cache.put("data", new byte[100 * 1024 * 1024]); // 100MB,存活
        doSomething(user, cache);
    }
    // ← 方法返回,栈帧弹出,user 和 cache 引用失效
    //   100MB 数组变为不可达,下次 GC 自动回收
}

为什么方法返回后对象就"释放"了?

方法返回时栈帧弹出,局部变量表随之销毁,其中引用全部失效。这些对象在下次 GC 时就可能被回收——这就是为什么我们说"局部变量不需要手动释放"。

面试加分项

即使方法内部,如果局部变量在后续代码中不再使用,JIT 编译器也可能提前将其从 GC Roots 中移除——这叫做局部变量活性分析(Liveness Analysis)。所以手动写 user = null 在大多数场景下是不必要的。

第 4 站

静态变量引用:伴随类加载器的长生命周期

static 字段属于类本身(存储在元空间),而非某个对象实例。类由系统类加载器(AppClassLoader)加载,其生命周期与 JVM 进程一致。因此静态变量引用的对象在整个应用运行期间都不会被回收

静态变量作为 GC Root
public class ConfigManager {
    // instance 是 GC Root,只要类没卸载就不会被回收
    private static ConfigManager instance = new ConfigManager();
    private static Map<String, Object> cache = new HashMap<>(); // cache 也是 GC Root
}

静态变量引用和栈帧引用有什么本质区别?

栈帧引用的生命周期是方法调用期间,方法返回就失效。静态变量引用的生命周期是类的生命周期,通常就是整个 JVM 运行期。"活"得太久意味着——如果不小心管理,就会造成内存泄漏。

静态集合:最常见的内存泄漏陷阱

静态 Map 导致的内存泄漏
public class EventBus {
    private static final Map<String, List<EventListener>> listeners = new HashMap<>();

    public static void register(String event, EventListener l) {
        listeners.computeIfAbsent(event, k -> new ArrayList<>()).add(l);
        // ⚠️ 只进不出!Map 随运行时间无限增长
    }
    // ❌ 缺少 unregister 方法
}

内存泄漏公式: 静态集合 + 只增不减 = 内存泄漏

解决方案:① 提供 remove 方法;② 使用 WeakHashMap;③ 限制集合大小(如 LRU Cache)。

第 5 站

常量池引用:字符串池与 Class 引用

运行时常量池(Runtime Constant Pool)是类加载后在方法区中维护的数据结构,存储字面量(如字符串常量)和符号引用(如类、方法、字段的引用)。

字符串常量池

字符串常量池引用
String s1 = "hello";                  // "hello" 进入常量池,成为 GC Root
String s2 = "hello";                  // 复用池中同一个对象,s1 == s2 为 true
String s3 = new String("hello");      // 堆中新对象,但 "hello" 仍在常量池中

字符串常量池中的字符串会被回收吗?

JDK 7 之前常量池在永久代,不会回收。JDK 7 移到堆中后,如果字符串常量不再被任何类引用(类被卸载),就可以回收。但实际应用中系统类加载的字符串几乎不会被卸载。

Class 引用

常量池中还包含类、接口、方法、字段的符号引用。解析后变为直接引用,指向方法区中的 Class 对象。只要引用类没被卸载,被引用的类也不会被回收。

面试高频追问

Q: 常量池引用导致的内存泄漏常见吗?
普通 Java 应用中不常见。但在动态类加载场景(OSGi、热部署、JSP 编译)中,如果旧版 ClassLoader 未正确释放,它加载的所有类和常量池都无法回收,导致元空间(Metaspace)溢出——这是经典的 ClassLoader 泄漏问题。

第 6 站

GC Roots 与内存泄漏:4 种典型模式

理解 GC Roots 的最大价值在于诊断内存泄漏。内存泄漏的本质:对象生命周期已结束,但仍被某个 GC Root 的引用链"拽住",无法回收。

模式一:静态 Map 持续增长

典型泄漏代码
public class SessionStore {
    private static final Map<String, Session> sessions = new HashMap<>();
    public static void add(Session s) { sessions.put(s.getId(), s); }
    // ❌ 没有 remove — 引用链: sessions(静态变量) → Session → 永不回收
}

模式二:ThreadLocal 未清理

ThreadLocal 泄漏
private static final ThreadLocal<UserContext> ctx = new ThreadLocal<>();

public void handleRequest() {
    ctx.set(new UserContext(currentUser));
    try { doBusinessLogic(); }
    finally { ctx.remove(); }  // ⚠️ 必须 remove!线程池中线程复用,变量残留
}
// GC Root: 线程栈帧 → ThreadLocalMap → UserContext → 线程不销毁则永不回收

模式三:未关闭的资源

数据库连接未关闭
// ❌ 忘记关闭 — Connection 被 native 层持有(JNI 全局引用 = GC Root)
Connection conn = dataSource.getConnection();
ResultSet rs = conn.createStatement().executeQuery("SELECT ...");

// ✅ 正确做法:try-with-resources 自动关闭
try (Connection c = dataSource.getConnection();
     ResultSet r = c.createStatement().executeQuery("SELECT ...")) { /* ... */ }

模式四:监听器注册未注销

事件监听器泄漏
public class Dashboard {
    public Dashboard() {
        EventBus.register("dataUpdate", this::onDataUpdate);
        // ❌ EventBus(静态变量=GC Root) 持有 Dashboard 引用,页面关闭后仍无法回收
    }
    public void destroy() {
        EventBus.unregister("dataUpdate", this::onDataUpdate); // ✅ 生命周期结束时注销
    }
}
泄漏模式涉及的 GC Root根因解决方案
静态 Map 增长静态变量只增不减提供 remove / LRU
ThreadLocal 残留栈帧(线程池线程)线程复用未清理finally 中 remove()
资源未关闭JNI 全局引用native 层持有try-with-resources
监听器未注销静态变量注册后忘记清理生命周期结束时注销
诊断思路

遇到内存泄漏时:① 用 MAT / JProfiler 找到大对象;② 查看 GC Roots 引用链(MAT 中 "Path to GC Roots");③ 找到是哪个 GC Root "拽住"了它;④ 在合适的时机切断引用链。

第 7 站

总结:GC Roots 知识清单

全文核心记忆点:

  • GC Roots 是可达性分析的起点,共 5 种类型
  • 栈帧局部变量:最常见,生命周期 = 方法调用期间,方法返回即释放
  • 静态变量:生命周期 = 类的生命周期,静态集合只增不减是泄漏高发区
  • 常量池引用:字符串池 + 类引用,动态类加载场景需警惕 ClassLoader 泄漏
  • JNI 全局引用:Native 代码持有 Java 对象,资源未关闭时造成泄漏
  • JVM 内部引用:ClassLoader、异常对象、系统线程等 JVM 自行管理
  • 内存泄漏诊断:Heap Dump → MAT 找大对象 → 查看 GC Root 引用链 → 切断引用
5 种 GC Roots 完整速查 ① 栈帧局部变量 局部变量 + 参数 生命周期 = 方法调用 最常见、最短暂 ② 静态变量 static 字段 生命周期 = 类生命周期 泄漏高发区 ③ 常量池引用 String Pool + Class Ref JDK 7+ 在堆中 动态加载时需注意 ④ JNI 全局引用 GlobalRef Native 代码持有 资源未关闭时泄漏 ⑤ JVM 内部 ClassLoader 异常对象 / 系统线程 JVM 自行管理 常见内存泄漏模式 静态集合只增不减 | ThreadLocal 未 remove | 资源未 close | 监听器注册未注销 诊断 4 步法: Heap Dump 抓取 MAT 找大对象 查看 GC Root 引用链 切断引用链
图 2 — 5 种 GC Roots 速查 + 内存泄漏诊断流程
最终面试话术

JVM 通过可达性分析判断对象是否存活,GC Roots 是分析的起点。有 5 种 GC Root:栈帧局部变量(最常见,方法返回即释放)、静态变量(伴随类的整个生命周期,是泄漏高发区)、常量池引用(字符串池和 Class 引用)、JNI 全局引用(Native 代码持有)、JVM 内部引用(ClassLoader 等)。理解 GC Roots 的实际价值在于诊断内存泄漏——当对象本应被回收却仍然存活时,一定是被某个 GC Root 的引用链"拽住"了,通过 MAT 的 "Path to GC Roots" 功能可以快速定位泄漏根源。