先说明一点:这次我不打算写那种“Java内存区域一张图 + 垃圾回收算法名词解释”的入门科普,那种文章网上已经太多了。我更想从一个对象的完整一生切入,把JVM内存模型、GC机制、调优参数串成一条线,讲清楚“对象是怎么来的”“住在哪”“什么时候死”“怎么被回收”,以及这套机制在真实生产环境里会踩到哪些坑。如果你正在准备JVM面试、遇到线上OOM/GC频繁、或者刚接手一个需要调优的服务,这篇文章应该对你有用。
下面内容比较多,建议先收藏再慢慢看。
1. JVM内存全景:对象诞生的物理基础
1.1 运行时数据区不是“一块内存”,而是五个各司其职的空间
很多初学者第一次看《Java虚拟机规范》都会被这张图吓到:堆、栈、方法区、程序计数器、本地方法栈,名字一堆。但本质上,这五个区域解决的是同一个问题——代码跑起来之后,数据到底放在哪里、谁来管、什么时候清。
我的理解方式是用“办公室”来类比:
- 程序计数器:相当于你当前读到第几行。它是唯一不会OOM的区域,因为每条线程只需要一个很小的整数记录字节码行号。
- 虚拟机栈:每个线程一个栈,栈里装的是“栈帧”。每调用一个方法,压入一个栈帧;方法返回,栈帧弹出。栈帧里存的是局部变量表、操作数栈、动态链接、方法出口。这就是为什么递归太深会StackOverflow——栈帧太多把栈压爆了。
- 本地方法栈:给native方法用的,原理和虚拟机栈一样,但服务对象是native代码。
- 堆:所有线程共享,存放对象实例。这是GC的主战场,也是JVM内存中最大的一块。
- 方法区/元空间:存放类元信息、常量、静态变量、JIT编译后的代码。JDK 8之后,这块从堆里的“永久代”搬到了本地内存,改名叫“元空间”。
说实话,五个区域真正需要你“操心”的只有两个:堆和元空间。因为这两块直接决定了OOM会不会发生、GC频繁不频繁,而另外三个区域在绝大多数业务场景下你根本碰不到瓶颈。
1.2 堆内存的分代设计:谁年轻谁高频被扫
堆内存为什么要分成新生代和老年代?这个设计背后其实是对一个经验规律的利用:绝大多数对象“朝生夕灭”。
拿一个电商下单接口举例:每秒创建几百个订单DTO、购物车临时对象、日志对象,这些对象在方法执行完就没人引用了,生命周期极短。而像Spring容器里的单例Bean、缓存里的配置对象,从服务启动一直活到服务停止。
如果所有对象都放在一起,GC每次都要全量扫描整堆,那种“活得很久的对象”会被反复扫描无数次,纯属浪费。所以JVM采用分代收集:
- 新生代(Young):存放刚创建的对象,包含Eden区和两个Survivor区(S0、S1)。这里GC非常频繁,但每次回收很快,因为大部分对象活不过第一轮。
- 老年代(Old):存放熬过了多次GC仍然存活的对象,以及大对象(比如大数组、大List)。这里GC频率低,但每次GC耗时较长。
新生代内部的比例默认是 Eden : S0 : S1 = 8 : 1 : 1。为什么Survivor要有两个?因为要用“复制算法”来回收内存——每次GC后,把存活对象从一个Survivor复制到另一个Survivor,然后清空Eden和被复制的Survivor。如果你只有一个Survivor,就没法区分“正在用的”和“可以清空的”两块空间。
1.3 栈上分配与TLAB:对象不一定立刻进堆
这里有一个很多面试官喜欢问的隐藏知识点:new出来的对象一定在堆上吗? 答案是不一定。
JVM在做逃逸分析之后,如果发现某个对象只在方法内部使用、不会被外部引用,就可能做两个优化:
- 栈上分配:把对象拆散成基本类型直接分配到栈帧的局部变量表里,方法结束自动销毁,完全不用GC参与。
- 标量替换:更彻底,直接把对象的字段拆成一个个独立的局部变量。
另外,即使是真正要进入堆的对象,也优先分配在Eden区的一块专属空间——TLAB(Thread Local Allocation Buffer)。每个线程在Eden区划一小块私有内存,分配对象时不需要加锁,直接在这块私有空间里分配。这也是为什么高并发场景下JVM能撑住每秒十万级对象创建,如果没有TLAB,所有线程分配对象都去抢同一把锁,性能会惨不忍睹。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一个对象的完整生命周期:从能用到被判死
2.1 出生:类加载检查到内存分配
对象诞生第一步其实不是分配内存,而是类加载检查。当JVM遇到 new 指令时,先检查这个类是否已经被加载、解析、初始化。如果没有,先触发类加载流程。
类加载完成后进入内存分配。分配方式有两种:
- 指针碰撞:堆内存规整(使用标记-整理算法或复制算法GC后),已用内存在一边,空闲内存在另一边,中间一个分界指针,分配就是移动指针。
- 空闲列表:堆内存不规整(使用标记-清除算法),JVM维护一张表记录哪些内存块是空闲的,分配时找一块足够大的。
使用哪种方式取决于GC算法,而GC算法又取决于垃圾收集器。比如Serial、ParNew这类带压缩整理能力的收集器,用指针碰撞;而CMS这种基于标记-清除的收集器,用空闲列表。
分配完内存后,还要做三件事:把对象头里的类型指针指向Class对象、把实例字段初始化为零值(注意,是零值不是构造器里的值)、最后调用构造函数。
2.2 生存:对象怎么进入老年代
对象不是一出生就注定要死的,它要经历一套“考核机制”来决定去向。这套机制有三条规则:
规则一:熬过15次Minor GC就进老年代。 每经历一次Minor GC仍然存活,对象的年龄就加1,默认到15岁就晋升老年代。这个“15”可以通过 -XX:MaxTenuringThreshold 调整。为什么默认15?因为对象头里用4bit存年龄,最大值就是15。
规则二:大对象直接进老年代。 超过 -XX:PretenureSizeThreshold 设置的大小(比如1MB)的对象,直接分配到老年代,不走新生代。这是为了避免大对象在Eden区频繁复制——大对象复制成本太高,而且Eden/Survivor空间宝贵。
规则三:动态年龄判定。 不是非得熬到15岁,如果Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代。这个规则的目的是让那些“其实活得很久”的对象提前晋升,别占着新生代地盘反复GC。
2.3 死亡:可达性分析是如何判定对象“该死”的
对象怎么才算“死”了?引用计数法(给对象加计数器,被引用+1,解除-1)看似简单,但解决不了循环引用问题——A引用B、B引用A,二者都不再被外部对象引用,但计数器永远不为0,这两个对象就永远无法被回收。
所以HotSpot虚拟机用可达性分析(GC Roots Tracing)。
思路很简单:从一组称为“GC Roots”的根节点出发,向下搜索引用链。对象不在任何根节点的引用链上,就判死刑。所以一个对象哪怕被另外一万个对象互相引用,只要它不在GC Roots的可达链上,照样被回收。
GC Roots包括哪些?我把常考的和实际排查中会用到的整理一下:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类的静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- 所有被同步锁(synchronized关键字)持有的对象
- JMXBean、JVMTI回调、系统类加载器
这里有个很多文章不会展开的细节:判断对象死亡不是一次完成,而是要经过两次标记。第一次标记后,如果对象没有覆盖 finalize() 方法或者 finalize() 已被调用过,就进入F-Queue队列等待一个低优先级线程执行 finalize(),这是对象“自救”的最后机会——在 finalize() 里重新把自己赋值给某个GC Roots可达的引用。实际开发中强烈不建议依赖 finalize(),它的执行时机不确定、影响GC性能、还可能抛异常,Java 9之后已经被标记为废弃了。
2.4 复活:对象在finalize里的最后一次挣扎
既然上面提到 finalize(),我多聊两句。这是一个面试加分项,也是很多人理解错误的地方。
对象在第一次标记为不可达后,如果有 finalize() 且未被调用过,会被放入F-Queue队列。JVM会启动一个Finalizer线程去执行这些对象的 finalize() 方法。注意,这里只说“执行”,不承诺等它执行完。因为如果 finalize() 是死循环,总不能让回收线程卡死。
在 finalize() 中,对象如果把自己(this)赋值给了一个静态变量或者某个根对象引用的字段,那么在下一次可达性分析中,这个对象就变成可达的了,成功“复活”。复活之后,JVM会把这个对象标记为“已经调用过finalize”,意味着下次再死,就没有机会自救了。
我用一个极简代码演示一下:
java复制public class CanRelive {
private static CanRelive SAVE_HOOK = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
SAVE_HOOK = this; // 自救
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new CanRelive();
SAVE_HOOK = null; // 第一次标记
System.gc();
Thread.sleep(500); // 等待finalize执行
if (SAVE_HOOK != null) {
System.out.println("I am still alive!");
} else {
System.out.println("I am dead...");
}
SAVE_HOOK = null; // 第二次标记
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
System.out.println("I am still alive!");
} else {
System.out.println("I am dead...");
}
}
}
实际运行结果会依次打印“I am still alive!”和“I am dead...”。这个实验能帮你深刻理解“两次标记”的含义,但请记住:生产环境写 finalize() 等于给自己埋雷。
3. GC机制深度剖析:分代回收的完整链路
3.1 Minor GC、Major GC、Full GC到底有什么区别
这是JVM面试里绕不过去的概念题。经常有人把Major GC和Full GC混为一谈,其实它们有明确区别:
- Minor GC(新生代GC):只回收新生代(Eden + Survivor)。触发条件:Eden区空间不足。频率高、速度快。
- Major GC(老年代GC):只回收老年代。不同收集器行为不同,CMS的Major GC是并发清理,Parallel Old是并行压缩。
- Full GC(整堆GC):回收新生代 + 老年代 + 元空间,是最重量级的GC操作,通常伴随STW(Stop The World,停顿所有业务线程)。
触发Full GC的常见场景:
- 老年代空间不足(Minor GC后晋升的对象放不下老年代)
- 元空间不足
System.gc()被显式调用(但JVM不保证一定执行)- CMS的“Concurrent Mode Failure”
- 大对象直接进入老年代,但老年代剩余空间不够
我特别想强调第1和第5种场景背后的连锁反应。JVM在每次Minor GC之前都会检查一件事:老年代最大可用连续空间是否大于新生代所有对象总大小。如果小于,再检查是否开启了 HandlePromotionFailure(JDK 6 Update 24后默认开启),开启则检查老年代最大可用连续空间是否大于历次晋升对象的平均大小。如果大于,正常进行Minor GC,舍弃风险;如果小于,直接升级为Full GC。这就是面试题“什么时候会从Minor GC升级为Full GC”的真正答案。
3.2 三色标记法:并发GC的基石
想理解CMS和G1的并发回收原理,必须弄懂三色标记法。
JVM把对象标记成三种颜色:
- 白色:未被访问的对象。标记结束时仍然为白色的对象,就是不可达对象,会被回收。
- 灰色:自身已被访问,但它的引用字段还没被全部扫描完。
- 黑色:自身和它的所有引用字段都已被扫描完。
标记过程就是从GC Roots出发,把可达对象标记为灰色,然后逐个扫描灰色对象的引用,把引用对象标记为灰色,同时自己变成黑色。最终所有白色对象都是垃圾。
问题来了:并发标记阶段,业务线程还在跑,引用关系会变。 这会导致两种问题:
- 浮动垃圾:标记过程中新产生的垃圾,本次标记处理不了,留到下轮。这个可以接受。
- 漏标(对象消失):一个黑色对象被修改,不再引用白色对象A,同时另一个灰色对象到A的引用也被删除了,那A就被错误地当成垃圾回收掉。这是致命问题,会把活对象回收掉。
为了解决漏标问题,出现了两种方案:
- 增量更新(Incremental Update):当黑色对象新增了一个指向白色对象的引用时,把这个黑色对象重新标记为灰色,后续重新扫描。CMS采用这个方案。
- 原始快照(SATB,Snapshot At The Beginning):记录对象在标记开始时所有引用关系的快照,当灰色对象到白色对象的引用被删除时,记录这个引用,后续重新扫描。G1和ZGC采用这个方案。
多说一句,G1为什么选SATB而不是增量更新?因为G1的Region跨区引用多,如果每次都重新扫描整个黑色对象,成本太高。SATB只需要记录“引用变更事件”,在并发标记结束时快速处理一遍变更记录,效率更高。
3.3 从Serial到ZGC:七款垃圾收集器怎么选
我把HotSpot VM的七款主要收集器按演进顺序整理成一张表:
| 收集器 | 工作模式 | 适用场景 | 核心特点 |
|---|---|---|---|
| Serial | 新生代 | 客户端、单核小内存 | 单线程GC,STW但效率高,无上下文切换开销 |
| ParNew | 新生代 | 服务端+CMS组合 | Serial的多线程版 |
| Parallel Scavenge | 新生代 | 服务端、吞吐量优先 | 关注CPU吞吐量,支持自适应调节 |
| Serial Old | 老年代 | Serial的后端 | 单线程标记-整理 |
| Parallel Old | 老年代 | Parallel Scavenge后端 | 多线程标记-整理 |
| CMS | 老年代 | 低延迟场景 | Concurrent Mark Sweep,并发标记清除,不压缩 |
| G1 | 全堆分区 | JDK 9+默认、大堆 | Region分区,可预测停顿时间 |
| ZGC | 全堆分区 | 超大堆、超低延迟 | 染色指针+读屏障,10ms内STW |
选型逻辑是这样的:
- JDK 8默认是
Parallel Scavenge + Parallel Old,看重吞吐量。对于计算密集型、后台批处理任务,这是合理选择。 - 如果追求响应时间(比如在线交易系统),用
ParNew + CMS组合。但CMS在JDK 9后标记废弃,JDK 14移除了。 - JDK 9以后的默认就是G1,JDK 11引入ZGC(实验性),JDK 15转正。
- 我个人现在直接用JDK 17 + G1,除非堆特别大或要求极低延迟,否则ZGC不是标配。ZGC在JDK 21里已经实现了分代,但这又是另一个话题了。
3.4 G1到底怎么革新了GC:Region分区与可预测停顿
G1的全称是Garbage First,意思是在回收时优先处理“垃圾最多”的区域。它把堆划分成一个个大小相同的Region(默认最大2048个Region,每个Region大小从1MB到32MB不等),每个Region可以被动态地扮演Eden、Survivor、Old的角色,甚至还有一类专门存放巨型对象的Humongous Region。
为什么说G1是革命性的?因为之前的CMS和Parallel都要求物理连续的堆空间——新生代是一整块、老年代是一整块,像一盘棋局。G1把棋盘打散成Region后,每个Region是独立的回收单元,不需要调整整个堆的物理布局就能实现压缩,所以可以做到“可预测的停顿时间”。
G1的GC过程分为四个阶段:
- Young GC:并行回收所有Eden Region,把存活对象复制到Survivor或晋升到Old Region。
- 并发标记:当堆使用率达到
-XX:InitiatingHeapOccupancyPercent(默认45%)时启动,标记活对象。这一步是并发执行的,业务线程不停。 - 混合回收:既回收新生代,也回收部分Old Region。筛选出垃圾比例最高的Region优先回收,这就是“Garbage First”名字的由来。
- Full GC:如果并发标记期间堆内存被耗尽,或者混合回收跟不上对象分配速度,G1会退化为Full GC(单线程或并行全堆STW压缩)。
G1的核心调优参数其实是目标:-XX:MaxGCPauseMillis,默认200ms。G1会根据历史GC数据动态调整年轻代大小,尽量满足这个停顿目标。但要注意,这个参数不是“设置越小GC越快”,如果设得太小(比如50ms),G1会缩小小年轻代,导致Minor GC频繁触发,反而降低吞吐量。我线上一般设150~200ms。
4. 元空间、堆外内存与内存分配规则
4.1 元空间:JDK8后永久代去哪了
JDK 8之前,类的元数据存储在永久代(PermGen),这个区域属于堆的一部分,大小需要手动设置 -XX:MaxPermSize。问题在于,类的元数据大小很难预估,尤其在动态生成类特别多的框架(Spring、CGLIB代理、热部署)下,容易OOM。
JDK 8把永久代整体移除,换成元空间。区别在于:
- 永久代在堆内,受
-Xmx限制;元空间在本地内存,只受物理内存限制。 - 默认情况下
-XX:MaxMetaspaceSize没有上限,这意味着元空间理论上可以无限增长,直到耗尽物理内存。 - 字符串常量池从JDK 7开始挪到了堆里,到JDK 8已经确认在堆中。
实际排查中要小心:如果元空间OOM,最常见的不是类太多,而是类加载器泄漏。比如反复使用 URLClassLoader 加载重复的jar包、热部署框架没清理旧类加载器。这会导致旧的类元数据无法回收,元空间持续增长。
4.2 堆外内存:DirectByteBuffer与Netty的malloc
堆外内存(Off-Heap Memory)指的是JVM堆之外、由操作系统分配的内存。最常见的场景是 DirectByteBuffer——Java NIO里用于零拷贝传输的缓冲区。
为什么不直接分配到堆里?因为堆内存的数据在发送到网卡或写入磁盘时,需要先“拷”到操作系统的直接内存区域,这个拷贝过程叫“堆内到堆外的切换”。如果你直接用 DirectByteBuffer,数据一开始就在操作系统管理的内存里,跳过堆拷贝,性能更好。
但堆外内存有两个大坑:
- 不受JVM堆大小限制,但受物理内存限制。堆外内存OOM时,JVM通常不报
OutOfMemoryError: Java heap space,而是报OutOfMemoryError: Direct buffer memory。 - 回收依赖Cleaner机制,不是常规的GC流程。
DirectByteBuffer创建时会注册一个Cleaner,当对象变得不可达时,Cleaner线程会调用unsafe.freeMemory()释放堆外内存。但如果对象创建和释放速度过快,Cleaner线程可能跟不上,就会出现堆外内存泄漏。
Netty之所以自己实现了内存池来管理堆外内存,就是为了避免频繁创建和释放 DirectByteBuffer。它在启动时预分配一块大的堆外内存,再切成小块复用,大幅减少系统调用。
4.3 内存分配规则总结:一个对象到底怎么住进堆里
上面聊了各种区域和规则,这条线很容易断。我把一个普通对象从诞生到死亡的完整路径整理成下面的顺序,建议你对照着手画一遍:
- 类加载检查,确认类的元数据已加载到元空间。
- 如果对象足够小且可以栈上分配,在栈帧里拆散成标量,方法结束直接销毁,GC全程不参与。
- 如果不能栈上分配,在TLAB中分配在Eden区(线程私有,无锁竞争)。
- Eden区空间不足,触发Minor GC。使用复制算法,把Eden + S0的存活对象复制到S1,清理Eden和S0,S0和S1角色互换。
- 对象每存活一次Minor GC,年龄+1。达到
MaxTenuringThreshold(默认15)或触发动态年龄判定,晋升老年代。 - 大对象(超过
PretenureSizeThreshold)创建时直接放老年代。 - 老年代空间不足,触发Major GC或Full GC,最终使用标记-整理或并发收集器回收无用对象。
- 对象被判定不可达后,经过两次标记,如果
finalize()存在且未调过,有机会自救一次,否则被正式回收。
这张图我建议你在脑子里存着,几乎所有JVM内存相关面试题最后都能追溯到这条路径上的某个环节。
5. 实战调优:从参数到工具的全链路
5.1 常用JVM参数详解:不要只记-Xmx和-Xms
很多人调优只知道 -Xmx 和 -Xms,先说这两个:
-Xms:堆初始大小。-Xmx:堆最大大小。-Xmn:新生代大小。-XX:SurvivorRatio:Eden与Survivor比例,默认8:1。-XX:MaxTenuringThreshold:晋升老年代年龄阈值,默认15。-XX:PretenureSizeThreshold:大对象阈值,单位是字节。-XX:ParallelGCThreads:并行GC线程数。-XX:+UseG1GC:使用G1收集器。-XX:MaxGCPauseMillis:G1目标停顿时间。-XX:InitiatingHeapOccupancyPercent:G1启动并发标记的堆占用百分比,默认45。
一个踩坑提醒:-Xms 和 -Xmx 建议设成一样大,避免运行期堆大小动态伸缩带来的性能开销。堆伸缩本身会触发STW,对于高并发的服务来说,一次堆扩容导致的停顿完全不该发生。
5.2 我踩过的一次线上OOM:一次内存泄漏排查实录
分享一个我实际经历过的案例。当时是一个订单导出服务,运行两周后接口响应越来越慢,最后直接OOM。
第一反应是看日志,结果是 OutOfMemoryError: Java heap space。然后用 jmap -dump:format=b,file=heap.hprof <pid> 抓堆转储文件,再导入MAT分析。
Dominator Tree一眼就看到问题:ThreadLocal 里挂了一个巨大的 HashMap,里面存的是所有导出任务的中间结果。问题出在代码里用了 ThreadLocal 存储任务上下文,但任务结束后没有调用 remove(),导致线程池里的线程在复用过程中,ThreadLocal里的旧数据一直存活。
这个案例想说明三件事:
- 内存泄漏不一定是大对象,很多时候是生命周期管理不当。
- ThreadLocal使用后一定要删除,尤其在线程池中。因为线程复用,ThreadLocalMap中的Entry指向的对象会一直保持强引用。
- 排查工具比直觉可靠。不要靠猜,先抓堆转储,让MAT告诉你答案。
5.3 常用排查工具与操作清单
排查JVM内存和GC问题,我常用的工具链如下:
- jps:列出Java进程PID,排查问题的起点。
- jstat -gcutil
1000 :每秒输出GC信息,观察Eden、Survivor、Old使用率、YGC/FGC次数、停顿时间。 - jmap -heap
:查看堆概要信息(各区域大小、使用率、GC算法)。 - jmap -dump:format=b,file=heap.hprof
:抓堆快照,后续用MAT或VisualVM分析。 - jstack
:查看线程栈,排查死锁、线程阻塞、ThreadLocal持有。 - jcmd
GC.class_histogram :查看各类实例数量和占用空间,快速定位大对象。
线上排查建议按这个顺序操作:
jps找到进程,jstat -gcutil确认GC是否频繁、停顿是否过长。jmap -heap看堆容量使用率,确认哪个区域OOM。- 如果怀疑代码逻辑问题,
jstack抓线程栈配合jstat观察线程状态。 - 确定要分析对象时,抓堆转储,用MAT分析Dominator Tree和Leak Suspects。
在生产环境抓 jmap -dump 要谨慎,大堆(比如4GB以上)转储文件很大,抓取过程会STW。我一般会先评估堆大小和抓取影响,再决定是立即抓还是等低峰期。JDK 11+建议用 jcmd GC.heap_dump,效果一样但命令更规范。
5.4 GC日志怎么看
GC日志是调优时最直接的反馈,给你看一段真实日志,我来逐行解释:
code复制[GC (Allocation Failure) [PSYoungGen: 122880K->16384K(143360K)] 122880K->24576K(455680K), 0.0312345 secs] [Times: user=0.05 sys=0.02, real=0.03 secs]
拆开解释:
GC (Allocation Failure):Eden区分配失败导致Minor GC。PSYoungGen: 122880K->16384K(143360K):新生代GC前122880K、GC后16384K、总容量143360K。122880K->24576K(455680K):整个堆GC前122880K、GC后24576K、总容量455680K。堆总容量 = 新生代 + 老年代。0.0312345 secs:GC耗时约31ms。Times里的 user/sys/real:user是CPU用户态耗时,sys是内核态耗时,real是实际耗时。多核GC线程并发时,user会大于real。
看GC日志的常见判断方法:
- 如果
YGC频繁且Eden回收后存活比例极高(Survivor都快塞不下),说明新生代太小或晋升阈值过低。 - 如果
FGC次数持续增长,先看Old空间是否已满;满了就直接找内存泄漏。 - 如果
FGC后Old使用率下降不明显,多半是老年代里驻留了大量本不该长期存活的对象。
GC日志打印需要加JVM参数:
code复制-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log
JDK 9以后的日志风格统一了,换成:
code复制-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags
5.5 一个完整的Spring Boot调优配置参考
最后给你一个我线上使用的Spring Boot服务JVM参数模板,JDK 17 + G1 + 4核8G内存,供参考:
code复制-Xms4g
-Xmx4g
-Xmn1536m
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2
-XX:InitiatingHeapOccupancyPercent=45
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heap.hprof
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags
说明几个设计思路:
-Xms和-Xmx相同,防止堆伸缩。-Xmn约占总堆的1/3多一些,业务对象偏短命时可以把新生代调大。MaxGCPauseMillis=200,G1在这个目标内尽量平衡吞吐。HeapDumpOnOutOfMemoryError一定要加,OOM时能自动留下线索,省去现场临场抓堆的尴尬。
这组参数不是万能解,但作为起步模板很稳。真正调优要结合业务实际看GC日志再微调,不要一上来就抄最激进的参数。
6. 常见问题与排查技巧速查
6.1 高频问题对照表
| 现象 | 可能原因 | 第一步排查 |
|---|---|---|
| 频繁Full GC但堆占用不高 | 元空间不够、System.gc()被代码调用、JNI本地内存问题 |
jstat -gcutil看FGC时间点,jstack找调用栈 |
| OOM: Java heap space | 堆满,存在内存泄漏或堆上限确实偏低 | jmap -dump抓堆,MAT分析 |
| OOM: Metaspace | 类加载器泄漏、动态生成类过多 | jcmd GC.class_histogram看类数量 |
| OOM: Direct buffer memory | 堆外内存耗尽,NIO使用不当 | 修改 -XX:MaxDirectMemorySize,排查DirectByteBuffer释放 |
| GC停顿时间超长 | 堆过大、CMS或G1回收老年代、系统内存压力 | 看GC日志,分析是YGC还是FGC,调整收集器参数 |
| CPU飙高但业务不忙 | 可能频繁GC或死循环 | top -Hp找线程,jstack对线程栈 |
6.2 新手最容易犯的三个错
第一,把所有JVM参数都堆上去。看到网上推荐文章就一股脑全加,结果GC频繁、停顿更严重。正确做法是先跑基线,观察日志,再做最小调整。
第二,盲目设置超大的堆。4G内存的机器你设置3G堆,系统剩余内存不到1G,操作系统频繁swap,GC停顿反而更长。堆大小要考虑系统总内存、其他进程占用、元空间和堆外内存的需求。
第三,以为GC调优能解决所有性能问题。很多所谓“GC频繁”问题,根源其实是代码写得太烂——循环里大量创建对象、没必要的装箱、超大集合没清理。先把代码层面的分配优化做好,再谈GC参数。
第三点尤其重要,我在实际项目里见到太多人把时间花在调参数上,结果用JFR(JDK Flight Recorder)一看,大部分对象分配都发生在明显的代码热点里。改掉那些低效写法,GC压力直接降一半,根本不涉及任何参数调整。
7. 最后一个实战感受
聊到这儿,JVM内存和GC从对象诞生到回收的完整链路基本串完了。我把这些年实际调优过程中最重要的体会分享给你:GC参数只是收益的最后一公里,真正的收益大头永远在代码质量和对象生命周期管理上。
一个对象如何被创建、何时不再被引用、是否长期驻留在老年代,这些都是代码逻辑直接决定的。JVM的GC机制再先进,也没法替你清理那些被错误持有一万年的ThreadLocal、被遗漏的IO流、被缓存永久封存的中间结果。
所以我的建议是:先能看懂GC日志,再会用jstat和jmap定位问题,最后再动手调参数。不要一上来就折腾 MaxGCPauseMillis 和 ParallelGCThreads 这些旋钮,而是先让工具告诉你问题到底出在哪个区域、哪个时间点、哪个对象身上。工具给出方向,代码解决源头,GC参数负责收尾,这才是合理的调优顺序。
把这些内容吃透之后,再看网上那些JVM面试题,你会发现核心问题永远绕不开几件事:内存分布是什么样、对象怎么分配、GC如何判定死亡、收集器怎么选、参数怎么调。这个框架建立起来了,剩下就是经验的积累了。
