很多人一提到JVM调优,马上就想到去网上搜一堆启动参数,什么 -Xms、-Xmx、用哪个GC,然后往启动脚本里一贴就完事了。但说实话,通用的JVM调优根本不是一个简单的"配置"动作,它更像是一个需要反复验证的精细活。
在动手改任何东西之前,最重要的工作其实是"诊断"。没有数据支撑的调优,基本和碰运气没两样。
你首先得非常清楚自己要优化什么。你的应用是对延迟特别敏感的API接口吗?那重点就得放在降低GC停顿时间上,哪怕牺牲一点总的吞吐量。还是说你做的是后台批量任务,只关心单位时间能处理多少数据?那就可以容忍单次GC停顿久一点,只要GC占用的总时间比例低就行。目标不一样,后续的策略就完全不同。
搞清楚目标后,你得去"解剖"你的应用。它在高峰期一秒钟大概会创建多少兆的新对象?这些对象的生命周期是怎样的?是那种请求一来就创建、请求一走就完蛋的"短命"对象多,还是像缓存那样需要一直"常驻"的对象多?这些特征直接决定了GC的频率和新生代老年代的内存划分。当然,最关键的,是摸清楚高峰期所有存活对象的总体积大概有多少,这是你设置堆大小的根本依据。
当这些都心中有数了,最后一步准备工作,就是在不改任何默认参数的情况下,给系统来一次完整的压力测试。把当前的性能指标,比如QPS、TP99响应时间、GC频率、GC停顿时间,全都原原本本地记录下来。这份"基线数据"非常重要,它是你后续所有调优工作的"参照物",没有它,你根本不知道你的修改到底是优化了还是劣化了。
有了这份基线数据,我们才算真正进入到"调优"环节。
这时候,你可以根据前面的分析数据来做决策了。比如选择垃圾收集器,现在绝大多数Web应用,G1收集器基本是首选,它在延迟和吞吐量之间平衡得很好。如果你的堆内存特别大,几十G甚至上百G,又对停顿极其敏感,那可以试试ZGC。
然后是设置内存参数。最基础的就是堆大小, -Xms 和 -Xmx 这两个参数最好设置成一样的值,比如都设成8G,这样可以避免堆内存动态伸缩带来的性能抖动。至于设多大,一般是你前面摸底的那个"峰值存活数据量"的1.5倍到2倍。至于新生代和老年代怎么分,就看你前面分析的"对象生命周期"了,短命对象多,新生代就多分点。不过用G1的话,你也可以不自己设新生代大小,通过设置一个期望的最大停顿时间(比如 -XX:MaxGCPauseMillis=200),让G1自己去动态调整。
参数选好了,就进入最考验耐心的"迭代验证"阶段了。
这里有个最重要的原则:一次只改一个参数。千万别图省事,一次把堆大小、新生代、GC全改了,那样一旦出了问题,你根本搞不清到底是哪个改动导致的。
正确的做法是,建立一个明确的假设,比如:"我发现Full GC太频繁了,我怀疑是新生代太小导致对象过早进入了老年代。好,那我这次就把新生代调大1G。" 然后,用和基线测试完全一样的压力,重新跑一次,收集新数据。
跑完后,拿着新数据和你的基线数据做对比。Full GC是不是真的减少了?TP99延迟降低了吗?有没有带来新的问题,比如虽然Full GC没了,但Minor GC的停顿时间变得无法接受了?如果效果好,那就保留这个修改,在这个基础上再建立下一个假设。如果效果不好,那就回退这个参数,换个思路再试。
这就是一个"假设-修改-验证-对比"的循环。你需要不断重复这个过程,直到性能指标达到你最初设定的目标,或者你发现再怎么调优收益已经很小了。
所以你看,JVM调优其实是个很严谨的工程活动,它考察的是你的分析能力、逻辑推理和严谨的工程素养,而不是你背了多少启动参数。