ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JVM参数全解析:内存配置、GC选型与Tomcat部署实战

JVM参数全解析:内存配置、GC选型与Tomcat部署实战 JVM参数表面上看起来就是命令行里一堆以-开头的字符串但真正用起来很多人容易混淆。经常有朋友在部署Tomcat时问我“启动脚本里的JVM参数到底该写在哪为什么我改了没生效-Xms和-XX有什么区别”这些问题几乎每个Java后端都遇到过。这篇文章就围绕JVM参数做一次系统总结从参数分类、内存配置、GC选型到Tomcat启动时如何正确设置JVM参数、如何验证生效把我踩过的坑和可复现的配置一并写清楚。适合刚接触JVM调优的新人也适合部署过多个Java服务、想系统梳理参数体系的开发者。1. JVM参数的三大家族标准参数、-X参数与-XX参数1.1 三类参数的区别与适用范围JVM参数从Java官方文档和HotSpot VM的实现来看可以分为三大类标准参数、非标准参数以-X开头和开发者参数以-XX开头。标准参数是所有JVM实现都必须支持的比如-version、-cp、-Dpropertyvalue这些参数在不同JDK版本之间相对稳定主要用于控制Java运行时本身的基础行为。非标准参数以-X开头是HotSpot VM特有的比如-Xms、-Xmx、-Xmn。虽然名字里带“非标准”但在实际生产环境里几乎就是事实标准因为内存配置绕不开它们。开发者参数以-XX开头是粒度最细、数量最多也最需要谨慎使用的一类比如-XX:UseG1GC、-XX:MaxMetaspaceSize它们负责控制JVM内部的各种开关和阈值。把这三类分清楚最大的价值在于定位问题和规避风险。标准参数在任何环境上行为都一致但-X和-XX参数则依赖具体虚拟机实现和JDK版本。同一个-XX参数在JDK8和JDK11里的默认值可能完全不同有些参数到了新版本甚至被直接移除、变成无效参数。所以在看网上流传的“最佳参数配置”时第一件事就是确认对方用的JDK版本和场景而不是直接复制。1.2 怎么快速判断一个参数属于哪一类这里分享一个我常用的快速判断方法看参数的名字和前缀。以-开头、后面直接跟完整单词的大部分是标准参数比如-D、-classpath以-X开头的通常是内存和基础行为相关的非标准参数以-XX开头的则是高级调优参数。不过也有特例比如-XX参数有些用布尔开关-XX:UseG1GC表示开启-XX:-UseG1GC表示关闭有些用键值对-XX:MaxHeapSize2g这些写法需要单独记忆后面我会专门展开。提示如果你拿不准某个参数在当前JDK版本是否支持直接执行java -XX:PrintFlagsFinal -version能打印出所有可用的-XX参数及默认值。这是排查参数是否生效最底层的手段没有之一。实际工作中我看到不少人喜欢把标准参数和-XX参数混写在同一行这没问题但要注意顺序。Java启动时对参数顺序并不敏感真正敏感的是-jar、-cp这类触发主类的参数位置。比如java -jar -Xms512m app.jar和java -Xms512m -jar app.jar效果一样但如果写成java -jar app.jar -Xms512m从命令行语义上参数依然会被JVM捕获不过为了可读性还是建议统一把JVM参数放在-jar之前并且按“标准参数→-X参数→-XX参数→应用参数”排序这样团队review起来也省事。2. 内存参数是JVM调优的基石2.1 堆内存参数-Xms、-Xmx、-Xmn如何搭配堆内存是Java对象的主要栖身之所所以JVM参数里最常被调整的就是堆大小。-Xms指定堆初始大小-Xmx指定堆最大大小-Xmn在JDK8及以前用来指定新生代大小JDK8之后使用-XX:NewSize和-XX:MaxNewSize控制更灵活但-Xmn仍然被支持并广泛使用。日常我推荐把-Xms和-Xmx设为相同的值避免堆在运行中动态伸缩带来的性能抖动。JVM在扩容和缩容时会触发堆的重新分配、对象移动以及STW停顿对于在线业务来说这种抖动虽然不算致命但完全可以通过参数规避。具体数值怎么定没有万能答案但有个底线先保证应用所需的最小堆再看机器内存和是否与其他进程共存。一般建议初始堆不超过物理内存的二分之一如果机器上还跑着MySQL、Redis这类内存大户堆占比更要保守留出足够的系统缓存和操作系统的page cache。生产环境里我遇到过把-Xmx直接设成机器内存80%的案例结果一启动系统就频繁swap接口延迟从20ms飙升到2秒后来降到60%才算稳住。-Xmn的设置需要结合对象生命周期来看。如果应用有大量短命对象新生代可以适当调大减少对象过早晋升到老年代如果应用对象普遍长命新生代太大会浪费空间。一个比较实用的参考是新生代占堆大小的三分之一到四分之一具体通过-Xmn或-XX:NewRatio都可以调整。用-XX:NewRatio3的意思就是老年代新生代3:1也就是新生代占堆的1/4。两种方式我建议用-Xmn直接指定绝对值因为数值更直观也方便和监控平台的数据对照。2.2 元空间与直接内存-XX:MetaspaceSize、-XX:MaxDirectMemorySizeJDK8之后方法区被元空间Metaspace取代永久代的-XX:PermSize和-XX:MaxPermSize随之失效。元空间默认使用本地内存理论上只受机器物理内存限制但实际操作中如果不设置-XX:MaxMetaspaceSize一旦应用因为动态生成类比如反射代理、CGLIB、热部署而无限加载类就可能直接把机器内存吃满最后连操作系统都被拖垮。所以我强烈建议设置-XX:MetaspaceSize和-XX:MaxMetaspaceSize前者是触发元空间GC的初始阈值后者是硬上限。一般-XX:MetaspaceSize设成256m-XX:MaxMetaspaceSize设成512m或1g起步具体要看类加载规模。直接内存Direct Memory是被ByteBuffer.allocateDirect等API使用的堆外内存默认最大等于-Xmx也就是说如果你不去管它它的上限和堆一样大。很多NIO框架Netty、gRPC都会使用堆外内存做缓冲区如果不显式设置-XX:MaxDirectMemorySize堆外内存可能占用过高叠加堆内存后造成总内存超限。常见的坑是设置了-Xmx2g以为机器8g内存足够结果Netty默认给每个连接分配缓冲堆外内存涨到4g再加上堆2g、元空间0.5g直接接近甚至超过8g。建议堆外内存上限根据实际连接数评估通常设为256m到1g之间并配合操作系统层的监控观察Resident Set SizeRSS而不是只看堆。2.3 一个从崩溃到稳定的内存配置案例之前有个同事负责的网关服务部署在4C8G的机器上JDK8原来启动脚本里只有-Xms256m -Xmx256m流量一上来就频繁Full GC最后直接OOM。接手后我做了三件事第一把-Xms和-Xmx统一定为4g先给足堆空间第二设置-XX:MaxMetaspaceSize512m避免类加载膨胀第三加-XX:AlwaysPreTouch让JVM启动时就把物理内存预定好避免运行时申请内存带来的停顿和swap风险。重启之后Full GC从原来的每分钟好几次降到几小时一次接口P99从800ms降到150ms。-XX:AlwaysPreTouch这个小参数很多人忽略它的作用是在JVM启动时将所有堆内存页都touch一遍让操作系统提前分配物理内存。代价是启动变慢但换来的是运行期间堆内存访问速度稳定适合堆比较大、又追求低延迟的场景。如果机器内存不宽裕这个参数反而可能因为启动时就要扛下全部堆内存而失败所以用了-Xmx4g且物理内存8g时是安全的但换成4g物理内存就要谨慎。3. GC参数选型与调优实战3.1 从Serial到ZGC怎么给应用选垃圾收集器垃圾收集器直接决定应用在内存管理上的停顿时间选错收集器调多少参数都白搭。JDK8默认的Parallel Scavenge Parallel Old组合吞吐优先适合后台任务、离线计算这类不敏感延迟的场景。CMS曾经是低延迟应用的主流但它在并发标记阶段存在内存碎片和“Concurrent Mode Failure”问题JDK9之后被废弃JDK14被移除。现在生产环境的主流是G1JDK9之后G1成为默认收集器它把堆划分为Region通过可预测的停顿时间模型兼顾吞吐量和延迟单个堆大小在6g到几十g都能表现良好。ZGC和Shenandoah是JDK11之后出现的超低延迟收集器目标把停顿时间控制在几毫秒甚至更低适合超大堆和极苛刻的低延迟场景。给应用选收集器我的思路很简单优先看JDK版本和业务对停顿的敏感度。JDK8且业务要求平均停顿小于100ms可以选择G1如果只是内部工具或批处理Parallel就够用。JDK11以上需要极低停顿就考虑ZGC。不要为了追新而强行用ZGCZGC对内存和CPU的额外开销不小在低于4核的机器上反而可能拖慢吞吐。3.2 常见GC参数的逻辑与使用注意G1最常用的参数包括-XX:UseG1GC、-XX:MaxGCPauseMillis、-XX:G1HeapRegionSize、-XX:InitiatingHeapOccupancyPercent简写IHOP。-XX:MaxGCPauseMillis用来指定期望的最大GC停顿时间G1会根据这个目标动态调整新生代大小和回收策略但要注意这只是一个软目标不是硬保证。-XX:InitiatingHeapOccupancyPercent控制G1启动并发GC周期的堆占用阈值默认是45如果堆内存紧张、Mixed GC回收不过来可以适当降低到30~35提前开始标记避免后期出现Full GC。使用-XX:G1HeapRegionSize时需要注意Region大小必须是2的幂次方范围从1MB到32MB。JVM默认会根据堆大小自动计算一般不需要手动设置。如果你强行设得很小Region数量变多RSetRemembered Set维护成本上升设得太大又可能导致大对象分配失败。我通常只在堆特别大、默认Region不合适时才干预。关于GC参数的第二个常见误区是“看网上说添加-XX:UseConcMarkSweepGC结果启动报错”。这是因为JDK9之后CMS已废弃JDK14后移除直接使用会报Unrecognized VM option或类似错误。所以网上教程里的GC参数一定要对照自己的JDK版本筛选。3.3 一次Full GC频繁的排查实录有一次线上服务出现大面积超时看一眼GC日志发现Full GC每秒一次老年代内存回收后仍然不下降。第一反应是内存泄漏但接下来并没有急着加内存而是dump了一份堆快照。用MAT打开后发现一个静态HashMap里存放了大量带时间戳的缓存对象原来是一个定时任务忘了清理过期条目清理线程又被异常中断日积月累就撑爆了老年代。处理办法很简单修复定时任务的异常处理并在参数上增加-XX:UseG1GC和合适的-XX:MaxGCPauseMillis让GC停顿更平滑。这个案例想说明的是JVM参数能缓解症状但不能根治代码层面的问题。遇到内存异常先分析应用本身的资源占用和代码逻辑再反推参数是否合理。很多“调优”最后都变成了“调参背锅”根源就是没有准备足够的运行证据。4. 日常诊断与日志参数遇到问题得先有证据4.1 必备的GC日志参数组合没有GC日志谈JVM调优就是盲人摸象。我最常用的一组参数是-Xloggc:/path/to/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintTenuringDistribution这组配置在JDK8下能记录GC前后的堆变化、耗时、对象年龄分布等关键信息。JDK9之后统一日志系统取代了老式的-XX:PrintGCDetails写法变成-Xlog:gc*:file/path/to/gc.log这个变化让很多从JDK8迁移的人一时不适应但其实是更强大的日志框架支持按标签和级别过滤。迁移时注意旧参数在JDK11中会报警告但不一定阻止启动最好直接改成新写法。GC日志的滚动策略同样重要。默认不滚动的情况下一个长期运行的服务日志文件会无限增长直到占满磁盘。建议在JDK8的话配合-XX:UseGCLogFileRotation和-XX:NumberOfGCLogFiles10、-XX:GCLogFileSize100MJDK9用-Xlog:gc*:file/path/gc.log:time,uptime,level:filecount10,filesize100M。这样GC日志既保留历史又不会撑爆磁盘。4.2 OOM时自动dump-XX:HeapDumpOnOutOfMemoryError生产环境最怕的就是OOM而且OOM之后现场被重启清理什么都看不到。-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath/path/to/dump.hprof是救命参数。当JVM因为堆内存耗尽抛出OutOfMemoryError时会自动导出当前堆快照到指定路径方便事后分析。注意HeapDumpPath指定的目录必须有足够空间和写权限最好单独放在数据盘避免和系统盘混用。如果担心dump过程本身造成长时间停顿可以采用-XX:CrashOnOutOfMemoryError让进程直接崩溃并输出core dump但一般场景下HeapDumpOnOutOfMemoryError足够。有朋友问堆dump文件太大动辄几个GB怎么传回本地分析我的做法是在服务器上先用jmap -dump:formatb,filexxx.hprof pid手动导出一份如果进程还能存活再压缩后用scp或对象存储转储本地用MAT的Keep Unreachable Objects选项导入避免文件过于臃肿。这个技巧在排查慢内存增长时特别有用。4.3 用JVM参数配合Arthas等工具做排查除了日志Arthas是排查线上JVM问题的利器。启动Arthas后可以用dashboard命令实时查看内存、线程、GC情况也能用jvm命令查看JVM已生效的参数值。这里想强调一个细节Arthas里看到的参数值是JVM实际生效值而不是启动参数里的原始值。两者可能出现差异例如某个参数被JVM根据自己的规则修正过或因为版本不支持被忽略。如果你想验证某个配置是否真的生效用Arthas的jvm命令对比启动脚本和运行时的值会非常直观。再比如要在不重启的情况下用Arthas触发一次类加载统计、查看类加载器数量帮助判断元空间压力是否来自类加载泄漏。配合-XX:PrintClassHistogram等参数可以在发生问题时通过jcmd快速生成类直方图。总之JVM参数只是外围的“红绿灯”真正的路上状况需要工具去观察。5. Tomcat启动设置JVM参数的正确姿势5.1 不同启动方式下的参数配置位置很多人学Tomcat启动JVM参数时第一反应是改catalina.sh里的JAVA_OPTS这是默认做法但并不是唯一做法。Tomcat提供了多种启动方式包括脚本启动、通过service/daemon方式启动、在IDE里启动、以及把Tomcat嵌入Spring Boot应用中启动不同方式下设置JVM参数的位置完全不同。先理清方式再动文件能省很多排查时间。对于传统的standalone TomcatLinux环境通常修改$CATALINA_HOME/bin/catalina.sh或setenv.sh。catalina.sh是核心启动脚本但官方推荐在setenv.sh中设置环境变量因为它更清晰也能避免升级Tomcat时把修改覆盖掉。Windows对应的是catalina.bat或setenv.bat。如果是通过systemd管理Tomcat则需要在service文件里指定EnvironmentJAVA_OPTS...。如果是在Spring Boot内嵌Tomcat那JVM参数就是在启动Java进程的命令行里设置和Tomcat脚本没有关系。所谓“tomcat启动设置jvm参数”这个问题首先要问的是你到底用的是哪种Tomcat启动形态下面重点讲最常见、也最容易出错的standalone Tomcat脚本方式。5.2 catalina.sh里JAVA_OPTS的写法要点在Linux下打开catalina.sh你会看到很多JAVA_OPTS相关的段落但最安全的方式是新建setenv.sh因为Tomcat启动时会自动加载$CATALINA_HOME/bin/setenv.sh并把其中定义的JAVA_OPTS、CATALINA_OPTS等环境变量传给JVM。以JDK8为例一个常见的组合是#!/bin/sh export JAVA_OPTS-Xms1g -Xmx1g -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseG1GC -XX:MaxGCPauseMillis200 -Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof设置完成后需要给setenv.sh执行权限并确认文件以JAVA_OPTS或export JAVA_OPTS...的格式书写注意多参数之间用空格分隔每个参数内部不要乱加引号。一个典型错误是参数里带有中文引号或反斜杠直接导致JVM启动失败。另外CATALINA_OPTS和JAVA_OPTS的区别也值得一说CATALINA_OPTS只对Tomcat主进程生效适合放Tomcat特有的配置JAVA_OPTS则会被所有Java进程读取如果你在同一个机器上通过一个公共脚本加载了JAVA_OPTS可能会导致其他Java应用也受到同样配置影响。所以在Tomcat里我一般推荐优先使用CATALINA_OPTS来放JVM参数尤其是堆和GC相关配置避免环境变量污染。这里还要补充一个细节即使你修改了setenv.sh如果之前Tomcat启动时加载的是另一个位置的环境变量文件比如/etc/profile里定义了JAVA_OPTS脚本的后加载可能会覆盖或追加变量造成“改了不生效”的假象。建议在启动脚本里直接用echo $JAVA_OPTS打印出来确认再启动。5.3 Windows环境catalina.bat的注意事项Windows下的Tomcat通常以catalina.bat或startup.bat启动对应环境变量在setenv.bat中配置。写法与Linux不同用的是set JAVA_OPTS-Xms1g -Xmx1g ...不需要export关键字。一个常见的坑是如果一行内容太长且换行时没有使用^转义批处理会把它拆成多行导致参数被截断。所以写setenv.bat时要注意参数尽量写在一行或者用^正确换行。在Windows服务化部署通过Tomcat Service场景下使用tomcat8w.exe图形工具或service.bat命令设置JVM参数会更可靠。直接修改注册表里的-D键也行但操作风险高不建议手动改。通过tomcat8w.exe的“Java”选项卡可以设置Initial memory pool、Maximum memory pool和JVM options这些信息最终会写入Windows服务配置。另外Windows服务用的JVM参数与启动脚本没有直接关系所以如果改了catalina.bat但服务没重启参数当然不生效。5.4 验证参数是否生效的三种方法改完Tomcat JVM参数后怎么确认真的生效我常用的方法有三种。第一种启动前在setenv.sh里添加echo JAVA_OPTS$JAVA_OPTS启动Tomcat后通过catalina.out日志看实际输出。第二种启动后执行jps -l找到Tomcat主进程的PID再用jinfo -flags pid查看JVM启动参数能够看到所有生效的-X和-XX参数。第三种通过jcmd pid VM.flags打印运行时标志或者直接观察GC日志是否出现在指定路径。注意使用jinfo或jcmd时进程的JVM必须和你运行的Java版本匹配否则可能报Unable to attach to process之类错误。如果本机有多个JDK版本务必切换到启动Tomcat的那个版本再执行。验证参数生效的最好时间点是启动后立刻做因为有些参数在运行过程中可以被动态修改有些则只能启动时设定一次。对于-Xms这类启动参数一旦进程运行就无法通过工具修改如果发现值不对只能重启才能纠正。6. 常见问题与避坑清单6.1 参数设置不生效的几大原因在生产环境里JVM参数配置了但没生效的原因我总结过大概四类。第一环境变量被覆盖或未加载例如Tomcat的JAVA_OPTS在setenv.sh和catalina.sh之间被后者重新赋值导致先前设置被覆盖。第二JDK版本不支持某些参数比如在JDK8上使用-XX:UseZGC直接启动失败而在JDK11上设置-XX:PermSize会被忽略。第三启动脚本启动的不是你修改的那个JVM比如通过systemd启动时实际读取的是/etc/systemd/system/tomcat.service里的EnvironmentJAVA_OPTS你改脚本当然无效。第四参数名写错或写法不规范例如布尔参数忘记加或-键值参数没有用等号JVM通常会在启动时报错提示但也可能因为拼错而被当作未知参数忽略如果日志里出现Unrecognized VM option字样就要立即修正。6.2 别乱调的参数及踩坑记录JVM参数里确实有一些“高危区”不是生产环境不要轻易动。比如-XX:SurvivorRatio调得太小可能导致对象在Survivor区装不下直接被提升到老年代反而加剧Full GC。-XX:MaxTenuringThreshold如果设得太大对象长期在Survivor区来回复制造成不必要的复制开销设得太小对象又容易被过早提升到老年代。-XX:NewSize和-XX:MaxNewSize如果不小心把新生代上限设得比-Xmx还大JVM启动时会直接报错。此外-XX:MaxDirectMemorySize如果设得低于实际需要NIO线程会频繁抛出OutOfMemoryError: Direct buffer memory但这个OOM不触发堆dump排查起来特别迷惑。我踩过最深的一个坑是在JDK8下使用-XX:UseConcMarkSweepGC并配合-XX:CMSInitiatingOccupancyFraction70本意是让CMS在老年代占用70%时启动并发回收结果老年代内存碎片化严重频繁出现Concurrent Mode Failure转成Full GC。后来换到G1并依赖-XX:MaxGCPauseMillis100来主动控制停顿才把问题压下去。所以在高版本JDK上尽量不要使用CMS相关参数不仅兼容性堪忧调优手法也和G1完全不同。6.3 JVM参数排查速查表下面这张表是我平时排查问题的速查手册分享出来供参考排查场景推荐参数或操作高频注意点堆内存不足频繁Full GC-Xms等于-Xmx-XX:UseG1GC-XX:MaxGCPauseMillis200先dump堆再调参别盲目加内存元空间膨胀-XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m关注类加载器数量和动态代理类OOM后自动导出堆快照-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dumpdump目录要独立盘且空间充足GC日志记录与滚动JDK8-Xloggc:/data/gc.log -XX:PrintGCDetails ... -XX:UseGCLogFileRotationJDK9-Xlog:gc*:file/data/gc.log:time,uptime,level:filecount10,filesize100M确认日志目录磁盘水位Tomcat启动验证jps -ljinfo -flags pid使用与JVM一致的JDK版本执行工具进程意外退出查看/var/log/messages或dmesg确认是否被OOM Killer杀掉关注物理内存和堆外内存总量这张表并不能覆盖所有场景但能帮你在紧急时刻快速找到方向。JVM参数调优没有银弹参数只是工具真正决定系统稳定的是应用自身的资源管理、代码质量和监控体系。把参数理解透配合dump、日志和工具去定位问题才是值得长期培养的能力。最后再分享一个小经验每次调整JVM参数前先在测试环境跑一轮压测记录调整前后的GC频率、暂停时间、接口延迟和吞吐量再决定是否上线。我见过太多人把生产环境当成试验场改一个参数就重启一次最后连哪个参数起了作用都说不清楚。这不是严谨的做法。如果你把参数变化和性能数据一起记录在一个变更表里几次之后你的JVM调优就不再是玄学而是有据可循的工程实践。
返回列表