
堆这个词在 JVM 内存结构里出现频率最高也最容易被讲得浮皮潦草。面试里问JVM 内存结构之堆多数人能背出新生代、老年代、Eden、Survivor 这几个词可真到了线上服务频繁 Full GC、堆外内存没涨但 RSS 一直往上飘、或者一个OutOfMemoryError: Java heap space砸到监控群里的时候能不能把这些名词换算成一条可执行的排查路径是另一回事。这篇东西我想干的事很具体把堆从概念拆到对象怎么落位、参数怎么算、日志怎么读、坑怎么绕这一层写给已经写过几年代码、但没系统梳理过堆内存的开发者也写给刚接触 JVM 调优、需要一份能照着动手的参考的新人。里面涉及的数字和参数都来自主流 HotSpot 实现的公开行为具体版本差异我会标出来。1. 先把堆这件事想明白它到底在管什么1.1 从一次典型的堆告警说起我印象比较深的一次是某后台服务在流量爬坡阶段QPS 翻了一倍多接口平均耗时从 40ms 涨到 300ms监控面板上 Full GC 次数从一小时 0 次变成一小时 12 次每次停顿 800ms 上下。运维第一反应是加机器加了以后单机压力下来了但 Full GC 频率没降多少因为流量还在涨。真正的问题不是机器不够是老年代被慢慢撑满了而撑满它的东西不是缓存是一批没被正确释放的请求上下文对象——它们被一个静态 Map 持有key 是请求 ID只在正常返回路径里 remove超时路径漏了。这个案例说明堆的两个侧面它是对象生存的地方也是对象泄漏最容易藏身的地方。你把堆想成一个仓库-Xmx是仓库面积GC 是保洁对象分配是入库回收是清仓。面积给多大、货架怎么摆、保洁多久来一次、什么货算垃圾这四件事决定了整个系统的吞吐和延迟表现。多数性能问题最后都能落到这四件事的某一环上。所以我理解堆习惯从三个基本面入手分配新对象落在哪、怎么落、布局堆内部被切成什么样、每块承担什么职责、回收什么时候清理、清理的代价由谁承担。这三个面互相咬合改了任何一个参数另外两个的表现都会跟着变。加-Xmn提高新生代比例分配更快了但老年代被压缩晋升压力变大Full GC 可能更频繁——这种连锁反应是调优里最常见的翻车点。1.2 堆在 JVM 内存版图里的位置JVM 运行时数据区分五块程序计数器、虚拟机栈、本地方法栈、堆、方法区。前三个是线程私有的随线程生灭不需要考虑并发安全堆和方法区是线程共享的所有对象实例和数组都分配在堆上所以堆是 GC 的主要战场也是唯一一个几乎所有调优参数都围绕它展开的区域。这里有个新手常混淆的点方法区在 HotSpot 里的实现叫永久代PermGenJDK 8 之后换成了元空间Metaspace元空间用的是本地内存不在堆里。所以-XX:MaxMetaspaceSize限制的不是堆而-Xmx也管不到元空间。我见过有人把-Xmx4g设成 4G结果容器内存限制也是 4G跑一段时间被 OOMKilled原因是堆 4G 加上元空间、线程栈、直接内存、CodeCache 一起超了。容器化部署下这个坑特别常见。堆本身还分两段堆内存在物理上可以是不连续的只要逻辑上连续就行这一点在给堆预分配大页或者容器内存受限时会有明显影响。堆的起始地址和最大地址由-Xms和-Xmx控制两者设成一样可以避免运行期反复扩容缩容带来的抖动生产环境基本都这么配。1.3 为什么非要分代一笔成本账分代不是拍脑袋想出来的设计它建立在一个经验观察上绝大多数对象朝生夕死。Web 请求里的 DTO、临时字符串、Stream 中间结果、JSON 序列化的缓冲区生命周期通常只有毫秒级。只有少数对象会长期存活比如连接池、本地缓存、Spring 容器的单例 Bean。如果堆不分代每次 GC 都要遍历全部对象成本跟存活对象数量成正比。分代之后新生代用复制算法只扫描少量存活对象剩下的整块清掉老年代用标记整理或标记清除回收频率低但单次成本高。这样整体成本被摊薄了。算笔账一个 4G 的堆假设每秒产生 500MB 垃圾如果每次都全堆扫描扫描成本按 4G 算分代之后新生代设 1.5G每 3 秒左右触发一次 Minor GC每次只扫 1.5G 里存活的那部分。单位时间内的扫描量差了一个数量级。这就是分代假设带来的收益它的前提是弱分代假说——大部分对象很快死亡少部分对象会活很久并且跨代引用远少于同代引用。需要提醒的是这个假说在有些场景下会失效比如大内存缓存服务、批处理任务、图计算。这类应用存活对象占比高新生代回收后大量对象被复制到 Survivor 又很快晋升反而制造了额外的复制开销。这种时候要么调整分代比例要么直接换 G1 或者不分代的收集器思路。我后面会单独讲这块。2. 堆的内存布局与分代模型拆解2.1 新生代Eden 与两个 Survivor 的分工HotSpot 的分代式堆默认按 1:2 切成新生代和老年代新生代内部再按 8:1:1 切成 Eden 和两个 Survivor这个比例由-XX:SurvivorRatio8控制。之所以是两个 Survivor 而不是一个原因是为了配合复制算法。具体流程是这样的新对象优先在 Eden 分配Eden 满了触发 Minor GC把 Eden 和 From Survivor 里还活着的对象复制到 To Survivor同时把这些对象的年龄加 1然后 Eden 和 From 清空From 和 To 的角色互换。下次 GC 再从新的 Eden 和新的 From 复制到新的 To。两个 Survivor 轮换使用保证任何时刻总有一块是空的可以接收存活对象。那 8:1:1 的比例意味着什么新生代可用空间只有 Eden 加一个 Survivor也就是 90%。剩下 10% 是预留的复制目标区。如果某次 Minor GC 后存活对象超过 To Survivor 的容量就会触发分配担保把放不下的对象直接晋升到老年代。这个机制是新生代设计里最容易被忽略的一环很多对象莫名提前晋升的问题都能从这里找到解释。我见过一个配置把SurvivorRatio设成 4新生代每个 Survivor 占 1/6Eden 占 4/6。看起来 Eden 变小了Minor GC 会更频繁但实际上 Survivor 变大了能容纳更多存活对象晋升到老年代的速度反而慢了。这种取舍没有标准答案得看应用的存活对象曲线。经验值是如果 Minor GC 后存活对象经常接近 Survivor 上限就该调大 Survivor如果 Minor GC 太频繁但存活对象很少就调大 Eden。2.2 老年代与晋升阈值对象什么时候退休对象在 Survivor 之间来回复制每次年龄加 1年龄达到阈值-XX:MaxTenuringThreshold默认 15CMS 下默认 6就晋升到老年代。但实际行为比这复杂HotSpot 用的是动态年龄判定在每次 Minor GC 时统计 Survivor 中各个年龄段对象的总大小如果某个年龄及以下的所有对象加起来超过 Survivor 空间的一半就把这个年龄设为新的晋升阈值不再等达到MaxTenuringThreshold。这个机制的目的是防止 Survivor 被塞满后被动触发分配担保。它的副作用是当应用在短时间内产生大量中等生命周期对象时晋升阈值可能被动态压低到 1 或 2导致对象很快涌入老年代老年代迅速膨胀。这就是为什么有些服务明明堆不小却频繁 Full GC——不是内存泄漏是晋升节奏被打乱了。还有一个特殊通道大对象直接进老年代。-XX:PretenureSizeThreshold可以设置阈值超过这个大小的对象跳过新生代直接分配在老年代。这个参数只对 Serial 和 ParNew 有效G1 下不生效G1 用 Humongous Region 处理大对象。设它的目的是避免大对象在 Eden 和 Survivor 之间反复复制那种复制的内存带宽开销很可观。但它也有风险阈值设得太小会让老年代快速堆积。2.3 元空间、直接内存与堆的边界前面提过元空间不在堆里这里再展开一点。JDK 8 移除永久代之后类的元数据放在本地内存的元空间里默认不设上限受物理内存约束可以用-XX:MaxMetaspaceSize限制。元空间泄漏的典型症状是OutOfMemoryError: Metaspace常见原因是动态生成类——CGLIB 代理、反射、脚本引擎、热部署。这类问题用jcmd pid VM.metaspace或者jstat -gcmetacapacity能看出来。直接内存Direct Buffer同样不在堆里由-XX:MaxDirectMemorySize控制不设的话默认约等于-Xmx。NIO 的ByteBuffer.allocateDirect、Netty 的池化缓冲区都走这块。它的泄漏在堆 dump 里看不出来得用Native Memory Tracking-XX:NativeMemoryTrackingdetail配合jcmd pid VM.native_memory才能定位。注意容器环境下算内存预算要按堆 元空间 线程栈 × 线程数 直接内存 CodeCache GC 自身开销来算通常还要留 10%~20% 的余量给本地库和系统开销。只盯-Xmx是容器被 OOMKilled 的头号原因。2.4 对象在堆里到底长什么样一个对象在堆里的内存布局分三块对象头、实例数据、对齐填充。对象头包含 Mark Word 和类型指针。64 位机器上 Mark Word 占 8 字节存哈希码、GC 分代年龄、锁状态标志、偏向线程 ID 这些信息内容会随锁状态变化而改变是个复用的位域。类型指针指向方法区里的类元数据开启指针压缩时占 4 字节否则 8 字节。如果是数组对象还要多 4 字节存数组长度。实例数据是按字段类型排列的HotSpot 会把相同宽度的字段放在一起父类字段在子类字段之前这样能减少 padding。对齐填充是为了让对象起始地址对齐到 8 字节的整数倍——这不是可选项是 HotSpot 的硬性要求因为指针压缩依赖这个对齐。举个头算一下一个只有两个 int 字段的对象Mark Word 8 字节 类型指针 4 字节 两个 int 8 字节 20 字节补齐到 8 的倍数就是 24 字节。如果字段换成 long84820还是 24。加个对象引用字段844824正好对齐。这些数字看起来琐碎但在评估百万级对象的集合内存占用时很有用。比如一个HashMap存 100 万个 Entry每个 Entry 大约 32~48 字节加上 Node 数组和扩容余量实际占用可能是数据本身的好几倍。这就是为什么有时候把HashMap换成数组或者专门的结构内存能省一半。3. 对象分配的完整链路与关键参数3.1 从一条 new 指令到内存落位写new User()的时候JVM 在背后做了这些事检查类是否已加载、解析、初始化没加载就先走类加载流程然后计算对象所需内存大小接着在堆上划出一块内存再把这块内存清零保证实例字段初始值正确设置对象头Mark Word、类型指针最后执行构造方法。划内存这一步有两种方式指针碰撞和空闲列表。前者要求堆内存规整用一个指针分隔已用和空闲区域分配时指针往后挪就行后者适用于内存不规整的情况需要维护一张空闲块列表从中找一块够大的。用哪种取决于收集器是否带压缩整理能力——Serial、ParNew 用指针碰撞CMS 用空闲列表G1 的 Region 内部用指针碰撞。并发安全是这一步的核心难点。多个线程同时 new指针会打架。HotSpot 的解法是TLABThread Local Allocation Buffer给每个线程在 Eden 里划一小块私有区域线程在自己的 TLAB 里分配不需要同步只有 TLAB 用完了才需要申请新的那时候才加锁。这个设计把分配操作的同步开销从每次分配降到每 TLAB 一次。3.2 TLAB被低估的性能细节TLAB 默认开启-XX:UseTLAB大小是 Eden 的 1%可以自适应调整。相关参数有-XX:TLABSize初始大小、-XX:MinTLABSize、-XX:TLABWasteTargetPercent允许的浪费比例默认 1%。TLAB 带来的副作用是空间浪费。每个 TLAB 末尾剩下的空间如果不够放下一个对象这块就被浪费掉了。浪费率由TLABWasteTargetPercent控制JVM 会根据实际浪费情况动态调整 TLAB 大小。如果应用大量分配小对象TLAB 太小会导致频繁申请太大又浪费空间。一般不需要手动调除非你观察到 GC 日志里 Eden 的使用量增长和实际分配量对不上。有个观测技巧jstat -gc pid 1000输出里ECEden 容量和EUEden 使用量的差值加上S0U/S1U能大致推算分配速率。想更精确就开 GC 日志用-Xlog:gcheapdebug能看到 TLAB 的统计信息。3.3 堆大小怎么定从业务指标倒推定堆大小不是拍脑袋填个 4G得从几个约束反推第一约束是容器内存限制。假设容器给了 8G堆最多给 6G剩下 2G 留给元空间、线程栈、直接内存、CodeCache 和系统开销。这个 6G 不是硬上限得看实际用量可以用jcmd pid VM.native_memory summary观察非堆部分的真实占用。第二约束是单次 GC 停顿目标。如果要求停顿不超过 200ms用 G1 的话单个 Region 的回收时间要控制住Region 数量一般控制在 2048 左右比较舒服。Region 大小 堆大小 / 2048向上取到 2 的幂范围 1MB~32MB。比如堆 8G8G/2048 4MBRegion 就是 4MB堆 32GRegion 就是 16MB堆超过 64GRegion 顶到 32MB 上限Region 数量会超过 2048单个 Region 变大回收时间变长。第三约束是对象晋升速率。用 GC 日志统计每分钟晋升到老年代的字节数老年代大小至少要能撑住两次 Full GC 之间的晋升量否则会陷入刚回收完又满了的循环。粗略算法老年代容量 ≥ 晋升速率 × 期望 Full GC 间隔 × 安全系数 1.5。第四约束是单对象最大尺寸。如果有大数组或者大缓冲区要确保堆能放下。G1 下超过 Region 一半大小的对象算 Humongous会单独占一个或多个连续 Region这类对象多了会导致碎片和并发回收压力。一个 8G 堆配 4MB Region超过 2MB 的对象就是 Humongous这个门槛比想象中低很多图片处理、批量查询的服务会踩到。综合下来一个中等规模的 Web 服务4G~8G 堆是个常见区间。给小了 GC 频繁给大了单次停顿变长、内存利用率下降。我的经验是先从保守值起步用压测和线上监控数据往上调而不是一上来就顶配。3.4 指针压缩一个被误解最多的开关-XX:UseCompressedOops在 64 位 JVM 上默认开启堆小于 32G 时。它的原理是对象按 8 字节对齐所以对象地址的低 3 位永远是 0可以省掉。4 字节的偏移量能表示 2^32 个 8 字节单位即 32GB 的地址空间。超过 32G 就没法用 4 字节表示了指针退回 8 字节对象引用字段膨胀一倍。这带来一个反直觉的结论堆从 31G 加到 33G可用对象容量可能反而变小因为指针膨胀吃掉了增量。这就是常说的32G 陷阱。如果确实需要更大的堆要么接受指针膨胀堆开到 48G 以上才划算要么把对象拆到多个 JVM 实例里。指针压缩的连带影响还有对象头里的类型指针从 8 字节变 4 字节每个对象省 4 字节引用类型字段从 8 字节变 4 字节含大量引用的对象比如链表、树省得更多。所以开启压缩不只是省地址空间还实打实省内存。验证是否生效可以用java -XX:PrintFlagsFinal -version | grep UseCompressedOops输出true就说明开着。# 查看与堆、压缩指针相关的关键参数实际取值 java -XX:PrintFlagsFinal -version 2/dev/null | \ grep -E UseCompressedOops|InitialHeapSize|MaxHeapSize|MaxTenuringThreshold|SurvivorRatio输出里MaxHeapSize的单位是字节除以 1024 的三次方就是 GB。这个命令在排查参数到底生效没生效时特别有用因为启动脚本层层嵌套很容易出现后面的参数覆盖前面的、或者被JAVA_OPTS环境变量悄悄改掉的情况。4. 垃圾回收与堆的关系分代假设怎么落到算法上4.1 可达性分析谁有资格被回收判断对象死活有两种思路引用计数和可达性分析。引用计数实现简单但解决不了循环引用HotSpot 用的是可达性分析。从一组称为 GC Roots 的对象出发沿着引用链遍历能到达的对象算存活到达不了的算垃圾。GC Roots 包括虚拟机栈中局部变量表引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI 引用的对象、JVM 内部引用基本类型的 Class 对象、常驻异常对象、系统类加载器、同步锁持有的对象。这个列表值得记一下因为排查内存泄漏时经常要问谁在持有这个对象答案往往就在这几类里。可达性分析期间要保证引用关系不变化否则会出现边遍历边改的错乱。这就引出了Stop The WorldGC 期间暂停所有用户线程。STW 的时长直接决定服务的响应抖动所以现代收集器的设计目标基本都是缩短 STW或者把 STW 的工作挪到并发阶段。4.2 新生代为什么用复制算法新生代的存活对象少复制算法的最优解就是复制少的、清掉多的。具体过程前面讲过这里说说它的代价复制需要一块空闲的目标空间所以新生代只能用到 90%Eden 一个 Survivor复制过程要修改对象的引用地址涉及指针更新存活对象多的时候复制成本会飙升。分配担保机制在这里扮演兜底角色。Minor GC 之前JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总大小如果不满足就看HandlePromotionFailure是否允许担保失败。允许的话继续尝试 Minor GC失败了再退化成 Full GC。这个判断逻辑在 JDK 6 之后被简化了不少但晋升失败触发 Full GC的现象依然存在GC 日志里会体现为to-space exhausted或者Promotion failed。提示看到 GC 日志里出现to-space exhausted说明 Survivor 空间不够接收存活对象通常需要调大 Survivor 或者降低分配速率。看到Promotion failed说明老年代空间不足要考虑扩容或者排查是否有大对象持续涌入。4.3 老年代的标记整理与并发标记老年代对象存活率高复制算法不划算所以用标记清除或标记整理。标记清除会产生碎片标记整理要移动对象、更新引用停顿时间长。CMS 选择前者用并发标记和并发清除把停顿压下去代价是碎片和 Concurrent Mode FailureG1 用 Region 化的思路把整理成本摊到多个小区域上靠-XX:MaxGCPauseMillis做软目标控制。并发标记里有个关键概念叫三色标记白色是未访问、灰色是访问了但引用还没扫完、黑色是访问完且引用扫完。并发标记期间用户线程在改引用会产生漏标问题——一个黑色对象新引用了一个白色对象同时灰色对象到白色的引用被删掉白色对象就被漏掉了。解决方案是写屏障加增量更新CMS或者原始快照G1。这块原理偏深但对理解 GC 日志里的concurrent mark阶段耗时很有帮助。CMS 在 JDK 9 被标记废弃、JDK 14 移除现在新项目基本不会选它。但老系统里还有大量 CMS 配置迁移到 G1 的时候要注意参数不是一一对应的比如-XX:CMSInitiatingOccupancyFraction在 G1 里对应的是-XX:InitiatingHeapOccupancyPercent语义也有差别。4.4 主流收集器的堆行为对比收集器适用堆规模新生代算法老年代算法停顿特征典型参数Serial几百 MB复制标记整理单线程 STW停顿明显-XX:UseSerialGCParallel数 GB复制标记整理多线程 STW吞吐优先-XX:UseParallelGC、-XX:MaxGCPauseMillisCMS数 GB复制标记清除并发低停顿但有碎片和 CMF 风险-XX:UseConcMarkSweepGC已移除G14G~64GRegion 复制Region 混合回收可预测停顿Region 化-XX:UseG1GC、-XX:MaxGCPauseMillisZGC数百 GB染色指针并发染色指针并发亚毫秒级停顿-XX:UseZGC选型的经验小堆、单核、客户端场景用 Serial批处理、离线计算、追求吞吐用 Parallel在线服务堆在 4G~32G 之间用 G1 是默认答案堆特别大比如缓存服务开到上百 G或者对停顿极度敏感交易撮合、实时风控考虑 ZGC。注意 ZGC 在 JDK 15 之后才转正早期版本有兼容性限制上生产前一定要看目标 JDK 版本的发布说明。5. 实战堆问题的排查路径与工具5.1 OOM 的几种形态与判读OutOfMemoryError不是一种错误是一族。常见的有这几种Java heap space—— 堆内存不够。要么是真不够流量涨了、缓存没设上限要么是泄漏对象该释放没释放。区分方法是看 Full GC 后老年代占用是否降下来如果 GC 后老年代还是接近满的基本可以判定为泄漏如果 GC 后能降下来但很快又满是容量不足或者分配速率过高。GC overhead limit exceeded—— GC 花了超过 98% 的时间却只回收了不到 2% 的堆。这是 JVM 的自我保护本质还是堆不够用。它比单纯 heap space 更能说明内存已经被榨干因为 JVM 已经判定继续 GC 没有意义了。Metaspace—— 元空间溢出前面讲过跟动态类生成有关。unable to create new native thread—— 线程数达到系统上限跟堆没直接关系但堆外内存不足也会导致线程创建失败。Direct buffer memory—— 直接内存超限Netty 或者 NIO 场景常见。判读的时候第一步永远是看完整堆栈和触发点的类名第二步是看 GC 日志确认是回收不掉还是分配太快第三步才是 dump 堆分析。跳过前两步直接 dump往往会在几个 G 的堆文件里迷失方向。5.2 常用命令与输出解读# 每秒打印一次 GC 统计共 10 次 jstat -gcutil pid 1000 10 # 查看堆各区域容量与使用量 jstat -gc pid # 生成堆转储会 STW生产慎用 jmap -dump:formatb,file/tmp/heap.hprof pid # 查看堆概要信息 jmap -heap pid # 触发一次 Full GC 后生成 dump更准确反映存活对象 jmap -dump:live,formatb,file/tmp/heap-live.hprof pid # 查看对象数量排名前 20 的类 jmap -histo:live pid | head -30jstat -gcutil的输出字段值得逐个说清楚S0、S1是两个 Survivor 的使用率E是 Eden 使用率O是老年代使用率M是元空间使用率CCS是压缩类空间YGC/YGCT是年轻代 GC 次数和总耗时FGC/FGCT是 Full GC 次数和总耗时GCT是总耗时。观察要点O持续上涨且 Full GC 后不回落泄漏嫌疑YGC频率突然变高可能是分配速率涨了或者新生代变小了FGCT/FGC就是平均单次 Full GC 停顿超过 1 秒在线服务就要警惕E的增长速度能反映分配速率配合YGCT能算出每秒分配的字节数。GC 日志比 jstat 信息量大得多JDK 9 之后统一用-Xlog-Xlog:gc*,gcheapinfo,gcagetrace:file/var/log/gc.log:time,uptime,level,tags:filecount10,filesize50Mgcagetrace会打印每次晋升的年龄分布排查对象提前晋升非常有用。filecount和filesize控制日志轮转线上一定要设不然日志能把磁盘写满。5.3 一次完整的排查记录说个虚构但典型的场景。某服务上线后第二天开始每天下午三点左右响应变慢持续半小时恢复。监控显示这段时间 Full GC 从每小时 1 次涨到 8 次单次停顿 1.2 秒。第一步拉 GC 日志看 Full GC 前后的老年代占用。发现每次 Full GC 后老年代只降到 65%然后继续爬升。这说明不是完全回收不掉但回收效率很低有大量对象处于半死不活状态。第二步用jmap -histo:live看存活对象排名。排前面的除了正常的业务对象还有大量java.util.HashMap$Node和一个自定义的SessionContext类。数量级明显超出正常范围。第三步dump 堆用 MAT 分析。Dominator Tree 显示SessionContext被一个ConcurrentHashMap持有而这个 Map 是个静态字段。Retained Heap 占了整个堆的 40%。第四步找到代码发现是会话管理器把每个请求的上下文存进 Mapkey 是 sessionId正常路径会 remove但有个分支第三方回调超时直接 return 了没走 finally。于是这些上下文永远留在 Map 里。第五步修复。把 remove 挪到 finally加个定时清理兜底超过 30 分钟的会话强制清除再加个 Map 大小的监控指标。第六步验证。上线后第二天同一时段Full GC 恢复到每小时 1 次老年代峰值从 85% 降到 55%。这个例子的价值在于排查顺序先看 GC 行为判断性质再看对象分布缩小范围最后才 dump 定位到具体代码。反过来做也就是上来就 dump 几个 G 的文件通常效率极低。5.4 常见问题速查表现象可能原因排查动作处理方向Full GC 后老年代不回落内存泄漏jmap -histo:live、MAT 分析引用链修复持有关系加清理兜底Full GC 后回落但很快又满容量不足或分配过快看 GC 日志的晋升速率扩容、优化对象创建、调大 SurvivorMinor GC 频繁但存活对象少Eden 太小jstat看 EC 和 YGCT调大-Xmn或整体堆对象提前晋升到老年代动态年龄判定压低阈值GC 日志gcagetrace调大 Survivor、减少中等生命周期对象单次 Full GC 停顿过长老年代大、标记整理耗时看FGCT/FGC换 G1/ZGC、减小堆、增量整理to-space exhaustedSurvivor 容纳不下存活对象GC 日志关键字搜索调大 Survivor、降低分配速率容器被 OOMKilled 但堆没满非堆内存超限NMT、jcmd VM.native_memory调小堆、限制元空间和直接内存堆 dump 文件巨大且分析慢dump 时未加 live改用jmap -dump:live先触发 Full GC 再 dump元空间持续增长动态类生成未回收jcmd VM.metaspace、看类加载数限制 MetaspaceSize、修 ClassLoader 泄漏6. 参数调优的经验与踩坑6.1 一份可用的参数清单# 基础堆配置 -Xms8g -Xmx8g # 初始和最大堆一致避免扩容抖动 -Xmn3g # 新生代大小G1 下不建议显式设置 -XX:SurvivorRatio8 # Eden : Survivor 8 : 1 -XX:MaxTenuringThreshold15 # 晋升年龄上限 -XX:PretenureSizeThreshold1m # 大对象阈值仅对 Serial/ParNew 有效 # 元空间与直接内存 -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:MaxDirectMemorySize1g # G1 相关 -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:G1HeapRegionSize8m -XX:InitiatingHeapOccupancyPercent45 -XX:G1NewSizePercent20 -XX:G1MaxNewSizePercent40 # 诊断与日志 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump -Xlog:gc*:file/var/log/gc.log:time,uptime:filecount10,filesize50M # OOM 时执行脚本可选 -XX:OnOutOfMemoryError/bin/sh /opt/scripts/notify.sh %p-XX:HeapDumpOnOutOfMemoryError这个开关我建议所有生产服务都打开它在 OOM 瞬间自动 dump是最有价值的现场证据。代价是 dump 期间会停顿而且文件可能很大所以要配HeapDumpPath指向有足够空间的磁盘别指向容器里的临时目录容器一重启就没了。G1 下不建议显式设-Xmn因为 G1 会动态调整新生代大小。硬设会关闭这个自适应能力反而让停顿目标失效。同理SurvivorRatio在 G1 下语义也变了用-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent更合适。6.2 踩过的几个坑坑一把所有参数塞进JAVA_OPTS环境变量。启动脚本里如果既有JAVA_OPTS又有命令行参数后出现的覆盖先出现的。我就遇到过脚本里写了-Xmx4g环境变量里又配了-Xmx2g实际生效的是 2g查了半天才发现。解决办法是用PrintFlagsFinal打印实际值跟预期对一遍。坑二堆设得比容器限制还大。Kubernetes 的 memory limit 是硬限制超了直接 kill不会给你 OOM 异常。堆 4G、limit 4G 是最典型的错误配置。我的经验比例是堆占 limit 的 60%~75%具体看应用的非堆开销。JDK 10 之后可以用-XX:MaxRAMPercentage70.0让 JVM 按容器限制自动算堆大小比硬编码灵活但要注意它跟显式的-Xmx同时存在时-Xmx优先级更高。坑三为了降低 GC 频率把堆调到 64G。结果指针压缩失效对象变大单次 Full GC 停顿从 500ms 变成 4 秒服务雪崩。堆不是越大越好超过 32G 这个坎要格外谨慎超过 64G 建议直接用 ZGC。坑四只调 GC 参数不看代码。有些性能问题的根源是每请求创建几十个大对象、日志打全量 JSON、字符串拼接在循环里这些改代码比调参数收益大得多。参数调优是最后手段不是第一手段。坑五在压测环境验证通过就直接上生产。压测环境的流量模型和真实流量差异可能很大尤其是对象生命周期分布——压测通常是均匀请求真实流量有明显峰值和长尾。我的做法是线上灰度先观察一周的 GC 日志确认峰值时段的表现再全量。6.3 该盯哪些指标堆相关的监控指标不需要很多但每个都要能解读堆使用率分代分开看—— 老年代使用率的曲线形状比绝对值更重要健康的曲线应该是锯齿状稳步下降不健康的是持续单调上升或者高位横盘。GC 频率和耗时 —— 分 Young GC 和 Full GC 分别统计看 P99 停顿而不是平均值平均值会掩盖偶发的长停顿。晋升速率 —— 每分钟晋升到老年代的字节数这个指标比老年代使用率更早反映问题。分配速率 —— 每秒分配的字节数突然上涨通常意味着流量变化或者有新代码路径。对象创建热点 —— 用 JFR-XX:StartFlightRecording或者 async-profiler 采样能看到哪些方法在大量分配是优化代码的直接依据。JFR 现在内置在 JDK 里开销很低默认配置下 1% 左右生产环境可以常开# 启动时开启 JFR保留最近 6 小时数据 -XX:StartFlightRecordingdisktrue,maxsize512m,maxage6h,dumponexittrue,filename/data/jfr/app.jfr出问题时用jfr print --events jdk.ObjectAllocationSample看分配热点比 dump 堆快得多也不需要 STW。最后分享一个我自己的习惯每次接手一个新服务第一件事不是改参数而是把它的 GC 日志拉出来看一天的完整曲线重点看峰值时段和凌晨低峰时段的差异。这个动作花不了十分钟但能快速判断出这个服务的堆是够用还是贴着上限在跑后面的所有调优决策都建立在这个判断上。跳过这步直接抄参数模板大概率是在给自己挖坑。