Tomcat频繁Full GC?JVM调优实战指南
Tomcat 这台 Java 应用服务器可以说是最普遍的 Web 容器之一了但不少团队在流量上来之后都会遇到一个很头疼的问题应用本身没改什么代码CPU 却时不时飙高接口响应越来越慢一看监控发现 GC 日志刷得飞起Full GC 动不动就几秒钟。我手里好几个项目都经历过这种从频繁 GC 到稳定运行的调优过程踩过不少坑也总结出一些行之有效的套路这篇文章就把我的实战过程完整分享一下尽量把每个参数背后的道理说清楚让你拿到手就能照着排查和调整。1. 调优思路先搞清楚 GC 优化的本质目标1.1 不要为了调优而调优Garbage Collection 的核心矛盾先说一句大实话JVM 调优不是玄学也不是把网上搜到的参数抄一遍就完事。调优的本质是在吞吐量Throughput和延迟Latency之间找平衡顺便把不必要的内存分配和回收开销减到最低。很多人在项目启动的时候直接堆一堆参数进去比如把-Xms和-Xmx调得老大以为内存越多越好结果 Full GC 一次要好几秒接口直接超时。为什么因为堆越大GC 扫描和整理时需要停顿的时间通常也越长。所以调优的第一步永远是搞清楚当前系统究竟卡在哪儿而不是盲目加大内存。以 Tomcat 为例它本身是一个多线程的 Servlet 容器每个请求都会在线程中处理产生大量的临时对象比如请求参数封装、响应体字节数组、Session 相关对象等等。这些对象大多朝生夕死会迅速填满年轻代Young Generation。如果年轻代设置得太小对象频繁晋升到老年代Old Generation老年代很快就满了就会触发 Full GC。反过来如果年轻代太大老年代相对就小长期存活的对象放不下也会频繁 Full GC。所以调优的第一步就是根据现有 GC 日志与运行情况定位到底是哪一种失衡。1.2 木桶效应先测量再调整我一直强调一个原则想调优先测量。不要凭感觉猜测堆内存够不够要看真实的运行数据。测量工具很简单JDK 自带的jstat、jmap、jstack、jvisualvm还有阿里开源的诊断工具都可以派上用场。我实际操作中习惯这样一套流程先看 GC 日志统计当前年轻代和老年代的回收频率、耗时用jstat -gcutil观察各个内存区域的使用率曲线用jmap -dump抓一次堆转储快照注意这个动作非常消耗资源建议在低峰期做分析大对象和内存泄漏嫌疑根据以上数据确定调整方向一次只改一个参数改完观察一段时间再用同样手段验证效果。这套流程看似普通但能帮你避免瞎调参带来的连锁反应。比如说有的项目线上请求量不大但频繁出现 Full GC查到最后竟然是代码里某个全局缓存 Map 只往里写、从不清理导致内存泄漏。这种情况你调什么 JVM 参数都没用得先修代码。JVM 调优解决的是资源配置不合理和设备利用效率问题解决不了应用代码本身的缺陷。2. 核心 JVM 参数详解配置前先弄懂每个参数的逻辑2.1 堆内存布局与容量参数JVM 堆内存分年轻代和老年代年轻代内部又分 Eden 区和两块 Survivor 区S0、S1。Tomcat 的默认 JVM 参数通常比较保守所以在高并发场景下必须显式配置。这里列出我常用的一组基础参数参数作用说明-Xms初始堆大小建议与-Xmx设置成一样避免运行期动态扩容带来的性能抖动-Xmx最大堆大小核心容量参数需根据物理内存和应用需求确定-Xmn年轻代大小控制新生代容量直接影响对象晋升频率-XX:NewRatio老年代/年轻代比例默认 2即老年代是年轻代的 2 倍如果 -Xmn 已设置此参数可以不配-XX:SurvivorRatioEden/Survivor 比例默认 8即 Eden 区是单个 Survivor 区的 8 倍-XX:MetaspaceSize元空间初始大小JDK8 之后方法区的实现默认可能偏小-XX:MaxMetaspaceSize元空间最大大小防止元空间无限制扩张导致 OS 内存耗尽很多人的疑问是-Xms和-Xmx到底该设多少我的经验是如果你给 Tomcat 单独分配了 4G 内存堆可以设置成-Xms2048m -Xmx3072m甚至直接-Xms3072m -Xmx3072m。注意留出足够的非堆内存线程栈、元空间、直接内存等。我见过一个反面案例物理机 8G 内存堆直接给了 6G结果创建线程的时候直接OutOfMemoryError: unable to create new native thread因为每个线程的栈空间也需要从系统内存里分配堆把系统内存几乎占光了。2.2 选择适合的垃圾收集器垃圾收集器的选型可以说是调优中的关键决策。JDK 8 默认使用的是 Parallel Scavenge Parallel Old这套组合注重吞吐量但缺点是暂停时间不可控。对于 Tomcat 这种对响应时间有要求的 Web 服务我通常不会用默认组合而是根据 JDK 版本选择 CMS 或 G1。CMSConcurrent Mark Sweep适合 JDK 8 及之前的项目特点是并发收集暂停时间短但会产生内存碎片且浮动垃圾无法在本次收集中清除。G1Garbage FirstJDK 9 之后默认使用它将堆划分为多个 Region可预测停顿时间适合大堆通常指 4G 以上。G1 在 JDK 11 之后已经非常成熟是我现在的主力选择。有个需要注意的点CMS 在 JDK 9 里被标记为废弃DeprecatedJDK 14 正式移除。如果你用的是 JDK 11 以上的版本没必要再纠结 CMS直接用 G1 就好如果你还在 JDK 8 上维护老项目CMS 依然是个好选择。2.3 GC 日志参数调优的地基没有日志调优就是盲人摸象。我有一次接手一个频繁 Full GC的 Tomcat 项目居然连 GC 日志都没有开启只能靠监控工具反推效率极低。后来在所有项目里都强制要求加上 GC 日志参数。JDK 8 及以前典型的日志参数是这样-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGCJDK 9 之后日志系统统一到了 Unified Logging参数变成了下面的形式-Xlog:gc*:file/data/logs/tomcat-gc.log:time,level,tags:filecount5,filesize20m我建议日志文件保留至少 5 个滚动文件每个 20MB 左右。GC 日志本身对性能影响很小但排障时能省下大量时间。而且调优完成后不要急着关掉日志持续观察一段时间确认稳定了再说。3. 一次真实调优案例从频繁 Full GC 到稳定运行3.1 现象GC 日志里的异常信号之前维护过一个模拟项目某内部运营管理系统基于 Tomcat 8 JDK 8流量高峰期经常出现接口响应变慢、超时报警。我看了一下 GC 日志发现 Full GC 频率高得离谱高峰期几乎是每 5 分钟就来一次每次耗时在 2 到 4 秒之间。日志里反复出现类似这样的内容[Full GC (Ergonomics) 2579353K-1532169K(3145728K), 3.2904802 secs]注意这个Ergonomics标记表示是 JVM 自适应调节策略触发的 Full GC。它在告诉你当前堆内存分配策略不合理JVM 在尝试自己调整却调不过来。这种日志一出现基本可以断定是参数配置 对象分配模式不匹配导致的。再配合jstat -gcutil 12345 1000观察发现老年代使用率一直在 95% 以上徘徊Eden 区倒是经常清空。说明对象晋升到老年代的速度太快老年代装不下了。3.2 诊断借助工具定位根因这一步很多人会漏掉直接去改 JVM 参数。我先抓了一次堆转储快照用 MATMemory Analyzer Tool分析发现有几个可疑的大对象某个定时任务一次性从数据库查询大量数据放到内存里做批量处理一个全局的缓存 Map 在高峰期承载了大量业务数据且没有 TTL 过期机制Session 过期时间设置过长导致存活对象过多。这里我也提醒一句代码层面的问题如果不去解决JVM 参数再优化也只是缓解不是根治。那次的处理是先把代码优化掉一部分分批查询、缓存加过期策略、Session 超时时间缩短然后才调整参数。如果你们项目没有条件做堆转储分析比如堆特别大、无法离线分析可以先用jmap -histo:live看排在前面的对象类型能比较直观地判断是哪类对象占用了大量内存。3.3 参数调整一步一步来代码修完之后我开始调 JVM 参数。原来的配置是这样的JAVA_OPTS-Xms1024m -Xmx2048m -XX:UseConcMarkSweepGC -XX:PrintGCDetails这个配置有几个问题-Xms和-Xmx不一致运行期堆扩容会导致性能抖动年轻代大小完全交给 JVM 自适应在高峰期容易失衡没有配置 MetaspaceSize元空间扩容引发 FMCFull Metadata Count类 GCGC 日志配置不完整没有日期标记和堆各区域使用量。我调整后的配置JAVA_OPTS-Xms2048m -Xmx2048m -Xmn1024m -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseConcMarkSweepGC -XX:UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction70 -XX:CMSParallelRemarkEnabled -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC -Xloggc:/data/logs/tomcat-gc.log逐个说一下这几个关键参数为什么这么调-Xmx和-Xms都设为 2048m是因为这台机器物理内存 4GTomcat 是唯一的 Java 进程给它 2G 堆比较合理剩下留给线程栈、元空间和操作系统缓存。两个值一致JVM 就不会在运行期花额外时间做堆扩容。-Xmn1024m把年轻代设成堆的一半。为什么高并发 Web 应用的典型特征是产生大量临时对象年轻代给足对象能在 Eden 区轻松分配不会过早进入老年代。但要留神年轻代太大老年代就只有 1G如果应用里长期存活对象多老年代照样容易撑满。所以这个比例不是固定的要根据对象分配情况来定。-XX:CMSInitiatingOccupancyFraction70告诉 CMS 当老年代使用率达到 70% 时就开始并发标记清理而不是等到快满了才动手。配合-XX:UseCMSCompactAtFullCollection在 Full GC 后做一次内存碎片压缩可以有效避免老年代碎片化带来的空间充足但分配大对象失败问题。这里有个细节值得展开说CMS 的CMSInitiatingOccupancyFraction如果设置得太低GC 会执行得很频繁浪费 CPU设置得太高比如 90又可能在前一次 CMS 还没完成时老年代就满了触发 Serial Old 兜底的 Full GC那停顿时间就不是 2 秒而是 10 秒往上了。70 是一个比较折中的起点后续观察日志再微调。为了搞清楚调整后的效果我把-XX:PrintHeapAtGC也加上了这样每次 GC 前后都能看到各区域的使用情况。3.4 效果验证与后续微调配置改完后我把 Tomcat 重启观察了一个完整的流量周期包含早高峰和午间高峰。GC 日志显示Full GC 从每 5 分钟一次下降到每 2 小时左右一次GC 单次停顿时间从 2~4 秒降到 600~800 毫秒年轻代 Minor GC 的频率也明显下降因为 Eden 区变大了对象分配空间更充裕。我也发现了一个新问题young GC 每次回收后存活对象进入 Survivor 区的比例偏高。这说明SurvivorRatio默认的 8 可能不够。我调整成-XX:SurvivorRatio4相当于增大了 Survivor 区容量让更多对象能缓一缓再决定是否进入老年代。这个小改动之后老年代的增长速度进一步放缓整个系统平稳了很多。4. 常见问题与排查技巧实录4.1 常见问题速查表根据我调优过程中的经验把常见的 GC 相关问题和排查方向整理成一张表方便大家直接对照现象可能原因排查方向Full GC 频繁但堆空间未满Survivor 区过小对象提前晋升调大 SurvivorRatio观察晋升对象大小Full GC 频繁且老年代满堆总量小 / 对象存活时间长 / 存在大对象分析堆转储定位大对象和缓存逻辑CPU 飙高且伴随大量 Minor GCEden 区过小对象频繁分配失败调大-Xmn或整体堆GC 日志中出现 Concurrent Mode FailureCMS 回收速度跟不上分配速度降低 CMSInitiatingOccupancyFraction或改用 G1系统 CPU 高但 GC 频率正常代码死循环 / 线程竞争用 jstack 抓线程快照分析应用启动后堆持续增长不回落内存泄漏jmap 连续采样、堆转储对比4.2 一个教训不要在生产环境随意抓线程堆栈有一次为了排查某个接口卡住的问题我在生产环境上直接执行了jmap -dump结果 Application 直接 JVM 暂停了很久线上超时一片。后来学乖了堆转储一定要在低峰期做或者用 gcore 先取 core 文件再离线分析。如果你是线上紧急排障优先用jstack看线程状态这个命令相对轻量确认是内存问题再考虑 dump。类似的坑还有一个jstat的 GC 统计值在某些情况下会跳变。不要根据一次采样就下结论多采几次取趋势。我通常用jstat -gcutil PID 1000 10连续观察 10 秒取平均这样更有参考价值。4.3 经验心得调优不是一次性工作JVM 参数调优核心原则是小步快跑一次只改一个变量。为什么因为 JVM 的参数之间往往互相影响比如你同时改堆大小和收集器出了问题就分不清是哪个改动导致的。正确做法是记录基线改一个小参数观察至少 24 小时要覆盖一个完整的业务周期再决定下一步。另外G1 收集器下的参数坑也值得一说。很多人在 JDK 11 下用 G1一上来就设-XX:MaxGCPauseMillis50结果发现 GC 更频繁了。因为 G1 会为了达到这个停顿目标不断调整 Region 回收策略把年轻代缩得很小导致对象晋升压力大增。我一般先设 100~200 毫秒观察实际 GC 频率再逐步收紧。记住停顿时间目标设置得越激进GC 频率就可能越高吞吐量会相应下降。5. 结尾留一个问题监控和日志你配齐了吗调优做到最后我反而觉得技术本身不是最难的难的是你有没有把监控和日志的基础工作做扎实。GC 日志不开、堆监控不看等到线上出故障再临阵磨刀往往要付出更大的代价。我个人实际操作中的体会是每次调优结束在复盘文档里记录清楚改了什么参数、为什么改、改完什么指标变好/变坏这个习惯帮我避免了很多重复踩坑。不同业务场景下的 JVM 参数不存在万能公式但你只要掌握了排查方法和参数背后的原理就能根据实际表现不断逼近最优配置。后续如果你想往深里走还可以关注线程池参数的配套调优比如 Tomcat 的maxThreads与 JVM 栈大小的关系、容器环境下的堆内存自动感知比如容器内存 Limit 与-Xmx的关系等等。总之调优是一条围绕测量与验证不断迭代的路在这个过程中积累的实战经验比参数本身更值钱。