GC Roots与可达性分析——对象是如何被标记存活的?
GC Roots与可达性分析——对象是如何被标记存活的?
前言
“对象不再被引用时就会被回收”——这句话我们听了无数遍,但JVM到底怎么判断“不再被引用”?
是数一数有多少引用指向它(引用计数法),还是从根出发找一找还能不能找到它(可达性分析法)?Java选择了后者。
但更微妙的问题是:一个对象明明代码里已经不使用了,为什么有时候还是没被回收?这就涉及局部变量表与内存泄漏的经典陷阱。
本文将从GC Roots出发,带你彻底理解可达性分析法,以及如何避免“意外”的内存泄漏。
一、可达性分析法的本质
1.1 核心原理
可达性分析法:JVM从一个根集合(GC Roots)出发,通过引用关系遍历对象图。所有能被遍历到的对象,就被标记为“存活”;遍历结束后,未被标记的对象就是“可回收”的垃圾。
这个过程可以想象成一张有向图:
- 对象是图中的节点
- 引用关系是图中的边
- GC Roots是图的入口节点
1.2 一个生动的例子:链表
classNode{Nodenext;}publicvoidtest(){Nodehead=newNode();// 头节点head.next=newNode();// 第二个节点head.next.next=newNode();// 第三个节点// 方法执行中...}可达性分析的过程:
- 从GC Root
head(局部变量)出发 - 沿着
head.next找到第二个节点 - 沿着
head.next.next找到第三个节点 - 三个节点全部标记为存活
如果中间断了:
head.next=null;// 中间引用断开结果:第二个和第三个节点从GC Roots无法到达,被标记为垃圾。
二、GC Roots到底是什么?
你可以把GC Roots想象成程序运行的**“起点”或“全局抓手”**。只有从这些点出发能找到的对象,才是有用的。
2.1 五种GC Roots
publicclassGCRootsExample{// 1. 静态变量(在方法区中)// 只要类GCRootsExample未被卸载,t1指向的对象就永远可达privatestaticTestt1=newTest();// 2. 常量池中的引用(也是方法区)privatestaticfinalTestt2=newTest();publicvoidmethod(){// 3. 局部变量表(在虚拟机栈帧中)// 当method()正在执行时,这两个变量就是GC RootsObjectobj=newObject();Stringstr="hello";// 4. 活跃的Java线程// 线程启动后,它自己就是“活的”Threadthread=newThread(()->{while(true){// 这个线程内部的对象通过线程本身可达}});thread.start();// obj 和 str 在方法执行完出栈后,就不再是GC Roots了}publicstaticvoidmain(String[]args){// 5. 方法参数(也是局部变量)// main方法执行期间,args是GC RootGCRootsExampleexample=newGCRootsExample();example.method();// example对象被main方法的局部变量引用,所以存活}// 6. JNI引用(本地方法栈中的对象)publicnativevoidnativeMethod();// 7. 同步监视器(synchronized持有的对象)publicvoidsyncMethod(){synchronized(this){// this就是GC Root// ...}}}2.2 关键理解
| GC Root类型 | 所在区域 | 生命周期 |
|---|---|---|
| 局部变量 | 虚拟机栈 | 方法执行期间 |
| 静态变量 | 方法区 | 类卸载前 |
| 常量 | 方法区 | 类卸载前 |
| 活跃线程 | 堆(Thread对象) | 线程结束前 |
| JNI引用 | 本地方法栈 | 方法执行期间 |
| 同步监视器 | 堆 | 锁释放前 |
三、避免内存浪费小技巧
下面是一个优化性能的小技巧,避免内存浪费。
3.1 一个极端的例子
publicvoidtest(){// 第一阶段:分配一个大数组并使用byte[]bigBuffer=newbyte[100*1024*1024];// 100MBprocessData(bigBuffer);// 处理数据// 第二阶段:执行耗时操作,不再需要bigBuffertry{Thread.sleep(10000);// 模拟耗时10秒的其他操作}catch(InterruptedExceptione){e.printStackTrace();}// 第三阶段:可能还有其他操作...computeOtherThings();// 方法结束}3.2 发生了什么?
在Thread.sleep(10000)执行的这10秒内:
test()方法的栈帧仍然存在(方法还没结束)- 局部变量
bigBuffer还在局部变量表中 bigBuffer仍然指向堆中的100MB数组- 从GC Roots可达性分析看:这个数组是可达的!
结果:整整10秒,100MB内存被白白占用,无法回收。
在方法执行的后期,这个对象已经不用了却仍然占用内存,造成了临时的内存浪费。
在长耗时的方法中,这种浪费可能很严重。
3.3 如何避免?
publicvoidtest(){// 第一阶段byte[]bigBuffer=newbyte[100*1024*1024];processData(bigBuffer);// 关键:主动切断引用bigBuffer=null;// 局部变量表中的引用被清空// 第二阶段Thread.sleep(10000);// 此时bigBuffer已不可达,可以被GC回收computeOtherThings();}手动置null的本质:把局部变量表中的引用值清空,让对象提前变成不可达。
四、可达性分析 vs 引用计数法
4.1 引用计数法的缺陷(循环引用)
classNode{Nodenext;}publicvoidtest(){Nodea=newNode();Nodeb=newNode();a.next=b;// b的计数+1b.next=a;// a的计数+1// 方法结束,a和b出栈// 但a还被b.next引用,b还被a.next引用// 计数永远不为0,无法回收!}关键分析:
- 栈帧销毁只销毁了引用变量
a和b(存储地址的内存) - 但堆中a对象的
next字段还在指向b,b对象的next字段还在指向a - 引用计数法统计所有引用,包括堆内的引用
- 两个对象的计数都是1,永远不会变成0
4.2 可达性分析法如何解决?
// 还是这个循环引用Nodea=newNode();Nodeb=newNode();a.next=b;b.next=a;// 方法结束可达性分析的过程:
- 从GC Roots开始找(a和b出栈后,没有根指向它们)
- 遍历引用链:从任何根出发都找不到a和b
- 结论:a和b都是不可达的,全部标记为垃圾
- 一次性全部回收
4.3 两种方法的本质区别
| 对比项 | 引用计数法 | 可达性分析法 |
|---|---|---|
| 关注点 | 有多少引用指向我 | 从根出发能否找到我 |
| 统计范围 | 所有引用(栈+堆) | 只关心从根出发的路径 |
| 循环引用 | ❌ 无法处理 | ✅ 轻松处理 |
| 并发开销 | 大(每次赋值要原子操作) | 小(只在GC时扫描) |
一句话总结:Java的GC不是在看“谁指着我”,而是在看“我能从根找到谁”。
五、一个帮你通透的类比
引用计数法:就像一个小区的保安,每个人进出都要登记(引用变化就要计数)。如果两个人互相说“我在他家”(循环引用),保安就糊涂了,以为两家都有人,永远不清理。
可达性分析法:就像警察查户口,从街道办(GC Roots)开始,挨家挨户查:你们家是谁管的?查到谁就登记,没查到的就是空房。不管你们邻里之间怎么互相说“我在他家”,只要街道办找不到你,你就是空房。
局部变量未清空:就像你搬家了(不再需要某个对象),但还在街道办的登记册上(局部变量还在引用),警察就会认为你家还有人。
六、总结
- 可达性分析的本质:从GC Roots出发,遍历对象图,能到达的存活,不能到达的回收
- GC Roots的五种类型:局部变量、静态变量、常量、活跃线程、JNI引用、同步监视器
- 局部变量表陷阱:方法未结束,即使不再使用的对象,只要还在局部变量表中,就仍然存活
- 手动置null的意义:提前切断引用,让对象提前变得不可达
- 引用计数法的死穴:循环引用导致计数永远不为0
- Java的选择:用全局扫描的可达性分析,换取正确性和并发性能
未完待续
理解了对象怎么被标记存活,下期我们来看对象存在哪里——内存区域的演进与直接内存,从永久代到元空间,从传统IO到零拷贝,带你理解JVM性能优化的权衡艺术。
参考资料
- 《深入理解Java虚拟机》周志明
- Oracle官方文档:Java Garbage Collection Basics
- HotSpot VM源码
如果你觉得本文有帮助,欢迎点赞、评论、转发!
