刚学 JVM GC 的时候,我在这些概念上卡了好久

刚学 JVM GC 的时候,我在这些概念上卡了好久
刚开始啃 JVM 的时候光是怎么判断一个对象该不该回收这个问题我就翻了好几次车。引用计数、可达性分析、GC Roots……每个概念单独看好像都懂合在一起就懵了。这篇是我自己理清楚之后记下来的。GC 是什么、什么时候干活GC 全称 Garbage Collection垃圾回收。它的工作就是帮 Java 程序员自动释放不再使用的对象占用的内存。你不必像写 C 语言那样手动 free省了很多事。但它的触发时机并不是程序闲了就回收一下。JVM 触发 GC 有几种情况每种我都花了点时间才搞明白。内存不够了才收这是最常见的触发方式。当 JVM 发现堆内存不够给新对象分配空间时自动触发垃圾回收。你可以把它理解成仓库快满了管理员才会来清理。如果清理完了空间还是不够那就 OutOfMemoryError。我第一次读到这个的时候想的是为什么不提前收非要等到不够了才收后来才明白GC 本身也要消耗 CPU 资源。频繁回收就像每隔十分钟打扫一次房间房间是干净了但你什么事都干不了。JVM 的选择是能拖就拖真撑不住了再动手。手动调用 System.gc()开发者可以通过System.gc()或Runtime.getRuntime().gc()建议 JVM 做一次垃圾回收。注意是建议不是命令。就像一个同事问你要不要休息一下决定权在你身上。在实际项目里频繁调System.gc()不是个好习惯因为它会触发 Full GC开销很大。我刚开始不知道这一点调试的时候喜欢在代码里加System.gc()看看效果后来被同事提醒才知道线上千万不能这么干。JVM 参数控制启动时可以通过参数调整 JVM 的内存行为比如-Xmx设置最大堆大小-Xms设置初始堆大小。这些参数不直接触发 GC但它们决定了 JVM 什么时候觉得内存不够了。比如你把最大堆设成 2 GBJVM 就会在接近 2 GB 的时候开始 GC。如果你设成 512 MBGC 就会触发得更频繁。分代触发不同区域有各自的节奏这是我最开始觉得绕的部分。JVM 把堆分成几个代每个代满了触发的 GC 类型不一样我一开始有个疑问为什么不直接说堆满了就 GC后来读资料才明白新生代和老年代的 GC 频率和耗时差太多了。Minor GC 一次几毫秒到十几毫秒Full GC 一次可能几百毫秒甚至几秒。如果每次 GC 都扫描整个堆程序动不动就停顿用户根本受不了。所以 JVM 选择分而治之新生代满了先做一次小范围的快速回收实在腾不出空间了再做全堆的大回收。Eden 区满了触发 Minor GC也叫 Young GC。年轻代里的对象大多朝生夕死Minor GC 很频繁但速度很快。老年代满了触发 Major GC 或 Full GC。老年代里的对象活得更久空间更大所以 Major GC 比 Minor GC 慢一个数量级。元空间Metaspace达到阈值也会触发 Full GC。G1 收集器比较特别不按分代满来触发。当堆占用率达到一个阈值默认 45%通过-XX:InitiatingHeapOccupancyPercent设置时启动并发标记周期。我当时记这些触发条件的时候很痛苦因为记不住哪些条件触发 Minor GC、哪些触发 Full GC。后来画了一张表贴在编辑器旁边每次忘了就瞟一眼慢慢就记住了。怎么判断一个对象是垃圾这是 GC 最核心的问题一个对象只要还被其他地方引用着你就不能回收它。但怎么知道它没人用了有两种判断方式。引用计数法简单但有漏洞这个方法的思路很直接每个对象头上挂一个计数器有人引用它计数器 1引用失效计数器 -1。计数器变成 0 的时候说明没人引用这个对象了可以回收。听上去很完美对吧我一开始也觉得这个方法挺好的。但它有一个致命的缺陷解决不了循环引用。看这个例子classNode{Nodenext;}NodeanewNode();NodebnewNode();a.nextb;b.nexta;anull;bnull;最后两行把 a 和 b 的引用都置空了。从能不能用的角度看这两个 Node 对象已经不可能被任何代码访问到了。但它们的引用计数却是 1因为 a.next 还指向 bb.next 还指向 a。引用计数法看到计数器不为 0就觉得还有人用但事实上已经没人能摸到它们了。这就是循环引用。两个对象互相抱着但外面的世界已经跟它们没有关系了。因为它们互相引用计数器永远到不了 0就永远不会被回收。Java 没有采用引用计数法循环引用是主要原因。不过话说回来Python 和 PHP 还在用引用计数法做主要的垃圾回收辅以其他机制所以这个方法也不是一无是处。只是对于 Java 来说它不够可靠。我查了一下 Python 为什么要用引用计数。Python 解释器的执行效率本来就比 Java 低如果再用可达性分析做全局遍历性能更难接受。引用计数虽然解决不了循环引用但它的开销分散在每次引用赋值中不会突然卡住整个程序做一次全堆扫描。这是取舍问题不是谁对谁错。可达性分析JVM 真正用的方案JVM 采用的判断方法不是从对象往外数引用而是从一组确定的根出发顺着引用链往下走。走得到的就算活的走不到的就是死的。这个思路有点像你站在一个房间里脚踩的地方就是根。你能伸手够到的东西以及那些东西能继续够到的东西都算活的。所有你站在原位够不到的东西都可以扔掉。GC Roots 到底是什么GC Roots 是 JVM 里一些特殊的锚点它们保证了一定不会被回收。从这些锚点出发能到达的对象都活着到达不了的就是垃圾。常见的 GC Roots 包括1. 虚拟机栈中引用的对象。就是当前正在执行的方法里的局部变量。比如你在main方法里new了一个对象这个对象就是 GC Root 可达的。2. 静态变量引用的对象。类的静态字段引用的对象。只要类还没被卸载这些引用就活着。比如public static final修饰的常量对象。3. 常量池中的引用。字符串常量池里被 intern 的字符串引用就属于这一类。4. JNI 引用。本地方法用 C/C 写的代码中引用的 Java 对象。JVM 不知道本地方法内部怎么使用这些对象所以只要传过去了就不能回收。5. 被 synchronized 锁住的对象。任何一个正在被 synchronized 持有的对象都不能回收。万一锁突然没了并发逻辑就全乱了。6. JVM 内部的一些特殊对象。比如系统类加载器、常驻的异常对象像 OutOfMemoryError、NullPointerException 这种基本异常、基本数据类型对应的 Class 对象等。我刚开始看这六条的时候觉得也太多了记不住。后来换了个角度想就简单了凡是 JVM 自己觉得这个必须活着的东西都是 GC Roots。它必须活着的原因可能是正在被代码用着“正在被锁等着”“正在被 native 代码用着”。理解了这一句那六条就不用硬背了。一个具体的例子帮你理解我用自己的代码捋一遍这个过程publicclassDemo{privatestaticUserServiceuserServicenewUserService();publicstaticvoidmain(String[]args){UserusernewUser();user.doSomething();}}执行到main方法的时候GC Roots 有哪些user是 main 方法里的局部变量它在虚拟机栈里所以它的 User 对象是活的。userService是Demo类的静态字段引用的对象它在方法区里所以 UserService 对象也是活的。如果user.doSomething()内部用到了 synchronized 锁住了一个对象那个对象也是活的。这三个就是 GC Roots。JVM 从它们出发沿着引用链往下找。能走到的对象都活着走不到的就是垃圾。我当时拿到这段代码的时候一个一个对照着六条 GC Roots 去对。走到userService发现它是静态变量引用的走了第二条走到user发现它是局部变量走了第一条。对着对着就自然记住了。不过这里有个细节值得注意user对象在main方法执行完后是不是就变成垃圾了没错。只要main方法执行完毕user这个局部变量就从虚拟机栈里弹出User 对象就不再被 GC Roots 可达了。下一次 GC 就会把它回收掉。同一个对象在执行期间和执行结束后存活状态完全不同。GC 到底是图什么说白了GC 解决的核心问题只有一个手动管理内存太容易出错了。在 C 和 C 里你malloc了一个对象用完必须自己free。忘了 free内存泄漏。free 早了别人再用就崩溃。free 晚了内存利用率低。手动内存管理需要开发者一直绷着根弦一不小心就出事。Java 选择了一条不同的路你只管new不用管 delete。JVM 在后台自动识别哪些对象没人用了把它们的空间腾出来。GC 带来的好处减少内存泄漏。JVM 会回收那些已经不可达的对象。但有一种情况 GC 也没办法如果你把对象的引用一直留着不用GC 不会主动去猜你是不是忘了。防止内存溢出。GC 会尽力腾空间。但如果你的程序就是不停地在创建对象而且创建出来的对象都还活着那 GC 也没辙最终还是会 OutOfMemoryError。GC 不是万能的。开发效率提高。少操心一件事就能多专注在业务逻辑上。不过这个好处的代价是你需要付出学习成本去理解 GC 的工作原理否则出了问题你都不知道怎么排查。GC 的代价GC 不是免费的。回收的时候程序可能会暂停Stop The World简称 STW。STW 期间所有工作线程都停下来等 GC 干完活再继续。暂停的时间越长对用户的影响越大。对于用户交互型的应用比如 Web 服务器一次几百毫秒的 STW 可能就意味着一次接口超时。对于低延迟系统比如交易系统或者实时推荐几毫秒的暂停都不能接受。这也是为什么后来出现了 ZGC 和 Shenandoah它们的目标就是把暂停时间控制在 10 毫秒以内。所以 JVM 才有了那么多不同的垃圾收集器比如 Serial、Parallel、CMS、G1、ZGC……它们的核心目标其实都是同一件事把 STW 的时间缩到最短或者把 STW 变成几乎不停。GC 的范围不只是堆很多人以为 GC 只管堆Heap其实方法区Method Area也在 GC 的管辖范围内。堆GC 的主战场绝大多数对象都分配在堆上。每次new都是堆上的一块空间。当对象从 GC Roots 不可达了它占用的堆空间就可以被回收。堆的 GC 也是最频繁的。方法区GC 的第二战场方法区存的是类信息、常量、静态变量、JIT 编译后的代码等。方法区的 GC 跟堆不太一样它主要回收两类东西1. 废弃的常量。比如一个字符串常量之前被 intern 了后来没人用了就可以从常量池里移除。这块相对简单跟堆的回收逻辑差不多。2. 不再使用的类。类要被卸载需要同时满足三个条件该类的所有实例都已被回收加载该类的 ClassLoader 已被回收该类对应的java.lang.Class对象没有被任何地方引用这三个条件缺一个都不行。其中ClassLoader 已被回收往往是最难满足的。很多框架Spring、MyBatis在运行时会生成动态代理类这些类的 ClassLoader 通常是应用类加载器。只要应用还在运行类加载器就活着那些动态类的信息就不会被卸载。方法区的 GC 不像堆那么积极而且效果也不明显。因为方法区里的大部分数据生命周期很长GC 能回收的东西非常有限。我之前一直以为类一旦被加载就会永远待在内存里后来读到方法区也能回收类的信息才发现不是这样。只是条件苛刻实际触发的场景不多。理完之后我的几点感受把这部分从头过了一遍有几个印象比较深的体会1. 可达这个概念比被引用更准确。引用计数法看的是有多少引用指向我可达性分析看的是从根出发能不能走到我。两种视角的差别就是循环引用能不能被处理的关键。引用计数法面对两个互相抱着的对象觉得它们还有人用可达性分析从根出发发现根本走不到它们俩就知道是垃圾了。2. GC Roots 清单不需要死记。理解什么是 JVM 必须保住的比背六条列表有用得多。你在业务代码里遇到的大部分对象要么在栈上被局部变量引用着要么被静态变量引用着。记住这两个就够应付大部分场景了。3. GC 不是免费午餐。它解决了手动内存管理容易出错的问题但引入了 STW 暂停的问题。不同的垃圾收集器就是在找平衡点。后面学 CMS、G1、ZGC 的时候本质上学的就是它们各自用什么方式减少暂停。4. 方法区也能 GC但效果有限。类卸载的条件很苛刻。在实际的 Java 应用中尤其是使用了大量动态代理和反射的框架Spring、MyBatis会有很多动态生成的类。如果真的发生 Full GC方法区也是扫描和清理的一部分但别指望能回收多少。如果你也在学 JVM我建议先把可达性分析这个概念彻底搞懂它是理解后面一切 GC 算法的基础。搞懂了它标记-清除、标记-整理、分代收集这些算法的设计思路就会顺很多。