首页 / Java 学习笔记 / 27

JAVA · Vol.III · DAY 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 内部引用。从这些根出发能到达的对象都不会被回收。

可达 ≠ 必定不回收:引用强度的限定词

面试里有个高频错误说法:"从 GC Roots 可达的对象一定不回收。"——少了一个限定词:只有"强可达"才是必定不回收。挂在 GC Root 上的引用未必是强引用——弱引用、软引用、虚引用同样能让对象从某个 Root"可达",但可达性分析要结合引用强度判定:仅被弱引用可达的对象本轮 GC 就回收,仅被软引用可达的对象在内存紧张(OOM 之前)回收。准确的说法是:可达性分析实际是"强可达判定 + 引用类型过滤"两步——先判强引用可达性(不可达必回收),再看是否仅弱/软可达(弱可达也回收,软可达可能回收)。可达 ≠ 存活,差的就是引用强度。

易错点

被问"WeakReference 引用的对象会被回收吗",如果答"不会,它从 GC Root 可达"——错了。标准答案:弱引用是 GC 中"最先牺牲"的引用,任何一次 GC(哪怕只是 Young GC)都可能回收仅被弱引用可达的对象。这正是 WeakHashMap 和 ThreadLocal 的 Entry key 用弱引用的原因(细节见「对象存活判定」)。

GC Roots 本身也在变:并发收集怎么跟上

另一个容易被忽略的深度点:GC Roots 不是静态集合。并发标记期间,Roots 自身就在变化——新线程启动(新栈帧、新的局部变量引用)、静态字段被赋新值、JNI 创建新的全局引用。如果收集器只按"标记开始时"的 Roots 快照来标记,就会把"新产生的可达对象"误判成垃圾。解决办法还是写屏障:引用关系的变化(包括根上的新赋值)都会被写屏障记录下来,交给增量更新(CMS)或 SATB(G1/ZGC)机制处理——这套机制在「对象存活判定」里细讲,这里只需记住结论:Roots 集合 = 初始快照 + 写屏障跟踪的变更日志,不是一份固定清单

第 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 在大多数场景下是不必要的。

栈帧的根不止"局部变量":操作数栈也算

严格地说,栈帧里算 GC Root 的引用来自两处:局部变量表(含方法参数)和操作数栈。很多字节码操作直接发生在操作数栈上——比如 new 出来的实例引用先压在操作数栈里,执行 invokespecial 期间它只被操作数栈引用,此刻若发生 GC,栈上的这个对象必须按可达处理。所以更准确的表述是"栈帧内所有引用(局部变量表 + 操作数栈)都是 GC Roots"。看一段字节码就很清楚:

new Object() 的字节码:引用在操作数栈与局部变量表之间流动
// javap -c 简化输出
  new java/lang/Object                 // ① 实例创建,引用压入操作数栈(此刻只有栈引用它)
  dup                                  // ② 复制一份引用(一份留栈上准备调构造器)
  invokespecial java/lang/Object.<init> // ③ 执行构造器
  astore_1                             // ④ 存入局部变量槽 1(此后由局部变量表引用)
  // ①→④ 任何时刻触发 GC,该对象都是可达的
第 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)。

"类不卸载,静态变量就永远活着"——有没有例外?

有。"类的生命周期 = 进程生命周期"这个等式成立的前提,是类由系统类加载器加载。如果类由自定义类加载器加载,当这个类加载器自己变成垃圾(类卸载三条件:无实例 + 加载器可回收 + 无 Class 引用,见「对象存活判定」),类被卸载,它静态字段引用的对象随之不可达。这正是热部署能"一键清空旧版本"的原理——反过来,也是第 6 站 ClassLoader 泄漏模式的镜像。

第 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 对象。只要引用类没被卸载,被引用的类也不会被回收。

"常量池引用"和"静态变量引用"有什么区别?不是会重叠吗?

会重叠,但入口不同:static final String NAME = "x" 是编译期常量,使用处编译器直接内联字面量,引用走运行时常量池进入(常量池引用);而 static String s = someMethod() 运行时才赋值,静态字段里存的就是对象本身(静态变量引用)。两者最终都是"从类可达",但 MAT 里看到的引用链不同:一条经过常量池入口,一条经过字段。面试时能把这个区分讲清楚,说明你理解常量池而不只是背清单。

面试高频追问

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

第 6 站

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

理解 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); // ✅ 生命周期结束时注销
    }
}

模式五:内部类隐式持有外部类(高频隐蔽坑)

内部类泄漏:Task 把 100MB 的外部类拖下水
public class OrderController {
    private byte[] bigData = new byte[100 * 1024 * 1024]; // 100MB

    public void asyncReport() {
        executor.execute(new ReportTask());
        // ❌ 方法返回后 Task 还在线程池队列里 → 它隐式持有外部类 → 100MB 收不掉
    }

    class ReportTask implements Runnable {
        // 非静态内部类字节码里藏着隐式字段:OrderController this$0; ← 泄漏链关键一环
        public void run() { /* 用 bigData 做报表 */ }
    }
}
// ✅ 修复:ReportTask 改 static 内部类(或顶层类),显式只持有需要的字段;确需外部实例用 WeakReference

泄漏链:线程池(静态变量 = GC Root)→ Task 队列 → ReportTask → this$0 → OrderController → bigData。MAT 的 Path to GC Roots 会显示这条链从线程池的静态字段出发——这是"我没写任何 static 字段却泄漏了"类问题里最常见的一种,排查时先想"有没有匿名/非静态内部类跑到了线程池里"。

模式六:ClassLoader 泄漏(Metaspace 方向)

当拽住对象的是 Class 对象或自定义类加载器,泄漏的就不是堆内存而是类元数据:自定义类加载器被静态字段持有、或它加载的类注册进了全局注册表(DriverManager、JMX、反射缓存),"模块"下线后加载器仍回收不了 → 它加载的所有类、运行时常量池都留在方法区 → Metaspace 只涨不跌,最终 OOM(Metaspace)。类卸载要同时满足三条件——无实例 + 类加载器可回收 + 无 Class 引用(三条件见「对象存活判定」;MAT 定位 Metaspace 实战见「内存泄漏排查」;加载器设计见「自定义类加载器」)。

泄漏模式涉及的 GC Root根因解决方案
静态 Map 增长静态变量只增不减提供 remove / LRU
ThreadLocal 残留栈帧(线程池线程)线程复用未清理finally 中 remove()
资源未关闭JNI 全局引用native 层持有try-with-resources
监听器未注销静态变量注册后忘记清理生命周期结束时注销
内部类隐式持有静态变量(线程池)→ Task非静态内部类的 this$0static 内部类 / 显式持有引用
ClassLoader 泄漏JVM 内部(Class / 注册表)旧加载器不可回收解注册 + 释放引用,让整批一起回收
诊断思路

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

实战:MAT 里的 GC Root 引用链长什么样

MAT — Path to GC Roots(勾选 exclude GC Roots / weak+soft+phantom references)
// 对可疑大对象右键 → Path to GC Roots → 排除 GC Roots 和弱/软/虚引用
java.lang.Class@3a2b1c4d                       ← JVM 内部引用(Class 对象)
└─ field: com.example.SessionStore.sessions    ← 静态字段 = GC Root
   └─ java.util.HashMap@7f3e2a1b
      └─ Node[8192]
         └─ Session@1d2e3f4a                    ← 目标对象(retained 2.4 GB)
   → 结论:被静态变量 sessions 拽住,修复 = 给 SessionStore 加过期淘汰/remove

// 配套命令行:
$ jmap -histo:live 8842 | head -20   # 每类实例数 + 总大小(:live 会先触发 Full GC)
$ jcmd 8842 VM.classloader_stats     # 各 ClassLoader 加载类数 → 一眼看出加载器泄漏

读引用链的技巧:从下往上读——最底是目标对象,越往上越接近根;链上出现 static 字段说明根是静态变量,出现 Thread / ThreadLocalMap 说明根是某个存活线程(往往指向线程池泄漏),出现 java.lang.Class 或自定义加载器则要往 Metaspace 方向想。

第 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 速查 + 内存泄漏诊断流程

面试回答模板

30 秒版

"从 GC Roots 出发不可达的对象才能回收。GC Roots 有五类:栈帧局部变量(含操作数栈)、静态变量、常量池引用、JNI 全局引用、JVM 内部引用(Class 对象、异常、系统线程)。注意一个限定词:只有'强可达'才必定不回收——仅被弱/软引用可达的对象照样回收。"

3 分钟版(展开)

每类根给一个"为什么":① 栈帧引用——方法执行期间活着,JIT 的活性分析甚至能提前释放不再使用的局部变量引用;② 静态变量——类不卸载就不死(系统类加载器加载时等于进程不死),所以是泄漏高发区;③ 常量池——字符串池 JDK 7 起在堆里,Class 引用跟着类卸载走;④ JNI——Native 显式创建的全局引用,不显式 delete 就一直在;⑤ JVM 内部——Class、未处理异常、系统线程由 JVM 自己管理。再落到实战:内存泄漏的本质是"该不可达却仍可达",MAT 的 Path to GC Roots 就是用来找这条链的,六种常见模式:静态集合、ThreadLocal 未 remove、资源未关闭、监听器未注销、内部类隐式持有、ClassLoader 泄漏。

高频追问表

追问答案要点
static 变量引用的对象一定不回收吗?系统类加载器加载的类——是;自定义类加载器加载的类——加载器被回收、类卸载后静态引用随之失效
字符串常量池里的字符串,谁是 GC Root?JDK 8 字符串池在堆里,字符串由运行时常量池 / Class 引用,引用链最终落到 JVM 内部引用(Class)或静态字段
线程为什么是 GC Root?线程存活期间,其栈帧里的引用都是根;线程泄漏(线程池未关)会拖住整条引用链——ThreadLocal 泄漏的根源在此
JVM 内部引用具体有哪些?Class 对象、未处理的异常(Throwable)、系统类加载器、正在执行的线程对象、GC 自身的数据结构
对象可达就一定能活吗?不一定——看引用强度:弱引用可达照样回收,软引用可达在内存紧张时回收,只有强引用可达才必定不回收
最终面试话术

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

Comments · 评论