1. 为什么需要理解垃圾回收的底层概念很多人学JVM的路径是这样的先看内存模型再看垃圾回收器参数最后背几个调优案例。结果一到线上环境看到GC日志里那些[GC (Allocation Failure)]、[Full GC (Ergonomics)]就懵了不知道从哪下手。我刚开始接触JVM调优那会儿也是这样明明背了一堆参数真遇到问题还是靠重启大法。后来我才意识到问题出在跳过了“垃圾回收相关概念”这一层。你连什么是“可达性分析”、什么是“Stop The World”、什么是“安全点”都没搞清楚直接去看G1和ZGC的参数就像没学过加减法直接去解微积分。这一章的内容表面上看都是概念性的东西实际上它是后面所有垃圾回收器和调优策略的地基。这篇文章我会把垃圾回收相关的核心概念一个一个拆开讲清楚。什么是垃圾、怎么判断垃圾、什么时候回收、回收的时候会发生什么、为什么需要STW、安全点和安全区域到底解决什么问题。每个概念我都会配上生活化的类比和实际场景让你不仅记住定义还能理解它为什么被设计成这样。适合已经写过Java代码、想深入理解JVM运行机制的朋友也适合正在准备技术面试、需要把知识点串起来的同学。2. 垃圾回收的核心问题拆解2.1 什么是“垃圾”从对象生命周期说起在Java里垃圾的定义其实很朴素不再被任何活跃对象引用的对象就是垃圾。但这句话展开来有很多细节。你写一个方法里面new了一个对象方法执行完栈帧销毁这个对象如果没有被外部引用它就变成了垃圾。但如果你把它塞进了一个静态集合里那它就一直活着直到你手动移除或者集合本身被回收。我见过不少内存泄漏的案例根源就是往静态Map里放东西放进去就忘了拿出来。这里有个容易混淆的点对象没有被引用 ≠ 对象立即被回收。垃圾回收器只是“有能力”回收它具体什么时候回收取决于回收器的策略和当前堆的状态。就像你家里有个纸箱子你不需要它了但它可能还在角落里放好几天直到你哪天大扫除才扔掉。从对象生命周期的角度看大部分对象都是“朝生夕死”的。有研究表明超过90%的对象在创建后很快就不再被使用。这个规律直接影响了分代收集的设计把新创建的对象放在新生代用复制算法快速回收只有少数活下来的对象才晋升到老年代。2.2 引用计数 vs 可达性分析为什么Java选了后者判断对象是否存活最直观的方法是引用计数给每个对象加一个计数器有引用就加一引用失效就减一减到零就是垃圾。这个方法实现简单判定效率高但有一个致命缺陷——循环引用。假设有两个对象A和BA引用了BB也引用了A除此之外没有任何其他引用指向它们。这时候A和B的引用计数都是1按照引用计数的逻辑它们不是垃圾。但实际上它们已经无法被访问了应该被回收。这就是循环引用问题。Java没有采用引用计数而是用了可达性分析。思路是从一组称为“GC Roots”的根对象出发沿着引用链向下搜索能被搜索到的对象就是存活的搜索不到的就是垃圾。这个“搜索”的过程叫做“可达性分析”。GC Roots包括哪些常见的有虚拟机栈中引用的对象局部变量表中的引用方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中JNI引用的对象Java虚拟机内部的引用比如基本类型的Class对象、常驻异常对象等所有被同步锁持有的对象可达性分析的好处是天然解决了循环引用问题。A和B互相引用但从GC Roots出发找不到它们所以它们会被判定为垃圾。这个设计上的取舍体现了Java在内存管理上的核心思路宁可多花一点分析时间也要保证不会漏掉该回收的对象。2.3 对象的“三次引用”状态强、软、弱、虚可达性分析判定的是“是否可达”但Java对引用做了更细粒度的划分一共四种引用类型强度从高到低强引用是最常见的Object obj new Object()就是强引用。只要强引用还在垃圾回收器就绝不会回收这个对象。哪怕内存快满了宁可抛出OutOfMemoryError也不回收。软引用用SoftReference表示。它的特点是内存足够时不回收内存不够时回收。这个特性非常适合做缓存。比如图片缓存用软引用包裹Bitmap对象内存紧张时系统自动回收不会导致OOM。弱引用用WeakReference表示。它的生命周期更短只要发生垃圾回收不管内存够不够弱引用关联的对象都会被回收。ThreadLocal的内部实现就用了弱引用ThreadLocalMap的key是弱引用这样当ThreadLocal对象没有强引用时key会被自动回收避免内存泄漏。虚引用用PhantomReference表示也叫幽灵引用。它最特殊你无法通过虚引用获取对象实例。它的唯一作用是在对象被回收时收到一个系统通知。虚引用必须和ReferenceQueue配合使用常用于管理堆外内存比如DirectByteBuffer的清理。这四种引用的强度关系是强 软 弱 虚。理解它们的关键是记住各自的回收时机这在实际开发中做缓存设计、资源管理时非常有用。3. 垃圾回收的触发时机与执行机制3.1 Minor GC、Major GC、Full GC的区别垃圾回收按回收区域的不同分为几种类型Minor GC也叫Young GC只回收新生代。触发条件是Eden区满了。因为新生代对象大多朝生夕死Minor GC非常频繁但每次回收速度很快。你可以在GC日志里看到类似[GC (Allocation Failure) [PSYoungGen: ...]的记录。Major GC通常指只回收老年代。触发条件比较模糊不同回收器行为不同。CMS回收器有独立的Major GC而Parallel Scavenge回收器在老年代满时直接触发Full GC。Full GC是回收整个堆包括新生代、老年代、方法区元空间。触发条件包括老年代空间不足、方法区空间不足、显式调用System.gc()、CMS回收器出现Concurrent Mode Failure等。Full GC通常伴随着较长的STW是调优的重点关注对象。这里有个容易搞混的点Major GC和Full GC经常被混用。严格来说Major GC只回收老年代Full GC回收整个堆。但在很多资料和日志里Major GC被用来指代Full GC。我建议你在看GC日志时以日志实际输出的回收器名称为准不要纠结术语。3.2 Stop The World为什么必须暂停用户线程Stop The World简称STW指的是垃圾回收过程中暂停所有用户线程只允许GC线程运行。这是JVM调优中最让人头疼的问题因为STW期间你的服务看起来就像卡死了所有请求都在等待。为什么必须STW核心原因是一致性。可达性分析需要从GC Roots出发遍历整个对象图如果在这个过程中用户线程还在修改引用关系那分析结果就不准确了。比如你刚标记完一个对象是存活的用户线程立刻把指向它的引用删了那这个标记就错了。STW的时间长短取决于堆的大小、存活对象的数量、回收器的实现。新生代回收因为存活对象少STW通常很短几毫秒到几十毫秒。老年代回收因为存活对象多STW可能达到几百毫秒甚至几秒。对于低延迟要求的服务比如交易系统一次几百毫秒的STW可能就是灾难。注意STW不是“暂停所有线程”而是“暂停所有用户线程”。GC线程本身还在工作而且有些回收器的某些阶段是并发的用户线程和GC线程同时运行。3.3 安全点与安全区域STW的精确落点既然要STW那在什么地方暂停线程呢总不能随便找个指令就暂停吧。JVM设计了安全点机制。安全点是指代码中一些特定的位置在这些位置上线程的状态是确定的堆栈信息是完整的可以安全地进行GC。常见的安全点包括方法调用、循环跳转、异常跳转等。JVM会在这些位置插入检查指令线程执行到这些位置时会检查是否需要进入安全点。为什么不在每条指令都设安全点因为那样开销太大。安全点的选择需要在“GC等待时间”和“运行时代价”之间平衡。安全点太少GC等待线程到达安全点的时间就长安全点太多每次检查都消耗性能。安全区域是对安全点的补充。有些线程处于Sleep或Blocked状态无法走到安全点。这时候就需要安全区域在这个区域内引用关系不会发生变化GC可以安全地进行。线程进入安全区域时标记自己离开时检查GC是否完成如果完成就继续否则等待。我踩过的一个坑是大量线程处于TIMED_WAITING状态时GC的STW时间会变长。因为GC需要等待这些线程到达安全点而它们可能在Sleep中需要等到超时或者被中断才能到达安全点。后来我们把一些不必要的Thread.sleep改成了更合理的等待机制STW时间明显下降。4. 垃圾回收算法与分代收集理论4.1 标记-清除、标记-复制、标记-整理三种基础算法垃圾回收算法解决的是“找到垃圾之后怎么清理”的问题。最基础的三种算法标记-清除分两个阶段先标记所有需要回收的对象然后统一清除。优点是实现简单缺点是产生内存碎片。碎片多了之后大对象可能找不到连续空间提前触发GC。标记-复制把内存分成两块每次只用一块。回收时把存活对象复制到另一块然后清空当前块。优点是没碎片分配速度快指针碰撞。缺点是浪费一半空间。新生代用的就是这种算法但做了优化Eden和两个Survivor的比例是8:1:1只浪费10%的空间。标记-整理先标记然后把存活对象向一端移动最后清理边界外的内存。优点是没碎片也不浪费空间。缺点是移动对象需要更新所有引用成本高。老年代常用这种算法。这三种算法没有绝对优劣关键看用在什么场景。新生代对象存活率低用复制算法最合适老年代对象存活率高用标记-清除或标记-整理更合适。4.2 分代收集为什么堆要分成新生代和老年代分代收集理论建立在两个假说之上弱分代假说绝大多数对象都是朝生夕死的。强分代假说熬过越多次垃圾回收的对象就越难被回收。基于这两个假说JVM把堆分成新生代和老年代。新生代放新创建的对象用复制算法快速回收老年代放长期存活的对象用标记-清除或标记-整理算法。分代的好处是针对不同区域的特点采用最合适的回收策略。新生代回收频繁但速度快老年代回收频率低但耗时长。如果不分代每次回收都要遍历整个堆效率会低很多。但分代也带来了新问题跨代引用。新生代对象可能引用老年代对象老年代对象也可能引用新生代对象。做新生代回收时需要找到从老年代指向新生代的引用。如果遍历整个老年代那分代就失去意义了。解决方案是记忆集和卡表。记忆集记录从非收集区域指向收集区域的指针集合。卡表是记忆集的一种实现把老年代分成一个个卡页每个卡页对应一个卡表字节。如果卡页内有对象引用了新生代对象就把对应的卡表字节标记为脏。新生代回收时只需要扫描脏卡页不用遍历整个老年代。4.3 对象晋升老年代的时机与条件对象什么时候从新生代晋升到老年代主要有几种情况年龄达到阈值。对象在Survivor区每熬过一次Minor GC年龄加一。默认阈值是15可以通过-XX:MaxTenuringThreshold调整。达到阈值后晋升老年代。动态年龄判定。如果Survivor区中相同年龄的所有对象大小总和超过Survivor空间的一半年龄大于等于该年龄的对象直接晋升老年代不用等到阈值。大对象直接进老年代。通过-XX:PretenureSizeThreshold设置阈值超过这个大小的对象直接在老年代分配。避免在Eden和Survivor之间来回复制。Survivor空间不足。如果Minor GC后存活对象太多Survivor放不下就需要依赖老年代做分配担保直接晋升。我遇到过一个案例某服务频繁Full GC排查发现是MaxTenuringThreshold设得太小对象过早晋升老年代导致老年代快速填满。后来调大了阈值让对象在新生代多熬几轮Full GC频率明显下降。但阈值也不是越大越好太大会导致Survivor区压力大需要根据实际对象存活情况调整。5. 常见问题与排查技巧实录5.1 GC日志怎么看从日志定位问题GC日志是排查GC问题的第一手资料。开启GC日志的参数-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:gc.logJava 9之后推荐用统一日志框架-Xlog:gc*:filegc.log:time,uptime,level,tags一条典型的GC日志2024-01-15T10:23:45.1230800: 12.345: [GC (Allocation Failure) [PSYoungGen: 65536K-8192K(76288K)] 65536K-16384K(251392K), 0.0123456 secs] [Times: user0.05 sys0.00, real0.01 secs]解读Allocation Failure是触发原因新生代从65536K降到8192K总堆从65536K降到16384K耗时0.012秒。如果real时间远大于user时间说明STW时间长可能是线程到达安全点慢。看GC日志重点关注GC频率、GC耗时、回收效果回收前后内存变化。如果Minor GC后存活对象很多说明对象生命周期长可能需要调大新生代如果Full GC频繁且回收效果差可能有内存泄漏。5.2 内存泄漏与内存溢出的区别与排查内存泄漏是指对象不再被使用但GC无法回收它们。内存溢出是指内存不够用了抛出OutOfMemoryError。内存泄漏长期积累会导致内存溢出但内存溢出不一定是内存泄漏造成的也可能是内存确实不够。排查内存泄漏的常用工具jps查看Java进程jstat查看GC统计信息jmap生成堆转储快照jhat或MAT分析堆转储我常用的流程是先用jstat -gcutil pid 1000观察GC频率和内存变化如果老年代使用率持续上升且Full GC后降不下来基本可以确定有泄漏。然后用jmap -dump:formatb,fileheap.bin pid导出堆快照用MAT分析支配树找到占用内存最大的对象和它们的引用链。注意导出堆快照会触发STW生产环境慎用。如果堆很大导出过程可能持续几秒到几十秒。建议在低峰期操作或者用jmap -histo:live pid先看对象直方图对问题有个大致判断。5.3 常见GC问题速查表问题现象可能原因排查方向解决思路Minor GC频繁新生代太小看Eden区大小和分配速率调大新生代或降低对象分配速率Full GC频繁老年代太小/内存泄漏/大对象多看老年代使用率和回收效果调大老年代排查泄漏调整大对象阈值GC耗时过长堆太大/存活对象多/回收器不合适看GC日志的real时间换低延迟回收器或拆分服务减小堆对象过早晋升Survivor太小/年龄阈值低看晋升年龄分布调大Survivor调高年龄阈值STW时间不稳定线程到达安全点慢看GC日志的user和real差异减少Sleep线程检查安全点设置这张表是我在实际排查中总结的基本上覆盖了80%的GC问题。但每个系统的情况不同关键还是要结合GC日志和业务特点来分析。5.4 几个容易踩的坑坑一盲目调大堆内存。堆大了Full GC频率确实会下降但单次Full GC的STW时间会变长。对于延迟敏感的服务堆不是越大越好。我见过一个服务堆调到32G结果一次Full GC停了8秒比之前更糟。坑二忽略元空间。Java 8之后方法区变成了元空间用的是本地内存。如果动态生成类很多比如大量反射、CGLIB代理元空间会持续增长触发Full GC。可以通过-XX:MaxMetaspaceSize限制但设太小会抛OutOfMemoryError。坑三System.gc()的显式调用。有些框架或代码会显式调用System.gc()这会触发Full GC。可以通过-XX:DisableExplicitGC禁用但要注意有些堆外内存管理依赖它禁用后可能导致堆外内存泄漏。坑四忽略GC日志的时间戳。排查问题时要把GC日志和业务日志对照看。如果Full GC发生在流量高峰那可能是分配速率过高如果发生在低峰可能是定时任务或内存泄漏。6. 从概念到实战的衔接建议学完这些概念你可能会觉得有点散。我的建议是用一个实际的GC日志把概念串起来。找一段生产环境的GC日志或者自己写个Demo程序制造GC然后对照日志分析这次GC是什么类型触发了什么概念STW了多久为什么比如你看到[GC (Allocation Failure) [PSYoungGen: ...]就知道这是Minor GC触发原因是Eden区分配失败回收的是新生代用的是复制算法。如果看到[Full GC (Ergonomics) [PSYoungGen: ...] [ParOldGen: ...]就知道这是Full GC触发了自适应调整回收了整个堆。再进一步你可以用jstat和jmap配合观察对象在新生代和老年代之间的流动。什么时候晋升晋升了多少晋升后老年代增长多快这些观察会让你对分代收集的理解从“知道”变成“感受到”。我个人在实际操作中的体会是概念不是用来背的是用来解释现象的。当你看到GC日志里一个奇怪的现象能调用某个概念来解释它那这个概念才算真正掌握了。比如看到Concurrent Mode Failure你能想到这是CMS回收器在老年代快满时来不及完成并发回收退化成串行Full GC那就说明你对CMS和STW的理解到位了。最后分享一个小技巧如果你在面试或工作中被问到GC问题不要一上来就背参数。先问清楚场景堆多大什么回收器延迟要求多少吞吐量要求多少有了场景再谈方案。GC调优没有银弹只有权衡。理解概念是为了让你在权衡时有依据而不是凭感觉试参数。
阅读完成 · 觉得有帮助?