Lesson 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 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 在大多数场景下是不必要的。
静态变量引用:伴随类加载器的长生命周期
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 引用
运行时常量池(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 泄漏问题。
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 未清理
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 "拽住"了它;④ 在合适的时机切断引用链。
总结:GC Roots 知识清单
全文核心记忆点:
- GC Roots 是可达性分析的起点,共 5 种类型
- 栈帧局部变量:最常见,生命周期 = 方法调用期间,方法返回即释放
- 静态变量:生命周期 = 类的生命周期,静态集合只增不减是泄漏高发区
- 常量池引用:字符串池 + 类引用,动态类加载场景需警惕 ClassLoader 泄漏
- JNI 全局引用:Native 代码持有 Java 对象,资源未关闭时造成泄漏
- JVM 内部引用:ClassLoader、异常对象、系统线程等 JVM 自行管理
- 内存泄漏诊断:Heap Dump → MAT 找大对象 → 查看 GC Root 引用链 → 切断引用
JVM 通过可达性分析判断对象是否存活,GC Roots 是分析的起点。有 5 种 GC Root:栈帧局部变量(最常见,方法返回即释放)、静态变量(伴随类的整个生命周期,是泄漏高发区)、常量池引用(字符串池和 Class 引用)、JNI 全局引用(Native 代码持有)、JVM 内部引用(ClassLoader 等)。理解 GC Roots 的实际价值在于诊断内存泄漏——当对象本应被回收却仍然存活时,一定是被某个 GC Root 的引用链"拽住"了,通过 MAT 的 "Path to GC Roots" 功能可以快速定位泄漏根源。