Java 后端线上问题排查:CPU、内存、GC、线程池、数据库与 Redis
线上 JVM 问题没有「一个命令直接定位」的捷径,但有可以复用的套路:监控发现异常 → 保护现场并取证 → 用工具缩小范围 → 定位根因 → 参数或代码修复 → 验证效果。本篇先讲这套通用方法论,再用 CPU 飙高、内存泄漏、频繁 Full GC 三个典型案例各走一遍。
通用方法论:四步走
第一步:监控发现,先判影响。 告警响后先确认用户侧是否受损:错误率、P95/P99、业务成功率有没有同步恶化,是单机还是全量实例。把告警前后十几分钟的请求量、CPU、内存、GC、线程池曲线叠在一起看,再对照发布、配置变更、流量突增的时间点——最早变化的那个指标,往往离根因最近。
第二步:止血与取证并行。 故障还在扩大就先按预案止血:摘流量、回滚、限流。条件允许时在重启前保留证据:GC 日志、应用日志、间隔数秒抓 1~3 份线程栈、监控时间范围截图。堆转储建议靠启动参数预置,而不是事发时手动触发:
-XX:+HeapDumpOnOutOfMemoryError |
第三步:分析定位,用数据验证假设。 每提出一个假设都要找到对应的证据:怀疑泄漏就看 Full GC 后老年代是否回落,怀疑热点代码就连抓几份线程栈。单份证据可能是巧合,证据链闭环才算定位。
第四步:修复并验证。 参数调整只改节奏,代码修复才治病根。修复后在相同流量下对比修复前后的 CPU、GC 次数与停顿、堆曲线,确认有效再收工。
案例一:CPU 飙高
现象:单机 CPU 长期 90%+,接口延迟上升。
取证定位:
top -Hp <pid> # 找到进程内高 CPU 线程的 tid |
三份栈都停在同一个正则表达式方法上——确认是回溯失控的热点代码,而非采样巧合。若高 CPU 的是 GC 线程,方向就改为查频繁 GC。
修复与验证:把正则改写为 possessive 量词/预编译并限制输入长度,发布后同流量对比 CPU 从 90% 落回 30%。
常见根因清单:死循环、正则回溯、大对象序列化、频繁 GC、锁竞争自旋。
案例二:内存泄漏(堆持续上涨)
现象:堆使用率单调上涨,Full GC 后不回落,最终 OOM。
取证定位:靠预置参数拿到 OOM 时的 heap dump(或摘流量后 jcmd <pid> GC.heap_dump),用 MAT 打开,看 Leak Suspects 报告和 Dominator Tree——一个静态 HashMap 占据了 60% 的堆。沿「Path to GC Roots」找到引用链:请求上下文被当成 key 塞进了一个无上限的全局缓存,线程复用后永不释放。注意 MAT 里「对象数量多」不等于泄漏,只有被 GC Roots 强持有且持续增长的才算。
修复与验证:缓存改为有界结构(如 LRU + 上限),或改用 WeakReference;同时补上缓存命中率与大小的监控。修复后观察一周,老年代在 Full GC 后稳定回落到基线。
高频嫌疑人:静态集合、无界缓存、ThreadLocal 没调 remove()、监听器注册后未注销。
案例三:频繁 Full GC
现象:Full GC 从每天一次变成每小时数次,单次停顿秒级。
取证定位:先翻 GC 日志看触发原因:
Metadata GC Threshold:元空间方向,查动态生成类与类加载器泄漏;System.gc():定位调用方,或加-XX:+DisableExplicitGC;Allocation Failure/担保失败:看回收后老年代占用——回收后仍高是对象长命(转案例二的泄漏路径),回收正常但很快又满是晋升过快或大对象多。
本例属于后者:一个批量查询一次性把几十万行数据加载进内存,大对象直接进老年代,Young GC 担保失败引发 Full GC。
修复与验证:查询改成分页分批、控制单批条数;短期把堆从 4G 调到 6G 争取修复窗口。上线后 Full GC 回到每天一次以下,停顿从 2 秒降到 200ms 内。
事后比事后排查更值钱
三起案例的共同教训:GC 日志常开、OOM 自动 dump 预置、线程池规范命名、缓存有上限、关键指标有告警——这些配置日常花十分钟,出事时省的是几小时的盲查。方法论的核心只有一句话:让每一步结论都有数据背书,而不是靠猜。

