如果搜索过“java基础”或者“java面试题”,你几乎一定会碰到GC(垃圾回收)这个话题。它既是很多人口中的八股文,也是线上系统出故障时最让人头疼的环节。我印象很深的是,运维同事半夜发来消息,说HBase集群GC延迟太高,写入请求大面积超时。打开监控一看,老年代使用率曲线像锯齿一样来回跳动,每次Full GC都让业务毛刺持续好几秒。那一刻我才意识到,过去对GC的认知全停留在“自动回收内存”这一句话上,等到真正要定位问题、调整参数时,脑子里全是零散的知识点,根本连不成线。
这篇文章就是按我自己重新梳理GC体系的思路写的。从对象怎么判定为垃圾,到各类收集器的设计取舍,再到日志怎么看、参数怎么调,我把这几个月踩过坑、查过源码、复盘过的内容一次性整理出来。适合正在准备Java面试的开发者,也适合已经在生产环境里被GC问题折磨的同行。全文尽量说人话,但该严谨的地方不会含糊。
1. 先回答“是什么”:GC的职责与一次OOM事故的启示
1.1 一次真实OOM:日志里没有答案,只有“insufficient memory”
先从一个典型事故说起。某个内部服务的错误日志里连续出现 java.lang.OutOfMemoryError: Java heap space,更早一些时候只有一行简短提示:java: outofmemoryerror: insufficient memory。第一次遇到这种情况,本能反应是“堆不够,加内存”,于是把 -Xmx 从4GB加到8GB。结果服务撑了三个星期,又倒下了,日志还是同样的报错。
问题到底在哪?后来通过分析堆转储才发现,服务里有一个全局缓存,键是用户ID,值是一个大对象,用户量一增长,缓存本身就把堆占满了。堆再大也经不起无边界增长。这个案例说明GC不是简单的“内存不够就触发垃圾回收”,它解决的是“如何判断哪些对象可以安全回收、何时回收、用什么方式回收”的问题。如果我们连内存区域都搞不清楚,遇到OOM只能瞎猜。
1.2 内存区域地图:GC主要活动在哪个区域
要理解GC,第一步是分清JVM运行时数据区。JVM把内存分为程序计数器、虚拟机栈、本地方法栈、方法区(Java 8之后是元空间)、Java堆这几块。程序计数器、虚拟机栈、本地方法栈的生命周期都跟随线程,栈帧执行完弹栈就释放,不需要GC介入。真正需要GC重点照顾的是堆,其次是方法区里的类型卸载和常量池回收。
| 区域 | 是否有OOM可能 | GC是否参与 | 说明 |
|---|---|---|---|
| 程序计数器 | 否 | 否 | 当前线程执行字节码的行号指示器 |
| 虚拟机栈 | 是(StackOverflowError或OOM) | 否 | 栈帧随方法调用结束自动释放 |
| 本地方法栈 | 是 | 否 | 为native方法服务 |
| Java堆 | 是 | 是 | GC最核心的区域,对象分配都在这里 |
| 方法区/元空间 | 是 | 是 | 类元数据、常量池回收,JDK8后用本地内存实现 |
有一个容易搞混的点:字符串常量池在JDK 7之后被移到了堆里,所以 String.intern() 相关的问题可能触发堆OOM。而类元数据在JDK 8以后使用本地内存,默认情况下只受到操作系统内存限制,如果加载的类太多,会出现 OutOfMemoryError: Metaspace。虽然这也属于OOM,但和堆OOM的原因完全不同,排查方向也完全不一样。
1.3 堆内外的边界:为什么元空间溢出也要算GC问题
很多人觉得GC只管堆,其实不然。方法区在JDK 8之后虽然换成了元空间,但它的回收依然属于GC的一部分。JVM只有在类加载器可以卸载、该类所有实例都回收、该类的Class对象没有任何地方引用时,才可能卸载类。Tomcat这类容器频繁热部署时,如果类加载器没有释放,元空间就会被撑爆。解决方法往往是检查是否有静态变量引用了被替换的类加载器,而不是单纯调大 -XX:MaxMetaspaceSize。
理解了内存区域,再回头看OOM,就能建立第一个排查框架:如果是堆OOM,先想对象分配速率和存活对象数量;如果是元空间OOM,先想类加载器是否泄漏;如果是创建线程时OOM,先想栈空间和线程数量。GC调优不是一上来就改参数,而是先定位到底哪个区域出了问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 判断“谁是垃圾”:可达性分析与其他边界情况
2.1 引用计数为什么被主流JVM放弃
判断对象能否回收,最直观的方案是引用计数。每个对象维护一个计数器,每有一个地方引用它,计数加一;引用失效,计数减一;计数为零时回收。这个思路简单高效,但有一个致命缺陷:处理不了循环引用。A对象持有B的引用,B对象持有A的引用,两者在外部都没有入口,但计数一直不为零,就永远无法回收。
Python的垃圾回收机制里就保留了引用计数,并额外用弱引用和循环检测来兜底。HotSpot等主流Java虚拟机没有选择这条路线,而是走可达性分析。理解这个差异很有用,因为很多人在面试时会把“引用计数”和“可达性分析”混在一起,却说不清为什么Java不用前者。
2.2 可达性分析与GC Roots的组成
可达性分析的核心是:从一组称为GC Roots的根对象出发,沿着引用链向下搜索,从根不可达的对象就是可回收对象。GC Roots并不是神秘的东西,主要包括:
- 虚拟机栈中引用的对象,也就是当前正在执行的方法里的局部变量;
- 方法区中类静态属性引用的对象和常量引用的对象;
- 本地方法栈中JNI引用的对象;
- Java线程本身持有的对象,如启动线程的Thread对象;
- 被同步锁(synchronized)持有的对象。
你可以把GC Roots想象成整棵对象树的树根。只要从树根能走到某个对象,这个对象就是存活的;走不到,说明这个对象已经孤立,可以回收。可达性分析比引用计数更强的点在于,只要从根出发遍历不到循环引用对象,它们就会被正确判定为垃圾。
2.3 强引用、软引用、弱引用、虚引用:各自适用的场景
在Java中,引用类型被分成强引用、软引用、弱引用、虚引用四种。它们的回收时机和使用场景完全不同,这也是面试的高频考点。
| 引用类型 | 回收时机 | 典型使用场景 |
|---|---|---|
| 强引用 | 永远不回收,除非不可达 | 最常见的Object obj = new Object() |
| 软引用 | 内存不足时回收 | 图片缓存、本地缓存,适合做内存敏感型缓存 |
| 弱引用 | 下一次GC时回收 | ThreadLocal的Key、WeakHashMap |
| 虚引用 | 对象回收时得到系统通知 | 管理堆外内存的回收,如DirectByteBuffer |
软引用和弱引用最大的区别在于生命周期目标。软引用是“能撑尽量撑,内存不够才回收”;弱引用是“下次GC就回收”。我在做缓存时通常会先考虑软引用,因为频繁缓存又允许回收的数据用软引用很合适,但对一致性要求较高的数据还是要用强引用配合淘汰策略。弱引用最经典的应用是ThreadLocal里的 ThreadLocalMap.Entry,它继承了弱引用,key在下次GC时可能被回收,避免长期线程中的ThreadLocal直接导致内存泄漏。
虚引用最特别,它不能通过 get() 获取对象,唯一作用是在对象被回收时收到一个通知。Netty等框架会利用虚引用和 Cleaner 机制来跟踪堆外内存的生命周期,当DirectByteBuffer对象被回收时,通过虚引用触发堆外内存释放。
2.4 finalize()与自救:被误解的“复活”机制
可达性分析后,对象不一定立刻被判死刑。如果一个对象覆盖了 finalize() 且之前从未执行过,JVM会把它放进一个低优先级队列,由Finalizer线程去执行。如果在 finalize() 里重新把这个对象赋给某个GC Roots可达的引用,对象就“自救”成功,不会在这次回收中被回收。
这个机制听起来很神奇,但实战中千万不要依赖它。finalize() 的执行时机完全不确定,可能拖到后面才执行,也可能因为异常导致对象无法正常清理。从Java 9开始,finalize() 就被标记为过时,官方建议用 try-with-resources 或 Cleaner 替代。我见过不少老项目在 finalize() 里释放连接,结果反而导致资源泄漏,排查起来非常痛苦。GC本身已经有足够多的不确定性,没必要再引入一个更不可预测的环节。
3. 回收算法的取舍与分代假说
3.1 标记-清除:能回收碎片不去除
判断出对象可回收之后,用什么算法回收?最基础的是标记-清除。它分两步:先标记出所有需要回收的对象,再统一回收。这个算法简单直接,但有两个明显缺点。第一,执行效率不稳定,堆里对象越多,标记和清理的成本越高。第二,会产生大量碎片,就像停车场里虽然有很多空位,但每个空位都很小,一辆大轿车就是停不进去。之后想要分配一个大对象时,明明总空闲空间够,却会触发一次Full GC。
在真实JVM中,没有任何一个区域单独使用最原始的标记-清除,它更多作为基础思想被组合使用。CMS的“并发清除”阶段用的就是类似思路。
3.2 标记-复制:用空间换时间的年轻代方案
年轻代使用的是标记-复制算法。核心思路是把内存按比例分成Eden区和两块Survivor区(S0、S1),默认比例是8:1:1。新对象统一分配在Eden区,Eden区满了触发Minor GC,把存活对象复制到空的那块Survivor区,同时把年龄加一,然后一次性清空Eden和上一块Survivor。
复制算法的优点是实现简单、无碎片,并且按顺序分配内存效率极高。缺点是浪费空间,因为始终有一块Survivor区是空的;如果存活对象太多,复制成本会很高。这也是为什么我们做GC调优时,不能把Young区设得太小,否则对象刚分配就会被复制来复制去,白白消耗CPU。
3.3 标记-整理:老年代的无碎片之选
老年代的存活率通常较高,如果继续用复制算法,复制成本会非常夸张。所以有了标记-整理算法:先标记存活对象,然后把存活对象向内存一端移动,直接清掉边界以外的空间。整理之后没有碎片,分配大对象也轻松,代价是移动对象需要更新所有引用,这也是一笔不小的开销。
CMS采用的是标记-清除,不做整理,所以会产生碎片;G1和Parallel Old更倾向于用标记-整理或复制配合整理来避免碎片问题。理解这一点就能明白,为什么CMS老年代碎片严重时,即使内存还有余量,也可能触发 Promotion Failed 或 Concurrent Mode Failure。
3.4 分代假说与对象生命周期:为什么参数可以调优
分代收集假说强调两件事:绝大多数对象朝生夕灭,活过几轮GC的对象会越来越难回收。基于这个规律,JVM把堆划分为新生代和老年代。新生代的对象存活率低,用标记-复制最划算;老年代的对象存活率高,用标记-整理更合适。
这也是为什么会有 -Xmn、-XX:NewRatio、-XX:MaxTenuringThreshold 这些参数。对象在新生代每次Minor GC后年龄加一,超过阈值就晋升到老年代。但JVM并不是死板地只看阈值,还有一个动态年龄判定规则:如果在Survivor空间中相同年龄所有对象大小之和超过Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。实际调优中,如果发现老年代增长过快,除了看晋升阈值,还要检查动态年龄判定的影响。
4. 收集器演进:从Serial到ZGC,不同时代的选型逻辑
4.1 Serial与Parallel:单线程与吞吐量的两个极端
Serial收集器是最古老的收集器,它在垃圾回收时只使用一条线程,会把整个业务线程暂停,所以STW(Stop The World)时间长。但它实现简单,对于单核CPU或者客户端小应用来说,停顿时间反而可能优于多线程收集器。
Parallel Scavenge则走了另一个极端,它以吞吐量为优先目标,通过多线程并行回收来缩短GC总耗时。这里的“并行”指多条GC线程同时工作,业务线程依然会暂停。吞吐量 = 用户代码执行时间 /(用户代码执行时间 + GC时间)。如果你更关心任务能在多长时间内完成,而不在意偶尔一次长停顿,Parallel是合适的组合。JDK 8默认就是 Parallel Scavenge + Parallel Old,所以很多老项目跑的就是这套。
4.2 CMS:第一款并发收集器的得与失
CMS(Concurrent Mark Sweep)是第一款真正意义上的并发收集器,设计目标就是低停顿。它把整个流程分成四个阶段:初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记仍然需要STW,但并发标记和并发清除可以和用户线程同时执行,所以整体停顿时间大幅缩短。
CMS的问题也很典型。第一,它并发执行时会占用CPU资源,在CPU核数较少时影响业务响应。第二,它无法处理“浮动垃圾”,并发清除阶段新产生的垃圾只能等下一次GC。第三,它是标记-清除算法,老年代碎片问题无法避免,可能导致高并发时出现 Concurrent Mode Failure,退化成Serial Old的完整STW。CMS在JDK 9被标记为过时,JDK 14被移除,新项目完全不应该再选它,但存量老系统里你还会看到它。
4.3 G1:兼顾停顿时间与吞吐量的区域化新贵
G1(Garbage First)把堆划分成一个个大小相同的Region,每个Region在逻辑上扮演Eden、Survivor或Old角色。G1不要求整个新生代或老年代连续,它可以优先回收垃圾最多的Region,所以叫Garbage First。通过 -XX:MaxGCPauseMillis 可以设置期望的停顿时间目标,G1会在内部动态调整各代大小来尽量达成这个目标。
G1有一个重要的内部机制叫“记忆集”,用来记录跨Region引用,避免在回收时扫描整个堆。记忆集本身也占用内存,Region越多,内存开销越大。所以G1并不是绝对的银弹。JDK 9开始G1成为默认收集器,很多线上服务从Parallel切换到G1后,虽然单次GC停顿变短,但如果配置不好,Full GC反而更频繁,常见原因是并发标记跟不上对象分配的速度,或者堆设置过小。
4.4 ZGC与Shenandoah:几乎无停顿的探索
ZGC和Shenandoah代表了更低停顿的探索方向。ZGC使用染色指针和读屏障,让大部分工作能与业务线程并发执行,官方目标是停顿时间不超过10毫秒,并且停顿时间不随堆大小线性增长。ZGC在JDK 11时以实验特性出现,JDK 15转正,JDK 21又引入了分代ZGC,进一步提升混合场景下的性能。
Shenandoah的思路和ZGC类似,它使用连接缓冲来管理并发移动对象时的引用更新。两者都适合超低延迟场景,比如股票交易、大型在线游戏等。但引入它们时要关注CPU占用、内存占用以及JDK版本的兼容性。对于大多数业务系统,G1已经足够;只有对停顿时间极其敏感时,ZGC才值得考虑。
4.5 收集器如何搭配与参数选择
建立收集器全景图有助于面试和选型:
| 收集器 | 回收代际 | 行为 | 目标 | 适用场景 |
|---|---|---|---|---|
| Serial / Serial Old | 年轻代/老年代 | 单线程STW | 简单稳定 | 客户端、小堆 |
| Parallel Scavenge / Parallel Old | 年轻代/老年代 | 多线程STW | 高吞吐量 | 后台计算、离线任务 |
| ParNew / CMS | 年轻代/老年代 | 多线程+并发 | 低停顿 | JDK 8时代低延迟应用 |
| G1 | 全堆Region化 | 并发+分区 | 可预期停顿 | JDK 9后默认,通用服务 |
| ZGC / Shenandoah | 全堆(或分代) | 并发为主 | 几乎无停顿 | 超低延迟、超大堆 |
用 -XX:+UseG1GC、-XX:+UseParallelGC、-XX:+UseZGC 这类参数切换。关键不是背参数,而是想清楚应用的核心诉求:是追求每秒处理更多请求,还是追求单次请求的延迟波动最小。如果后台任务跑批,追求吞吐量,Parallel是合适的;如果是在线高并发接口,响应时间抖动不能太大,G1或ZGC更合适。
5. GC日志和排查工具:把每次STW看明白
5.1 先读懂GC日志格式:以一段真实日志为例
不读GC日志,调优就是瞎调。JDK 8下开启 -XX:+PrintGCDetails 后,年轻代GC日志大致长这样:
code复制[GC (Allocation Failure) [PSYoungGen: 6144K->672K(6144K)] 6144K->672K(19968K), 0.0021 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
这段日志的意思:新生代从6144KB回收为672KB,总堆从6144KB变为672KB,耗时2.1毫秒。Allocation Failure表示这次GC是因为年轻代空间不足以分配新对象。Full GC日志会多出老年代和元空间的信息,并带有 Full GC 字样。
JDK 9之后日志系统统一,用 -Xlog:gc* 开启,格式也变了:
code复制[0.021s][info][gc] GC(0) Pause Young (Allocation Failure) 6M->1M(20M) 2.123ms
它把时间戳、GC类型、容量变化和停顿时间整合到一行,配合 -Xlog:gc*:file=gc.log 可以输出到文件。新项目用这套更清晰。
5.2 年轻代GC与Full GC的判定标准
很多新手分不清Minor GC、Major GC、Full GC。Minor GC只回收年轻代,StW时间一般较短。Major GC通常指回收老年代,但在某些上下文中也可能指Full GC。Full GC是整堆回收,最常见的原因是老年代和元空间空间不足、System.gc() 被调用、或者CMS/G1并发失败。
有个技巧:看日志里的 (Allocation Failure) 还是 (Metadata GC Threshold)。前者是年轻代分配失败,后者是元空间达到阈值,触发条件完全不同。如果Full GC非常频繁,第一步要看是老年代持续上涨还是元空间持续上涨,这一步能直接缩小排查范围。
5.3 三个命令行工具:jstat、jmap、jcmd的配合用法
线上排查GC问题时,我通常先看GC日志,再看 jstat 的趋势,最后按需用 jmap 检查堆内对象。
bash复制# 每1秒输出一次gcutil,显示年轻代、老年代使用率和GC时间
jstat -gcutil <pid> 1000
# 查看堆配置和各代容量
jmap -heap <pid>
# 查看堆中存活对象直方图(有一定成本)
jmap -histo:live <pid>
# 更推荐用jcmd,作用更集中
jcmd <pid> GC.heap_info
jmap -dump:format=b,file=heap.hprof <pid> 可以导出堆转储,但生产环境要谨慎,导出瞬间可能造成完整GC或长STW。更安全的做法是先保留现场,在业务低谷期操作。jcmd 是JDK自带的瑞士军刀,底层很多命令和 jmap 重复,但更优雅,建议优先用 jcmd <pid> help 看它能干什么。
5.4 从日志里快速定位异常的三个指标
读完一轮GC日志,不要被数字淹没,先看三个指标:
- 单次GC停顿时间:如果年轻代GC都要几百毫秒,说明新生代对象太多或Survivor空间不足。
- GC频率:如果几秒钟一次Minor GC,说明对象分配速率很高,要么代码里频繁创建临时对象,要么S0/S1空间太小。
- 空间变化趋势:观察每次GC后老年代是稳定在一个水平,还是持续爬升。持续爬升基本指向内存泄漏或晋升速率异常。
根据这三个指标再决定调整方向。分配速率高,优先优化代码,而不是盲目加大堆;老年代持续上涨,优先查缓存静态集合、连接池、线程池;元空间上涨,优先查类加载器泄漏。
6. 调优实战:以“HBase GC延迟太高”为切入点的经验教训
6.1 延迟敏感型应用遇到的GC暂停表现
热词里有一条“HBase gc延迟太高”,这几乎是所有HBase运维都遇到过的痛点。HBase的RegionServer在响应读写请求时对延迟非常敏感,一旦发生Full GC,整个JVM在STW期间无法处理任何请求,客户端只能等超时重试。表现就是监控曲线出现长尾延迟,RegionServer日志里出现多条GC暂停超过几秒的记录。
这种情况下,直接加内存不一定有效。堆太大反而会让Full GC扫描存活对象的时间更长,后果是停顿时间从之前的两三秒变成五六秒。HBase社区很早之前就建议使用G1并控制Region大小,实际上G1也并非万能。如果RegionServer上的对象分配速率过高,G1并发标记跟不上,最终还是会退化成Full GC。
6.2 先量化再调优:怎么读取GC日志和监控指标
调优之前先收集数据。我会把GC日志解析成几个量化指标:
- 每轮GC的pause时间,尤其是Pause Young和Pause Full的P99值;
- GC间隔时间,看两次GC之间的平均时长;
- 老年代增长速率,通过旧日志或
jstat -gcutil连续采样计算。
比如从日志里看到老年代每隔20秒增长200MB,那就可以大致算出晋升速率是老年代增长速率加上Full GC后可回收的对象数量。如果晋升速率远远高于预期,需要进一步查看年轻代是否过小,导致对象过早晋升;或者代码中存在持续累积的集合。用数据说话,比盯着 -Xmx 猜要可靠得多。
6.3 核心参数清单与避坑记录
以下是我在实战中常用的一组参数,按优先级排列:
bash复制-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:NewRatio=2
-XX:MaxTenuringThreshold=4
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-Xlog:gc*:file=/data/logs/gc.log
-Xms 和 -Xmx 必须设置成相同值,避免程序运行时动态扩容带来的额外开销。MaxGCPauseMillis 不要设得像1毫秒那么离谱,G1会在内部做大量平衡,反而可能增加Full GC概率。MaxTenuringThreshold 不是越大越好,如果Survivor空间不足,对象还是会提前晋升。
踩过最大的坑是修改参数后没有做对比验证。GC调优本质是动态平衡,一次只改一个变量,然后观察至少一个完整业务周期的GC日志。同时要记住,GC参数只是最后一道闸门,如果代码本身不停地分配大对象、缓存不设上限,再好的收集器也挡不住。
6.4 从“面试八股”到真正解决问题:答好GC题的底层逻辑
很多人把GC知识点背得很熟,但面试官一问“CMS和G1有什么区别”就只能背参数。要真正答好这类问题,得把内存区域、对象判定、回收算法、收集器设计意图串成一条线。CMS是为了低延迟在并发标记上做了尝试,但基于标记-清除它无法避免碎片;G1用Region划分和记忆集来平衡停顿与吞吐量,并把这套逻辑放到堆的全局视角里。
真正理解之后,你甚至可以提出一个面试官没问到的点:G1的停顿时间目标是通过预测模型动态调整Region数量的,所以它叫Garbage First,而不是“垃圾优先回收”这么简单。这样的回答才不是背题。同样,回到文章开头那个HBase问题,解决问题的关键也不是背参数,而是能够把GC日志、对象分配、晋升速率和收集器行为综合起来看。这才是核心知识点的真正价值。
