做了这么多年Java开发,面试问得最多、线上踩坑最深的,就是Java虚拟机垃圾回收这块。刚开始接触那会儿,我也和不少人一样,总觉得GC是个“黑盒”——对象什么时候被回收、老年代为什么突然涨满、GC日志里那些数字到底是什么含义,全靠猜。后来花了大把时间啃规范、翻源码、在测试环境反复调参,才慢慢把整条链路串起来。
这篇文章不打算从教科书式的概念开始念,而是直接把我实际排查问题、做调优时用到的理解方式、关键细节和踩过的坑整理出来。如果你正在准备面试,或者线上遇到过“堆内存飙高”“频繁Full GC”这类问题,这篇内容应该能帮你省不少时间。文中涉及的思路和参数,都是我在真实项目里验证过的,你可以直接拿去参考,再结合自己的业务场景做调整。
1. 先从堆说起:GC的“主战场”长什么样
1.1 分代设计解决了什么核心问题
JVM的内存模型里,线程私有的有虚拟机栈、本地方法栈、程序计数器,线程共享的有堆和方法区,GC主要盯着的就是堆。堆为什么要分成新生代和老年代?这背后是一个很朴素的观察:大多数Java对象朝生夕灭,存活时间极短。如果所有对象都放在同一块区域里,每次垃圾回收都得扫描整个堆,那效率根本扛不住。
基于“弱分代假说”,HotSpot把堆划分成新生代和老年代。新生代再细分为Eden区和两个Survivor区(一般叫From和To),默认比例是8:1:1。新对象优先在Eden区分配,Eden区满的时候触发Minor GC,把存活对象复制到空的那个Survivor区,同时把存活对象的年龄加1。对象每熬过一次Minor GC,年龄就加一,默认达到15岁就会晋升到老年代。这个“15”不是拍脑袋定的,是因为HotSpot在对象头里用4个bit存储年龄,4 bit最大值就是15,所以MaxTenuringThreshold默认15,如果你想调大到16,JVM根本不认。
Survivor区用两个来回倒腾,本质上是“复制算法”的落地。每次Minor GC结束,From和To的身份就对调一次,保证其中一个永远是空的,下次GC有地方可去。
1.2 两个容易忽略的内存细节
大对象直接进老年代
不是所有对象都先在Eden区出生。如果一个对象大小超过-XX:PretenureSizeThreshold设定的值,它会被直接分配到老年代。这个参数默认是0,表示不限制,也就是说默认情况下大对象也先往Eden区放。为什么要提供这个参数?因为大对象在新生代里复制成本太高,Eden区、Survivor区都小,一个大对象就可能把Survivor区塞满,导致对象提前晋升,引发不必要的Full GC。
我遇到过最典型的场景是:一个定时任务每次查出一大批数据塞进ArrayList,这个List瞬间变得很大,直接在新生代里把Eden区占满,紧接着Survivor区也装不下,直接扔进老年代。结果老年代短短几小时就涨了几G。后来通过调整PretenureSizeThreshold,让超过一定大小的对象直接进老年代,反而减少了新生代的GC压力——事情不是绝对的,要结合实际对象大小分布来判断。
TLAB先在线程内部划一块地
对象的分配还有一层优化叫TLAB,即Thread Local Allocation Buffer。JVM为每个线程在Eden区划出一小块私有区域,线程内分配对象时不需要和其他线程竞争同一把锁。假设没有TLAB,每个新对象分配都要做同步,多线程场景下锁竞争会非常激烈,内存分配本身就变成瓶颈。
TLAB默认是开启的,通过-XX:+UseTLAB控制。它的空间大小由JVM根据线程数量动态调整,不用手动改。但要注意,TLAB空间是“预占”的性质,即使线程只分配了一个小对象,这块区域也要等到线程结束或者TLAB用完才参与回收集。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 怎么判断对象“该死”:两大判定法与三大算法
2.1 可达性分析:GC Roots才是真正的锚点
判断对象能否被回收,很多人第一反应是引用计数法——每个对象维护一个计数器,引用加一、失效减一,减到零就回收。这个方法简单直接,但有个致命bug:循环引用。A引用了B,B引用了A,两者都不再被外部引用,但计数器永远不为0,谁也回收不了。
HotSpot用的是可达性分析(Reachability Analysis)。思路是从一组称为GC Roots的根对象出发,沿着引用链向下搜索,能被搜索到的对象就是存活的,搜不到的就判定为可回收。GC Roots包括:虚拟机栈中引用的对象、本地方法栈中引用的对象、方法区中类的静态属性引用的对象、方法区中常量引用的对象、以及被同步锁持有的对象。
这里有一个经常被问到的点:为什么不能把整个堆当根?因为从GC Roots出发的遍历能保证“有向无环”地标记所有存活对象,而如果把所有对象都当作根,那所有对象都不可回收,垃圾回收就成了笑话。
2.2 标记-清除、复制、标记-整理怎么选
三种基本回收算法各有利弊,实际收集器都是它们的组合变种。
标记-清除(Mark-Sweep)分两阶段:先标记所有存活对象,再统一回收所有未标记对象。它的问题是会产生内存碎片,内存看起来还有空间,但都是零散的,连一个连续的大对象都分配不出来,最终还会触发提前Full GC。
复制(Copying)把内存分成两块,只用一块,GC时把存活对象复制到另一块,然后整块清空。优点是实现简单、没有碎片,代价是内存空间只有一半。新生代用8:1:1的Eden加两个Survivor,实际上就是把“对半分”优化成“只浪费10%”,因为大量对象活不过第一次GC,能复制走的对象很少。
标记-整理(Mark-Compact)在标记的基础上,把所有存活对象往内存一端移动,然后直接清理边界以外的空间。它既能避免碎片,又不需要额外的空间,但移动对象需要更新所有引用地址,这个开销不可忽略。老年代适合用这种方法,因为老年代对象存活率高,复制算法的成本反而更高。
3. 经典收集器逐个拆解:Serial到G1
3.1 Serial、Parallel、CMS:老将们的定位
Serial是最古老的单线程收集器,GC时必须暂停所有用户线程,也就是Stop The World。虽然听起来很落后,但它在客户端模式、单核场景下反而高效,因为省去了线程切换的代价。
Parallel Scavenge是JDK 8默认的新生代收集器,它的核心目标不再是“单次GC快”,而是“吞吐量最大化”。吞吐量定义为运行用户代码时间占总时间的比例,-XX:GCTimeRatio可以设置这个比例,默认99%,也就是允许1%的时间花在GC上。Parallel Old是它的老年代搭档,同样采用多线程并行回收。
CMS(Concurrent Mark Sweep)曾经是互联网应用的首选。它把GC过程拆成初始标记、并发标记、重新标记、并发清除四个阶段,其中初始标记和重新标记需要STW,但时间很短,并发标记和并发清除可以和用户线程一起运行。CMS的优势是低停顿,但问题也很明显:一是并发模式下用户线程继续产生垃圾,这些浮动垃圾只能留给下次处理;二是内存碎片化严重,老年代空间足够却找不到连续内存分配大对象时,会触发“promotion failed”导致Full GC;三是“concurrent mode failure”一旦出现,就退化成Serial Old串行Full GC,停顿反而更夸张。
3.2 G1:分区回收思路为什么厉害
G1(Garbage First)把整个堆划分成多个大小相等的Region块,每一个Region在逻辑上都可以扮演Eden、Survivor、Old的角色。它不再像传统收集器那样物理分代,而是逻辑分代。G1的另一个关键设计是能追踪每个Region的回收价值和回收成本,在有限的停顿时间预测内(默认200ms),优先回收“垃圾最多、回收最快”的Region,这就是“Garbage First”的含义。
G1的跨Region引用问题通过记忆集(Remembered Set)解决。每个Region维护一个记忆集,记录哪些外部Region引用了自己。这种“我记录谁引用我”的思路,让GC时扫描根集合不需要遍历整个堆,只需要查各Region的记忆集。代价是记忆集本身需要维护,写入操作有额外开销,所以G1适合大堆、多核环境,在小堆上未必比Parallel好。
-XX:MaxGCPauseMillis可以设定Expected pause time目标,但不保证一定达成,G1只是在向这个目标努力。设计停顿目标时,不要设得太小,比如设成10ms,G1为了满足目标会频繁启动回收周期,导致CPU开销暴增,吞吐量反而下降。
3.3 收集器参数速查
| 收集器组合 | 适用场景 | 常用参数 |
|---|---|---|
| Serial + Serial Old | 客户端、单核小堆 | -XX:+UseSerialGC |
| Parallel Scavenge + Parallel Old | CPU核心多、追求吞吐量 | -XX:+UseParallelGC |
| ParNew + CMS | 低延迟优先的互联网应用(JDK8时代) | -XX:+UseConcMarkSweepGC |
| G1 | 大堆、可预测停顿 | -XX:+UseG1GC -XX:MaxGCPauseMillis=200 |
网上经常有人纠结JDK 1.8到底该用CMS还是G1。我的建议是:如果项目还在JDK 8且堆小于4G,Parallel其实挺好的;堆大于8G,或者对停顿非常敏感,直接上G1并做好日志监控。CMS在JDK 9之后已经被废弃,没必要用在一个即将被移除的收集器上。
4. 一看就会的GC日志分析与调优实战
4.1 日志参数怎么开,怎么看
排查GC问题,第一步是打开日志。在JDK 8里我常用这样一组参数:
bash复制-Xms4G -Xmx4G -Xmn2G
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC
-Xloggc:/data/logs/gc-%t.log
JDK 9之后的版本,日志参数统一成-Xlog:gc*:file=/data/logs/gc.log,旧的PrintGCDetails会被弃用。线上最好给日志文件带上时间戳,%t会把日期拼进文件名,这样每次重启不会覆盖之前的日志,方便追溯。
拿到GC日志后怎么看关键信息?我举一段典型的Minor GC日志来说明:
bash复制GC (Allocation Failure)
PSYoungGen: 1745920K->182722K(1827840K)]
1745959K->192803K(4019584K), 0.0289143 secs]
括号外的数字是堆总使用量,括号内是新生代使用量。1745920K->182722K表示新生代从约1.7G降到约180M,1745959K->192803K表示整个堆从约1.7G降到约190M。后面是GC耗时28ms。注意堆总使用量降得不多,说明有大量对象留在了老年代,这不是好信号——如果长时间这样,老年代迟早扛不住。
Full GC日志里还会出现老年代的数字:
bash复制Full GC (Metadata GC Threshold)
PSYoungGen: 0K->0K(1048576K)]
Metaspace: 98689K->98689K(1048576K)
老年代没变,Metaspace占满了,触发原因是元空间达到阈值。这种Full GC和堆内存关系不大,重点看Metaspace是否持续增长,如果一直涨,多半是类加载器泄漏。
4.2 一个典型调优案例
前两年我接手过一个订单服务,高峰期每5分钟一次Full GC,每次停顿接近3秒。拿到GC日志,发现老年代好几次GC之间就暴涨1G以上,检查代码后发现,有个报表模块每次查询都会创建大量中间对象,LocalDateTime的parse操作频繁,并且在一个大循环里反复拼接字符串。
先调整JVM参数——把堆从4G扩到6G,新生代从原来的默认大小调整到2G,同时给-XX:MaxTenuringThreshold=6,让对象别那么快晋升。结果Full GC频率降低到每小时一次,但仍然存在。
继续优化代码,把字符串拼接改成StringBuilder,把循环里的Stream流式处理改成普通for循环,避免重复创建中间对象。改完后Full GC基本消失,高峰期最多每半小时一次Minor GC,停顿几十毫秒。
这个案例的启示是:JVM参数调优只能缓解症状,根因往往在代码里。先看日志找出问题区域,再针对代码做优化,顺序一定不要反。
4.3 调优中踩过的坑
我之前遇到过一个问题:把-Xms和-Xmx设成相同值后,以为堆内存固定了就没问题了,结果忽略了-XX:MaxDirectMemorySize。一个用Netty做文件传输的服务,堆内存一直很健康,却频繁报OutOfMemoryError: Direct buffer memory。后来才想起来Netty用的是堆外内存,默认大小等于-Xmx,但GC对堆外内存没有直接的回收能力,只能依赖它内部的Cleaner机制。处理方式是显式设置-XX:MaxDirectMemorySize=1G,并且监控Netty的堆外内存使用。
另外一个很常见的坑是系统内存不足。JVM参数配得很健康,GC也正常,但容器整机内存被其他进程吃满,导致操作系统开始swap,应用运行变成龟速。这个阶段GC日志看不出毛病,因为用户线程阻塞在网络IO或锁等待上,要看操作系统层面的内存状态,或者直接观察CPU的sys态占比。
5. 从OOM到排查实录:别等堆爆了才想起GC
5.1 常见的几类OOM速查
| OOM类型 | 触发原因 | 排查重点 |
|---|---|---|
| Java heap space | 堆内存不足或存在泄漏 | dump堆,查大对象、类加载器 |
| GC overhead limit exceeded | GC吞吐低于98%且回收效果差 | 老年代持续涨满,检查内存泄漏 |
| Direct buffer memory | 堆外内存超限 | 检查NIO、Netty相关代码 |
| Metaspace | 元空间不足或类加载器泄漏 | 查动态生成类的场景 |
| Unable to create new native thread | 系统线程数达到上限 | 检查线程创建、连接池配置 |
5.2 一次线上老年代增长过快的完整排查
有段时间线上告警频繁,老年代占用曲线呈阶梯式上升。先把GC日志捞出来,确认是不是频繁Full GC,然后带上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump参数,等下次Full GC时自动导出堆快照。这里推荐使用Eclipse MAT打开dump文件,重点看“Leak Suspects”和“Dominator Tree”。
那次实际原因挺讽刺的——一个看似无害的ConcurrentHashMap缓存,key用的对象重写了hashCode,但hashCode里包含了一个会变化的字段。对象作为key存入缓存后,hashCode变了,后续通过新key查缓存时找不到,于是一次次往缓存里加新对象,旧的永远清理不掉。从堆dump里看到这个Map对象占用了整堆的60%,但代码审查时很难发现,因为问题不在“往Map里放数据”,而在“key的hashCode不稳定”。
这种场景说明,排查内存问题不能只盯GC本身。GC只是结果,原因是业务代码创造了太多“本不该存活”的对象,或者让本该回收的对象一直被引用。
根据我个人的经验,排查GC和OOM最可靠的分工是:先看GC日志确定问题在哪个区域,再用内存分析工具定位到具体对象,最后回代码里找谁创造了这些对象。不要一上来就堆参数,参数是最后的手段。把日志、dump、代码审查这三点串起来,大多数垃圾回收问题都能在1小时内定位。
最后再分享一个实用小技巧:在测试环境用-Xmx256M这种小堆跑一遍完整的核心流程,如果应用能扛住日常压测而不OOM,说明业务代码的对象生命周期是可控的;一旦小堆频繁OOM,即使生产配了8G堆,也只是把问题延后,总有一天会爆发。所以平时养成主动关注对象分配的习惯,比事后调参更值钱。
