
1. 堆内存的分区逻辑为什么JVM偏偏要分代而不是一把梭1.1 Eden区、S0/S1、老年代各自扮演什么角色很多Java开发者学了几年脑子里对堆内存还是只有一个模糊印象new出来的对象都在堆上堆不够了就OOM。但真要回答Minor GC、Major GC、Full GC到底清理的是哪块地方立马卡壳。这其实不怪大家因为JVM官方文档在命名上本来就留了个大坑Major GC到底是指老年代GC还是整个堆GCHotSpot和《Java虚拟机规范》的表述并不完全一致。所以我先明确一个共识Minor GC只管年轻代Major GC管老年代Full GC先处理年轻代再处理老年代如果当年还有永久代/元空间也一并纳入。堆内存默认分三个区块Eden区、两个Survivor区S0和S1、老年代Old/Old Generation。在HotSpot默认的Parallel ScavengeParallel Old组合下年轻代和老年代的比例默认是1:2也就是如果堆大小是1GB年轻代约占333MB左右老年代约667MB。而年轻代内部Eden与两个Survivor的比例默认是8:1:1可调这也是很多面试题喜欢问的数字。1.2 分代假设多数对象朝生夕死分代设计的根基是一条经验法则绝大多数对象的存活时间极短。比如你在一个方法里new个临时字符串、组装个DTO、循环里产生的中间对象方法一结束它们就成了垃圾。如果所有对象都混在一整块大内存里让垃圾收集器每次全量扫描那么几百GB的堆会造成漫长停顿项目基本没法用。所以JVM把堆切成两代用不同的策略应对不同的对象生命周期。年轻代用复制算法快速回收那些朝生夕死的对象老年代则存放从年轻代熬过多次GC、存活时间较长的对象用标记-清除/标记-整理类算法来处理。这是一种明显的空间换时间思路年轻代给一个相对较小的空间GC频繁但每次收集快老年代空间大GC频率低但每次收集代价大。1.3 对象晋升阈值与动态年龄判断对象不是一出生就注定待在老年代它要走晋升通道。每次Minor GC后Eden和S0/S1里活下来的对象年龄age会1当年龄达到阈值时就会被移动到老年代。这个阈值默认是15-XX:MaxTenuringThreshold但HotSpot不是死板的它会做动态年龄判定如果S区里相同年龄对象的总大小超过Survivor空间的一半那么年龄大于等于这些对象的对象会直接晋升不需要等到15。这套机制保证了Survivor区不会被长期占不满而溢出到老年代算是一种自适应保护。理解了这三块区域的分工再去看Minor GC、Major GC、Full GC就不会晕。你只需要记住一句话谁的空间装不下了谁就会被触发GC而被GC的对象所在区域决定了这个GC叫什么名字。2. Minor GC年轻代里的高频小扫除为什么天天发生2.1 Minor GC的触发点Eden区不够用了Minor GC只在年轻代空间不足时触发更准确说是Eden区几乎被塞满时触发。为什么要强调Eden因为绝大多数新对象分配在Eden区大对象例外而Survivor区主要作复制中转用不会主动承载新对象分配。假设你启动了一个电商订单接口每秒钟几百次请求在创建订单对象、商品快照、日志实体Eden区很快被打满于是JVM发起一次Minor GC。Minor GC的特点非常鲜明频率高、速度快、停顿时间短。我的实际经验里使用G1收集器的服务Minor GCG1中叫Young GC一般停顿在几十毫秒以内即使业务高峰期也基本看不到上百毫秒的年轻代停顿。相比之下Full GC动辄几百毫秒甚至秒级两者的体感完全不在一个量级。2.2 复制算法在S0/S1之间的搬运细节年轻代的GC核心是复制算法流程可以拆成五步找出Eden和当前存活Survivor区比如S0中所有活着的对象。将这些对象按年龄和大小分类有的直接晋升到老年代有的复制到另一块空闲Survivor区S1。清空Eden区和原来的S0。交换S0和S1的角色。更新对象引用。这里面有个关键性能点复制算法不需要像标记-清除那样遍历整个区域找碎片它的空间是连续分配的复制过去后对象紧凑排列所以Minor GC后年轻代是规整的分配新对象时只要移动一个指针就能完成。代价就是空间浪费——你有一部分Survivor始终空闲等着做中转站。2.3 Minor GC中的对象晋升路径年轻代里活下来的对象并不是全都能继续留在年轻代。晋升路径大概分三条年龄达到MaxTenuringThreshold默认15的对象晋升到老年代。动态年龄判定超过Survivor一半容量的对象组晋升。超大对象超过-XX:PretenureSizeThreshold设置值Parallel收集器下此参数默认无效或Survivor空间不足时直接晋升到老年代。这里有个很多开发者会忽略的细节大量对象提前晋升老年代往往是Minor GC频繁之后的老年代快速膨胀根源。我见过一个典型的案例某服务把SurvivorRatio调成了1:1:8Survivor区极小一旦流量稍微上来年轻代存活对象装不进Survivor全部直接涌向老年代结果Minor GC和Full GC交替频繁发生服务RT直接从50ms飙升到800ms。后来把Survivor区调大并压低了晋升阈值情况才好转。所以你看Minor GC看似小扫除它其实是老年代的重要入口处理不当会连累整个堆的GC节奏。3. Major GC与Full GC两个高代价事件的边界在哪里3.1 概念陷阱Major GC不等于Full GC先泼一盆冷水很多人把Major GC和Full GC混为一谈但严格说Major GC是老年代空间的回收Full GC是整堆回收年轻代老年代元空间/Metaspace。为什么混淆因为大部分收集器的Major GC和老年代回收过程都会至少触发一次全局停顿Stop The WorldSTW甚至还会连带触发一次年轻代回收导致看起来像是Full GC。以Parallel ScavengeParallel Old组合为例共生代的Major GC真的就是老年代回收但因为老年代和年轻代之间存在跨代引用收集老年代时往往需要额外扫描年轻代里的引用对象这会导致停顿很长表现和Full GC几乎一样。而在CMS收集器中如果老年代CMS回收失败会退化为Serial Old的一次停顿极长的Full GC。所以我们在实践中常把停顿时间很长的GC笼统称为Full GC但面试时最好能把概念边界讲清楚别自己先乱了阵脚。3.2 空间分配担保机制老年代GC还有一个非常关键的触发场景年轻代Minor GC前的空间分配担保失败。HotSpot会先检查老年代最大可用连续空间是否大于年轻代所有对象总大小如果是说明这次Minor GC即使所有对象都晋升也放得下放心执行如果不够则检查一个历史参数HandlePromotionFailureJDK 6 Update 24之后已基本废弃只做参考输出判断是否允许担保失败。如果老年代可用空间不足且冒险担保失败JVM会直接触发一次Full GC把老年代腾出空间然后再进行Minor GC。这就是为什么有时候你明明只是年轻代满了日志里却出现Full GC——那其实是JVM在救场先想办法腾出老年代空间避免Minor GC时晋升对象无处安放导致更严重的OOM。3.3 大对象直接进入老年代的隐患除了晋升大对象也是一个老年代GC的导火索。当代码里创建了超过阈值的大数组、大List或大对象比如一个大缓存Map、一个几十MB的byte[]JVM会绕过Eden直接把它们丢进老年代。原因是大对象在年轻代拷贝时消耗太大反复在两个Survivor之间复制这种大家伙会拖慢Minor GC。这种设计从局部看是优化从全局看却埋了个雷要是代码里频繁new大对象老年代空间会被垃圾大对象快速占满最终让Full GC提前到来且频繁发生。我在实际项目里看到过最典型的代码是一个消息消费线程每处理一条消息就new一个1MB的byte[]去装消息体峰值流量下老年代每秒被塞入近100MB垃圾半小时后Full GC就变成每分钟一次。后面改成复用byte[]池以后Full GC直接消失了。大对象进老年代这个设计本身没问题问题出在开发者的滥用上。4. 用GC日志看清每次垃圾收集的真实动作4.1 GC日志格式拆解靠猜和靠看监控面板总觉得隔层纱真正的GC优化第一课一定是读GC日志。在JDK 8时代启动参数加上-verbose:gc -XX:PrintGCDetails -Xloggc:/data/logs/gc.log就能记录详细日志。JDK 9之后日志参数改成了-Xlog:gc*:filegclog.log统一用统一日志框架Unified Logging。我们拿一段典型的Parallel收集器日志来拆解[GC (Allocation Failure) [PSYoungGen: 102400K-12640K(128000K)] 102400K-22148K(256000K), 0.0186920 secs]方括号里[GC (Allocation Failure)说明这次是Minor GC触发原因是Eden区分配失败[PSYoungGen: 102400K-12640K(128000K)]表示年轻代回收前后大小括号里是年轻代总容量紧接着的外层102400K-22148K(256000K)表示整个堆的回收前后大小最后的0.0186920 secs表示这次GC停顿了约18.7毫秒。Full GC的日志会是[Full GC (Metadata GC Threshold)、[Full GC (Ergonomics)或者[Full GC (Allocation Failure)等后面跟着[PSYoungGen: xxxK-0K(...)] [ParOldGen: xxxK-xxxK(...)]这种同时列出年轻代和老年代变化的格式。看到这种日志你就要警惕了老年代已经参与回收停顿时间大概率在百毫秒以上。4.2 各收集器日志差异从Serial到G1不同收集器的日志长得完全不一样但核心信息是一致的GC原因、回收前后内存量、耗时、用户态和内核态时间。Serial/Parallel的日志一眼能看出来因为出现了PSYoungGen、ParOldGen、PSPermGen这类字样。CMS则有CMS Initial Mark、CMS Concurrent Mark、CMS Final Remark、CMS Concurrent Sweep这些阶段名。G1更特殊它有GC pause (G1 Evacuation Pause)年轻代转移停顿、GC pause (G1 Humongous Allocation)巨对象分配停顿、GC pause (G1 Mixed)混合回收等阶段。我重点说下G1的日志因为现在新项目基本都是G1。G1把堆分成了很多大小相同的小区域Region默认最多2048个每个Region可能担任Eden、Survivor、Old角色。G1 Full GC的日志通常长这样[Full GC (Allocation Failure) 5120M-2145M(7168M), 3.2840690 secs] [Eden: 1024.0M(1024.0M)-0.0B(1024.0M) Survivors: 0.0B-0.0B Heap: 5120.0M(7168.0M)-2145.5M(7168.0M)]如果你看到G1日志里出现Full GC说明G1的并发标记和混合回收已经跟不上对象分配速度了只能退化成单线程/多线程全暂停回收。这是服务健康状态亮红灯的信号不能只盯着日志感叹怎么又Full GC了应该立刻做内存dump分析。4.3 调优参数组合从堆大小到收集器选型调优参数的坑很深我只挑最常被用到的组合来展开参数作用备注-Xms初始堆大小建议与-Xmx一致避免运行时动态扩容-Xmx最大堆大小服务器内存的50%-60%比较常见-Xmn年轻代大小设太大增加Minor GC成本太小导致对象提前晋升-XX:SurvivorRatioEden与Survivor比例默认8追求低延迟可适当调大Survivor-XX:MaxTenuringThreshold晋升年龄上限默认15G1中默认也是15-XX:UseG1GC启用G1JDK 9以后默认就是G1-XX:MaxGCPauseMillisG1的期望停顿目标默认200ms不能设得太激进否则吞吐量下降一个常见的线上配置案例4核8G机器上的Java服务java -Xms4g -Xmx4g -Xmn1536m -XX:SurvivorRatio6 \ -XX:UseG1GC -XX:MaxGCPauseMillis100 \ -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags这套配置在多数中等并发业务下表现稳定。不过我要提醒一句调参数前先看业务场景。你是IO密集还是CPU密集峰值流量有多大对象存活时间长不长每个因素都会改变最优参数网上抄一套参数直接用大概率翻车。5. 面试与实战中关于Full GC的高频问题5.1 为什么Full GC频繁发生的根因排查Full GC频繁几乎是所有Java服务线上故障里出现频率Top3的问题而且很多情况不是参数问题而是代码问题。最常见的根因排序是内存泄漏对象被全局静态集合持有、ThreadLocal未清理、连接池泄漏等。大对象频繁被创建比如在循环里读取大文件、构造大List后不释放。元空间Metaspace不断膨胀动态生成类或加载大量Class元空间触发Full GC。堆分配过小业务增长后没有同步调大堆。逻辑错误导致对象生命周期被意外拉长比如把原本应该局部变量作用域内的对象误存到缓存。排查链路也有标准动作我会在下一节用一个实际案例走一遍。面试时你只要能讲清楚从监控报警到日志分析、再到堆dump、最后定位到代码位置这条完整链路就已经远超只会背参数的大多数候选人了。5.2 逃逸分析、TLAB、锁消除对GC的隐藏影响这个话题容易在面试中被问到也会影响你对GC的认知。HotSpot JVM会在C2/JIT编译时做逃逸分析Escape Analysis如果确定一个对象不会逃逸出方法就把它分配到栈上栈上分配而不是堆上这样连Minor GC都省了对象随栈帧弹出直接消失。配套的优化还有TLABThread Local Allocation Buffer。TLAB是Eden区里给每个线程预留的私有分配区域线程在TLAB内分配对象不需要加锁同步减少了分配开销。很多人不知道TLAB的大小也会影响GC行为如果对象过大装不进TLABJVM会直接在Eden区非TLAB区域分配甚至直接晋升老年代。-XX:UseTLAB默认开启一般不用手动调但理解这个机制对排查为什么这个对象直接进老年代很有帮助。锁消除Lock Elision同样是逃逸分析的副产品如果一个对象不会和其它线程共享JVM会把Synchronized代码块的锁去掉。它能间接减少临界区阻塞导致的线程堆积线程不堆积创建的对象自然减少GC压力也会小一些。5.3 Java 17和Java 21的默认GC变化如果你还在用JDK 8面试官问JDK 17的GC变化时别说错。JDK 9开始默认收集器就是G1JDK 11引入了ZGC的实验性支持JDK 15中ZGC转正JDK 17之后ZGC已经可以支持并发的类卸载和堆栈处理并且被进一步打磨JDK 21中ZGC和G1仍然是长期支持版本里的主力。ZGC的核心理念是读写屏障染色指针它的停顿时间基本不随堆大小增长最大停顿通常在几毫秒级别。不过ZGC也有自己的坑它非常依赖内存带宽在物理内存小的机器上可能会因为映射大量内存页而拖慢整体性能而且JDK 17里的ZGC还不太适合超大堆比如超过几十GB的场景。所以别盲目追求ZGC先搞清楚自己的场景是偏吞吐量还是偏低延迟。6. 我在真实项目中处理过的一次老年代飙升6.1 问题表象与初步判断前年我负责一个物流订单同步服务某天监控告警GC次数突然暴增。查看GC日志出现[Full GC (Allocation Failure)频率大约每3分钟一次每次停顿2-5秒服务接口RT从120ms飙升到1.2秒部分上游调用直接超时。那时堆大小是-Xmx4g老年代占约2.6GB。从日志看Full GC之前Minor GC也很频繁每分钟好几次年轻代回收后存活对象比例异常高大量对象被晋升到老年代。初步判断方向有两个一是年轻代设太小导致对象晋升过快二是存在内存泄漏有大量对象长期滞留在老年代。为了区分这两种情况我做了个简单实验临时调大年轻代到-Xmn2g观察是否好转。结果Full GC频率几乎没变于是基本锁定是内存泄漏而不是参数问题。6.2 排查链路jstat、jmap、MAT、Arthas锁定方向后完整的排查链路是这样走的jstat确认GC趋势jstat -gcutil pid 1000 10观察E区、S区、O区占用率变化。当时观察到老年代占用率从30%一路快速爬升到90%没有回落趋势。jmap生成堆dumpjmap -dump:formatb,fileheap.hprof pid。注意生产环境执行jmap会造成一定停顿最好在低峰期操作或者用jcmd GC.heap_dump。MAT分析用Eclipse MAT打开堆dump查看Dominator Tree找到占用最高的对象。当时发现一个ConcurrentHashMap实例占据了老年代约1.8GB被一个静态工具类持有。Arthas定位出问题代码用Arthas的trace命令追踪这个Map的put调用栈很快就定位到了罪魁祸首——某个定时任务每次运行会把一批订单对象放进一个静态Map做缓存但清理逻辑只删了key没有删value而且这个缓存根本没有过期策略。6.3 修复方案与事后反思修复方案很简单给缓存加上基于Guava Cache或Caffeine的过期策略并定时清理。上线后Full GC消失老年代占用稳定在600MB左右接口RT回到正常水平。但复盘时有个点很值得反思这个泄漏其实存在了很久为什么之前没爆因为测试环境的流量只有生产几十分之一老年代一直没满。而生产流量增长后泄漏对象累积速度远超预期最终把老年代填爆。这次经验让我养成了一个习惯任何引入静态集合做缓存的地方必须回答三个问题——缓存最大能涨到多大有没有过期机制如果缓存永远不被访问内存会不会持续增长如果这三个问题答不上来这张代码就是一枚定时炸弹。7. GC调优的最终体会先诊断后动手别迷信参数7.1 最常见的调优错误无脑调大堆很多同学遇到GC频繁第一个动作就是把-Xmx调大。这个动作有一定道理因为堆太小确实容易触发频繁GC但如果问题是代码造成的调大堆只会推迟爆炸时间不会消除爆炸风险。而且堆调大后Full GC的停顿会更长到时候可能从GC频繁但每次快变成GC次数降低但一次停好几秒用户体验反而更差。我的建议是调大堆只作为临时缓解手段根本解法还是通过堆dump定位代码问题。尤其是Java 8之后服务器内存普遍充足绝大多数GC问题都不是缺内存而是内存被垃圾逻辑占用。7.2 如何为特定业务选择合适的收集器选收集器没有银弹但可以给一个粗略的判断依据内网批处理、离线计算任务优先Parallel ScavengeParallel Old追求最高吞吐量不介意短暂停顿。高并发Web服务单机堆在4GB-16GB之间G1是稳妥选择停顿可控默认参数通常已经不错。低延迟高可用服务单机堆几十GB以上考虑ZGC或Shenandoah尤其JDK 21之后ZGC稳定性已经相当好。老年代碎片化严重的老服务如果还在用CMS建议考虑迁移到G1或ZGCCMS已经JDK 9中废弃、JDK 14中删除不要用古董技术撑着。7.3 运维侧的建议GC监控与主动告警设置最后分享一个非常实用的运维习惯。线上服务建议配置GC指标监控重点盯三个指标Full GC次数、Full GC平均耗时、老年代使用率上涨速率。老年代使用率如果是锯齿形上涨再回落说明GC在正常工作如果一直是单边上涨且回落幅度越来越小就要小心泄漏了。告警阈值可以这么设置Full GC次数超过5次/分钟、单次停顿超过1秒、老年代使用率在30分钟内从低位涨到80%以上都值得立即查看。初期可以先不加告警动作只在日志里记录等数据积累一两周后再定合理阈值避免一上来被海量误报淹没。GC调优不是背一堆参数就完事而是用数据和代码说话的过程。理解Minor GC、Major GC、Full GC的本质差异掌握读GC日志、定位泄漏的能力你已经比大多数八股文背诵选手强得多了。