JAVA · Vol.III · DAY 27 · JVM 原理与调优
GC Roots 详解:哪些对象不会被回收?
面试官:JVM 怎么判断一个对象是否可以回收?
"看有没有引用指向它。" 大部分候选人说到这里就停了。面试官想听的下一句是——从哪些引用开始找?
JVM 判断对象是否存活,使用可达性分析(Reachability Analysis):从一组固定的"根对象"出发,沿引用链向下遍历。能遍历到的对象"存活",遍历不到的就是"垃圾"。
这组根对象就叫做 GC Roots。它们不是特殊对象,而是 GC 可达性分析的起点集合。只有从 GC Roots 可达的对象才不会被回收。
理解 GC Roots 是理解内存泄漏的基石——内存泄漏的本质就是:你以为对象应该被回收,但它仍然被某个 GC Root 引用链"拽着"。JVM 中共有 5 种对象可以作为 GC Roots。
5 种 GC Roots 一览
| # | GC Root 类型 | 说明 | 典型场景 |
|---|---|---|---|
| 1 | 栈帧局部变量 | 每个线程栈帧中的局部变量和参数 | 方法内 new 出的对象 |
| 2 | 静态变量 | 类中 static 字段引用的对象 | 单例模式、静态缓存 |
| 3 | 常量池引用 | 运行时常量池中的引用 | 字符串常量池、Class 引用 |
| 4 | JNI 全局引用 | Native 代码通过 JNI 创建的全局引用 | C/C++ 库持有的 Java 对象 |
| 5 | JVM 内部引用 | JVM 自身维护的内部对象 | ClassLoader、异常对象、系统线程 |
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 集合 = 初始快照 + 写屏障跟踪的变更日志,不是一份固定清单。
栈帧引用:方法执行中的局部变量
这是最常见的 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"。看一段字节码就很清楚:
// javap -c 简化输出 new java/lang/Object // ① 实例创建,引用压入操作数栈(此刻只有栈引用它) dup // ② 复制一份引用(一份留栈上准备调构造器) invokespecial java/lang/Object.<init> // ③ 执行构造器 astore_1 // ④ 存入局部变量槽 1(此后由局部变量表引用) // ①→④ 任何时刻触发 GC,该对象都是可达的
静态变量引用:伴随类加载器的长生命周期
static 字段属于类本身(存储在元空间),而非某个对象实例。类由系统类加载器(AppClassLoader)加载,其生命周期与 JVM 进程一致。因此静态变量引用的对象在整个应用运行期间都不会被回收。
public class ConfigManager { // instance 是 GC Root,只要类没卸载就不会被回收 private static ConfigManager instance = new ConfigManager(); private static Map<String, Object> cache = new HashMap<>(); // cache 也是 GC Root }
静态变量引用和栈帧引用有什么本质区别?
栈帧引用的生命周期是方法调用期间,方法返回就失效。静态变量引用的生命周期是类的生命周期,通常就是整个 JVM 运行期。"活"得太久意味着——如果不小心管理,就会造成内存泄漏。
静态集合:最常见的内存泄漏陷阱
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 泄漏模式的镜像。
常量池引用:字符串池与 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 泄漏问题。
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 未清理
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); // ✅ 生命周期结束时注销 } }
模式五:内部类隐式持有外部类(高频隐蔽坑)
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$0 | static 内部类 / 显式持有引用 |
| ClassLoader 泄漏 | JVM 内部(Class / 注册表) | 旧加载器不可回收 | 解注册 + 释放引用,让整批一起回收 |
遇到内存泄漏时:① 用 MAT / JProfiler 找到大对象;② 查看 GC Roots 引用链(MAT 中 "Path to GC Roots");③ 找到是哪个 GC Root "拽住"了它;④ 在合适的时机切断引用链。
实战:MAT 里的 GC Root 引用链长什么样
// 对可疑大对象右键 → 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 方向想。
总结:GC Roots 知识清单
全文核心记忆点:
- GC Roots 是可达性分析的起点,共 5 种类型
- 栈帧局部变量:最常见,生命周期 = 方法调用期间,方法返回即释放
- 静态变量:生命周期 = 类的生命周期,静态集合只增不减是泄漏高发区
- 常量池引用:字符串池 + 类引用,动态类加载场景需警惕 ClassLoader 泄漏
- JNI 全局引用:Native 代码持有 Java 对象,资源未关闭时造成泄漏
- JVM 内部引用:ClassLoader、异常对象、系统线程等 JVM 自行管理
- 内存泄漏诊断:Heap Dump → MAT 找大对象 → 查看 GC Root 引用链 → 切断引用
面试回答模板
"从 GC Roots 出发不可达的对象才能回收。GC Roots 有五类:栈帧局部变量(含操作数栈)、静态变量、常量池引用、JNI 全局引用、JVM 内部引用(Class 对象、异常、系统线程)。注意一个限定词:只有'强可达'才必定不回收——仅被弱/软引用可达的对象照样回收。"
每类根给一个"为什么":① 栈帧引用——方法执行期间活着,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 · 评论