
做Java开发这些年有个很反直觉的现象写业务代码时思路清晰一遇到JVM参数就靠网上抄一段配置贴上完事。问起来为什么这样设置多半答不上来。真正在线上出过事的人都知道JVM参数这玩意儿只会在两种时刻被人想起来——一是应用启动后频繁Full GC导致接口大面积超时二是OOM后服务直接躺平机器上除了重启脚本什么都救不了。今天这篇就把JVM参数这件事从头捋一遍重点说说Tomcat这种Web容器启动时到底该怎么设置各个参数背后的调整逻辑是什么。先说明一下适用人群如果你用的是Spring Boot内嵌容器或者还在维护传统Tomcat部署的WAR包应用再或者只是想搞清楚别人推荐的那行参数到底啥意思这篇文章都值得花几分钟读完。我不会把一份参数列表丢给你完事我尽量说清楚每个参数解决的是什么问题、什么情况下需要动它、动它之后可能产生什么副作用。1. 三个参数家族标准参数、-X参数与-XX参数1.1 三种前缀背后是不同的承诺JVM启动参数按前缀可以分成三类这是理解所有调优动作的第一步。第一类是标准参数以单个-开头比如-version、-classpath、-Dpropertyvalue。这类参数是所有JVM实现都必须支持的由Java语言规范约束行为稳定你换个JDK厂商、升级个JDK版本基本不会有破坏性变化。实际业务里用得最多的是-D它往系统属性里注入配置项Spring读取application.yml、日志框架读取路径占位符背后都依赖它。第二类是非标准参数以-X开头比如-Xms、-Xmx、-Xmn。名字叫非标准但实际上是HotSpot虚拟机事实上的标准因为绝大多数生产环境跑的就是HotSpot。这类参数在不同JDK版本之间可能调整默认值但不至于天天变是日常调优的绝对主力。第三类是高级参数以-XX开头这一类最庞大也最危险垃圾回收器的选型、GC日志行为、内存区域比例、JIT编译行为全在这一层控制。-XX参数又分布尔型和键值型两种写法布尔型用或-表示开启或关闭比如-XX:HeapDumpOnOutOfMemoryError键值型用赋值比如-XX:MaxMetaspaceSize512m。1.2 为什么必须先弄清JDK版本我见过不少线上事故源头就是照抄了一份老博客的JVM参数而对方用的JDK是8u131你的是JDK 11。由于-XX参数很多没有标准化承诺新版本移除旧参数或者调整默认阈值都是常事。举个例子-XX:PermSize和-XX:MaxPermSize在JDK 8里彻底失效因为方法区被元空间Metaspace替代了。你写了-XX:MaxPermSize256mJVM不会报错但这个配置根本不生效真正限制元空间的是-XX:MaxMetaspaceSize。再比如JDK 9引入了模块系统某些命令行参数被移除JDK 11开始默认使用G1垃圾收集器而你还在按JDK 8的默认Parallel GC思路调优整个调优逻辑就全拧了。我的习惯是接到现网问题先跑一句java -version再跑java -XX:PrintFlagsFinal -version | grep -E MaxHeapSize|InitialHeapSize这种命令看看当前环境真实生效的默认值然后再决定动哪些参数。所谓调优不是把别人文档里的参数抄一遍而是基于你当前JVM版本的默认行为做增量调整。2. 内存结构的核心参数堆、栈、元空间怎么分配2.1 堆内存的两个最关键旋钮-Xms和-Xmx分别指定堆内存的初始大小和最大大小这是全网出现频率最高的两个JVM参数但很多人并不明白一件事为什么不建议把这两个值设得不一样JVM在运行过程中如果发现当前堆使用率逼近上限会触发堆扩容。扩容本身不是免费操作它需要重新分配连续的堆内存空间并且在极端情况下可能引发STWStop The World停顿。反过来如果堆内存使用率降低JVM又可能缩容。这种扩容—缩容—再扩容的循环在流量有明显波峰波谷的应用里非常容易出现每次调整都带来额外的性能损耗。所以在给线上应用做配置时我几乎总是把-Xms和-Xmx设置为相同的值。这样做等于告诉JVM这个堆从一开始就是这么大你别折腾了。付出的代价是启动时JVM会一次性向操作系统申请完整大小的堆启动速度稍慢一点——但这在服务端场景下几乎可以忽略。一个4核8G内存的典型应用服务器我通常这样起步操作系统和基础服务预留2G左右Tomcat进程自身各种非堆内存线程栈、JIT编译器、元空间、直接内存预留1G到1.5G剩下的4.5G到5G留给堆。如果应用本身没有明确的内存需求指标我会先用-Xms2g -Xmx2g跑几天再根据GC日志里的堆占用趋势调整而不是一上来就分配4G。2.2 年轻代大小-Xmn是矛也是盾-Xmn控制新生代大小。这个参数选得好不好直接影响Minor GC的频率和对象晋升老年代的节奏。大多数互联网后端应用的存活对象分配呈现朝生夕灭的特征大量业务对象创建后几秒内就不再被引用这些对象如果能及时在新生代被Minor GC回收掉代价极低一旦涌入老年代就必须等Full GC或混合GC才能回收代价高一个数量级。-Xmn设置得太小新生代空间很快塞满Minor GC频繁发生短命对象可能在被回收前就熬过了几次GC顺势晋升到老年代造成老年代快速增长。设置得太大又会挤压老年代空间让存活对象的容身之处变小老年代GC反而更频繁。我见过很多教程说-Xmn设为堆大小的三分之一其实这个经验值只适合串行回收器时代的老皇历。现代G1垃圾收集器本身有自适应调整年轻代大小的机制你非要用-Xmn把年轻代大小钉死反而破坏了G1的弹性。我的建议是默认不显式指定-Xmn把年轻代调整的灵活性交给GC器如果GC日志显示Minor GC频率明显异常再来干预。传统Parallel GC环境下-Xmn还是有一定价值的但也需要根据GC日志持续观察。2.3 元空间和线程栈这些小地方-XX:MaxMetaspaceSize限制元空间最大大小。JDK 8之后类的元数据不再放在堆内而是直接使用本地内存默认情况下上限只受本机物理内存约束。这个设计本意是更好的弹性但副作用是如果应用里有类加载器泄漏或者动态生成代理类的框架配置不当元空间会无限制增长直观表现就是java.lang.OutOfMemoryError: Metaspace而且服务器内存肉眼可见地被吃掉。处理方式很简单显式设置一个合理上限。一般业务应用256m到512m已经绰绰有余除非你的应用动态生成的类非常多。同时配合-XX:MetaspaceSize设置一个期望的初始水位JVM会在元空间使用量超过这个值后考虑触发类卸载。-Xss用来设置每个线程的栈大小。栈太深会抛StackOverflowError但栈太大则白白浪费内存——线程栈用的不是堆内存它直接占用操作系统内存。一个典型的默认值512k到1M如果你的应用没有明显的深递归逻辑没必要往大了调。这里有个容易被忽略的坑-Xss设置得越大单机能够创建的线程数越少因为线程栈内存是直接从进程地址空间里划走的。3. Tomcat启动场景参数到底写在哪里3.1 还没开始调优先把参数放对位置热搜词里的Tomcat启动设置JVM参数十有八九是新手在配置之后发现完全不生效。问题通常不在参数本身而是参数写错了地方。Tomcat有两套环境变量JAVA_OPTS和CATALINA_OPTS。很多教程直接让改JAVA_OPTS这个变量名太通用容易被误认成所有Java程序共用的标准变量并且Tomcat的启动脚本catalina.sh确实会把它拼进最终的Java命令。但更规范的姿势是使用CATALINA_OPTS因为启动脚本会有意区分CATALINA_OPTS只在Tomcat主进程启动时加入JAVA_OPTS则连stop、version等辅助命令也会加载。如果你在JAVA_OPTS里写了类似-Dcom.sun.management.jmxremote这类只希望主进程生效的参数配置管理上容易混乱。具体操作时不建议直接修改catalina.sh这个核心脚本因为升级Tomcat版本时它会被覆盖。推荐的做法是在Tomcat的bin目录下新建一个setenv.sh文件Windows对应setenv.bat直接创建即可。Tomcat的启动脚本会自动检测这个文件并在启动前加载它里面放环境变量赋值export CATALINA_OPTS-Xms2g -Xmx2g -XX:MaxMetaspaceSize512m -XX:HeapDumpOnOutOfMemoryError这样既保留了官方脚本的完整性又让这台机器的Tomcat启动参数有了独立可维护的落点。改完之后启动Tomcat再用jps -l找到Tomcat进程然后用jcmd pid VM.flags确认参数真的生效了。3.2 一组典型的Tomcat生产配置样例下面给出一份我在业务应用里常用的Tomcat启动参数集注意里面加了不少GC参数后面章节会逐一拆解含义export CATALINA_OPTS -Duser.timezoneAsia/Shanghai -Xms2g -Xmx2g -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/tomcat -Xloggc:/data/logs/tomcat/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps 这里-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath组合是防御OOM的黄金搭档——JVM内存溢出时自动生成堆转储文件事后用MAT分析比啥日志都没有强百倍。有人会问-XX:PrintGCDetails不是JDK 9之后被废弃了吗严格说是的JDK 9之后官方建议用-Xlog:gc*统一日志风格但很多中间件维护的老环境中还在用旧参数而且JDK 9到JDK 11这几个版本对旧参数也做了兼容大版本升级到JDK 17之后才开始有明确的告警。我的观点是如果你是全新部署直接用-Xlog:gc*如果还在维护存量JDK 8应用旧参数照用不误重点是把GC日志输出到文件并配置日志轮转。关于日志轮转Tomcat场景里有一个使用简便的专属参数可以考虑-XX:UseGCLogFileRotation配合-XX:NumberOfGCLogFiles5 -XX:GCLogFileSize10M在JDK 8下能够有效防止GC日志无限膨胀。如果你用新版-Xlog语法那就在配置里指定file输出以及filecount和filesize。3.3 堆大小真的不是越大越好Tomcat部署中常见的第一大误区是以为堆越大系统越强。腾讯云、阿里云上买一台16G内存的机器直接把-Xmx拉到12G。结果是什么堆是大了但每次Full GC的STW时间也跟着极其夸张而且在容器环境或者内存超售严重的物理机上大内存JVM很容易触发操作系统级别的内存交换应用表现为偶尔卡顿但CPU占用率又不高。从经验来看堆大小应该根据活跃数据量来定而不是根据物理内存大小来定。一个简单的估算方法先以物理内存的二分之一到三分之二作为初始堆上限跑一周观察GC日志里Full GC的频率和老年代使用率峰值如果能稳定在理想水位且GC频率正常说明当前堆是够的如果堆使用率长期贴着上限再逐步增加。还要提醒一个特别反直觉的坑如果某个Java进程的堆设置了4G实际物理内存占用可能远不止4G。因为除了堆还有元空间、线程栈、代码缓存、直接内存堆外内存这些区域NIO框架Netty、Tomcat的NIO连接器大量使用直接内存这部分由-XX:MaxDirectMemorySize控制默认等于堆上限。一个4G堆的应用实际RSS内存占用到6G是常态服务器内存规划时必须把这笔账算进去。4. 垃圾回收器选型与核心GC参数4.1 默认GC是什么、该不该换JVM参数调优里最绕不开的就是垃圾回收器。JDK 8及以前默认是Parallel Scavenge Parallel Old简称Parallel GCJDK 9及以后默认换成了G1。这个默认值变化本身就说明了一个倾向大堆场景下G1是更稳妥的默认选择。Parallel GC的思路简单粗暴追求最大吞吐量能不停就多撑一会儿一旦停就停个大时间。它在多核多线程和大堆场景下的Full GC停顿经常以秒为单位虽然吞吐量高但延迟无法接受。G1的设计哲学是分区域收集把堆划分为众多Region不再严格区分年轻代和老年代的连续物理空间每次GC只回收一部分Region目标是把停顿时间控制在可预期的范围内。对大多数追求接口响应时间稳定的互联网应用来说G1是更合理的选择。4.2 G1的核心参数怎么理解-XX:MaxGCPauseMillis是G1的软性目标注意是软性——G1会努力让每次GC停顿不超过这个值但它不保证绝对满足。如果你把值设成10G1会过度激进地压缩每次收集的Region数量导致GC频繁且回收效率偏低表现为吞吐量大幅下降甚至出现为了降低停顿而频繁GC反而拖垮系统的怪圈。根据实践一般设置200ms是一个比较稳健的起点。系统允许的情况下可以慢慢往100ms靠每调整一次观察一天GC日志。这个参数本质上是个预期管理你得让JVM的自动调整机制有足够的空间去运作而不是给它一个不可能完成的硬指标。另一个值得关注的是-XX:G1HeapRegionSize。Region是G1的基本单位默认情况下JVM会根据堆大小自动计算目标是把堆划分为2048个Region。特殊情况下比如堆特别大手动指定Region大小可以改善回收粒度和记忆集Remembered Set的开销。但这个参数和-Xmn一样属于知道的越多越容易管不住手的类型绝大多数应用保持自动就对了。4.3 什么时候别动GC参数我见过一些刚入门的同学启动参数里把-XX:UseSerialGC、-XX:UseParallelGC、-XX:UseG1GC全写上期望保险——实际上JVM只认最后一个以最后一次设置为准这种叠加反而制造了混乱排查问题时根本不知道哪个生效了。另外一个原则如果应用本身是低并发、小堆1G以内的批处理脚本默认的GC配置完全够用不要画蛇添足。GC调优的所有动作都应基于GC日志的反馈来进行而不是拍脑袋提前预设一堆参数。所谓调优本质上是一个观察-假设-实验-复盘-再观察的迭代过程上来就抄十行GC参数的人往往连GC日志还没打开过。5. 日志、监控与故障诊断相关参数5.1 GC日志和堆转储救命的现场证据有一次线上OOM事故重启后是黑的。后来发现运维把堆转储配置忽略了应用只记录了简单的异常堆栈。那天的教训是JVM参数里最重要的一行不一定是最炫的GC调优参数而是确保事故发生时有现场可查的数据。-XX:HeapDumpOnOutOfMemoryError配合-XX:HeapDumpPath是必须的。路径要写到独立磁盘或者有足够剩余空间的目录堆Dump文件通常是堆大小的80%到100%一个4G堆的Dump文件就是3G多你把它写在系统盘很可能还没等分析就把磁盘塞满了反而引发二次故障。GC日志同理。日志里能看到每次GC前后的堆占用、停顿时间、各区域动态变化。没有这些数据做任何调优分析都是盲人摸象。统一日志系统是更好的方案-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount5,filesize10M这种写法在JDK 11里一行搞定输出、格式和轮转。5.2 JMX远程监控参数与防火墙的恩怨如果应用部署在服务器上需要远程查看堆内存、线程和GC情况可以开启JMX-Dcom.sun.management.jmxremote、-Dcom.sun.management.jmxremote.port9999、-Dcom.sun.management.jmxremote.authenticatefalse、-Dcom.sun.management.jmxremote.sslfalse。这是一套很常见的配置但有两个注意点。第一authenticatefalse和sslfalse意即完全裸奔任何能访问该端口的人都能拿到JVM的运行时信息甚至能执行一些管理操作。生产环境必须设置认证或者至少通过防火墙白名单限制来源IP。第二JMX服务端口和RMI端口不是一个概念Tomcat场景下经常出现远程端口通但连不上的怪事大概率是RMI动态端口被防火墙挡了。稳妥的做法是额外指定-Djava.rmi.server.hostname外网IP并且用隧道方式访问或者干脆用jstatd配合跳板机。5.3 应用日志里看不到的东西用jstat补上别误会哪怕日志配得再好有些现场状态只有实时命令才能看到。进程出现异常时终端执行jstat -gcutil pid 1000输出里的FGC列显示Full GC次数FGCT显示累计停顿时间O列显示老年代使用百分比。如果FGC数字在飞速增长且O一直在95%以上下不来不用等OOM报错你现在就能判断老年代已经告急可以着手分析堆Dump或者考虑扩大堆内存。jmap -dump:formatb,file/data/logs/heap.hprof pid可以主动触发堆转储。注意这个动作本身会造成JVM停顿在流量高峰期谨慎使用。6. 常见问题与排查技巧实录6.1 参数没生效是哪儿出了问题症状明明在setenv.sh里写了-Xmx4g启动后一看还是默认大小。排查顺序先确认Tomcat到底加载了哪个配置文件用ps -ef | grep catalina看完整命令行很多服务器上安装的Tomcat是通过systemd服务启动的Setenv配置路径可能跟手工启动不同再确认机器上是否配了全局的JAVA_TOOL_OPTIONS环境变量这个变量的优先级很高会先于命令行参数注入某些基础组件比如监控Agent为了给所有Java进程注参会在系统环境变量里设JAVA_TOOL_OPTIONS它能覆盖你在启动脚本里的设置。这不是玄学我就在生产环境踩过运维部署了一套开源的Java监控Agent它通过JAVA_TOOL_OPTIONS塞了固定堆参数所有Tomcat的catalina.sh配置全部失效排查了整整半天。6.2 OOM类型不同排查方向完全不同遇到java.lang.OutOfMemoryError先看冒号后面的具体提示Java heap space堆内存耗尽。优先用MAT或JVisualVM分析Heap Dump定位是内存泄漏还是内存峰值过大。如果确实是内存不够增加-Xmx是合理的但增加之前务必先确认有没有泄露否则堆加到多大都不够填。Metaspace元空间溢出。优先检查自定义类加载器是否频繁创建类、反射代理类数量必要时提高-XX:MaxMetaspaceSize但更重要的是修复类加载层面的问题。unable to create new native thread操作系统线程数打满或进程内存不足。检查ulimit -u限制、线程栈大小-Xss、以及是否有线程泄漏。这个错误跟堆参数没有直接关系无脑加堆反而更糟。Direct buffer memory堆外直接内存耗尽NIO框架使用不当是头号嫌疑。6.3 Full GC频繁但堆内存还够怎么分析有一种情况很迷惑-Xmx设了4G老年代用量一直不到1G但Full GC每隔几分钟就发生一次。这时候不要急着调堆大小而是打开GC日志仔细看是不是有System.gc()被显式调用了。JDK的-XX:DisableExplicitGC可以禁止显式GC调用但配置它之前要想清楚会不会影响依赖System.gc()的组件比如某些RMI框架和堆外缓存框架需要它来触发周期性清理。还有一种可能是堆外部压力导致的如果物理内存本身就紧张操作系统可能主动触发内存回收间接导致JVM性能骤降。这种情况服务器监控能看到明显的swap使用增长从JVM参数角度无解只能减少部署在同一台机器上的应用数量或者给机器加内存。6.4 调优的节奏感小步快跑胜过翻天覆地基于这些年的经验JVM参数调优最忌讳一步到位。一次性把堆大小、GC器、Region大小、日志策略全改一遍如果性能变差了你根本不知道是哪项改动引发的如果性能变好你也不知道有没有潜力更大的方向被你乱改掩盖了。我的实操节奏是先从监控和GC日志暴露的明确问题出发一次只改一个参数。比如发现Minor GC太频繁就先观察新生代占用曲线再小幅调整-Xmn或者-XX:MaxGCPauseMillis每次调整间隔至少一天用数据说话不靠感觉。我个人在实际操作中还有个习惯性动作每次调参之前在启动脚本里顺手记一行注释写清楚这次改了什么、目的是什么、预期观察哪些指标。上线一个月后再看一眼很多当时想不通的GC行为回头看都因为记录还在而变得有迹可循。所谓JVM参数总结说到底总结的不仅是参数本身而是那一整套观察、假设、验证的方法。