☰
Java垃圾回收机制核心原理与Full GC排查实战指南
2026/10/12 8:00:25 网站建设 项目流程

前阵子有个同事的线上服务频繁Full GC,每次GC停顿接近一秒,接口超时一个接一个。他把堆内存调大,以为是内存不够,结果重启后照样卡。后来我们用jstat看,发现老年代占用一直在涨,GC日志里每次Full GC之后只降了一点点,典型的“对象没能回收”症状。如果你懂Java垃圾回收机制,排查起来其实有清晰的路径;如果不理解底层原理,就只能靠重启和加内存碰运气。

Java垃圾回收机制,简单说就是JVM自动帮我们管理堆中对象的回收,不用像C/C++那样手动free。但它也不是万能药,理解它怎么判断垃圾、怎么回收、选用什么收集器、怎么排查和调优,是Java开发者绕不开的基本功。同时这也是Java面试八股里的高频考点,很多面试题翻来覆去都是围着GC问。这篇就用实战视角把GC的核心原理、分代模型、收集器对比、排查方法和常见误区串一遍,适合准备面试的朋友,也适合正在被线上GC问题折磨的工程师。

1. 垃圾回收到底在回收什么

1.1 对象何时才算“垃圾”

Java里的GC对象是堆内存中那些“不再被任何可达引用指向”的对象。判断对象能否回收,现代JVM主流做法是可达性分析。简单说,从一系列称为GC Roots的根对象出发,沿着引用链往下走,能被走到的对象就是“活的”,不能被走到的就是垃圾,可以被回收。

为什么不主推引用计数?引用计数算法道理简单:每个对象维护一个计数器,有引用指向就加一,引用失效就减一,减到零就回收。但它有一个致命问题:循环引用。比如A持有B的引用,B也持有A的引用,当外部这两者都没有任何引用了,它们的计数器仍然是1,系统没办法把这两个互相抱团的对象回收掉。可达性分析从根出发,只要没有任何根能到达这个对象团,就判定为垃圾,天然绕过了循环引用问题。

你可以把JVM想成一座体育馆,GC Roots是几个长期打开的入口,从入口出发沿着通道能走到的地方是“还有人使用的区域”,走不到的死角就是垃圾区域。两个互相锁住的门虽然都在那儿,但因为没有入口路径到达,也照样会被当作废弃区域清理掉。

1.2 GC Roots 具体有哪些

面试时最常问的一个问题就是:GC Roots到底包括什么?它并不是一个固定不变的小清单,不同JVM版本和实现会补充细节,但核心这几类一定要记住:

  • 虚拟机栈中局部变量表里引用的对象:比如方法内正在使用的局部变量。只要栈帧还在,说明方法还没结束,这个对象有“活着”的理由。
  • 方法区中静态属性引用的对象:static变量是跟着类走的,类在则引用在,所以它是根。
  • 方法区中常量引用的对象:比如常量池里的字符串引用。
  • 本地方法栈中JNI引用的对象:也就是Native方法引用的Java对象。
  • 被同步锁(synchronized)持有的对象:锁对象本身也是根。
  • 活跃线程对象、系统类加载器等。

理解这些根有个实际价值:用内存分析工具查看堆dump时,经常看到某些对象“占着内存却不释放”,你沿着引用链一看,发现是static集合或局部变量在引用它。这时候你就能意识到,它不是“GC没回收”,而是“它还被GC当成活对象”,问题出在代码引用的生命周期,而不是GC本身。

1.3 四种引用类型:强软弱虚

GC的判定也和引用类型有关。强引用是最常见的:Object obj = new Object(),只要强引用存在,对象永远不被回收。软引用在内存不足时才会被回收,适合做缓存。弱引用下一次GC就可能会被回收,ThreadLocal里的Entry就用到了WeakReference。虚引用不能通过它访问对象,主要用于对象被回收时收到通知,比如堆外内存的清理器。

面试题经常问“强软弱虚有什么区别”,如果能和GC判定联系起来说,会显得你理解得更透。比如软引用并不是“一定会被回收”,它是否回收取决于内存压力和回收器策略;弱引用也并不是“立刻消失”,而是最多活到下一次GC。

2. 为什么要分代:堆内存的分代模型

2.1 弱分代假说决定了分代设计

JVM堆被划分为新生代和老年代,这个设计基于一个观察:绝大多数对象是“朝生夕死”的,活着的时间很短;只有少数对象能存活较久。如果每次GC都扫描全堆,成本太高,于是把不同寿命的对象放在不同区域,分别用不同频率的GC策略去处理。

这就像整理衣柜:每天都穿的衣服放在最外面的衣架,过季的衣服按年份放上层。你不会每次换衣服都把整个衣柜翻一遍,只会收拾需要收拾的那块区域。

2.2 新生代内部:Eden与Survivor

新生代又细分为Eden区和两个Survivor区,默认比例是8:1:1。新对象一般在Eden区分配,Eden区满了触发Minor GC。Minor GC采用复制算法:把Eden和一个Survivor里存活的对象复制到另一个Survivor,同时把这些对象的年龄加1。如果对象年龄达到MaxTenuringThreshold,默认15,就晋升老年代。

这里有个细节很多人不知道:Survivor区为什么是两个?因为复制算法需要有一个空区域作为目标。如果只有一个Survivor,那复制时目标区域可能被存活对象占用,没法干净地腾出来。两个Survivor轮流当“备份”,保证每次Minor GC后总有一个区域是干净的。

大对象则不同。JVM会把超过一定大小的大对象直接分配到老年代,或者通过TLAB在Eden分配本地缓冲区。这样做的目的是避免大对象在Eden和Survivor之间反复复制,因为大对象复制代价太高。你可以通过-XX:PretenureSizeThreshold设置大对象阈值,但G1等收集器对这块的处理也不太一样。

再提一下TLAB。为了减少多线程并发分配时的竞争,JVM为每个线程在Eden区划了一块私有区域——TLAB。线程优先在自己的TLAB里分配对象,TLAB不够了再重新申请。这也是为什么GC日志里会有“TLAB: ... bytes”之类的信息,很多新手看到一头雾水,其实就是线程本地分配缓存的统计。

2.3 Minor GC、Major GC与Full GC

Minor GC只清理新生代,通常很快,STW时间也很短。Full GC会清理整个堆,老年代为主,也会涉及新生代,STW往往更长。Major GC这个词有歧义,有的资料指老年代GC,有的指Full GC,所以面试时如果对方问“Major GC和Full GC区别”,你可以先反问一下对方怎么定义,再回答,显得你很严谨。

什么情况下会触发Full GC?常见的有老年代空间不足、元空间不足、调用System.gc()(JVM不保证)、上次Minor GC后晋升的对象大于老年代剩余空间等。Full GC频繁意味着系统停顿明显,是排查性能问题的首要目标。

2.4 堆参数配置的基础逻辑

配置堆大小时要理解几个常用参数:

  • -Xms:初始堆大小。生产环境建议和-Xmx设成一样,避免堆扩容时的性能和停顿开销。
  • -Xmx:最大堆大小。不要盲目设置超大,堆越大GC的暂停时间可能越长。
  • -Xmn:新生代大小。过小会导致对象频繁晋升老年代,过大则老年代空间不足,Full GC更容易发生。
  • -XX:SurvivorRatio=8:Eden和单个Survivor的比例。
  • -XX:MaxTenuringThreshold=15:晋升年龄上限。
  • -XX:+HeapDumpOnOutOfMemoryError:OOM自动导出堆dump,强烈建议加上。

这些参数不是拍脑袋定的,要结合对象存活情况。如果绝大多数对象很快死亡,新生代可以适当大一点,减少Minor GC次数;如果有大对象较多,考虑调整大对象阈值,免得老年代被塞爆。

3. 垃圾回收算法与主流收集器

3.1 三种基本回收算法

在具体收集器出来之前,先搞懂三种基础算法:

标记-清除(Mark-Sweep):标记阶段先找出所有可达对象,清除阶段把不可达对象的内存回收。缺点是容易产生内存碎片,而且标记和清除两个阶段的效率随对象数量下降。

复制(Copying):把内存分成两块,存活对象复制到另一块,然后把原块整体清空。不会产生碎片,但要浪费一半空间,且存活对象多时复制开销大。

标记-整理(Mark-Compact):标记存活对象,然后把所有存活对象向内存空间的一端移动,直接清理边界以外的内存。不会产生碎片,但移动对象需要额外的整理负担。

不同分代适合不同算法:新生代存活对象少,用复制算法效率高;老年代存活对象多,用标记-清除或标记-整理更合适。理解这个逻辑,后面看CMS、G1的选型就顺了。

3.2 Serial、Parallel、CMS的定位

Serial收集器是老牌单线程收集器,进行GC时只能用一个CPU,优点是简单、没有线程切换开销,适合小型应用或者客户端环境。Parallel收集器又叫吞吐量优先收集器,比如Parallel Scavenge和Parallel Old,它用多线程并行回收,追求的是单位时间内完成的任务量最大,适合后台计算任务。

CMS收集器则是第一个真正意义上并发回收老年代的收集器,目的是减少STW时间。CMS的缺点也很多:它是基于标记-清除算法的,会产生浮动垃圾;并发阶段会占用CPU;默认不处理内部碎片,可能出现老年代空间足够但找不到连续内存分配大对象的情况,进而触发Full GC。所以在JDK8时代,虽然CMS在很多大厂用过,但官方已经不再推荐。

这里必须纠正一个常见误区:JDK8的默认收集器并不是CMS,而是Parallel Scavenge + Parallel Old。很多面试资料写成“JDK8默认CMS”,是想当然。实际上JDK8默认是-XX:+UseParallelGC,对应Parallel收集器。你可以用jcmd或者JVM参数打印验证。

3.3 G1:区域化分代的代表

G1在JDK 9以后成为默认收集器,它把堆划分为一个个大小相等的Region。每个Region在逻辑上可以扮演Eden、Survivor或者Old,但不是物理上固定分代。G1维护一个优先列表,根据每个Region的垃圾占比和回收收益,优先回收“垃圾最多”的Region,所以叫Garbage First。

G1适合大堆和延迟敏感场景,可以用-XX:MaxGCPauseMillis指定目标停顿时间,比如200ms。但要注意,目标停顿是“尽量达到”,不是硬性保证。RSet是G1的核心机制之一,用来记录其他Region对当前Region的引用,方便在回收局部Region时快速判断跨Region引用,但也会带来额外内存和写屏障开销。所以小堆上G1不一定比Parallel好,堆在2-4GB以下反而可能更差。

3.4 ZGC与Shenandoah:低延迟路线

ZGC目标是把STW控制在10毫秒以内,甚至更低。它通过染色指针和读屏障实现并发标记、并发转移,GC过程中的大多数阶段都不暂停应用线程。ZGC支持到TB级别的堆,适合超大内存场景。不过要注意,ZGC在JDK 11中实验性引入,在JDK 15转正,用的时候要确认JVM版本。

Shenandoah也是一个低延迟收集器,思路和ZGC类似但实现不同。它一定程度上让STW时间与堆大小无关。多数人不需要在生产环境同时深入两个,知道各自特点即可。面试问到“ZGC vs G1”,能说出G1用Region和RSet,ZGC用Region加染色指针、并发转移,ZGC目标更低延迟,就足够了。

3.5 收集器选择建议

场景推荐收集器理由
小型应用、延迟不敏感Serial / Parallel实现简单,开销低
多核后台任务、吞吐优先Parallel多线程并行回收,吞吐量大
4GB以上堆、追求低停顿G1可预测停顿,按Region回收
超大堆、极低延迟ZGC / ShenandoahSTW极短,支持超大堆

如果还在用JDK8,了解G1可以加到-XX:+UseG1GC。如果升级到JDK17,G1是默认,想体验ZGC就-XX:+UseZGC。

4. 线上GC问题排查与调优实录

4.1 第一步:开启GC日志,别裸奔

排查GC问题,第一件事是看日志。JDK8时代用-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log。JDK11以后统一日志格式为-Xlog:gc*:file=gc.log:time,uptime,level,tags。我建议把日志直接输出到独立文件,并且按大小和时间滚动,避免单文件无限增长。

GC日志怎么看?比如:

[Full GC (Allocation Failure) 3734M->3267M(4096M), 0.9876543 secs]

Full GC后的堆占用只降了467M,说明回收到的空间很少。如果频繁出现这种“回收效果差”的Full GC,基本可以断定老年代里大量对象是存活的或者被根引用着,并不是GC参数的问题。

4.2 第二步:用jstat观察实时数据

jstat -gcutil <pid> 1000可以每秒打印一次GC汇总:E表示Eden占用百分比,O表示老年代占用百分比,M表示元空间,FGC表示Full GC次数,FGCT表示Full GC总耗时。你观察几条数据,如果O一直在95%以上,且FGC次数持续增长,就说明老年代有压力。

再配合jstack看线程栈,jmap看堆直方图。比如jmap -histo <pid>会输出对象实例数和字节数,能快速定位到占用最高的对象类型。如果某个业务模型类排在前面,很可能就是它在堆积。

4.3 第三步:Dump堆,分析引用链

当直方图不够用时,就要做堆转储。jmap -dump:format=b,file=heap.hprof <pid>会导出堆快照,然后可以用MAT打开。MAT的Dominator Tree功能可以帮你找到“支配树”上最大的对象和GC Root引用路径。我见过太多人拿到dump后只看Total Size,其实重点要看“GC Roots Path”:为什么这个对象还被引用,引用链上是谁在持有它。

注意:生产环境执行jmap dump可能触发STW,谨慎操作。建议在低峰期或者通过Arthas等工具使用更安全的方式。线上服务最好加上-XX:+HeapDumpOnOutOfMemoryError,至少OOM时能留个现场。

4.4 实战案例:静态Map吞掉所有请求对象

回到开头那个同事的案例。我们当时看GC日志,发现每次Full GC后老年代占用只掉了三五百MB,O区长期90%以上。用jmap -histo看,排第一的是我们的业务实体类,实例数几十万。再dump堆用MAT分析,发现这些对象全被一个静态Map引用着,那个Map是同事为了做“本地缓存”加的,每次请求都往里面put,但只有一个简单的size判断,从不remove。结果请求越多Map越大,对象全被强引用,GC再勤快也回收不掉。

解决办法不是调JVM参数,而是改代码:把缓存改成带过期机制的本地缓存,或者直接交给Redis等外部组件。改完之后Full GC从每分钟一次降到几小时一次。这个案例告诉我们:GC调优不是先动参数,而是先确认有没有内存泄漏。

5. 常见误区与面试考点

5.1 System.gc()真的会立刻Full GC吗

System.gc()只是给JVM一个“建议”,JVM可以接受也可以忽略。尤其设置了-XX:+DisableExplicitGC之后,它完全无效。所以生产代码里不要依赖它,更不能以为调用System.gc()就能“清干净内存”。有个典型场景是某些框架在释放连接时调用System.gc(),反而造成频繁Full GC。排查时可以关注是不是有代码在主动刷GC。

5.2 finalize问题

面试必问的坑是finalize:JVM在回收一个对象前,如果对象重写了finalize(),会先把对象放入F-Queue,由Finalizer线程以低优先级执行。问题在于执行时机不确定,执行过程中对象还可能“复活”,比如把自己引用给到其他对象,反而让对象被错误保留。更严重的是,这个机制本身会增加回收成本。所以现代Java实践中基本都会告诉你:不要依赖finalize做资源清理。配套的替代方案是try-with-resources、Cleaner或者PhantomReference。

5.3 GC调优不是万能的

很多人遇到GC问题,第一反应是改堆大小、换G1。但如果是代码层面的内存泄漏,比如静态集合、未关闭的IO、ThreadLocal用完不清理,改任何参数都救不了你。GC只能回收“不可达”对象,一个对象被强引用占着,它就还是可达的,根本不是垃圾。所以排查顺序应该是:先确认有没有泄漏,再考虑参数和收集器。

5.4 面试八股高频问题速记

  • 对象什么时候晋升老年代?年龄达到阈值,大对象直接进入老年代,Survivor放不下时。
  • 什么情况触发Full GC?老年代空间不足、元空间不足、晋升对象过大、System.gc()请求等。
  • CMS和G1的区别?CMS是并发清除老年代,会产生碎片;G1是Region化、可预测停顿,JDK9+默认。
  • 如何减少Full GC?先排除内存泄漏,再合理设置堆和新生代大小,大对象单独处理,选对收集器。
  • GC Roots有哪些?栈帧局部变量、static变量、常量引用、JNI引用、锁对象、活跃线程等。

这些知识点串起来,就是一套完整的GC答题框架。遇到问题时,先讲可达性分析,再讲分代,再讲收集器,再讲自己怎么排查,面试官会认为你真的有实战经验。

我个人更推荐的做法是:不要等到线上出问题才临时抱佛脚,把GC日志开启成服务标配,配合监控面板定期扫一眼Full GC次数和耗时。如果让我给一个入门的排查路径,就是先看日志趋势,再上jstat,最后再考虑dump;一上来就jmap,既影响线上,也容易让你淹没在海量对象里。把这些基础概念真正理解了,遇到GC问题你就不会只知道重启加内存了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询