在面试和日常排障中,JVM内存与GC机制永远是绕不开的硬骨头。很多人把《深入理解Java虚拟机》翻了好几遍,一到线上OOM或者GC瓶颈还是懵。这篇文章不打算复述教科书,而是从对象在内存里的完整旅程讲起,把运行时数据区、对象创建、垃圾回收算法、常见垃圾回收器选型以及真实场景下的调优排查串成一条线。无论你是准备面试还是正在被线上问题折磨,这篇都能给你一个可以直接落地的认知框架。
1. 先从一张内存全景图说起:为什么JVM要自己管理内存
C和C++程序员需要手动malloc和free,稍不注意就内存泄漏或者野指针崩溃。Java之所以敢说“自动内存管理”,核心就是JVM把内存这块地盘全部接管了。但这不等于你可以完全不管内存,恰恰相反,不懂JVM怎么划分内存、怎么分配对象、怎么回收垃圾,遇到问题的时候连排查方向都没有。
1.1 运行时数据区的五个核心区域
JVM的内存布局在《Java虚拟机规范》里定义得很清楚,主要分为五大块:程序计数器、虚拟机栈、本地方法栈、堆、方法区。前三个是线程私有的,生命周期跟线程相同;后两个是线程共享的,是GC的主战场。
- 程序计数器:当前线程执行字节码的行号指示器。字节码解释器就是靠它来选取下一条需要执行的指令。分支、循环、跳转、异常恢复、线程恢复这些基础功能都依赖它。这块区域是唯一不会出现OutOfMemoryError的地方。
- 虚拟机栈:每个线程创建时都会创建一个虚拟机栈,里面存放一个个栈帧。每个方法执行时都会创建一个栈帧,栈帧里包含局部变量表、操作数栈、动态链接、方法返回地址等。局部变量表里存的是基本数据类型、对象引用和returnAddress类型。线程请求的栈深度超过虚拟机允许的深度会抛StackOverflowError。
- 本地方法栈:为虚拟机执行Native方法服务。HotSpot把虚拟机栈和本地方法栈合二为一了,所以参数-Xss同时对两者生效。
- 堆:几乎所有对象实例和数组都在这里分配。它是GC管理的主要区域,也是内存调优时最常打交道的区域。堆可以细分为新生代和老年代,新生代又分为Eden区和两个Survivor区。
- 方法区:存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。JDK 8以后,方法区的实现从永久代变成了元空间,字符串常量池也移到了堆里。
我一直建议新手先把这个区域划分背到滚瓜烂熟,因为后面所有关于GC、OOM、调优的问题,最终都会落到“某个区域满了”或者“某个区域分配不了”上。
1.2 堆内存的逻辑分区:新生代、老年代与永久代/元空间
堆内部并不是一块铁板,而是按照对象的存活时间划分成几个逻辑区域。默认情况下,新生代占堆内存的三分之一,老年代占三分之二。新生代里Eden区和两个Survivor区(From和To)的默认比例是8:1:1,可以通过-XX:SurvivorRatio调整。
这种划分是为了“分代回收”服务的。绝大多数对象都是朝生夕灭的,把它们集中在新生代,每次Minor GC只用复制算法扫描一小块区域,性价比很高。老年代存放那些熬过多次GC仍然存活的对象,以及大对象直接进入老年代的情况。元空间(Metaspace)在JDK 8以后不再使用堆内存,而是使用本地内存,所以默认情况下元空间的大小只受物理内存限制,可以通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来约束。
有个很容易混淆的点:永久代和元空间不是同一个东西。永久代是HotSpot在JDK 8之前对方法区的实现,它占用堆内存;元空间是JDK 8之后对方法区的实现,它使用本地内存。字符串常量池在JDK 7的时候就移到了堆里,JDK 8的元空间已经不包含字符串常量池了。
2. 对象的完整一生:从类加载到被回收的每一步
搞清楚了内存布局,下一步就是把对象从出生到死亡的完整路径走一遍。对象的生命周期大致是:类加载检查、分配内存、初始化零值、设置对象头、执行构造方法、被使用、不可达后被回收。这一步一步拆开来看,每一步都有值得深挖的细节。
2.1 对象的创建:类加载检查与内存分配
当虚拟机遇到一条new指令时,首先会去常量池里检查这个类的符号引用,并检查这个类是否已经被加载、解析和初始化过。如果没有,就会先执行类加载过程。类加载过程包括加载、验证、准备、解析、初始化五个阶段,其中准备阶段会为类变量分配内存并设置初始值,初始化阶段会执行<clinit>()方法。
类加载完成之后,虚拟机开始为新生对象分配堆内存。对象所需内存的大小在类加载完成后就可以完全确定。分配方式有两种:指针碰撞和空闲列表。如果堆内存是规整的,用过的内存在一边,空闲的在另一边,中间放一个指针作为分界点,分配内存就是把指针向空闲方向挪动一段与对象大小相等的距离,这就是指针碰撞。如果堆内存不规整,已使用的内存和空闲内存交错在一起,虚拟机就必须维护一个列表记录哪些内存块可用,分配时从中找一块足够大的划分给对象实例,并更新列表记录,这就是空闲列表。
选择哪种分配方式由堆是否规整决定,而堆是否规整又由采用的垃圾收集器是否带有压缩整理功能决定。所以使用Serial、ParNew这类带Compact过程的收集器时,系统采用的分配算法是指针碰撞;而使用CMS这种基于标记清除算法的收集器时,通常采用空闲列表。
2.2 内存分配的安全点:TLAB与CAS
并发情况下,给对象分配内存不是线程安全的。即使修改一个指针指向的位置,也不是原子操作。解决方案有两种:一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行——每个线程在Java堆中预先分配一小块内存,称为线程本地分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存就在哪个线程的TLAB上分配,只有TLAB用完需要重新分配新的TLAB时,才需要同步锁定。
通过-XX:+UseTLAB参数可以开启TLAB,JDK 8默认是开启的。如果你发现大量小对象分配导致GC频繁,可以考虑适当调大-XX:TLABSize,减少TLAB重新分配的次数。不过TLAB调整需要结合压测结果来做,盲目调大不一定有效。
2.3 对象的内存布局:对象头、实例数据与对齐填充
对象在堆内存中的存储布局分为三块:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。HotSpot虚拟机的对象头包括两部分信息:第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位虚拟机中分别为32比特和64比特,官方称它为Mark Word。第二部分是类型指针,即对象指向它的类型元数据的指针,虚拟机通过这个指针来确定该对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也就是我们在代码里定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的字段,都需要记录起来。对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot要求对象起始地址必须是8字节的整数倍,所以对象大小必须是8字节的整数倍,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
2.4 对象的访问定位:句柄还是直接指针
创建完对象后,Java程序需要通过栈上的reference数据来操作堆上的具体对象。主流的访问方式有句柄和直接指针两种。句柄访问的话,堆中会划分一块内存作为句柄池,reference中存储的是对象的句柄地址,句柄中包含对象实例数据与类型数据各自的地址信息。直接指针访问的话,reference中存储的直接就是对象地址。
HotSpot主要使用直接指针访问方式。它的优势是速度更快,节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,这类开销积少成多也是一笔可观的执行成本。而句柄访问的好处是reference中存储的是稳定的句柄地址,对象被移动时只会改变句柄中的实例数据指针,reference本身不需要修改。
3. 什么时候需要回收:对象存活判定与引用类型
对象什么时候算“死”了?不是你觉得没用了就算,而是JVM判定它不可达了才算。判定算法主要有两种:引用计数法和可达性分析算法。
3.1 引用计数法的致命缺陷与可达性分析算法
引用计数法的思路是:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。这个算法简单高效,但是有一个致命缺陷——它解决不了循环引用问题。假设对象A引用了B,B又引用了A,除此之外这两个对象没有任何其他引用,那么它们的引用计数器永远不为0,永远不会被回收。Java虚拟机没有采用这种算法。
主流的商用虚拟机都用可达性分析算法。这个算法的思路是从一组称为GC Roots的根对象出发,向下搜索引用链,搜索走过的路径称为Reference Chain。当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。可以作为GC Roots的对象包括:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象、Java虚拟机内部的引用(如基本数据类型对应的Class对象、常驻的异常对象、系统类加载器等)以及被同步监视器锁持有的对象。
3.2 强引用、软引用、弱引用、虚引用:四种引用类型的行为差异
引用类型直接影响对象的回收时机。强引用是最传统的引用定义,Object obj = new Object()这种就是强引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。软引用用来描述一些还有用但非必需的对象,在系统将要发生内存溢出异常之前,会把只被软引用关联着的对象列进回收范围之中进行第二次回收,如果这次回收还没有足够内存,才会抛出内存溢出异常。JDK提供了SoftReference类来实现软引用。
弱引用也是用来描述非必需对象的,但强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。JDK提供了WeakReference类,ThreadLocal的ThreadLocalMap中key就是弱引用。虚引用是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的,就是能在这个对象被收集器回收时收到一个系统通知。JDK提供了PhantomReference类。
3.3 finalize()方法:一个你应该忘掉但面试常考的点
即使在可达性分析算法中判定为不可达的对象,也不是非死不可的。被判定不可达的对象会先被标记,然后判断是否有必要执行finalize()方法。如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。
如果这个对象被判定了有必要执行finalize()方法,那么对象会被放入一个名为F-Queue的队列中,由一个低优先级的Finalizer线程去执行。这里需要注意的是,虚拟机不承诺一定会等待finalize()方法执行结束,因为如果这个方法执行缓慢或者死循环,会导致F-Queue队列中的其他对象永久等待,甚至导致整个回收系统崩溃。
finalize()方法是对象逃脱死亡命运的最后一次机会。如果在finalize()方法中重新与引用链上的任何一个对象建立关联,比如把自己赋值给某个类变量或者对象的成员变量,那么第二次标记时它就会被移出即将回收集合。如果对象这时候还没有逃脱,那基本上就真的被回收了。不过我要郑重提醒:永远不要在业务代码里依赖finalize()方法来做资源清理。Java官方已经从Java 9开始标记它为废弃方法,推荐使用try-with-resources和Cleaner机制来替代。
4. 垃圾回收算法:从标记清除到分区回收的演进逻辑
了解了如何判定对象存活之后,接下来要解决的是怎么回收。垃圾回收算法的演进过程,本质上是在吞吐量、停顿时间、内存碎片、回收效率之间反复权衡的过程。
4.1 标记-清除算法:最基础的方案,但有两个明显缺陷
标记-清除算法分为两个阶段:标记阶段先把所有需要回收的对象标记出来,清除阶段统一回收被标记的对象。这个算法的第一个缺点是执行效率不稳定,如果堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。第二个缺点是内存空间碎片化,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致后续程序在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记-清除算法是后续很多算法的基础,但实际商用虚拟机很少直接使用它。CMS收集器是个例外,它基于标记清除思想实现,所以会产生碎片问题。
4.2 标记-复制算法:新生代回收的主流方案
标记-复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样每次都是对整个半区进行内存回收,内存分配时也就不用考虑空间碎片等复杂情况,只要移动堆顶指针,按顺序分配即可,实现简单,运行高效。
代价是可用内存缩小为原来的一半,空间浪费比较多。HotSpot虚拟机的新生代没有按照1:1的比例划分,而是用了Eden和两块Survivor区的8:1:1比例。每次分配只使用Eden和其中一块Survivor,发生Minor GC时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor上,然后直接清理掉Eden和已用过的Survivor空间。当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域进行分配担保,这些对象会直接进入老年代。
4.3 标记-整理算法:老年代的选择,解决碎片问题
标记-整理算法的标记过程与标记-清除算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。这个算法既避免了碎片问题,又不需要浪费一半空间,但是移动存活对象的成本比较高,而且需要暂停用户线程。
这里有一个很重要的权衡点:标记清除不移动对象,所以垃圾收集过程中不需要暂停用户线程,但会产生碎片;标记整理移动对象,可以解决碎片问题,但必须全程暂停用户线程。所以在老年代收集器的设计上,CMS选择了标记清除,而G1在混合回收阶段选择了标记复制加局部标记整理的方式。
5. 主流垃圾收集器选型:从Serial到ZGC的演进
垃圾收集器是GC机制落地执行的具体实现。每个收集器都有自己适用的场景和权衡取舍,不存在所谓“最好”的收集器,只有最适合当前业务场景的收集器。
5.1 新生代收集器:Serial、ParNew与Parallel Scavenge
Serial收集器是最基础、历史最悠久的收集器,它是一个单线程工作的收集器,进行垃圾收集时,必须暂停其他所有工作线程,直到收集结束。这个“Stop The World”听起来很可怕,但对于客户端模式下的简单应用来说,Serial收集器垃圾收集的时间通常只有几十毫秒,完全在可接受范围内。而且Serial实现简单、没有线程切换开销,在单核处理器或者内存较小的环境下反而很高效。
ParNew收集器本质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余行为完全一致。它是很多运行在服务端模式下的虚拟机首选的新生代收集器,一个重要原因是除了Serial以外,只有它能与CMS收集器配合工作。Parallel Scavenge收集器也使用复制算法,支持多线程并行收集,它的特点是更关注吞吐量,即运行用户代码时间占总运行时间的比例。通过-XX:MaxGCPauseMillis可以控制最大停顿时间,通过-XX:GCTimeRatio可以设置吞吐量大小。
5.2 老年代收集器:Serial Old、Parallel Old与CMS
Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法。它主要给客户端模式下的虚拟机使用,在服务端模式下还有两种用途:一种是与Parallel Scavenge收集器搭配使用,另一种是作为CMS收集器发生Concurrent Mode Failure时的后备预案。
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,使用标记-整理算法。这个收集器在JDK 6之后才出现,它的出现让Parallel Scavenge收集器终于有了一个可以搭配的、重视吞吐量的老年代收集器。如果系统对吞吐量要求比较高,可以优先考虑新生代Parallel Scavenge和老年代Parallel Old搭配的组合。
CMS收集器是第一个真正意义上实现垃圾收集线程与用户线程并发工作的收集器,它基于标记-清除算法实现。它在初始标记和重新标记这两个阶段需要Stop The World,但在并发标记和并发清除阶段可以和用户线程同时工作。CMS的优势是并发收集、低停顿,但有两个显著缺点:一是对CPU资源敏感,并发阶段虽然不会导致用户线程停顿,但会占用一部分线程导致应用程序变慢,总吞吐量会降低;二是无法处理浮动垃圾,可能出现Concurrent Mode Failure而导致另一次Full GC的产生。
5.3 G1收集器:面向局部收集的里程碑
G1收集器是JDK 9之后服务端模式默认的垃圾收集器。它开创了“面向局部收集”的设计思路,不再坚持新生代和老年代的物理划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演Eden、Survivor或者老年代空间。
G1的Region划分带来一个核心能力:可预测的停顿时间模型。用户可以指定期望的停顿时间,G1会根据这个目标去规划哪些Region需要回收,优先回收价值收益最大的那些Region。这就是G1名字的由来——Garbage First,优先处理垃圾最多的区域。
G1的回收过程大致分为四个阶段:初始标记、并发标记、最终标记、筛选回收。其中初始标记和最终标记需要短暂停顿用户线程,并发标记阶段与用户线程并发执行,筛选回收阶段会根据用户期望的停顿时间制定回收计划,选择多个Region构成回收集,把存活对象复制到空闲Region中。
G1整体上采用标记-复制算法,从两个区域之间复制存活对象,这样不会产生内存碎片。G1的缺点是内存占用和额外负载比传统收集器要高,需要维护大量数据结构来跟踪Region之间的引用关系。
5.4 ZGC:超低停顿的探索,适合超大堆
ZGC的目标是把停顿时间控制在10毫秒以内,无论堆多大。它通过染色指针、读屏障等新技术实现了几乎全并发的垃圾回收。ZGC的着色指针把标记信息直接记录在指针上,通过读屏障在访问对象时感知对象状态,判断是否需要进行内存屏障处理。
ZGC适合超大堆场景,比如几十GB甚至几百GB的堆。它能做到在TB级堆上,停顿时间依然保持在10毫秒以内。不过ZGC对内存占用有一定要求,因为它需要在堆内存之外额外分配一部分空间存放着色指针相关信息。如果你的应用堆内存超过32GB,而且对停顿时间有极致要求,ZGC是值得考虑的选择。
5.5 收集器选择速查表
这里整理一份不同场景下垃圾收集器的选择参考:
| 场景 | 推荐组合 | 关键参数 |
|---|---|---|
| 单核小内存客户端应用 | Serial + Serial Old | -XX:+UseSerialGC |
| 多核、追求吞吐量 | Parallel Scavenge + Parallel Old | -XX:+UseParallelGC |
| 低延迟、互联网服务 | ParNew + CMS | -XX:+UseConcMarkSweepGC |
| JDK 9+ 默认服务器场景 | G1 | -XX:+UseG1GC |
| 超大堆、极低停顿 | ZGC | -XX:+UseZGC |
6. 触发GC的时机:Minor GC、Major GC与Full GC的区别
很多人对GC分类概念模糊。搞清楚Minor GC、Major GC、Full GC之间的区别,不仅是面试的基础题,也是实际排查问题时判断依据。
6.1 三种GC的触发条件与回收范围
Minor GC是新生代垃圾收集,触发条件非常频繁,Eden区空间不足时就会触发Minor GC。Minor GC采用复制算法,把Eden和Survivor From中存活对象复制到Survivor To,如果Survivor To空间不够,就通过分配担保机制进入老年代。Minor GC速度通常很快,因为新生代里大多数对象都是朝生夕灭的,存活对象很少。
Major GC是老年代的垃圾收集,CMS收集器在并发清理阶段可能会触发Major GC。Major GC的停顿时间通常比Minor GC长很多,因为老年代对象的存活率高,复制或整理对象的成本高。
Full GC是收集整个堆包括新生代、老年代、元空间的垃圾。Full GC的特点是停顿时间长,是系统性能瓶颈最常见的原因。触发Full GC的情况包括:老年代空间不足、元空间不足、调用System.gc()、CMS的Concurrent Mode Failure、堆内存分配失败等。
6.2 对象何时从新生代晋升到老年代
对象晋升老年代的规则有几条,每条都是面试题的热门考点。第一,对象优先在Eden区分配,如果Eden区没有足够空间,虚拟机发起一次Minor GC。第二,大对象直接进入老年代,大对象是指需要大量连续内存空间的Java对象,比如很长的字符串和元素数量庞大的数组。通过-XX:PretenureSizeThreshold参数可以设置大对象阈值的字节数。
第三,长期存活的对象将进入老年代。虚拟机给每个对象定义了一个对象年龄计数器,对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度,默认是15岁,就会被晋升到老年代。这个阈值通过-XX:MaxTenuringThreshold设置。
第四,动态年龄判定。虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到要求的年龄。
7. 线上实战:性能问题排查与调优实录
说完了理论,来看实际的排障过程。GC调优不是玄学,是一套有章可循的流程:先发现问题、收集数据、分析瓶颈、调整参数、验证效果。
7.1 第一步:获取可靠的GC日志
GC日志是分析GC问题的基础。JDK 8推荐使用这些参数来输出GC日志:
-Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprofJDK 11及以后,日志参数改变了统一格式:
-Xlog:gc*:/path/to/gc.log:time,uptime,level,tags建议线上环境一定要保留GC日志,并且通过logrotate做日志轮转,否则日志文件会越来越大。很多问题只有在事发当时的GC日志里才能找到线索,事后想复现非常困难。
7.2 第二步:看懂GC日志的关键信息
一段典型的GC日志长这样:
[GC (Allocation Failure) [PSYoungGen: 65536K->8112K(76288K)] 65536K->9765K(251392K), 0.0123456 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] [Full GC (Ergonomics) [PSYoungGen: 0K->0K(76288K)] [ParOldGen: 116480K->116480K(175104K)] 116480K->116480K(251392K), [Metaspace: 4950K->4950K(1056768K)], 0.3456789 secs] [Times: user=0.35 sys=0.00, real=0.34 secs]第一行是Minor GC,PSYoungGen从65536K降到8112K,堆总量从65536K降到9765K,说明这次GC回收到了一些空间,耗时12毫秒。第二行是Full GC,新生代和老年代都没有回收掉空间,耗时345毫秒,这是很危险的信号,说明堆中可能存在大对象无法回收,或者存在严重的内存泄漏。
有个细节需要注意:日志中GC前后的堆大小差距不大,不代表没有内存问题。有时候Full GC持续发生但每次都回收不了多少空间,这种情况基本可以断定是内存泄漏,必须通过heap dump来定位问题根因。
7.3 第三步:常见的GC问题模式与调整方案
模式一:Minor GC非常频繁,每秒超过一次,每次回收后Eden区占用率又迅速飙升。这种一般是Eden区太小,或者对象分配速率太高。解决方案是适当调大新生代空间,通过-Xmn或者-XX:NewRatio调整新生代比例。如果对象创建速率过高,需要从业务代码入手,检查是否存在不必要的对象创建。
模式二:Full GC频繁,每次Full GC后老年代占用率依然很高。这种一般是老年代空间不足,或者有对象无法被回收。首先检查堆参数是否合理,然后通过jmap或MAT分析heap dump,确认是否有内存泄漏。如果确认没有泄漏,只是内存真的不够,可以适当增大堆内存,或者考虑优化业务代码减少常驻对象。
模式三:GC停顿时间过长,用户线程等待时间超过业务可以接受的范围。这种需要评估当前GC收集器是否适合,从Parallel GC换到G1或者ZGC,通常可以降低停顿时间。G1可以通过-XX:MaxGCPauseMillis设置期望停顿时间,比如设置成100毫秒或者200毫秒。
7.4 实战案例:一个Spring Boot应用频繁Full GC的排查过程
我处理过一个典型的Spring Boot应用,线上环境每两分钟就出现一次Full GC,每次停顿超过500毫秒,接口响应时间受到明显影响。拿到GC日志之后,发现老年代每次Full GC之后几乎还是满的,说明垃圾根本回收不掉。然后用jmap命令dump了堆内存:
jmap -dump:format=b,file=/tmp/heap.hprof <pid>用MAT分析dump文件,找到了一个ArrayList持有大量缓存对象,这些对象被静态字段引用,一直不释放。进一步分析代码发现,这是一个定时任务每次运行都把大量结果缓存到内存中,没有清理机制。问题根因定位后,修复方案是把缓存改成带过期策略的本地缓存,比如Caffeine,并限制最大容量。上线后Full GC频率从两分钟一次降到几乎为零,接口响应时间恢复了正常。
这个案例说明一个重要的道理:GC调优的核心不是调参数,而是先搞清楚对象为什么堆积。参数调整只能缓解症状,代码问题才是根源。
8. 高频面试题背后的考察点与标准回答思路
JVM是Java面试的重灾区。面试官问JVM问题,考察的不仅是记忆,更是你有没有实际排查经验和深层理解。
8.1 “请说一下JVM的内存模型”应该怎么答
这个问题考察的是对运行时数据区的掌握程度。回答时要分两个层面:线程私有区域和线程共享区域。线程私有区域包括程序计数器、虚拟机栈、本地方法栈,它们的生命周期与线程相同;线程共享区域包括堆和方法区。堆是GC的主战场,划分为新生代和老年代;方法区在JDK 8之后由元空间实现,使用本地内存。
如果要拿高分,可以补充两点:一是JDK 8和JDK 7在方法区实现上的差异,永久代换成元空间;二是堆内部的结构以及为什么这样划分,为分代回收服务。
8.2 “什么时候会触发Full GC”应该怎么答
这个问题考察的是对GC触发条件的理解。标准回答包括:老年代空间不足、元空间不足、调用System.gc()、CMS的Concurrent Mode Failure、堆内存分配失败等。如果能结合实战经验,说一个自己遇到过的Full GC案例,加分效果很明显。
8.3 “如何判断对象可以被回收”应该怎么答
先说出判定算法:引用计数法和可达性分析算法,说明为什么Java选择可达性分析。然后说明GC Roots包含哪些对象。接着可以展开四种引用类型对回收时机的影响。最后可以提一下finalize()方法以及它为什么不被推荐使用。这样层层递进的回答,既展示了知识的完整性,也显示了对实践的理解。
8.4 “常见的垃圾回收算法有哪些”应该怎么答
这个问题的完整回答要包括三种算法的基本思想、优缺点,以及每种算法在商用虚拟机中的应用场景。比如标记-复制算法用于新生代,标记-整理算法用于老年代,标记-清除算法是CMS的基础。顺着这个思路可以自然引出垃圾收集器的演进史,从Serial到G1再到ZGC,说明为什么需要不断演进——核心就是在吞吐量和停顿时间之间做权衡。
9. 站在实操角度的一些心得与建议
文章的最后,分享一些我在实际排查和调优过程中的体会,这里面有太多踩过的坑。
第一,调参之前先确认问题。很多新手一看到GC频繁就上网搜索参数,然后一通乱调。正确做法是先获取GC日志和heap dump,分析清楚问题类型,再决定调什么参数。没有数据支撑的调优就是碰运气,运气不好还会把原本正常的系统调出问题来。
第二,不要迷信某个具体参数。网上一搜能搜到很多“XX参数让你的JVM性能提升十倍”的标题党文章。实际情况是,参数是否有效完全取决于你的应用场景、堆大小、对象分配速率、硬件配置等。一个好的参数组合一定是要结合压测结果反复迭代出来的。建议每次只调一个参数,其他保持不动,这样才能判断出是哪个参数起了作用。
第三,优先优化代码而不是调整参数。我在排查中遇到的绝大多数GC问题,根因都在业务代码上——对象无意义地创建、缓存无限增长、SQL查出大量数据等。参数调整只是让症状减轻,代码优化才能治本。看到一个频繁GC的应用,先想到的应该是“哪里创建了不必要的对象”,而不是“该用什么垃圾收集器”。
第四,监控系统很重要。线上JVM运行情况需要持续监控,推荐接入Prometheus加Grafana体系,通过Micrometer采集JVM指标,包括堆内存使用、GC次数、GC耗时、线程数等。建议设置GC耗时告警,比如Full GC超过1秒就触发告警,这样可以在问题影响用户之前及时发现。
希望这篇文章能帮你把JVM内存和GC机制这条线彻底串起来。记住:纸上得来终觉浅,绝知此事要躬行。在你自己的环境里创建一个测试程序,打开GC日志,观察对象分配和回收的过程,比看一百篇文章都管用。