
1. 为什么“垃圾回收”这四个字值得单独拎出来讲很多人学JVM一上来就啃GC算法、收集器参数、调优案例结果背了一堆名词真遇到线上问题还是两眼一抹黑。我见过不少工作两三年的开发者能说出CMS和G1的区别但问他“什么是垃圾”“怎么判断一个对象该不该回收”回答就开始含糊了。这就是典型的跳过了概念层直接冲实现层根基不稳。这一章的内容在整个JVM知识体系里位置很特殊——它不教你写代码也不给你调优参数它解决的是一个更底层的问题我们到底在回收什么凭什么说它是垃圾回收的代价是什么。这三个问题想不清楚后面学收集器就是死记硬背。我写这篇的出发点很简单把“垃圾回收相关概念”这章里那些看起来平淡、实际上处处是坑的知识点用一线排查问题的视角重新讲一遍。适合谁看适合已经能写Java代码、但对JVM内存管理只有模糊印象的开发者也适合那些背过八股文、但没真正理解“可达性分析为什么要从GC Roots出发”的人。我会尽量把每个概念背后的“为什么”讲透而不是只告诉你“是什么”。先给一个整体认知垃圾回收这件事本质上是在正确性和效率之间做权衡。判断一个对象是不是垃圾判得太严格会漏回收判得太宽松会误杀活对象——前者浪费内存后者直接让程序崩溃。所以后面你会看到几乎所有GC概念的设计都是在回答“怎么在保证不误杀的前提下尽量多回收”。2. 什么是“垃圾”从引用计数到可达性分析的认知升级2.1 引用计数为什么被主流JVM抛弃最直觉的“判断对象是否存活”的方法就是引用计数给每个对象配一个计数器有地方引用它就加一引用失效就减一减到零就是垃圾。这个思路简单、实时性也好对象一死立刻能发现。但它有两个致命问题导致主流的HotSpot虚拟机根本没用它。第一个问题是循环引用。两个对象互相持有对方的引用除此之外没有任何外部引用指向它们。按引用计数的逻辑它俩的计数都是1永远不会归零于是永远回收不掉。你可以写个最简单的例子验证public class RefCountDemo { public Object instance null; public static void main(String[] args) { RefCountDemo a new RefCountDemo(); RefCountDemo b new RefCountDemo(); a.instance b; b.instance a; a null; b null; // 此时a和b互相引用但外部已无法访问引用计数无法回收 } }第二个问题是计数器的维护成本。每次引用赋值都要更新计数这个操作在多线程环境下还需要保证原子性开销不小。而且计数器本身也占内存。所以引用计数更多出现在一些脚本语言或特定场景里Java没有采用。注意面试里经常问“引用计数能不能解决循环引用”标准答案是“单纯引用计数不能需要配合可达性分析或其他机制”。别答成“能”。2.2 可达性分析从GC Roots出发的“图遍历”HotSpot用的是可达性分析。思路是从一组被称为GC Roots的根对象出发沿着引用链往下遍历能被遍历到的对象就是“存活”的遍历不到的就是“可回收”的。这本质上是一次图的遍历从根节点出发标记所有可达节点。那GC Roots具体包括哪些这是必须记牢的因为排查内存泄漏时你就是要找“谁在引用这个对象让它一直可达”。常见的有虚拟机栈栈帧中的局部变量表中引用的对象方法区中类静态属性引用的对象方法区中常量引用的对象本地方法栈中JNI引用的对象虚拟机内部的引用比如基本类型的Class对象、常驻异常对象、系统类加载器被同步锁持有的对象反映JVM内部情况的JMXBean、JVMTI中注册的回调等这里有个容易混淆的点GC Roots是一组不是单个。很多人以为“根”就是一个对象其实是一批。可达性分析是从这一批根同时出发做遍历。2.3 可达性分析和引用计数的本质差异引用计数是“局部判断”——只看这个对象自己有没有人引用。可达性分析是“全局判断”——从根出发看整个引用图。这个差异决定了可达性分析能天然处理循环引用a和b互相引用但从GC Roots出发到不了它们所以照样被回收。但可达性分析也有代价它需要Stop The World。因为遍历过程中如果对象的引用关系还在变标记结果就不准。所以GC时必须暂停所有用户线程等标记完成。这就是为什么GC会带来停顿——不是算法本身慢而是为了保证正确性必须暂停。理解了这一点你就能理解后面各种收集器为什么拼命在“缩短停顿时间”上做文章。3. 对象的“生死”不是非黑即白四种引用与finalize的真相3.1 强软弱虚四种引用对应的回收策略很多人只知道“强引用”其实Java把引用分成了四个强度等级这直接决定了对象在GC时的命运。理解这四种引用是理解“对象什么时候被回收”的关键。引用类型回收时机典型用途强引用只要可达就永不回收普通对象引用软引用内存不足时回收缓存弱引用下次GC必回收弱化映射、ThreadLocal虚引用随时可能被回收对象回收跟踪强引用就是我们平时写的Object o new Object()只要这个引用还在对象就不会被回收。哪怕内存快爆了JVM宁可抛OOM也不动它。软引用SoftReference适合做缓存。它的特点是“内存够就不回收内存不够才回收”。这个“内存不够”的判定依据是“即将发生OOM之前”。所以软引用适合那种“有最好、没有也能重新算”的数据。弱引用WeakReference更弱只要发生GC不管内存够不够弱引用指向的对象都会被回收。ThreadLocal的内部实现就用了弱引用这是为了防止Entry的key一直强引用ThreadLocal对象导致内存泄漏。虚引用PhantomReference最特殊你甚至无法通过它获取对象。它的唯一作用是对象被回收时收到一个通知。它必须和ReferenceQueue配合使用常用于管理堆外内存的释放。3.2 finalize方法一个被废弃的“自救”机制对象被判定为不可达之后并不是立刻就被回收。它还有一次“自救”机会——如果这个对象重写了finalize()方法并且没有被调用过JVM会把它放进一个叫F-Queue的队列由一个低优先级的Finalizer线程去执行它的finalize方法。如果在这个方法里对象重新把自己和某个GC Roots关联起来比如赋值给一个静态变量那它就“复活”了这次GC不会回收它。但这个机制问题很多执行时机不确定、可能拖慢GC、甚至可能导致对象永远不被回收。所以从JDK 9开始finalize()被标记为废弃JDK 18之后基本可以认为它已经退出历史舞台。我个人的建议很直接永远不要依赖finalize做资源清理。清理工作交给try-with-resources或者显式的close方法。finalize能不用就不用面试里知道它的原理即可。3.3 方法区回收为什么“类”也能被回收大部分人聊GC只关注堆其实方法区JDK 8之后是元空间也会回收只不过回收条件非常苛刻。一个类要被回收必须同时满足该类的所有实例都已经被回收加载该类的类加载器已经被回收该类对应的Class对象没有被任何地方引用无法通过反射访问这三个条件同时满足很难所以方法区回收的“性价比”很低。但也不是完全没用比如大量使用反射、动态代理、CGLIB生成类的场景如果类加载器能一起回收方法区的空间就能释放。这也是为什么一些热部署、OSGi、JSP等场景要特别关注类加载器的生命周期。4. 垃圾回收算法标记-清除、复制、标记-整理到底怎么选4.1 标记-清除最基础但最“碎”的算法标记-清除分两步先标记所有需要回收的对象然后统一清除。它是最基础的算法后面的算法都是在它基础上改进的。但它有两个明显缺点第一是执行效率不稳定。如果堆里大部分对象都是垃圾标记和清除的过程都会很慢。第二是内存碎片。清除之后会产生大量不连续的内存块当需要分配一个大对象时可能明明总空间够却找不到一块连续的空间从而提前触发下一次GC。你可以把标记-清除想象成整理房间先把不要的东西贴上标签然后一件件扔出去。扔完之后房间里到处是空隙想放一个大柜子就放不下了。4.2 复制算法用空间换效率复制算法把内存分成两块每次只用其中一块。GC时把存活对象复制到另一块然后一次性清空当前块。它的优点是没有碎片、分配效率高直接指针碰撞分配。缺点是浪费一半空间。实际JVM里并不是简单地对半切。HotSpot的年轻代用了“Eden 两个Survivor”的结构默认比例是8:1:1。每次只用Eden和一个SurvivorGC时把存活对象复制到另一个Survivor。这样只浪费10%的空间而不是50%。这个设计很巧妙因为年轻代里绝大多数对象都是“朝生夕死”的存活对象很少复制成本低。提示如果Survivor空间不够放存活对象会触发分配担保直接把这些对象挪到老年代。这也是为什么大对象容易直接进老年代。4.3 标记-整理老年代的“搬家式”回收标记-整理和标记-清除的前半段一样都是标记。区别在清除阶段它不是直接清除而是把所有存活对象往一端移动然后清理掉边界以外的内存。这样既没有碎片也不浪费空间。代价是移动对象需要更新所有引用成本更高而且必须Stop The World。老年代为什么用标记-整理或者标记-清除而不是复制算法因为老年代对象存活率高复制算法要复制的对象太多效率反而低而且没有额外空间做担保。所以老年代的选择是“要么忍受碎片要么忍受移动成本”。4.4 三种算法的选择逻辑算法优点缺点适用区域标记-清除实现简单碎片多、效率不稳定老年代CMS复制无碎片、分配快浪费空间年轻代标记-整理无碎片、不浪费移动成本高老年代这个选择不是拍脑袋定的而是根据“对象存活率”来的。年轻代对象存活率低复制成本小适合复制算法老年代存活率高复制不划算适合标记-清除或标记-整理。理解了这层逻辑你再看各种收集器的设计就不会觉得是死记硬背了。5. 分代收集与GC分类Minor GC、Major GC、Full GC到底谁触发谁5.1 分代收集理论不是规定是经验总结分代收集不是什么理论上的硬性规定而是基于两个经验法则弱分代假说绝大多数对象朝生夕死和强分代假说熬过越多次GC的对象越难死。基于这两个假说把堆分成年轻代和老年代不同区域用不同策略就能用最小的成本回收最多的垃圾。这个设计思路其实很朴素既然大部分对象很快就死那就把新对象集中放一块频繁但快速地回收少数活下来的对象挪到另一块低频但彻底地回收。就像公司里新员工流动快老员工稳定管理策略自然不同。5.2 三种GC的触发条件和影响范围Minor GC / Young GC只回收年轻代。触发条件是Eden区满。它发生得非常频繁但速度很快因为存活对象少。注意Minor GC会触发Stop The World但因为年轻代小、存活对象少停顿通常很短。Major GC / Old GC只回收老年代。目前只有CMS会单独触发老年代回收其他收集器如G1很多时候是混合回收。触发条件通常是老年代空间不足。Full GC回收整个堆和方法区。它是最“重”的GC停顿时间最长。触发条件包括老年代空间不足、方法区空间不足、显式调用System.gc()不保证一定执行、CMS出现Concurrent Mode Failure等。这里有个常见误区Minor GC不一定比Full GC快。如果年轻代里存活对象特别多比如你创建了大量长生命周期对象Minor GC的复制成本会很高停顿也可能很长。所以“年轻代GC一定快”是个伪命题关键看存活对象比例。5.3 对象晋升老年代的几条路径对象从年轻代到老年代不是只有“熬过15次GC”这一条路。实际有好几条年龄达到阈值默认15岁-XX:MaxTenuringThreshold每熬过一次Minor GC加一岁。大对象直接进老年代通过-XX:PretenureSizeThreshold设置超过这个大小的对象直接在老年代分配避免在Eden和Survivor之间反复复制。动态年龄判定如果Survivor中相同年龄的所有对象大小总和超过Survivor空间的一半年龄大于等于该年龄的对象直接进老年代不用等到15岁。分配担保Minor GC时Survivor放不下直接进老年代。这几条路径里动态年龄判定最容易被忽略但它在实际调优中影响很大。有时候你会发现对象“莫名其妙”就进了老年代很可能就是动态年龄判定提前触发了晋升。6. 从概念到排查这些知识在真实问题里怎么用6.1 内存泄漏排查顺着GC Roots找引用链线上遇到内存持续上涨、Full GC后内存降不下来第一步就是dump堆然后找“谁在引用这些本该被回收的对象”。这时候可达性分析的知识就派上用场了你要找的是从GC Roots到目标对象的引用链。具体操作上可以用jmap dump堆文件然后用MAT或JProfiler分析。重点看支配树和引用链。支配树能告诉你哪个对象占的内存最多引用链能告诉你它是怎么被GC Roots引用的。常见的内存泄漏原因包括静态集合类持有对象、监听器未注销、ThreadLocal未remove、连接未关闭等。我踩过的一个坑是用ThreadLocal存了一个大对象线程池里的线程一直复用ThreadLocal的Entry一直不释放导致大对象一直可达。后来改成用完就remove()才解决。这个问题的根因就是“线程作为GC Root一直活着它持有的ThreadLocalMap一直可达”。6.2 频繁Full GC从晋升逻辑反推原因如果监控发现Full GC特别频繁可以从“对象为什么频繁进老年代”这个角度反推。常见原因大对象太多检查是否有大数组、大字符串考虑调大PretenureSizeThreshold或优化代码。Survivor太小导致对象过早晋升可以调大Survivor比例-XX:SurvivorRatio。动态年龄判定提前触发如果Survivor里同年龄对象多会提前晋升可以适当调大Survivor。元空间不足JDK 8之后方法区在元空间如果动态生成类太多会触发Full GC需要调大MetaspaceSize。这些排查思路的共同点是先理解对象晋升的机制再从机制反推现象。而不是一上来就瞎调参数。6.3 软引用缓存用对了是利器用错了是灾难软引用做缓存很常见但有个坑软引用对象在内存不足时会被回收但“内存不足”的判定是“即将OOM”。如果你的缓存对象很大可能在回收软引用之前就已经OOM了。所以软引用缓存适合“单个对象不大、总量可控”的场景。另外软引用和引用队列配合使用可以感知回收时机但大多数场景下不需要这么复杂。我个人的经验是如果缓存数据能接受重新计算用软引用如果不能接受用带容量限制的强引用缓存比如Guava Cache别指望软引用帮你兜底。7. 几个容易被问倒的细节STW、安全点与并发可达性分析7.1 Stop The World为什么不可避免前面提过可达性分析必须暂停用户线程否则标记结果不准。这个暂停就是Stop The World。很多人问“能不能不暂停”答案是在经典的“标记-清除”模型里不能。因为如果标记过程中引用关系还在变你标记的对象可能下一秒就不可达了或者反过来。但也不是完全没有优化空间。比如并发标记CMS和G1都用了允许在标记阶段和用户线程并发执行但需要额外的“重新标记”阶段来修正并发期间变化的对象。这个重新标记阶段仍然需要STW只是时间被大大缩短了。7.2 安全点不是想停就能立刻停JVM要暂停线程不是随便找个指令就能停。线程必须跑到一个安全点Safepoint才能暂停。安全点通常选在方法调用、循环跳转、异常跳转这些“边界”位置。为什么因为在这些位置线程的引用关系是明确的栈帧状态是清晰的GC才能准确找到所有GC Roots。这就带来一个问题如果线程一直在跑一个没有方法调用的大循环它可能很久都到不了安全点GC就得一直等。这就是所谓的“安全点停顿”。解决办法是让循环也插入安全点轮询但这会增加运行时开销。所以JVM有个参数-XX:UseCountedLoopSafepoints来控制可数循环是否插入安全点。7.3 三色标记与并发标记的“漏标”问题并发标记用了一种叫三色标记的方法白色表示未访问、灰色表示访问了但引用还没扫完、黑色表示访问完且引用都扫完了。并发标记结束时黑色对象不应该指向白色对象否则白色对象会被漏标本该存活却被回收。这个问题有两个解决方案增量更新CMS用记录黑色对象新增的引用重新标记时再扫和原始快照G1用记录灰色对象删除的引用按旧快照标记。这两个概念在面试里出现频率很高理解它们的关键是搞清楚“并发期间引用关系变化会导致什么后果”。8. 我在实际项目里总结的几条经验第一条别把GC日志当摆设。很多人调优靠猜其实GC日志里信息非常全每次GC的类型、原因、耗时、回收前后各区域大小。加上-Xlog:gc*参数把日志打出来用GCViewer或在线工具分析比瞎调参数靠谱得多。第二条理解概念比背参数重要。参数是死的场景是活的。你理解了“对象晋升逻辑”就知道该调Survivor还是调年龄阈值你理解了“标记-整理要移动对象”就知道为什么老年代GC停顿长。概念清楚了参数自然知道往哪调。第三条软引用、弱引用别乱用。我见过有人用弱引用做缓存结果GC一跑缓存全没了性能反而更差。引用类型的选择要基于“数据丢失的代价”来判断不是越弱越好。第四条finalize能不用就不用。这个前面说过了但值得再强调一次。资源清理用try-with-resources别指望finalize。它执行时机不确定还可能让对象复活是bug的温床。第五条排查内存问题先dump再分析。别一上来就重启重启只能暂时缓解根因还在。dump下来用工具分析引用链找到“谁在持有”才能真正解决问题。这个习惯我坚持了很多年帮我定位过好几次隐蔽的内存泄漏。