拓冰建站拓冰建站
首页 / 资讯中心 / 正文

JVM参数全解析:从内存模型到GC调优的生产配置指南

1. JVM参数能不能全交给默认值先说结论默认值本身是用来“能跑”的不是用来“跑得好”的。我见过太多线上事故起因就是开发觉得“JVM参数不用配默认就行”。这话在本地写个hello world没毛病一旦上了生产环境默认值就成了定时炸弹。为什么这么说JVM的很多默认参数是动态计算出来的它根据你机器的物理内存、CPU核数、JDK版本甚至是操作系统类型来推断。同一个jar包部署在8G内存的笔记本上是一个表现部署在32G内存的服务器上完全是另一个表现。最典型的就是-Xmx如果没显式配置JVM默认取物理内存的1/4。你服务器上跑着三四个Java进程每个都自动吃掉物理内存的25%几个进程叠加机器直接就OOM了——这压根不需要什么高并发进程多了就够你喝一壶。还有个更隐蔽的问题默认值会随JDK版本悄悄变化。比如JDK 8默认垃圾回收器是ParallelGCJDK 9开始换成G1JDK 14进一步增强了G1。你什么都没改升个JDK版本垃圾回收行为完全变了性能曲线全乱。如果你在生产环境里做了显式配置至少升级JDK后行为是可预期的不至于“玄学变慢”。换句话说显式配置JVM参数的核心目的就三个可控、可预期、可排查。可控是说资源的分配你说了算可预期是说不管部署到哪台机器行为一致可排查是说出了问题有log、有dump、有现场。这三条都依赖你主动去配置关键参数而不是把命运交给JVM的启发式算法。所以这篇文章我就把那些“应该显式配置”的JVM参数好好捋一遍。不堆参数重点讲清楚每个参数的原理、适用场景、配置建议和踩坑记录。顺序上会覆盖内存模型、垃圾回收、诊断排障、部署运维这几个维度最后给一个可以直接抄的生产配置模板。2. 内存模型参数这些不配出事是迟早的关于JVM内存网上经常把“JVM内存模型”和“Java内存模型JMM”混着说这是两回事。JMM是规范讨论的是多线程可见性那一套而我们调优时说的内存参数指的是运行时数据区——堆、栈、元空间、直接内存这块。2.1 堆内存的“下限”和“上限”-Xms 与 -Xmx这是JVM参数里最基础的没有之一。-Xms是堆内存初始值-Xmx是堆内存最大值。为什么必须显式配置先说-Xmx不配的后果。前面提过JVM取物理内存的1/4作为默认最大堆这个在容器环境里尤其致命。Docker容器限制的是容器内存但JVM默认看的是宿主机的物理内存——你可能开了个-Xmx以为单位是容器内存实际JVM拿宿主机内存算的直接超配额被杀掉。虽然JDK 8u191有了UseContainerSupport会好一点但只显式配-Xmx依旧比裸奔安全得多。再说-Xms。JVM启动时不会立即占满最大堆而是一边用一边扩容。扩容和缩容这个过程是有开销的涉及堆内存重新分配和GC暂停。高并发应用如果Xms设得很小、Xmx设得很大业务流量一上来堆疯狂扩容响应时间会明显抖动。我的建议是**-Xms和-Xmx设置成相同值**一启动就把堆分配到位省掉扩容开销。配置上有两点要注意两个值相等会让JVM启动稍慢一些因为初始化就要分配大块内存但这个代价在长时间运行的服务面前微不足道。堆内存大小不要拍脑袋定。经验上留出整个进程内存的50%到60%给堆剩下给元空间、线程栈、直接内存和JIT编译器。你总内存8G堆建议给4G到5G左右而不是随手填个6G、7G。2.2 新生代参数-Xmn 与 -XX:NewRatio新生代大小直接决定了对象分配和Minor GC的频率。它是个“过小频繁GC、过大浪费空间”的典型参数。-Xmn是直接指定新生代大小比较简单粗暴。-XX:NewRatio是设置老年代和新生代的比值默认是2意思是老年代:新生代 2:1新生代占堆的1/3。重点说下选多大合适。如果服务对象大多朝生夕死比如请求处理产生的大量临时对象新生代可以稍大些占到堆的1/3到1/2。如果服务里长期存活的大对象多缓存、连接池新生代可以小一些。优化目标是让Minor GC后晋升到老年代的对象尽量少而不是把新生代无限调大——新生代越大Minor GC单次停顿时间越长反过来拖累性能。我个人在G1垃圾回收器下不太建议显式设置-Xmn和NewRatio。G1和ParallelGC不一样它自己会动态调整年轻代大小来适配目标停顿时间你硬设了反而绑住它的手脚。但如果你用的是ParallelGC或者CMS这两个参数还是值得一配的。这里顺带提一句网上很多文章把G1和-Xmn混着讲实操时是要分开看的。能接受吗能。但先确认你的垃圾回收器型号再决定要不要设置新生代比例。不同垃圾回收器对新生代参数的处理差异很大这是踩坑第一步。2.3 线程栈大小-Xss-Xss指定每个线程的栈大小默认值因平台而异Linux x64下通常是1MB。很多人不关心这个直到业务用了递归、或者在栈里塞了超大本地变量然后莫名其妙StackOverflowError。这个参数的核心权衡是栈越大单线程能支持的调用深度越大但线程栈是从进程内存里划出去的线程一多栈太大会导致总内存暴涨。比如一个进程开500个线程每个线程栈1MB光栈就占500MB。如果缩减到512KB就是250MB省一半。经验上看普通业务服务无深递归、无复杂调用链-Xss512k绰绰有余。需要递归或者方法调用层次深的场景给到1MB甚至2MB。不要无脑调大尤其你开了很多线程池线程的时候。这里有个排查技巧如果线上高频出现StackOverflowError先看是不是递归逻辑写bug了别急着调栈大小。我见过有人为了“解决”栈溢出把-Xss调到8MB结果溢出依旧——根本就是递归没写退出条件。栈溢出先查代码再动参数。2.4 元空间上限-XX:MaxMetaspaceSizeJDK 8把永久代PermGen换成了元空间Metaspace一个重大变化是默认情况下元空间没有上限它用多少就去拿多少本地内存。这在类加载特别多的场景热部署、动态生成类、多应用打包在一起下可能吃掉大量本地内存最终导致进程被操作系统杀掉。为什么默认不设上限因为元空间用的是本地内存JVM设计者觉得反正内存很大没必要限制。但生产环境从来不是单进程的宿主机上还有其他进程本地内存被某个JVM吃光了大家一起遭殃。所以建议显式设置-XX:MaxMetaspaceSize。值设多少保守点1GB规模大的服务可以给2GB。太小的话运行中动态生成类多的会频繁触发Full GC甚至OOM。另外如果你用了CGLib、ASM、反射代理这种动态生成类库元空间要多留一些余量。和元空间相关的还有-XX:MetaspaceSize这个参数是触发类卸载的阈值不是初始大小别理解错了。它默认约20MB但这个值只影响元空间GC触发的频率一般不用动。2.5 忽略一个容易爆掉的区直接内存与代码缓存这两个参数经常被遗忘但出事往往就在它们身上。-XX:MaxDirectMemorySize控制堆外直接内存DirectByteBuffer的上限默认等于堆大小上限。NIO、Netty用的就是这个玩意儿。不设的话堆外内存能占用多少完全靠JVM内部算很容易出现“堆看起来还健康容器内存已经没了”的情况。建议显式设置一般给堆内存的25%到50%关键看你的Netty/Dubbo连接数和IO量。-XX:ReservedCodeCacheSizeJIT编译后的本地代码缓存。默认约240MBJDK 8开始如果你的应用用了C2编译器并且方法非常多代码缓存满了JIT编译器会自动关闭继续编译性能断崖式下跌——这个坑特别隐蔽。大型微服务建议设置到512MB。内存这块总结成一句话堆要固定、栈要克制、元空间要有上限、直接内存和代码缓存要心里有数。这四个维度覆盖了JVM进程内存的全部出口任何一块失控都不是“靠参数默认值”能兜住的。3. 垃圾回收器与GC日志性能的关键开关垃圾回收是JVM里最容易被面试官问、也最容易在实际调优中出错的环节。如果没有显式指定GC算法你的应用可能在不同JDK版本里用着完全不同的回收策略生产环境一旦出现问题根本没法稳定复现。3.1 显式指定GC器性能的“定海神针”前面说过JDK 8默认ParallelGCJDK 9默认G1。问题在于很多人并没有意识到“升版本GC行为会变”这回事。我建议所有生产环境都要显式指定追求吞吐量、批处理、后台计算任务选-XX:UseParallelGC。追求低延迟、响应时间优先的在线业务选-XX:UseG1GC。如果还在用JDK 8但用的是CMS-XX:UseConcMarkSweepGC尽量早点迁移——JDK 9以后CMS被废弃JDK 14直接移除了留着它等于给自己埋雷。用G1的话有几个参数也值得显式配置-XX:MaxGCPauseMillis目标停顿时间默认200ms。不要为了追求极端低延迟把这个值调得过小比如10msG1为了达到这个目标会疯狂调小年轻代导致GC频繁且对象晋升到老年代变多。一般设置在100ms到200ms是务实的。-XX:ParallelGCThreads并行GC线程数默认和CPU核数相关。如果是容器环境又没显式设置UseContainerSupport这个值可能按宿主机核数算导致线程数爆炸。可以配合-XX:ActiveProcessorCount一起控制。-XX:ConcGCThreads并发标记线程数一般建议是ParallelGCThreads的1/4左右。默认值在大部分场景下够用但如果你的对象图特别大可以适当调大。3.2 GC日志的正确“打开方式”GC日志是非常关键的排障数据能告诉你什么时候Full GC、停顿多久、晋升多少对象。不同JDK版本的GC日志参数差异特别大这也是很多人升级之后发现日志出不来的原因JDK 8及以下-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/to/gc.logJDK 9及以上统一用-Xlog:gc*:file/path/to/gc.log这种格式旧的PrintGCDetails已经没用了。还有几个好用的日志级别的参数-Xlog:gc*:file/opt/logs/gc.log:time,uptime,level,tags输出GC日志并附带时间和JVM运行时长排查问题时能快速对齐时间线。-XX:HeapDumpOnOutOfMemoryError配合-XX:HeapDumpPath/opt/logsOOM时自动导出堆转储文件这个太重要了。没有它OOM发生后你连现场都看不到全靠猜。这个参数我建议无条件加上——又不要性能纯保命。GC日志也要做定期清理或者依赖logrotate。很多线上事故排查时发现gc.log已经巨大无比光打开就卡半天别说分析了。这属于“参数配了但没配套管理”的典型教训。3.3 GC调优的一个“反直觉”建议很多新手调GC性能一上来就看GC停顿时间恨不得压到0。但实际上GC停顿时间和吞吐量是矛盾的目标停顿时间设得太小G1/Shenandoah会牺牲吞吐量来达到这个目标最终结果是CPU上去了QPS反而下来了GC频繁到“平均停顿2ms但每分钟停100次”。所以我调优时的顺序是先保证不OOM再控制Full GC频率最后才谈单次停顿时间。如果一个服务每隔10分钟就Full GC一次每次停顿500ms你先别急着调MaxGCPauseMillis先去看看老年代为什么涨这么快——是不是大对象太多是不是连接池没复用是不是Metaspace到了阈值触发Full GC原因往往在代码不在GC参数。这里也推荐一个相对冷门但实用的参数-XX:ExitOnOutOfMemoryError。字面意思就是OOM时候直接让进程退出让容器/进程管理器去拉起新副本。这对无状态服务其实非常合适比“OOM后继续卡着半死不活”要健康得多。用了这个参数配合K8s的自动重启服务的自愈能力会强很多。4. JIT编译与字节码控制类参数GC管的是内存JIT管的是“代码执行效率”。Java代码是解释执行的热点代码会被JIT编译成机器码这里面的参数虽然不像堆内存那样日常被讨论但关键时刻非常有用。4.1 编译模式-client 与 -server 的取舍老生常谈的-client和-server。现在64位JDK基本已经忽略这两个参数了JDK 8以后-server是默认。但如果你接手老项目还是注意看看启动脚本-client模式下JIT只做C1编译轻量级优化-server模式用C2重度优化。线上环境没有理由用-client除非你明确知道自己在干什么。4.2 分层编译与编译阈值JDK 8开始默认开启分层编译就是C1和C2结合使用-XX:TieredCompilation如果被显式关闭了要确认你确实有理由这么干——处理不好性能会掉一大截。编译阈值是-XX:CompileThreshold默认是10000次方法调用触发编译。这个参数在分层编译下意义不大了因为C1的阈值是动态调整的。除非你是做性能测试需要精确控制否则不要动。我实际遇到比较有用的JIT参数是-XX:-UseCounterDecay。默认JVM有“方法调用计数器热度衰减”机制某些方法如果调用频率不够高计数器会减下去不被编译。对于长期稳定低频调用、但一旦调用就必须快速响应的接口关闭衰减可以让它们尽快被JIT编译提升稳定性。这个参数在压测中尤其能体现差异。4.3 内联优化别跟JIT抢活JIT有个重要的优化就是方法内联把小方法直接嵌入调用方避免栈帧切换开销。-XX:MaxInlineSize默认35字节也就是小于35字节的方法会被考虑内联。很多人觉得“既然内联好那我把这个值调大”其实不然——内联过大会导致代码膨胀ICache命中率下降反而变慢。我建议信任JVM默认不要乱调MaxInlineSize。真正该关注的是-XX:InlineSmallCode和-XX:MaxTrivialSize这类更细节的参数但它们属于“高难度玩家专用”普通业务没有调整必要。如果你发现某个方法执行热到怀疑人生先用JFRJava Flight Recorder看看热点方法长什么样再决定调不调编译参数。盲目调JIT参数往往是瞎忙活。4.4 逃逸分析与栈上分配很多人不知道JVM的逃逸分析Escape Analysis是默认开启的它会分析对象是否会被方法外部引用。如果判断对象不逃逸可以直接在栈上分配不需要进入堆也就没有GC压力。这是现代JVM的一个大杀器。有个参数-XX:DoEscapeAnalysis可以显式开启JDK 7以后默认就是开启的一般不需要动。我提它是想说一个反面教训有些人不知道从哪看到“关闭逃逸分析可以减少GC压力”这种话——那完全是反的。关闭之后对象全跑堆上GC频繁到起飞。不要随便关JVM默认开启的优化功能除非你有JMH基准测试数据支撑。5. 诊断与运维参数出事时能救命的那几个这部分参数平时用不上但一旦线上出故障它们能决定你是花半小时定位问题还是花三天三夜拍脑袋。我见过太多生产事故因为日志不齐全、dump文件没导出来排障过程极其痛苦。5.1 OOM自动导出堆转储前面提到过这里展开讲一下。-XX:HeapDumpOnOutOfMemoryError是必须加的配合-XX:HeapDumpPath指定导出路径。OOM是JVM能产生的极少数强信号堆转储是事后分析最宝贵的“案发现场”。需要注意路径权限问题。如果HeapDumpPath指向的目录没有写权限JVM会尝试在启动目录写如果也失败dump文件就直接没了白配。我建议专门建一个目录比如/opt/logs/heapdump并把挂载到外部存储不要在容器销毁时把dump文件一起“销毁”。5.2 打印启动参数与配置校验-XX:PrintCommandLineFlags这个参数会在JVM启动时打印最终生效的显式参数和默认值。它的价值在于启动完成后你立刻能在日志里确认JVM到底用的是你配的参数还是被覆盖成了别的。还有一种情况你把参数写进了JAVA_OPTS结果应用启动脚本里又硬编码了一套参数后者的优先级覆盖了前者。你排了半天最后发现“配置根本没生效”非常无语。所以打印启动参数是排查“参数没生效”类问题的第一板斧。配合-XX:PrintFlagsFinal还能打印所有参数的最终值可以按需过滤比如-XX:PrintFlagsFinal -version | grep MaxHeapSize一条命令就能确认堆上限是多少。这个用法我几乎每次调优都用。5.3 类加载与JVM线程的日志开关排查类冲突、方法缺失问题-verbose:class会打印JVM加载了哪些类、从哪个jar加载的。这个参数平时别开日志量巨大但调试“jar包版本不一致”“ClassNotFoundException”非常管用。线程方面主要看线程dump。建议在启动脚本里配置好jstack、jcmd这些工具的可执行路径方便出事时快速抓取线程栈。线程dump配合GC日志、堆转储这三样东西基本能解决99%的线上JVM问题。还有个小参数-XX:UnlockDiagnosticVMOptions -XX:PrintHeapAtGC它会在每次GC前后打印堆内存的使用情况。对怀旧型优化和“想看GC之后内存是否真的降下来”这类场景特别有意义但日志量也不小生产环境视情况开启。5.4 本地内存与轻量级监控有些人会忽略一个名为Native Memory TrackingNMT的诊断功能用-XX:NativeMemoryTrackingsummary开启。它能统计堆外内存、元空间、线程栈、JIT编译器这些本地内存的占用分布。本地方向的OOM比如此前说的直接内存爆掉靠它是能精确定位到元凶的。启用NMT有少量性能开销约5%左右考虑是否常态化开启。我的建议是线上服务如果对性能极其敏感可以不开但阶段性压测、容量评估、排查内存泄漏开一下能省很多时间。加上-XX:PrintNMTStatistics可以退出时打印详细数据压测结束后直接看汇总。6. 一份可直接“抄作业”的生产参数模板说了这么多是时候把方案汇总成模板了。以下是我在大多数Spring Boot/Dubbo微服务场景下用的基准配置结合了前面聊的所有要点JAVA_OPTS-Xms4g -Xmx4g \ -Xss512k \ -XX:MaxMetaspaceSize1g \ -XX:MaxDirectMemorySize1g \ -XX:ReservedCodeCacheSize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:ParallelGCThreads4 \ -XX:ConcGCThreads1 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/opt/logs/heapdump \ -XX:ExitOnOutOfMemoryError \ -XX:PrintCommandLineFlags \ -Xlog:gc*:/opt/logs/gc.log:time,uptime,level,tags几个说明堆给了4G总内存按8G设计留了4G给元空间、线程栈、直接内存和操作系统本身。G1作为低延迟默认选项。如果你的服务是批处理或离线计算可以换成-XX:UseParallelGC吞吐优先。ParallelGCThreads显式设为4是因为容器环境可能识别到宿主机核数过多避免GC线程数失控。ConcGCThreads按1/4原则取1。HeapDumpPath和GC日志都放/opt/logs下上线前记得确认目录存在且有写权限。ExitOnOutOfMemoryError适合无状态服务如果有状态比如跑定时任务、单机事务谨慎使用。这个模板不是银弹。比如堆大小就要根据具体业务调整元空间大小也要看动态代理使用情况。但作为起步基线比裸奔默认值稳太多了。7. 压测与生产环境的注意事项配置完参数不等于调优完成上线前压测、运行中持续观察、出问题时果断回滚这三件事一个都不能少。7.1 压测时关注什么你配的-Xmx4g、-XX:MaxGCPauseMillis200到底合理不合理压测是检验的唯一标准。做压测时重点看这几个指标Full GC频率压测过程中Full GC如果频繁出现说明老年代压力过大要么堆给小了要么晋升率太高。GC总停顿时间用GC日志统计一下压测期间GC引起的总停顿时间占比要控制在可接受范围一般低于5%。如果太高说明GC开销已经肉眼可见地吃掉吞吐量了。内存泄漏前瞻观察压测12小时、24小时的堆内存曲线如果老年代使用量一直在缓慢爬升、不下降小心内存泄漏。工具上jstat -gcutil pid 1000可以每秒刷新一次各区间使用率和GC次数快捷直接。想看得更细上jvisualvm、JMC或者Arthas都行。7.2 上线后的“黄金15分钟”每次发布后前15分钟我基本都在盯三个东西GC日志是否在正常滚动、RSS内存是否稳定、Full GC有没有出现。如果你配了PrintCommandLineFlags启动日志里会先打印一行参数顺手确认下参数正确。另外一个容易被坑的点同一套参数不同机器跑出来的内存曲线完全不一样。不是因为配置写错了而是因为流量不同。所以不要拿测试环境的数据去推生产容量生产的数据要以生产自身的监控为准。7.3 参数回滚与变更管理JVM参数变更看起来很“小”但影响面极大——一个-Xmx改大5%可能带来完全不同的GC行为。所以JVM参数也应该是“变更管理”的一部分每次改动都要有记录、有理由、有回滚方案。尤其要注意GC参数的改动效果经常需要在长时间运行后才能看出来短时间压测看不出区别。不要因为压测十几分钟没差别就认为某个参数没意义这种判断太草率。8. 常见问题与避坑技巧快查最后整理一张快查表把生产环境JVM参数相关的典型问题和排查思路列一下遇到症状可以直接对号入座。典型症状可能原因排查思路容器内存超限被杀堆内存过大或本地内存未设上限确认-Xmx、MaxMetaspaceSize、MaxDirectMemorySize显式配置启动后CPU奇高GC线程数按宿主机核数计算显式设置ParallelGCThreads或ActiveProcessorCountGC日志没有产生JDK版本升级导致日志参数失效JDK9改用-Xlog:gc*格式频繁Full GC但老年代不大Metaspace到达阈值触发Full GC查看日志确认调大MaxMetaspaceSizeOOM后没有dump文件未加HeapDumpOnOutOfMemoryError或路径无权限补充参数并确认目录可写代码缓存耗尽后性能骤降JIT停止编译调大ReservedCodeCacheSize观察日志里“CodeCache is full”升级JDK后响应时间恶化默认GC器变更显式指定GC算法对比升级前后行为几个反复踩到的坑再啰嗦一次把-Xms当空气。有人只配了-Xmx没配-Xms结果堆从256M慢慢扩到4G中间每次扩容都伴随一次Full GC。启动时全部分配到位香得多。在G1下强行设置-Xmn。不是说不可以而是G1的动态调整机制会被你搞乱建议让G1自己管年轻代。不考虑容器限制。JDK 8u191以下版本没有容器感知能力最好升级JDK版本而不是靠一堆绕弯的参数去模拟容器支持。盲目模仿网上大厂模板。阿里的参数适合阿里的场景字节的参数适合字节的场景你直接抄过来大概率水土不服。参数设置一定要结合自己的业务形态、流量模型、硬件条件去试。9. 根据实际场景的经验调整这些年踩踩爬爬我感觉JVM参数调优一半是科学一半是艺术。所谓科学就是遵循内存模型、GC原理这些基本规律所谓艺术就是面对具体业务场景做出取舍。每个服务都有自己的脾气一个写日志的服务和一个处理订单交易的服务JVM参数不可能完全一致。我现在的习惯是所有项目先套统一基线模板然后在压测和线上监控数据的驱动下逐个调整。如果服务是典型的低延迟在线业务就优先保证GC停顿时间可控吞吐量可以让步如果是离线的数据计算任务就优先保证吞吐量GC停顿时间反而无所谓。这就是为什么我一直强调“显式配置参数”而不是“背一堆参数值”——参数是死的思路是活的。在踩过无数次坑之后我还有一个习惯每次调整参数都会把调整前后的GC日志和性能指标存下来。同一台机器、同一份代码、不同参数下的表现对比是积累调优手感最重要的素材。有一些参考价值非常大的对比比如G1默认参数和优化后参数的GC耗时对比能直观看到不同参数配置对性能的影响。时间久了你会形成一种直觉看到GC曲线就大概知道哪块设置不合理这也是调优经验的核心。希望这篇整理能帮你少走一些弯路至少让那些“该显式配置”的参数不再裸奔。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门