资讯动态

JVM调优实战:从内存结构到Full GC、垃圾回收与参数应用的完整路径

发布时间:2026/10/18 9:13:47 来源:尧图企业网站定制
我最早系统性啃 JVM是因为一个看起来并不复杂的服务在线上每两个小时必来一次大批量的 Full GC接口响应时间像过山车。当时查了一堆资料越看越乱内存结构、GC 算法、分代模型、参数调优、堆外内存、元空间每个概念都能单独写一本书但真正落到“我这个进程为什么会卡顿”上反而没人能给我一个清晰的操作路径。后来我才明白JVM 这块内容最大的学习门槛不是难而是东西太散。你如果按“内存区域 - 对象分配 - 垃圾回收 - 参数调优”这个顺序去学会发现前后知识点是互相纠缠的。调优本身其实不是玄学核心就是在回答三个问题你的对象活多久你的内存够不够用你的 GC 停顿能不能被业务接受这篇文章我会把 JVM 从入门到调优的完整路径讲清楚重点放在推演过程而不是死记参数。适合刚接触 JVM 的开发者也适合那些已经写过一些 JVM 参数、但遇到线上问题依然不知道怎么下手的同学。全文偏实战很多内容来自我自己的踩坑记录和排查体会不保证是标准答案但保证能给你一条清晰的思考主线。1. 从“黑盒”到“白盒”学 JVM 到底在学什么1.1 先搞清楚 JVM 的“地盘”运行时数据区JVM 在启动的时候会从操作系统申请一块内存然后在这个范围内规划自己的运行空间。这块空间的格局就是你学 JVM 的第一张地图。先说线程私有的部分程序计数器、虚拟机栈、本地方法栈。这三个区域是每个线程一份互不干扰生命周期跟随线程。其中虚拟机栈也就是俗称的“栈”每调用一个方法就压入一个栈帧栈帧里存了局部变量表、操作数栈、动态链接、方法出口。栈深度不够的时候会抛StackOverflowError这不是 JVM 的锅通常是无限递归或者方法调用层级过深。如果你给每个线程分配的栈内存过大反过来会压缩可用线程数量这也是一个常见的隐蔽坑——线程栈大小默认在 1MB 左右如果你开 500 个线程光栈就吃掉 500MB。线程共享的部分才是重头戏堆Heap和元空间Metaspace。堆是对象分配的主战场也是垃圾回收GC作用的重点区域。堆内部又分了新生代和老年代新生代里再细分 Eden 区和两个 Survivor 区通常叫 From 和 To或者 S0 和 S1。对象优先在 Eden 区分配经过一轮 Minor GC 仍然存活的对象会被移动到 Survivor 区存活年龄够了再晋升到老年代。这套分代设计的核心理由很简单绝大多数对象朝生夕死分代让 GC 可以区别对待年轻对象用复制算法快速回收老对象用标记整理或标记清除来控制碎片整体效率远高于每次都对整个堆做一次全量回收。元空间这个名字挺容易让人误解它其实主要存类的元数据信息——类的结构、方法信息、字段描述、常量池等。在 JDK 8 之前这类信息放在方法区PermGen永久代JDK 8 之后移到了本地内存。为什么移因为永久代的大小很难估算你永远不知道一个应用在运行期会加载多少个类尤其是动态代理、反射生成类比较多的框架一旦 PermGen 大小设置不当很容易 OOM。挪到本地内存后默认情况下只受物理内存限制极大降低了这一类 OOM 的概率。内存区域的划分不是孤立知识点它是后面理解 GC 行为和对象分配逻辑的基础。比如你看到一个对象的 GC 日志里显示的PSYoungGen、ParOldGen如果心里没有这张内存地图日志就是天书。1.2 从对象分配到内存模型理解“谁在花钱”有了内存地图接下来要理解的是“钱”是怎么花出去的。这个“钱”指的就是堆内存支出的方式就是对象的创建和晋升。一个 Java 对象从诞生到死亡大致经历这样的路径new 出来的对象如果在新生代有空间优先分配在 Eden 区。注意这里有个预设前提——现在的商业 JVM 普遍使用 TLABThread Local Allocation Buffer线程本地分配缓冲机制每个线程在 Eden 区划一小块私有的缓冲区域线程内分配对象先在自己这块缓冲区里做避免多线程并发分配时的锁竞争。如果一个大对象在 TLAB 内分配不了会直接尝试在 Eden 区用 CAS 方式分配如果 Eden 区空间也不够就会触发一次 Young GC 来腾空间。这里有一个经常被误读的点大对象直接进入老年代。很多教程喜欢画一条线说“大对象直接进老年代”但准确的表达应该是——超过-XX:PretenureSizeThreshold设定的对象会直接分配到老年代。这个参数只在 Serial 和 ParNew 这两个回收器下生效在 G1 或 Parallel Scavenge 回收器下这个阈值并不生效。另外在 G1 下大对象有专门的 Humongous 区域分配超过区域大小一半的对象会被判定为巨型对象。所以“大对象直接进老年代”这句话在新生代的语境内只对特定回收器成立你不能拿着一条通用性不强的结论去做全局推理。对象经历过一轮 Young GC 后如果还活着年龄加 1存活的就移动到 Survivor 区。Survivor 区之间会互相拷贝年龄到达阈值默认 15也可以通过参数-XX:MaxTenuringThreshold修改就晋升到老年代。这里有个动态年龄判定规则值得注意如果 Survivor 区中相同年龄的所有对象大小总和大于 Survivor 空间的一半年龄大于等于该年龄的对象可以直接进入老年代不必等到阈值。这个设计是为了避免 Survivor 区被长期存活对象占满但有些时候会让对象比预期更早晋升是调优中容易忽略的因素。老年代的对象越来越多了会怎样就会触发 Major GC 或 Full GC。老年代回收的代价远高于新生代所以调优思路通常围绕两个方向展开一是减少进入老年代的对象数量二是让老年代的 GC 发生得更晚、更少。理解了对象分配和晋升模型你看到 GC 日志才能倒推“为什么这个对象会在老年代”而不是“它本来就应该在老年代”。2. 核心细节解析与实操要点垃圾回收与内存管理的门道2.1 垃圾回收判定的两种主流算法判定谁是垃圾是回收的前提。主流方案有两种引用计数法和可达性分析算法。引用计数法思路简单给每个对象维护一个计数器被引用加 1引用失效减 1到 0 就是垃圾。但一个致命的缺陷是循环引用无法解决——A 引用 BB 引用 A外部没有入口计数器永远不为 0这两个对象就永远回收不掉。所以现在主流的 JVM 都使用可达性分析算法从 GCRoots根对象集合出发做引用链遍历凡是不可达的对象视为垃圾。这里“GCRoots 是什么”很关键。通俗地说就是那些“不经过任何对象引用也能直接访问到”的起点包括虚拟机栈中引用的对象、静态变量引用的对象、常量池引用的对象、JNI 指针引用的对象、被同步监视器锁定的对象等。很多线上问题排查到最后发现某个对象无法被 GC原因就是被某个全局静态集合长期持有这个是可达性分析在实践中的经典案例。顺带提一句System.gc()触发的不一定是 Full GC具体行为取决于-XX:DisableExplicitGC参数和回收器特性。很多框架比如某些 RPC 框架在特别注重响应延迟的场景下会主动加上-XX:DisableExplicitGC防的就是业务代码里调用了System.gc()导致无谓的全停顿。这也是你在线上排查 GC 频繁问题时第一个要排除的干扰项——找一下代码里有没有显式调用 GC。2.2 主流垃圾回收器的适用场景与选型选回收器之前先明确一个基本事实没有最好的回收器只有最匹配你业务场景的回收器。串行回收器Serial单线程回收适合客户端、桌面应用和极小堆内存场景因为停顿时间长但实现简单不会带来 CPU 竞争。并行回收器Parallel Scavenge Parallel Old关注吞吐量适合后台计算型任务——批处理任务、日志分析任务、能容忍垃圾回收时短暂停顿的业务。比如一个大数据的离线计算任务跑 10 分钟中间停顿 10 秒完全可以接受那就选并行回收器把资源最大化用于计算而不是回收。CMSConcurrent Mark Sweep曾一度是互联网应用的主流选择设计目标是最小化停顿时间用“并发标记、并发清理”来降低 STW 时间。但 CMS 有三大硬伤一是对 CPU 资源敏感并发阶段会占用 CPU 和业务线程抢资源二是浮动垃圾问题——并发清理时业务线程还在继续产生垃圾需要在回收完成后递延处理三是老年代碎片化问题标记-清除算法不整理空间长期运行后可能出现“空间还够但找不到连续空间分配”的尴尬局面最终退化为 Serial Old。CMS 在老版本 JDK 8 中还能见到JDK 14 之后官方已经移除了 CMS 支持。G1Garbage First是 JDK 9 之后默认的回收器设计模型和前几个完全不同。它把堆拆成一系列大小相等的 Region逻辑上分代但物理上不再严格连续。G1 的回收过程是把部分 Region 回收掉来腾出空间不再像以前那样对整代做回收它通过维护一个优先级列表优先回收价值最大垃圾最多、回收成本最低的 Region因此得名 Garbage First。G1 引入了 Remembered Set 来处理跨区域引用有点空间换时间的意思这也是 G1 内存开销略高的原因。JDK 11 引入 ZGCJDK 15 引入 Shenandoah它们把停顿时间推到了几毫秒甚至亚毫秒的级别。ZGC 的着色指针和读屏障设计非常精妙它是通过一次性把整个堆空间的引用映射到多个视图利用指针的额外位来记录对象状态在并发阶段对引用做图变换。ZGC 支持 TB 级堆停顿时间不随堆大小增长但代价是 CPU 占用和内存开销较高。如果你的业务要求极高响应、低延迟且服务器 CPU 核心够多ZGC 是非常值得尝试的方向。虚拟线程普及之后大量短生命周期对象会显著增加 GC 压力ZGC 这类低停顿回收器的价值会更突出。2.3 调优不止是加大内存这是一条必须反复强调的经验遇到 OOM 或 GC 频繁第一反应不应该是加内存而是先分析内存都去哪了。加内存这个操作确实能让 Full GC 频次降低因为老年代空间大了触发 GC 的阈值会延迟。但你同时扩大了 GC 的回收范围单次 Full GC 的停顿时间反而会被拉长对延迟敏感的接口来说这是火上浇油。更关键的是加内存掩盖了根因。比如你用了一个 HashMap 做缓存没有设置过期策略数据持续堆积你加到 8G只是把爆发时间推迟到三个月后。到时候再排查数据量更大现场更难复原。调优的正确副产品是让你发现业务代码层面的问题。比如我发现很多 OOM 的根因是单个对象异常巨大比如一次查了全表、一次性加载了海量数据到内存、线程池未设置合理队列上限、使用了 ByteBuffer 申请堆外内存未释放。这些问题不是 JVM 参数能解决的是你必须从业务侧下手。参数层面的调优核心目标是让对象都在新生代内完成生命周期让老年代足够稳定。那就要看新生代到底放得下多少对象、Survivor 区能不能承接住存活对象、晋升阈值是否匹配业务对象存活特征。每次调参你都要能找到逻辑支撑而不是今天看一个博客说加大新生代就加大新生代明天看另一个说调大晋升阈值就调大晋升阈值。参数背后一定有代价比如新生代变大老年代相对变小Full GC 频率可能上升晋升阈值变大Survivor 区可能溢出导致对象提前晋升。3. 实操过程与调优落地从启动日志到参数矩阵3.1 先摸清家底常用诊断命令与可视化工具调优第一步不是改参数而是摸清楚应用当前的运行状态。这一步像医生问诊只有先问清楚病情才能开药。推荐几条最常用的命令路径。jps查看当前机器上的 Java 进程列表拿到 PID。jinfo pid查看进程当前的 JVM 参数确认生效的参数和启动命令里配置的做对比很多时候你会发现某些参数没生效比如写进了代码但启动脚本没同步。jstat -gc pid 1000每秒输出一次 GC 统计信息重点看FGCFull GC 次数、FGCTFull GC 总耗时、YGCYoung GC 次数和堆各区域的使用率。jmap -heap pid看一下堆内各区域的实际用量。jmap -dump:live,formatb,fileheap.hprof pid导堆快照线下分析用。然后是jstack pid看线程栈排查死锁和线程阻塞的现场非常有用。jcmd pid GC.heap_info是 JDK 8 之后比较推荐的命令可以替代一部分jmap -heap的用途且更安全。可视化工具方面JDK 自带的jvisualvm和jconsole在本地开发调试够用了但线上通常不具备 GUI 访问条件建议学会用命令行和离线分析工具。离线分析堆快照用 Eclipse MAT 或者 JProfiler 都可以。MAT 的Dominator Tree视图能直观看出“哪个对象支配了多少内存”——这是寻找内存泄漏入口最重要的一张表。GC Roots查询功能可以告诉你一个对象为什么没有被回收定位到某一根引用链。如果你可以安装中间件或工具包Arthas 值得尝试。它最大的价值是可以在线 dashboard 看内存和线程状态还能用trace命令动态追踪方法调用耗时和对象分配热点很多 JVM 运行时问题用它可以快速定位到具体业务代码行而不只是停留在“JVM 层面有问题”这种模糊结论。我在排查一个 CPU 使用率忽高忽低的问题时就是用 Arthas 的trace命令发现某个第三方 SDK 内部在频繁做反射调用继而定位到是对象池参数配置不当导致的。3.2 算清楚你的内存账本堆大小的计算思路确定堆大小不是拍脑袋而是基于“业务负载 对象特征 预留余量”三条线的综合估算。先说业务负载。假设你的服务每秒处理 500 个请求每个请求短期内大约产生 2MB 的临时对象存活周期在 100 毫秒以内。那么在 1 秒的时间片内新生代至少要有能力容纳 500 * 2MB 1000MB 的瞬时对象。按 Eden 区与 Survivor 区比例 8:1:1 来算新生代总大小 1250MB 左右才能比较宽裕地完成一次新生代内的对象分配和回收。当然这个计算很粗糙因为对象产生的速率不是均匀的、部分对象会在 Survivor 区周转、TLAB 也有内部浪费但作为第一版估算模型它能让你对“新生代到底需要多大”有一个合理的下限判断。再看对象特征。如果你的服务里有一批“慢对象”——比如长连接会话对象、配置缓存对象、定期更新一次的全局数据对象它们会稳定进入老年代。你统计一个 Full GC 周期内老年代的增长速率乘以你期望的 Full GC 间隔就能估算老年代需要多大。比如老年代每天增长 4GB你希望 Full GC 至少 3 天发生一次那么老年代至少要 12GB再加上 30% 的冗余就是 16GB 左右。然后是预留。堆内存之外还有元空间、线程栈、堆外缓冲如 Direct Buffer、Socket 缓冲区这些不占用堆但占用进程内存。你别把容器内存全部塞给堆至少要留出 20%~30% 给非堆内存和操作系统缓存。我有一个很痛的教训早期我部署应用时把堆设为容器内存的 80%结果频繁掉进 Swap 交换GC 时间突然飙升到秒级。后来查证发现机器上的其他进程比如监控 Agent 和日志采集也在占用内存再加上堆外分配物理内存已经吃紧。之后我养成了习惯容器内存 8G堆设 4.5G剩下 3.5G 留给 JVM 自己吃。如果一个 JVM 进程承载的业务类型差异很大比如同时处理异步消息和同步 API 请求那么在评估堆时要把两边的流量叠加在一起别只看综合指标否则流量高峰时会出现意外。3.3 制定调优方案从 GC 日志到参数矩阵GC 日志是调优最重要的“病例记录”。没有日志一切分析都是猜测。所以启动参数里必须加日志输出配置。以 JDK 8 为例常用的一组参数是-verbose:gc -Xloggc:/data/logs/gc-%t.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC -XX:PrintTenuringDistribution -XX:PrintGCApplicationStoppedTimeJDK 9 之后日志参数统一为-Xlog例如-Xlog:gc*info:file/data/logs/gc-%t.log:time,uptime,level,tags日志是事后分析的基石。你会从中看到每次 GC 前后的堆占用、各个分代的变化、停顿时间。多采集几轮你就能摸出一个规律新生代每次 GC 前占用率是多少、存活对象多少、晋升对象多大、老年代以什么速度增长。把几组关键数据填到表格里调优方案自然浮现。指标含义调优相关性YGC 频率与耗时新生代 GC 次数和平均停顿时长频率过高说明新生代太小或对象分配过多FGC 频率与耗时Full GC 次数和总耗时频率上升说明老年代或元空间压力过大晋升平均大小每次 Young GC 晋升到老年代的对象总量数值过大容易触发 Full GCSurvivor 区占用GC 后 Survivor 区实际使用量占用率接近 100% 可能导致对象提前晋升老年代增长速率两次 Full GC 之间老年代的使用增量用于估算老年代大小是否合理调参不是一步到位我习惯每次只改一个变量观察两到三个完整业务周期的 GC 日志再决定下一步。一批常用的调优参数组合-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio8 -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:ParallelRefProcEnabled -XX:G1HeapRegionSize4m这里有个取舍原则-Xms和-Xmx设置一致避免 JVM 运行期动态扩缩容带来的性能损耗。-Xmn则要根据你前面算出来的新生代需求来设。启动时直接把堆预分配到位减少扩容 GC。G1 的核心参数是-XX:MaxGCPauseMillis它是软目标不是硬性指标G1 的IHOPInitiating Heap Occupancy Percent决定并发标记周期启动时机默认值是 45意味着堆使用率达到 45% 时开始并发标记。你调整参数时不要只看停顿目标还要配合老年代的增长速率。如果老年代增长很快45% 的阈值启动标记可能太迟可以适度调低到 35% 左右让 G1 更早介入。但调低也意味着并发标记周期更频繁整体回收开销增大这个平衡只能靠实际 GC 日志来验证。JDK 17 和更高版本G1 和 ZGC 都是可以组合的策略选项虚拟线程Project Loom正式化之后超大量轻量线程带来的分配压力也在影响 GC 参数选型。技术在往前走但调优的核心方法论没变日志驱动、量化分析、小步验证。4. 常见问题与排查技巧实录4.1 典型 OOM 场景及排查路径OOM 不是故障而是 JVM 在向外界呼救。不同区域的 OOM 有不同的排查方向。堆内存 OOM 最常见报错通常是java.lang.OutOfMemoryError: Java heap space。第一步用jmap导快照装 MAT 或 JProfiler 分析。在 MAT 里重点看Dominator Tree和Leak Suspects报告前者告诉你谁占内存最大后者直接帮你圈出可疑的泄漏入口。我排查过一个内存泄漏案例Leak Suspects直接指出某个静态 HashMap 一直膨胀顺着引用链发现是某个业务模块把每次请求的历史对象都塞进了一个“待通知列表”因为异常分支没有清理数据越积越多。修复其实非常简单——在 finally 块中移除引用但定位过程花了大半天原因是没有第一时间导堆快照前面净靠瞎猜了。java.lang.OutOfMemoryError: Metaspace则是元空间问题常见于使用动态代理、CGLIB 增强类、热部署框架和脚本引擎时。排查路径侧重查看加载的类数量和类加载器jstat -pid的MCMC和MCMU列可以看元空间大小。如果是大量重复生成类重点检查代码里是否有循环创建代理类的逻辑如果是热部署检查类加载器是否泄漏。有一类隐蔽场景是 Groovy 脚本引擎每次执行都编译新的脚本类元空间消耗快且回收不了最终 OOM。这种情况需要从使用方式上改而不是调大 MetaspaceSize。Direct buffer memory的 OOM 也不少见根因通常是堆外内存未释放。排查时建议打开-XX:MaxDirectMemorySize并限制堆外内存同时用jcmd查看直接缓冲区的使用量。我之前查过 linux 上 NIO 相关 OOM发现 ByteBuffer 的明确释放依赖显式clear()或将引用置空否则依赖于 GC 回收对象时关联的 cleaner 机制不可控。4.2 高 CPU 和频繁 Full GC 的排查GC 导致的 CPU 高特别容易误判——你看到 CPU 100%以为业务代码有死循环实际上就是 Full GC 在打转。排查路径第一步用top找高 CPU 的 Java 进程 PID然后用top -Hp pid找出进程内的高 CPU 线程号再转成十六进制用jstack pid | grep -A 20 nid匹配到线程栈看这个线程在干什么。如果栈顶是GCTaskThread或VMThread之类就基本确定是 GC 线程在疯狂工作。用jstat -gcutil pid 2000看 O老年代的使用率的变化速度几分钟内从 30% 涨到 95%那必然有异常流量在快速填充老年代或者 GC 本身出现了问题。一个我印象深刻的案例某服务老年代隔几分钟就从 40% 涨到 90%Full GC 后回落到 5%如此往复。从业务侧看每次 Full GC 的回收到 5% 说明前一次的老年代垃圾确实被清掉了说明不是泄漏而是一个周期性的大对象分配在塞满老年代。用jmap -dump抓一个 Full GC 前的堆快照发现有个byte[][]占了 70% 老年代顺着调用链定位到是某个定时任务每 10 分钟加载一次全量数据做比对直接 new 了 2GB 的数组。稍微改下数据结构用分段读取替换全量内存加载后问题彻底消失。这个案例提醒我频繁 Full GC 的问题根因往往不在 GC而在某个业务环节的“内存吞吐”设计。还有一种低延迟场景特有的大坑是G1的ConcurrentMarking线程在并发标记时触发高 CPU这不是故障是 G1 的正常工作周期。如果并发标记阶段 CPU 飙升频繁你要检查堆使用率阈值和对象产生速度别把正常现象当异常处理。4.3 我的几条避坑经验不要把系统内存全部交给堆。JVM 本身、线程栈、元空间、Direct Buffer、日志缓冲、监控 Agent 全都要内存堆设置过高会直接挤占系统页面缓存和进程内存GC 反而更慢。别用-Xmx无限加大。内存翻倍不一定能解决 OOM可能只拖延了爆发时间还带走了更长的 STW。打开 GC 日志是底线。没有日志就没有任何可以量化的依据。很多生产事故事后复盘一大半时间都浪费在“重新模拟现状”上。一个进程不要承载超过一种跨度极大的业务。如果这个进程同时处理大量在线请求和离线批处理堆内存分配会非常撕裂GC 特征混乱很难调优。关注元空间的回收行为。元空间扩容是均摊式的频繁加载类时它可能反复扩容收缩带来 JVM 层面无谓的停顿。必要的时候设一个偏大的-XX:MetaspaceSize初始值能减少运行期的元空间扩容开销。实验环境尽量保持跟生产一致。JDK 版本、容器内存限制、CPU 配额这些都会影响 GC 的决策你在本地 JDK 17 调出来的参数拿到生产 JDK 8 裸奔等于全部作废。在真实排查中我发现过一些“现象很有迷惑性”的情况jmap -heap显示堆占用只有 60%但进程内存已经接近容器上限这时候问题往往在堆外内存。排查方法是用jcmd pid VM.native_memory查看 JVM 原生内存的细分结合-XX:NativeMemoryTrackingsummary启动参数记录。早些年我踩过这个坑一个用 Netty 的网关服务堆内一切正常结果进程反复 OOM查到最后是ByteBuffer.allocateDirect频繁分配堆外内存且大量未释放加上每连接缓冲区的默认大小过大直接把业务进程的内存预算打爆了。还有一次被重复加载的类坑了同一个类被多个类加载器加载了几十份元空间飙到 2GB。定位起来非常费劲最后是线程栈里发现多个不同加载器创建的同名类实例根源是应用容器配置了热部署且每次部署创建新的类加载器老类加载器没有被卸载。这类问题在排查时几乎不会第一时间想到如果你遇到元空间异常膨胀又找不到业务侧的类生成点优先检查热部署的类加载器引用链。最后说一点个人体会我常说 JVM 调优是“七分监控、三分调整”。真正让线上服务稳定下来的往往不是某个精妙参数而是你把 GC 日志、内存指标、业务流量关联起来的那套分析习惯。每次调优之后请把当时的 GC 日志、参数、业务场景记录下来因为隔一段时间再回头看相同现象你很可能会有新的理解。如果你刚开始接触 JVM先把运行时数据区、对象分配晋升流程、GC 日志里的字段含义吃透再考虑去调-XX参数。没有地图就去分析跑偏的方向只会越跑越远。按“地图 - 分配流程 - GC 行为 - 日志分析 - 参数验证”这条链走一遍你也能建立属于自己的 JVM 问题排查体系。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑