【 JVM 调优】一次生产系统业务高峰期行锁堆积故障的 JVM 调优分析


一次业务高峰期行锁堆积故障的 JVM 调优分析

某号线系统业务量翻倍后出现大面积行锁堆积与订单处理阻断。本文基于实际排查过程,从现象、排查、根因分析到解决方案,完整记录一次 JVM 调优的实战案例。

一、问题现象

系统背景:

  • 应用:Tomcat 8 + Java 7,两台服务器(7号、8号)通过 Nginx 负载均衡
  • 运行历史:已稳定运行近20年,未调整配置5年
  • 触发事件:6月23日营销活动,业务量较平日激增(上游未提前通知下游)

异常表现:

现象 具体描述
订单处理阻断 上游订单到达后处理失败,订单积压
应用频繁宕机 两台服务器频繁宕机,重启后恢复,间隔数小时再次宕机
系统严重卡顿 页面加载超时,操作无响应
数据库行锁堆积 监控显示大量 UPDATE 语句锁等待,阻塞业务执行

业务影响持续扩大,订单积压不断增加,需立即定位根因。

二、排查过程

2.1 基础设施层排查(排除法)

首先排除主机资源瓶颈:

检查项 命令 结果
CPU top 使用率正常,无异常峰值
内存 free -g 总内存39G,free 5G,cached 23G,实际可用28G
磁盘 I/O iostat -x 1 读写等待正常,无 I/O 瓶颈
网络 netstat -antp 连接数正常,无异常堆积

free -g 输出中 cached 是 Linux 文件系统缓存,应用程序需要内存时可随时释放,不影响 JVM 内存分配。主机层面无异常。

2.2 应用层排查

数据库 SQL 排查:

检查引发锁等待的 UPDATE 语句,WHERE 条件字段均已建立索引,执行计划正常,单条 SQL 执行效率在毫秒级。排除 SQL 性能问题。

应用日志排查:

查看应用日志,虽有部分报错,但与宕机和卡顿的严重程度不匹配。排除日志报错为主因。

2.3 JVM 层排查

前三步均无异常,问题指向 JVM 层面。

使用 jstat 获取 GC 实时数据:

bash 复制代码
jps -l
jstat -gcutil <pid> 5000

采集结果:

列名 含义 当前值 健康阈值 状态
S0 Survivor 0 区使用率 0% 应与 S1 交替为 0 🔴 异常
S1 Survivor 1 区使用率 0% 应与 S0 交替为 0 🔴 异常
E Eden 区使用率 98.86% 波动 ⚠️ 频繁填满
O 老年代使用率 3.07% < 90% ✅ 正常
M 永久代使用率 8.40% < 90% ✅ 正常
YGC Young GC 次数 154 次 - 正常
YGCT Young GC 总耗时 0.815 秒 - 正常
FGC Full GC 次数 62 次 极少 🔴 严重异常
FGCT Full GC 总耗时 517.655 秒 占比应 < 10% 🔴 严重异常
GCT GC 总耗时 531.470 秒 - -

异常数据识别:

  • 老年代使用率仅 3.07%,永久代仅 8.40%,内存完全充足
  • Full GC 发生 62 次,FGCT 占 GCT 的 97.4%(517.655/531.470)
  • 单次 Full GC 平均耗时 8.3 秒(517.655/62)
  • Survivor 区完全失效(S0/S1 均为 0)
  • Eden 区持续在 98%-102% 之间波动,YGC 频繁

进一步确认 :经过代码审查,确认项目中没有任何地方调用 System.gc()

数据指向:问题与 Parallel GC 的晋升失败(Promotion Failure)机制有关。

jstat -gcutil 各列含义表:

列名 全称 含义
S0 Survivor 0 Space 年轻代 Survivor 0 区已使用百分比
S1 Survivor 1 Space 年轻代 Survivor 1 区已使用百分比
E Eden Space 年轻代 Eden 区已使用百分比
O Old Generation 老年代已使用百分比
M Metaspace/PermGen 元空间/永久代已使用百分比
YGC Young GC Count 年轻代 GC 累计次数
YGCT Young GC Time 年轻代 GC 累计耗时(秒)
FGC Full GC Count Full GC 累计次数
FGCT Full GC Time Full GC 累计耗时(秒)
GCT GC Total Time 全部 GC 累计耗时(秒)

三、根因分析

3.1 对象在 JVM 中的分配与流转

在展开根因分析之前,先明确对象的分配和晋升规则。这有助于理解后续的晋升失败机制。

对象什么时候分配在新生代?

绝大多数对象(约 98%)刚创建时分配在年轻代的 Eden 区。这是 JVM 内存分配的基本规则。方法内的局部变量、循环中创建的临时对象,都是朝生夕灭,生命周期极短,分配在 Eden 区是最优选择。

对象什么时候从新生代晋升到老年代?

对象不会永远留在新生代。当满足以下任一条件时,对象会晋升到老年代:

触发条件 说明
年龄达到阈值 每经历一次 YGC 且存活,年龄加 1。默认 15 岁晋升(-XX:MaxTenuringThreshold=15)
动态年龄判定 Survivor 中某年龄及以上的对象总大小超过 Survivor 的一半时,该年龄及以上的对象提前晋升
提前晋升 YGC 时 Eden 中存活对象总量 > Survivor 可用空间,所有存活对象直接晋升老年代
大对象直接分配 超过 -XX:PretenureSizeThreshold 阈值的对象直接在老年代分配

其中,提前晋升是导致 Survivor 为 0 的直接原因,也是本次案例的触发机制。

3.2 原始 JVM 配置的内存布局

bash 复制代码
JAVA_OPTS="-Xms2048m -Xmx8192m -Xss1024K \
           -XX:PermSize=1024m -XX:MaxPermSize=2048m \
           -Dfile.encoding=UTF8 -Dsun.jnu.encoding=UTF8 \
           -Djava.awt.headless=true"

问题分析:

  1. 未指定 GC 算法 → Java 7 默认使用 Parallel GC(并行回收器)
  2. Parallel GC 的设计目标:吞吐量优先,适用于批处理场景,Full GC 会产生较长的 STW 停顿
  3. Survivor 区配置隐性不足:未显式调整年轻代布局,默认配置在业务量翻倍后无法承载

年轻代与老年代的默认比例:

Java 7 中,年轻代和老年代的比例由 -XX:NewRatio 控制,默认值为 2,即:

老年代 : 年轻代 = 2 : 1

这意味着年轻代约占整个堆的 1/3。但需要注意的是,由于 -Xms 为 2G、-Xmx 为 8G,堆是动态扩容的。各区域大小不是固定的,而是随着堆从 2G 膨胀到 8G 而逐渐增大。 营销活动流量翻倍来得太快,堆可能尚未完全扩容到 8G,或者 Survivor 还没涨到最大容量时,Eden 就已经满了。

Eden 与 Survivor 的默认比例:

年轻代内部,Eden 和 Survivor 的比例由 -XX:SurvivorRatio 控制,默认值为 8,即:

Eden : S0 : S1 = 8 : 1 : 1

这意味着 Eden 占年轻代的 80%,每个 Survivor 占 10%。

根据原jvm参数配置中(堆达到 8GB 时):

区域 计算公式 实际大小(约)
年轻代(总量) 8GB × 1/3 2.67GB
Eden 2.67GB × 8/10 2.13GB
Survivor 0 2.67GB × 1/10 267MB
Survivor 1 2.67GB × 1/10 267MB
老年代 8GB × 2/3 5.33GB

这里已经可以看出一道隐患:Eden 与单个 Survivor 的差距是 8 倍。 Eden 区 2.13GB,而 Survivor 只有 267MB。正常流量下,YGC 存活率通常低于 10%,267MB 足够容纳。但业务量翻倍后存活率升高,267MB 便成为瓶颈。

3.3 Survivor 为什么是 0------提前晋升(Premature Promotion)

正常状态下,S0 和 S1 应交替使用,永远有一个是 0,另一个为非 0:

复制代码
YGC1: Eden → S0(S0=30%,S1=0%)
YGC2: Eden+S0 → S1(S1=40%,S0=0%)
YGC3: Eden+S1 → S0(S0=35%,S1=0%)

但在gc监控数据中 S0=0,S1=0,两者同时为 0,说明 Survivor 已完全失效。

原因:提前晋升(Premature Promotion)

YGC 发生时,JVM 的执行流程如下:

复制代码
Eden 区填满(E 列持续 98%+)
        ↓
触发 YGC(并行执行,STW)
        ↓
计算 Eden 中存活对象总量(LiveSize)
        ↓
比较 LiveSize 与 Survivor 可用空间(SSize)
        ↓
if (LiveSize < SSize) {
    存活对象放入 Survivor(正常情况)
} else {
    触发提前晋升(Premature Promotion)
    所有存活对象直接进入老年代
    → Survivor 保持为 0
}

参考配置的数据:

参数 估算值
Eden 总容量 EC 约 2.13GB
YGC 前 Eden 使用量 98% × 2.13GB ≈ 2.09GB
业务高峰期 YGC 存活率 约 25%(正常可能是 5%-10%)
本轮存活对象 LiveSize 2.09GB × 25% ≈ 522MB
Survivor 容量 SSize 267MB
判定 522MB > 267MB → 提前晋升

结论:不是 Survivor 没有对象,而是存活对象太多(522MB),Survivor 装不下(267MB),所有对象直接去了老年代。 S0/S1 根本没有对象可存,所以显示为 0。

3.4 Parallel GC 晋升失败机制详解

上一节描述了对象从 Eden 到老年代的提前晋升过程。当老年代连续可用空间不足以容纳提前晋升的对象时,便发生晋升失败(Promotion Failure),触发 Full GC。

晋升失败与 Full GC 的流程:

复制代码
提前晋升触发
        ↓
晋升到老年代时,检查老年代可用连续空间
        ↓
┌─────────────────────────────────────┐
│ 连续空间是否满足晋升需求?           │
├─────────────────────────────────────┤
│ 满足 → 晋升成功,YGC 完成            │
│ 不足 → 晋升失败(Promotion Failure) │
└─────────────────────────────────────┘
        ↓
晋升失败 → 触发 Full GC(STW,扫描整个堆)

本次案例中的表现:

  • S0/S1 均为 0,Survivor 完全失效,每次 YGC 存活对象直接尝试晋升老年代
  • 老年代整体使用率 3.07%,但晋升需要的是连续可用空间
  • 业务量翻倍后,晋升频率和单次晋升量同时增加,超过连续空间阈值
  • 触发 Full GC,8GB 堆的 Full GC 单次耗时 8.3 秒

3.5 老年代为什么"放不下"------连续空间 vs 总空间

这是排查中最容易产生误解的地方。老年代总空间 5.33GB,不等于它能容纳每一次晋升请求。

晋升到老年代时,JVM 要求的是连续可用空间(即一块完整的内存区域),而非总空闲量。

内存碎片化问题:

复制代码
老年代 5.33 GB(初始状态):
[███████████████████████████████████████████████████████████████████████████]
                       全部为连续可用空间

经过多次 YGC 晋升和对象释放后:
[███████][ 空洞 ][█████][  空洞  ][████████][ 空洞 ][███]  
          ↑ 总空闲空间可能还有 3GB
          ↑ 但最大连续空闲块可能只有 300MB

碎片化如何快速恶化:

步骤 事件 对老年代的影响
第一次 YGC 晋升 500MB 老年代分配一块 500MB 连续空间
第二次 YGC 晋升 500MB 分配另一块 500MB 空间
部分对象被回收释放 释放的空间变成碎片,夹在使用中的空间之间
重复多次 连续空间被切割成大量碎片
下一次 YGC 需晋升 500MB 最大连续块已不足 500MB → 晋升失败 → Full GC

Parallel GC 的碎片处理缺陷:

Parallel GC 的 Full GC 会进行压缩 (整理碎片),但压缩过程是 STW(Stop-The-World) 的,会暂停所有应用线程。而且 Parallel GC 不会在每次 YGC 后都进行压缩,碎片是持续累积的,直到触发 Full GC 才一次性处理。

这就是为什么老年代总空间 5.33GB,却依然发生晋升失败的原因: 不是空间不够,而是连续空间被碎片化,且 Parallel GC 平时不整理碎片。

3.6 为什么稳定运行五年才出问题?

阶段 业务量 Eden 存活率 单次存活对象 Survivor 能否容纳 GC 表现
五年常态 正常负载 ~10% ~200MB ✅ 267MB 勉强够 YGC 正常,FGC 极少
营销活动期间 翻倍 ~25% ~522MB ❌ 267MB 不够 提前晋升,FGC 风暴

核心原因:Survivor 容量是按常态流量配置的。 267MB 在正常负载下刚好够用(存活对象约 200MB)。业务量翻倍后,存活对象翻倍至 500MB+,Survivor 被击穿。

3.7 Full GC → 行锁堆积的因果传导链

这是本次故障中最关键的逻辑链条。排查中观察到数据库行锁堆积,需要准确定性其与 Full GC 的因果关系:

传导机制:

复制代码
Full GC 频繁发生(STW,每次 8.3 秒)
        ↓
请求处理时间被严重拉长
        一个 UPDATE 请求原本 50ms 完成
        执行过程中被 GC 暂停插入 8.3 秒,总耗时变为 8.35 秒
        ↓
数据库连接被长时间占用
        每个请求占用连接的时间从毫秒级变为秒级
        ↓
连接池有效连接迅速耗尽
        请求处理速度下降 → 新请求不断涌入
        → 连接释放速度远低于获取速度
        ↓
UPDATE 持有的行锁长时间不释放
        SQL 执行过程中被 GC 暂停打断,事务提交被延迟
        锁持有时间从 50ms 变为 8.35 秒
        ↓
大量后续请求等待锁释放
        行锁等待队列持续堆积
        ↓
业务线程全面阻塞
        线程池占满 → 连接池无可用连接 → 订单处理失败

量级变化对照:

对比项 正常 GC 干扰后 放大倍数
单次请求耗时 50ms 8.35s 167x
单次锁持有时间 50ms 8.35s 167x
并发请求排队效应 持续堆积 指数放大

关键结论:

  • 数据库行锁堆积 不是 SQL 性能问题 ,而是 Full GC 的次生灾害
  • 如果 GC 停顿消除,UPDATE 会在毫秒级完成并释放锁,行锁堆积自然消失
  • Full GC 是原因,行锁堆积是结果

3.8 问题链条总结

复制代码
6月23日营销活动 → 业务量翻倍(上游未提前通知)
        ↓
Eden 区填满加速 → YGC 频率翻倍
        ↓
Survivor 区短板暴露 → 存活对象直接晋升老年代
        ↓
晋升所需连续空间 > 老年代可用连续空间 → 晋升失败
        ↓
Parallel GC 触发 Full GC(STW,8.3秒/次,62次)
        ↓
请求处理时间被拉长(50ms → 8.35s)
        ↓
连接池耗尽 + 行锁堆积
        ↓
订单处理失败,应用宕机

四、解决方案

4.1 方案选择

方案 分析 结论
调大 Survivor 区 需反复试错,无法根治流量突增 不采用
G1GC(Java 7) 实验特性,稳定性不足 不采用
切换到 CMS 并发回收消除 Full GC,Java 7 最成熟 采用

选用 CMS 的核心逻辑: 老年代并发回收 → 消除 Full GC 式 STW → GC 暂停从秒级降至毫秒级 → 请求处理不再被 GC 中断 → 锁持有时间恢复正常 → 行锁堆积自然消失。

4.2 优化后配置

bash 复制代码
JAVA_OPTS="-Xms6144m -Xmx6144m \
           -Xss256K \
           -XX:PermSize=512m -XX:MaxPermSize=1024m \
           -XX:+UseConcMarkSweepGC \
           -XX:+UseParNewGC \
           -XX:+CMSClassUnloadingEnabled \
           -XX:+CMSPermGenSweepingEnabled \
           -XX:CMSInitiatingOccupancyFraction=70 \
           -XX:+UseCMSInitiatingOccupancyOnly \
           -XX:+UseCMSCompactAtFullCollection \
           -XX:CMSFullGCsBeforeCompaction=0 \
           -XX:+DisableExplicitGC \
           -XX:+PrintGCDetails \
           -XX:+PrintGCTimeStamps \
           -XX:+PrintGCDateStamps \
           -Xloggc:$CATALINA_BASE/logs/gc.log \
           -Dfile.encoding=UTF8 \
           -Dsun.jnu.encoding=UTF8 \
           -Djava.awt.headless=true"

4.3 参数设计依据

堆与线程栈:

参数 原值 设计依据
-Xms 6144m 2048m 初始与最大一致,避免动态扩容带来的开销和不确定性
-Xmx 6144m 8192m 老年代仅用3.07%,6GB已足够;降至6GB降低CMS扫描开销
-Xss 256K 1024K 500并发线程节省约370MB内存

永久代:

参数 原值 设计依据
-XX:PermSize 512m 1024m 使用率仅8.4%,无需初始1GB
-XX:MaxPermSize 1024m 2048m 降低上限,减少GC扫描范围

CMS 核心参数:

参数 设计依据
-XX:+UseConcMarkSweepGC 启用 老年代并发回收,消除 Full GC
-XX:+UseParNewGC 启用 年轻代并行回收,配合 CMS 使用
-XX:CMSInitiatingOccupancyFraction 70% 老年代70%触发并发回收,预留30%缓冲应对晋升
-XX:+UseCMSInitiatingOccupancyOnly 固定阈值 禁用JVM动态调整,保证行为可预测
-XX:+UseCMSCompactAtFullCollection 启用 Full GC时压缩内存,解决CMS碎片问题
-XX:CMSFullGCsBeforeCompaction 0 每次FGC都压缩,避免碎片累积

GC 日志:

参数 作用
-XX:+PrintGCDetails 打印 GC 详细信息
-XX:+PrintGCTimeStamps 打印 JVM 启动后相对时间
-XX:+PrintGCDateStamps 打印日历时间戳
-Xloggc:$CATALINA_BASE/logs/gc.log 输出到文件,持久化存储

五、灰度验证

5.1 验证设计

两台服务器(7号、8号)通过 Nginx 负载均衡,流量均等,业务一致,具备对照条件。

灰度策略:

步骤 操作 目的
第一步 6月23日晚,只升级 8号服务器(CMS 新配置) 实验组
第二步 7号服务器保持原配置(Parallel GC) 对照组
第三步 8号稳定运行 24 小时后采集对比数据 验证效果
第四步 验证通过后,按同样配置升级 7号服务器 全量发布

5.2 验证数据(6月24日 11:00)

指标 7号(Parallel GC 对照组) 8号(CMS 实验组) 对比结论
老年代使用率 O 9.55%(刚被 FGC 清过) 34.73%(自然积累,平稳) CMS 老年代稳定
Survivor 使用率 98.37%(逼近溢出) 5.48%(正常交替) CMS 恢复 Survivor 功能
当日 FGC 次数 3 次(587ms/次) 0 次 CMS 消除 Full GC
日常 GC 暂停 高峰期持续累积 0.047~0.827ms CMS 暂停用户无感知
数据库行锁 持续堆积 消失 切断因果链
订单处理 阻断、超时 正常 业务恢复

数据解读:

7号(对照组)特征:

  • 老年代 9.55%,说明刚被 Full GC 清理过
  • Survivor 98.37%,逼近溢出,每次 YGC 都触发晋升
  • FGC 当日已 3 次,处于"FGC → 清空 → 再 FGC"的恶性循环

8号(实验组)特征:

  • 老年代 34.73%,平稳积累,CMS 后台并发回收
  • Survivor 5.48%,正常交替(S0/S1 之间轮转)
  • FGC 为 0,无 STW 暂停,行锁堆积自然消失

5.3 验证步骤

即时验证(重启后执行):

bash 复制代码
# 1. 验证参数是否生效
ps -ef | grep java | grep CMS

# 2. 验证 GC 日志是否开始写入
ls -la /irms/provider8082/logs/gc.log

# 3. 验证应用基本功能
# 登录、OBD 查询等核心功能正常

早高峰验证(次日监控):

bash 复制代码
# 1. 验证 Survivor 区是否正常交替
jstat -gcutil <pid> 5s 10
# S0 和 S1 不能全是 0,应有一个在 30-90% 之间交替

# 2. 验证 Full GC 是否停止增长
# 盯住 FGC 列,新参数下 FGC 基本不涨

# 3. 验证老年代是否平稳
# 盯住 O 列,应在 50-70% 之间平稳波动,不会突然跳涨

# 4. 验证 GC 日志中无并发模式失败
grep "concurrent mode failure" /irms/provider8082/logs/gc.log
# 应该看不到 concurrent mode failure

5.4 验证结论

8号服务器稳定运行 24 小时后,7号服务器按同样配置升级。两台均切换至 CMS 回收器后:

  • ✅ FGC 停止增长
  • ✅ Survivor 恢复正常交替
  • ✅ 行锁堆积消失
  • ✅ 订单处理恢复正常
  • ✅ 系统卡顿消除

六、新老配置对比

6.1 参数变更对照

类别 参数 原配置 新配置 变更目的
初始堆 -Xms 2048m 6144m 与最大堆一致,避免动态扩容带来的开销和不确定性
最大堆 -Xmx 8192m 6144m 原8GB降至6GB,降低CMS扫描开销;老年代仅用3.07%,6GB足够
线程栈 -Xss 1024K 256K 500并发线程节省约370MB内存
永久代初始 -XX:PermSize 1024m 512m 使用率仅8.4%,无需初始1GB
永久代最大 -XX:MaxPermSize 2048m 1024m 降低上限,减少GC扫描范围
GC算法 -XX:+UseConcMarkSweepGC 无(默认Parallel GC) 新增 启用CMS并发回收,消除Full GC式STW
GC算法 -XX:+UseParNewGC 新增 年轻代并行回收,配合CMS使用
类卸载 -XX:+CMSClassUnloadingEnabled 新增 允许回收永久代中的类元数据
永久代清扫 -XX:+CMSPermGenSweepingEnabled 新增 启用永久代清扫,配合类卸载
CMS触发阈值 -XX:CMSInitiatingOccupancyFraction 70% 老年代70%触发并发回收,预留30%缓冲
CMS阈值策略 -XX:+UseCMSInitiatingOccupancyOnly 新增 固定阈值,禁用JVM动态调整
CMS碎片压缩 -XX:+UseCMSCompactAtFullCollection 新增 Full GC时压缩内存
CMS压缩频率 -XX:CMSFullGCsBeforeCompaction 0 每次FGC都压缩,避免碎片累积
禁用显式GC -XX:+DisableExplicitGC 新增 屏蔽代码中可能存在的System.gc()调用
GC详细日志 -XX:+PrintGCDetails 新增 打印GC详细信息
GC相对时间 -XX:+PrintGCTimeStamps 新增 打印JVM启动后相对时间
GC日期时间 -XX:+PrintGCDateStamps 新增 打印日历时间戳
GC日志输出 -Xloggc: 新增 输出到文件,持久化存储

6.2 配置变更总结

复制代码
┌─────────────────────────────────────────────────────────────────────────────┐
│                         配置变更三大核心方向                                  │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ① GC算法:Parallel GC → CMS                                                │
│     ├─ 从"吞吐量优先"切换为"低延迟优先"                                      │
│     ├─ 老年代并发回收,消除 Full GC 式全堆暂停                               │
│     └─ GC暂停从秒级(8.3s)降至毫秒级(<1ms)                                │
│                                                                             │
│  ② 内存布局优化                                                             │
│     ├─ 堆大小:8GB → 6GB(降低CMS扫描开销)                                  │
│     ├─ 线程栈:1MB → 256K(节省内存)                                       │
│     └─ 永久代:1024m/2048m → 512m/1024m(按需分配)                         │
│                                                                             │
│  ③ 可观测性建设                                                             │
│     ├─ 开启 GC 日志(PrintGCDetails + PrintGCDateStamps)                   │
│     └─ 输出到独立文件(Xloggc),便于采集和分析                              │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

6.3 效果对比(验证数据支撑)

指标 原配置(Parallel GC) 新配置(CMS) 改善
FGC次数(当日) 3次(持续增长) 0次 ✅ 完全消除
FGC单次耗时 587ms 无FGC ✅ 消除停顿
日常GC暂停 高峰期加速 0.047~0.827ms ✅ 降低99%+
Survivor状态 98.37%(逼近溢出) 5.48%(正常交替) ✅ 恢复正常
老年代稳定性 波动剧烈(FGC清空→再积累) 34.73%(平稳波动) ✅ 稳定可控
数据库行锁 持续堆积 消失 ✅ 根治
订单处理 阻断、超时 正常 ✅ 业务恢复

6.4 新旧版本完整配置对比

bash 复制代码
# ============================================================
# 原配置(问题版本)
# ============================================================
JAVA_OPTS="-Xms2048m -Xmx8192m -Xss1024K \
           -XX:PermSize=1024m -XX:MaxPermSize=2048m \
           -Dfile.encoding=UTF8 -Dsun.jnu.encoding=UTF8 \
           -Djava.awt.headless=true"

# ============================================================
# 新配置(优化版本)
# ============================================================
JAVA_OPTS="-Xms6144m -Xmx6144m \
           -Xss256K \
           -XX:PermSize=512m -XX:MaxPermSize=1024m \
           -XX:+UseConcMarkSweepGC \
           -XX:+UseParNewGC \
           -XX:+CMSClassUnloadingEnabled \
           -XX:+CMSPermGenSweepingEnabled \
           -XX:CMSInitiatingOccupancyFraction=70 \
           -XX:+UseCMSInitiatingOccupancyOnly \
           -XX:+UseCMSCompactAtFullCollection \
           -XX:CMSFullGCsBeforeCompaction=0 \
           -XX:+DisableExplicitGC \
           -XX:+PrintGCDetails \
           -XX:+PrintGCTimeStamps \
           -XX:+PrintGCDateStamps \
           -Xloggc:$CATALINA_BASE/logs/gc.log \
           -Dfile.encoding=UTF8 \
           -Dsun.jnu.encoding=UTF8 \
           -Djava.awt.headless=true"

七、总结

7.1 完整因果链

复制代码
6月23日营销活动 → 业务量翻倍(上游未提前通知)
        ↓
Eden 填满加速 → YGC 频率翻倍
        ↓
Survivor 区过小 → 存活对象直接晋升老年代
        ↓
晋升所需连续空间 > 老年代可用连续空间 → 晋升失败(Promotion Failure)
        ↓
Parallel GC 触发 Full GC(STW,8.3秒/次,62次)
        ↓
请求处理时间被拉长(50ms → 8.35s,放大167倍)
        ↓
数据库连接占用时间延长 → 连接池耗尽
        ↓
UPDATE 锁持有时间延长(50ms → 8.35s)→ 行锁堆积
        ↓
业务线程全面阻塞 → 订单处理失败,应用宕机

7.2 关键结论

问题 结论
行锁堆积是原因还是结果? 结果。根因是 Full GC 拉长请求处理时间,间接导致锁无法释放
为什么内存充足还会 FGC? 晋升失败(Promotion Failure),与老年代整体使用率无关
为什么 CMS 有效? 并发回收消除 STW 暂停,切断整条因果链
CMS 碎片怎么处理? CMSFullGCsBeforeCompaction=0 每次压缩,中长期升级 G1GC

7.3 触发 Full GC 的常见条件

触发条件 说明
老年代空间不足 O 持续增长接近 100% 时触发,属于正常回收机制
晋升失败(Promotion Failure) YGC 时存活对象大于 Survivor 容量,提前晋升到老年代但老年代连续空间不足。与老年代整体使用率无关。本次案例的直接原因
永久代/元空间空间不足 类元数据、静态变量、常量池等占满时触发
System.gc() 显式调用 代码中主动调用,建议 JVM 执行 Full GC。本次案例中代码审查确认无此调用
CMS 并发模式失败 CMS 回收器执行期间,老年代在并发回收完成前就被填满,CMS 退化为 Full GC
大对象直接进入老年代 超过 -XX:PretenureSizeThreshold 阈值的对象直接分配到老年代,连续分配可能导致空间不足

7.4 排查方法论回顾

步骤 命令/动作 关键判断
① 排除外围 topfree -giostat 主机资源正常
② 排除 SQL 检查执行计划 WHERE 条件均有索引
③ 排除日志 查看 error 级别日志 无致命错误
④ 采集 GC jstat -gcutil <pid> 5000 获取 GC 指标
⑤ 识别异常 O 低 + FGC 高 晋升失败,非内存不足
⑥ 确认配置 检查 JVM 启动参数 确认当前 GC 算法
⑦ 验证修复 灰度对照实验 数据确认效果

7.5 后续优化建议

优先级 事项 说明
建立 GC 监控告警 FGC 次数 > 1/小时 或暂停 > 200ms 告警
GC 日志接入 ELK 便于问题回溯和趋势分析
升级 Java 8+ G1GC 比 CMS 更优秀,可预测停顿,自带压缩
升级 Java 17 ZGC 亚毫秒级停顿,需评估兼容性