news 2026/10/21 15:28:08

深入浅出JVM底层:垃圾回收、内存分配与线上调优实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入浅出JVM底层:垃圾回收、内存分配与线上调优实战

搞JVM底层这块,很多人第一反应就是啃那本大部头的经典书,但说实话,真没几个人能坚持从头翻到尾。尤其到了工作三五年这个阶段,业务代码写了无数,一到线上出现内存暴涨、CPU飙高、频繁Full GC的时候就抓瞎,回头再去翻书,知识点又散又杂,根本静不下心。这篇是“深入浅出JVM底层”的下篇,咱们不聊虚的,直接围绕垃圾回收、垃圾回收器选型、内存分配策略和线上调优这几个最硬核的方向展开,把经典书籍里那些值得记的东西捞出来,结合我实际踩过的坑一起讲,看完你至少能知道线上JVM参数该怎么调、GC日志该怎么看、OOM该怎么查。

这系列的上篇主要讲了类加载机制和运行时数据区,那是地基,这篇的内容则是真正决定Java应用“稳不稳”“快不快”的部分。如果你正准备面试、或者正被线上JVM问题折磨,这篇文章正好适合你。我会尽量用大白话把底层原理讲透,也会给出可以直接抄走的命令和参数,哪怕你之前没看过任何JVM书籍,跟着走一遍也能建立一套完整的知识框架。

1. 垃圾回收:JVM最核心的“自动内存管理”机制

1.1 为什么说GC是JVM的灵魂

Java和C++最本质的区别,就是内存由谁负责清理。C++开发者要亲手delete每一个new出来的对象,稍有不慎就是内存泄漏;Java开发者只管new,不用管回收,JVM的垃圾回收器(GC)会在后台自动把不再使用的对象清掉。这听起来很美,但代价就是回收过程本身需要消耗CPU资源,而且回收时机不可控,一旦出现频繁的Full GC,应用就会卡顿甚至假死。

要理解GC,先得搞清楚两个基础问题:哪些内存需要回收?怎么判断对象“已死”?

1.2 判断对象存活的两种算法

第一个是引用计数法。每个对象有一个计数器,被引用一次就加一,失效就减一,计数器为零就判定为可回收。这算法简单高效,但解决不了循环引用的问题——A引用B、B引用A,两个对象计数器都不为零,但对外已经没有引用了,这俩就成了永远回收不掉的内存垃圾。JVM没有采用这个方案。

第二个是可达性分析算法。这个思路是从一组称为GC Roots的根对象出发,沿着引用链往下走,能走到的对象就是“活的”,走不到的统统判死刑。这组GC Roots包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象,以及Java虚拟机的内部引用(如基本数据类型对应的Class对象、常驻的异常对象、系统类加载器等)。

注意:可达性分析里的“可达”,不是指对象之间必须直接引用,而是指从根节点出发能否通过引用链到达。这个判定过程必须在一个一致性的快照中进行,所以会触发Stop The World(STW),也就是暂停所有用户线程。

1.3 四大垃圾回收算法盘点

判断出垃圾之后,就要真正回收了。经典书籍里讲了四种基础算法,我按“从简单到现代”的顺序给你串一遍:

标记-清除算法,分两个阶段:先标记出所有需要回收的对象,然后统一回收。缺点很明显:标记和清除的效率都不高,而且清除之后会产生大量不连续的内存碎片。碎片多了,后面分配大对象时找不到连续空间,就会提前触发下一次GC。这就像房间里堆了一堆杂物,你只是把不要的东西拿走,但东西还是散落各处,想放个大柜子却发现没整块地方。

标记-复制算法,把内存按容量划成大小相等的两块,每次只用其中一块。这块用完了,就把还活着的对象复制到另一块,再把已用过的空间一次性清空。解决了碎片问题,但代价是内存可用空间直接减半。现在的商业虚拟机都用这个思想来回收新生代,但不是按1:1划分,而是分成一块较大的Eden空间和两块较小的Survivor空间,比例默认是8:1:1,这样只有10%的内存会被浪费。这里有个前提:新生代对象“朝生夕灭”的特性决定了每次GC存活对象很少,复制成本低。如果Survivor空间不够用,就需要老年代做分配担保。

标记-整理算法,老年代的“专属算法”。标记过程跟标记-清除一样,但后续不是直接清掉,而是让所有存活对象向内存的一端移动,然后直接清理掉端边界以外的内存。好处是没有碎片,坏处是移动对象需要STW,停顿时间更长。这里有个取舍:是老年代CMS那种“不移动对象、用空闲列表分配”但回收有碎片好,还是G1那种“移动对象、用Region复制”但停顿可控好?现代回收器的选择逻辑基本都围绕这个权衡展开。

分代收集理论,严格来说不算独立算法,而是上述算法的组合拳。既然不同生命周期的对象特性不同,那就分而治之:新生代对象存活率低,适合复制算法;老年代对象存活率高,适合标记-整理或标记-清除。这就像管理仓库:临时周转的东西放在门口随取随扔,长期存放的货品往仓库深处码放整齐。

2. 从Serial到ZGC:垃圾回收器的演进与选型逻辑

2.1 经典七款回收器一张表看清

JVM发展几十年,累积了多款垃圾回收器,我整理了一下核心差异:

回收器工作模式适用区域核心算法特点与短板
Serial单线程新生代标记-复制简单可靠,STW时间长,适合客户端或小内存场景
ParNew多线程新生代标记-复制Serial的多线程版本,常与CMS搭配使用
Parallel Scavenge多线程新生代标记-复制追求吞吐量,可设置自适应调节策略
Serial Old单线程老年代标记-整理Serial的老年代版本,CMS的备胎
Parallel Old多线程老年代标记-整理与Parallel Scavenge搭配,注重吞吐量
CMS并发老年代标记-清除并发收集,低停顿,但碎片多、CPU敏感
G1并发新生代+老年代标记-复制+标记-整理分区化、可预测停顿,替代CMS成为默认
ZGC并发新生代+老年代标记-复制染色指针+读屏障,停顿控制在10ms以内

2.2 CMS的辉煌与谢幕

CMS(Concurrent Mark Sweep)曾经是老年代回收的主流选择,它的核心追求是“最短回收停顿时间”,广泛应用于互联网场景的Web服务端。它的工作流程分为四步:初始标记(STW,标记GC Roots直接可达的对象)、并发标记(与用户线程并发执行,从已标记对象出发继续标记所有可达对象)、重新标记(STW,修正并发标记期间因用户线程运行导致变动的标记记录)、并发清除(与用户线程并发,真正删除垃圾对象)。

看起来很美好,但CMS有三个硬伤。第一,它对CPU资源非常敏感,并发阶段虽然不会STW,但会占用一部分线程导致应用变慢,默认并发线程数是(CPU核数+3)/4,CPU核数越少,影响越大。第二,它是标记-清除算法,无法避免碎片堆积,老年代空间被碎片拆散后,分配大对象会提前触发Full GC。第三,它无法处理“浮动垃圾”——并发清除阶段用户线程新产生的垃圾只能留到下一次GC处理,所以必须预留一部分空间给并发收集期间运行的用户线程使用。

我当年运维过一台4核8G的机器,跑着CMS,一到业务高峰Full GC就频繁,因为老年代剩余空间不足,CMS并发收集失败后直接退化成Serial Old做全堆STW式回收,停顿长达好几秒。后来换了G1才解决。

2.3 G1:里程碑式的分区化回收器

G1(Garbage First)的设计思路跟传统回收器完全不同。它不再物理划分新生代和老年代,而是把整个堆划分为一个个大小相等的Region(默认2048个,每个1MB到32MB),每个Region都可以独立扮演Eden、Survivor或者Old区。它还有一个专门的Humongous区,用来存放大对象(超过Region容量50%以上的对象)。

G1最牛的地方在于“可预测停顿模型”。它维护一个优先级列表,跟踪每个Region回收的价值大小(回收收益、回收成本等),然后根据用户设定的目标停顿时间(-XX:MaxGCPauseMillis,默认200ms)优先回收“垃圾最多、回收最快”的Region,这就是“Garbage First”名字的由来。在G1中,新生代不再是一个连续的内存块,而是一组离散的Eden Region集合,每次YGC就是把这些Region里的存活对象复制到Survivor Region,然后把整块Eden Region直接清空。

G1的混合回收(Mixed GC)也很有意思——它不会只回收老年代,而是会选择一部分老年代Region和所有新生代Region一起回收。这让G1能够同时处理新生代和老年代的垃圾,但又不至于像传统Full GC那样把整个堆都STW。

我这里要分享一个要点:G1的停顿预测模型是基于历史统计的“衰减平均值”,默认会参考最近10次GC的统计数据。如果实际停顿常常超出目标值,可以尝试调整-XX:MaxGCPauseMillis和-XX:G1MixedGCLiveThresholdPercent等参数,但要记住,停顿目标设得太小会导致GC频率上升,吞吐量反而下降,得在低延迟和高吞吐之间找平衡。

2.4 ZGC:面向大堆的超低延迟方案

如果你追求的是“不管堆多大,停顿都控制在10毫秒以内”,那就得上ZGC了。ZGC的核心技术是染色指针(Colored Pointer)和读屏障(Load Barrier)。染色指针把64位指针中腾出几位来存放颜色信息(Marked0、Marked1、Remapped等),GC标记过程直接通过指针上的颜色状态来判断对象是否被标记,避免了对对象的访问和修改。读屏障则让应用线程在读取堆中对象引用时,可以即时感知GC的移动情况,从而让对象的搬运和应用线程的运行并发进行。

ZGC的整个收集过程基本都是并发执行的,除了初始标记和最终标记等极短阶段需要STW外,绝大部分时间应用线程都在运行,所以停顿极低。它特别适合超大堆(几十GB甚至几百GB)的场景,比如一些内存型数据库、大数据处理引擎。

不过ZGC也不是银弹。它的CPU开销相对较高,读屏障每次读取引用都要检查颜色状态,这对高频访问的应用来说是有额外成本的。我自己的经验是:堆内存小于8GB的常规业务服务,G1已经足够优秀,没必要上ZGC;只有堆上到几十GB、对停顿特别敏感的延迟型业务,才值得引入ZGC。

3. 内存分配与回收策略:对象到底怎么“分房子”

3.1 对象优先在Eden分配

绝大多数对象在新生代Eden区分配。当Eden区没有足够空间时,虚拟机发起一次Minor GC(新生代GC)。Minor GC非常频繁,回收速度也很快,因为它只处理新生代,而新生代里绝大部分对象都是“用完即弃”的。

这里有个关键参数可以看:-Xmn设置新生代大小。新生代太小会导致Minor GC频发,太多对象提前晋入老年代;新生代太大又会压缩老年代空间,导致Full GC压力增大。经验值是新生代占堆的1/3到1/2,具体要看应用的对象生命周期特征。如果是典型的“请求-响应”型Web应用,对象大多是一次性创建、一次请求结束后就变成垃圾,新生代可以适当大一点。

3.2 大对象直接进入老年代

如果一个对象的大小超过-XX:PretenureSizeThreshold参数设定的值(该参数只对Serial和ParNew有效),或者大对象在G1中超过一个Region容量的50%,就会被直接分配到老年代。这样做的目的是避免在Eden区和两个Survivor区之间发生大量内存复制。

写代码时要注意避免“朝生夕灭”的大对象,比如一次性构造几百KB甚至几MB的byte数组。我之前排查过一个案例:某个服务每秒钟都要往一个缓存Map里塞一个巨大的JSON解析结果,GC日志显示每次Minor GC都在频繁复制这个对象,老年代也在持续增长。后来改成对象复用+分页处理,GC频率直接降了一半。

3.3 长期存活的对象进入老年代

每个对象有一个对象年龄计数器,每熬过一次Minor GC、被复制到Survivor区,年龄就加一,默认到15岁(-XX:MaxTenuringThreshold,CMS里常用6到15)就会晋升到老年代。对象晋升年龄只是其中一个条件,还有一个动态年龄判定机制——如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等够设置的最大年龄。

这个动态年龄机制很关键。我见过一个场景:业务高峰时大量中等生命周期对象涌入Survivor区,Survivor区装不下,又没达到晋升年龄,结果对象在Survivor里反复复制,触发过多次“提前晋升”。后来把-XX:TargetSurvivorRatio(默认50%)适当调低,让Survivor区更早“让位”,情况才缓解。

3.4 空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间。如果大于,Minor GC可以安全执行;如果不大于,则会检查-XX:HandlePromotionFailure参数(JDK 6 Update 24之后该参数已失效,规则变为只要老年代连续空间大于新生代对象总大小或历次晋升的平均大小就进行Minor GC,否则转为Full GC),判断是否允许担保失败。这是复制算法在新生代的核心前提:Survivor区装不下的对象,需要一个“后盾”来接收,这个后盾就是老年代。

我在压测一个高并发下单服务时,把-Xmn设置得过大,结果Minor GC频繁触发老年代担保失败,导致大量Full GC,吞吐量直接崩了。后来把新生代调回合理范围,同时给老年代留足余量,问题消失。

4. 调优实战:一次真实的JVM性能排查与参数优化过程

4.1 现象:应用卡顿、RT飙高、Full GC频发

有一年我们维护一个电商核心交易系统,平时稳如老狗,一到促销大促就出问题:接口平均响应时间从50ms飙升到2秒多,监控面板上能看到频繁的Full GC告警。线上配置是-Xmx4g -Xms4g -Xmn1g,老年代使用CMS。GC日志显示Full GC平均一秒一次,每次停顿1.5秒以上。

我用jstat -gcutil和jmap把这些命令跑了一遍:

# 查看GC统计信息,每秒采样一次 jstat -gcutil 12345 1000 10 # 堆内存使用情况和分区占比 jmap -heap 12345 # 导出堆转储文件 jmap -dump:live,format=b,file=/tmp/heap.hprof 12345

jstat的输出里,FGC那一列的数值在快速上涨,FCT(Full GC耗时)也很大。jmap -heap看到老年代使用率已经到95%以上,而且老年代里大量空间都是不可用的碎片。

4.2 定位:内存里到底住着谁

用jmap导出的堆转储文件导入分析工具(比如某开源的堆分析工具,具体名字就不提了),发现老年代里占据了将近60%空间的是一个静态Map对象,里面保存着大量用户的会话数据。进一步追代码,发现这个Map的键是用户ID,值是最近一小时内所有操作记录的对象,而且这个Map没有做淘汰策略,只有一个定期清理任务在跑,但清理逻辑写得有Bug,有些旧数据永远清不掉。

这类问题其实很多见:业务代码里静态集合类当缓存用,又没控制好容量,或者把ThreadLocal和线程池混用,导致线程对象无法释放。这些问题在JVM层很难用参数救回来,根子还是在代码上。

4.3 解决:双管齐下,代码优化+参数调整

代码层面做了三件事:把静态Map改成带过期淘汰机制的本地缓存组件;把历史操作记录从内存中挪到数据库,只保留最近30分钟的数据;对超大对象增加大小上限判断,超过阈值的直接走拆分逻辑。

参数层面做了一次“保守优化”:

-Xmx4g -Xms4g -Xmn1536m -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly -XX:MaxTenuringThreshold=6 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:/data/logs/gc.log

解释一下关键点:CMSInitiatingOccupancyFraction设为70,意思是老年代使用率达到70%时就启动CMS的并发收集,避免等到快满了才被动Full GC;MaxTenuringThreshold从15降到6,尽量减少对象在Survivor区里熬的次数;PrintGCDetails和PrintGCDateStamps把每次GC的细节和绝对时间打到gc.log里,方便后续回溯定位。

经过代码优化和参数调整之后,Full GC从每秒一次降到两三分钟一次,接口RT恢复到80ms以内,整个促销期间服务器稳稳当当。这次经历给我最大的启发是:JVM调优不是上来就调参数,而是先定位是谁在占用内存,参数只是辅助手段。

4.4 常用调优命令速查

平时排查JVM问题,我用的最多的就是下面这几个工具:

命令作用典型场景
jps -l列出Java进程及主类确认进程ID
jstat -gcutil PID 1000查看GC实时统计判断GC频率和耗时
jmap -heap PID查看堆配置和使用率判断堆和分区是否合理
jmap -dump:format=b PID导出堆转储OOM时离线分析
jstack PID打印线程栈排查线程死锁、CPU飙高
jinfo PID查看运行时JVM参数确认生效参数值

“CPU飙高”的场景通常用jstack配合top命令:先用top -Hp PID找到CPU占用最高的线程ID,转成十六进制,再到jstack输出里定位线程栈,看业务代码卡在哪里。大多数情况都是锁竞争、死循环或者正则回溯导致的。

5. 常见故障排查与核心经验速查

5.1 四类典型OOM的区分与应对

OOM(OutOfMemoryError)是JVM最经典的问题,但很多人分不清OOM和OOM之间的区别。我整理了一张区分表:

异常类型触发原因排查方向
Java heap space堆内存不足,对象无法分配堆转储分析,查大对象、集合泄漏
GC overhead limit exceededGC回收效果极差,98%时间在GC但回收不到2%堆基本等同于堆太小或内存泄漏,先dump堆
Metaspace元空间不足,加载类过多查动态代理、反射生成类、热部署场景
Unable to create new native thread操作系统线程数达到上限查线程池、JVM参数-Xss是否过大、系统ulimit

例子:元空间OOM常见于不停用反射生成代理类或频繁部署的应用,每次生成新类都会占用Metaspace,如果类加载器无法回收,就会一直涨。排查时用jstat -class PID可以看加载的类数量,如果曲线只增不减,基本就是类加载器泄漏。

5.2 堆溢出与栈溢出:别把两者搞混

堆溢出(Java heap space)是对象太多,栈溢出(StackOverflowError)是线程栈深度不够,比如无限递归、无出口的方法调用。栈溢出对应-Xss参数,服务端线程栈默认通常是1MB,当你需要创建大量线程时(比如每线程一个独立任务),大栈会导致线程数上限变低;但栈太小又会引发深层调用的StackOverflowError,两者需要权衡。

之前优化过一个线程池应用,每开一个线程就占1MB栈空间,结果系统支持最大的线程数远低于预期。调小-Xss到512KB之后,线程数直接翻倍,业务完全不受影响。

5.3 我的JVM参数设置基线

按个人经验,一个常规的Spring Boot服务上线前,我至少会确认这些参数:

-Xms4g -Xmx4g # 初始堆和最大堆保持一致,避免扩容时停顿 -Xmn1536m # 新生代大小,约占堆的1/3到1/2 -XX:MetaspaceSize=256m # 元空间初始大小 -XX:MaxMetaspaceSize=512m # 元空间上限,防止元空间无限膨胀 -XX:+UseG1GC # JDK 8的高版本和JDK 11+默认就是G1 -XX:MaxGCPauseMillis=100 # 设置期望的最大GC停顿 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log -XX:MaxTenuringThreshold=6 -XX:SurvivorRatio=8 -Djava.net.preferIPv4Stack=true

这两个参数值得展开:HeapDumpOnOutOfMemoryError在OOM时自动导出堆转储,没有这个你只能干瞪眼;MaxGCPauseMillis设100是给G1低头认怂,告诉它“宁可多GC几次,也别停顿太久”,可以配合G1HeapRegionSize调整Region大小。

5.4 JDK版本选择对JVM参数的巨大影响

很多人还在用JDK 8,但要知道,JDK 8的G1并不像JDK 11、JDK 17里的G1那样成熟。JDK 9之后,默认回收器换成了G1;JDK 11引入了ZGC(实验性);JDK 17的ZGC已经相当能打。如果你的应用跑在JDK 8上,想用ZGC是没戏的,得升到JDK 11+。

我强烈建议:新项目直接上JDK 17,不仅GC更先进,而且有record、sealed class这些语法糖,安全补丁也更及时。老项目如果跑在JDK 8上,至少要升级到较新的8u版本,因为早期JDK 8的G1有不少Bug,线上出问题会很难排查。

结尾:一点个人实操体会

做JVM性能分析这几年,最大的体会是“先现象、后定位、再解决”,顺序千万不能乱。很多人一上来就改参数,结果GC日志更乱了,问题却还在。JVM参数只是“做手术的工具”,真正决定内存健康的还是代码质量。尤其是高频创建对象、定时器持有内存、集合类缓存不设上限这类经典问题,参数怎么调都救不回来。建议每个Java开发者都养成一个习惯:线上应用一定要提前开启GC日志和OOM自动转储,这两个开关对故障排查的帮助远超你想象。另外,有空的时候用压测工具给自己负责的服务做一次“极限测试”,看看堆内存和GC表现怎么样,比起等线上出故障再急救,这个成本低得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 14:06:08

多智能体协作系统agency-agents:架构设计与工程落地实践

项目标题: agency-agents做智能体协作类项目有一段时间了,踩过不少坑,也重构过好几轮。今天把“agency-agents”这个项目从设计初衷到落地实现完整拆开聊一聊。这个项目的核心不是再做一个单一对话机器人,而是把多个具备不同专长的AI代理组合…

作者头像 李华
网站建设 2026/10/20 15:34:55

马士HHH刷屏背后:热词传播机制与内容创作避坑指南

大半夜的,我刚准备关电脑睡觉,群里突然炸出来一张截图。配文就四个字母:HHH。底下跟了上百条回复,全是清一色的“马士HHH”。我盯着屏幕看了几秒,职业病就上来了——这又是一个典型的热词苗子,而且八成又要…

作者头像 李华
网站建设 2026/10/15 10:22:07

DSec学习资料汇总:DeepSeek本地部署与Agent沙盒架构实战指南

1. 从一份资料汇总说起:DSec到底在解决什么问题第一次看到“DSec学习资料汇总”这个标题,很多人会以为这只是一份普通的链接集合。但真正翻过一遍内容的人会发现,它其实是一张围绕DeepSeek 生态、Agent 开发、沙盒隔离与系统架构铺开的知识地…

作者头像 李华
网站建设 2026/10/16 12:54:25

AI工作流失败后如何续跑?检查点机制与人工接管设计指南

1. 自动化跑完最后一公里,人反而更忙了你有没有遇到过这种场景:凌晨两点,你盯着屏幕上那个跑了三个小时的自动化工作流,进度条卡在87%不动了。日志里最后一行写着“等待元素超时”,然后就是一片死寂。你心里清楚&#…

作者头像 李华
网站建设 2026/10/16 13:01:52

VS Code配置GCC编译器完整指南:从安装到调试

1. 从零开始:为什么非要在VS Code里折腾GCC经常有朋友问我,编译器那么多,为什么非要在VS Code里配置GCC?直接用Visual Studio不行吗?或者干脆装个Code::Blocks?我个人的看法是:VS Code作为编辑器…

作者头像 李华
网站建设 2026/10/15 13:56:17

华为语音网关调试利器IPT_LMT:信令跟踪与排障实战指南

简介:IPT_LMT(IPT Line Maintenance Tool)是华为官方推出的语音网关专业调试与维护工具,面向企业通信运维人员、网络管理员及华为语音设备技术支持工程师,用于对eSpace U1900与SoftCo V200R003C20SPC300等版本进行故障…

作者头像 李华