堆这个词在 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 里的实现叫永久代(PermGen),JDK 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:SurvivorRatio=8控制)。之所以是两个 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/6,Eden 占 4/6。看起来 Eden 变小了,Minor GC 会更频繁,但实际上 Survivor 变大了,能容纳更多存活对象,晋升到老年代的速度反而慢了。这种取舍没有标准答案,得看应用的存活对象曲线。经验值是:如果 Minor GC 后存活对象经常接近 Survivor 上限,就该调大 Survivor;如果 Minor GC 太频繁但存活对象很少,就调大 Eden。
2.2 老年代与晋升阈值:对象什么时候"退休"
对象在 Survivor 之间来回复制,每次年龄加 1,年龄达到阈值(-XX:MaxTenuringThreshold,默认 15,CMS 下默认 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:NativeMemoryTracking=detail配合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 字节。如果字段换成 long,8+4+8=20,还是 24。加个对象引用字段,8+4+4+8=24,正好对齐。这些数字看起来琐碎,但在评估百万级对象的集合内存占用时很有用。比如一个HashMap存 100 万个 Entry,每个 Entry 大约 32~48 字节,加上 Node 数组和扩容余量,实际占用可能是"数据本身"的好几倍。这就是为什么有时候把HashMap换成数组或者专门的结构,内存能省一半。
3. 对象分配的完整链路与关键参数
3.1 从一条 new 指令到内存落位
写new User()的时候,JVM 在背后做了这些事:检查类是否已加载、解析、初始化,没加载就先走类加载流程;然后计算对象所需内存大小;接着在堆上划出一块内存;再把这块内存清零(保证实例字段初始值正确);设置对象头(Mark Word、类型指针);最后执行构造方法。
划内存这一步有两种方式:指针碰撞和空闲列表。前者要求堆内存规整,用一个指针分隔已用和空闲区域,分配时指针往后挪就行;后者适用于内存不规整的情况,需要维护一张空闲块列表,从中找一块够大的。用哪种取决于收集器是否带压缩整理能力——Serial、ParNew 用指针碰撞,CMS 用空闲列表,G1 的 Region 内部用指针碰撞。
并发安全是这一步的核心难点。多个线程同时 new,指针会打架。HotSpot 的解法是TLAB(Thread 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输出里EC(Eden 容量)和EU(Eden 使用量)的差值,加上S0U/S1U,能大致推算分配速率。想更精确就开 GC 日志,用-Xlog:gc+heap=debug能看到 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。比如堆 8G,8G/2048 = 4MB,Region 就是 4MB;堆 32G,Region 就是 16MB;堆超过 64G,Region 顶到 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 World:GC 期间暂停所有用户线程。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 Failure;G1 用 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:+UseSerialGC |
| Parallel | 数 GB | 复制 | 标记整理 | 多线程 STW,吞吐优先 | -XX:+UseParallelGC、-XX:MaxGCPauseMillis |
| CMS | 数 GB | 复制 | 标记清除(并发) | 低停顿但有碎片和 CMF 风险 | -XX:+UseConcMarkSweepGC(已移除) |
| G1 | 4G~64G | Region 复制 | Region 混合回收 | 可预测停顿,Region 化 | -XX:+UseG1GC、-XX:MaxGCPauseMillis |
| ZGC | 数百 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:format=b,file=/tmp/heap.hprof <pid> # 查看堆概要信息 jmap -heap <pid> # 触发一次 Full GC 后生成 dump(更准确反映存活对象) jmap -dump:live,format=b,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*,gc+heap=info,gc+age=trace:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=50Mgc+age=trace会打印每次晋升的年龄分布,排查"对象提前晋升"非常有用。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%。
第四步,找到代码,发现是会话管理器把每个请求的上下文存进 Map,key 是 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 日志的晋升速率 | 扩容、优化对象创建、调大 Survivor |
| Minor GC 频繁但存活对象少 | Eden 太小 | jstat看 EC 和 YGCT | 调大-Xmn或整体堆 |
| 对象提前晋升到老年代 | 动态年龄判定压低阈值 | GC 日志gc+age=trace | 调大 Survivor、减少中等生命周期对象 |
| 单次 Full GC 停顿过长 | 老年代大、标记整理耗时 | 看FGCT/FGC | 换 G1/ZGC、减小堆、增量整理 |
to-space exhausted | Survivor 容纳不下存活对象 | 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:SurvivorRatio=8 # Eden : Survivor = 8 : 1 -XX:MaxTenuringThreshold=15 # 晋升年龄上限 -XX:PretenureSizeThreshold=1m # 大对象阈值,仅对 Serial/ParNew 有效 # 元空间与直接内存 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1g # G1 相关 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=8m -XX:InitiatingHeapOccupancyPercent=45 -XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=40 # 诊断与日志 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump -Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=10,filesize=50M # 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:MaxRAMPercentage=70.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:StartFlightRecording=disk=true,maxsize=512m,maxage=6h,dumponexit=true,filename=/data/jfr/app.jfr出问题时用jfr print --events jdk.ObjectAllocationSample看分配热点,比 dump 堆快得多,也不需要 STW。
最后分享一个我自己的习惯:每次接手一个新服务,第一件事不是改参数,而是把它的 GC 日志拉出来看一天的完整曲线,重点看峰值时段和凌晨低峰时段的差异。这个动作花不了十分钟,但能快速判断出这个服务的堆是"够用"还是"贴着上限在跑",后面的所有调优决策都建立在这个判断上。跳过这步直接抄参数模板,大概率是在给自己挖坑。