目录
一、背景
二、前期排查(失败)
三、使用神器JFR
四、学习JIT&思考解决方案
五、最终的解决方案
五、总结
一、背景
我们有一个QPS较高、机器数较多的Java服务;该服务的TP9999一般为几十ms,但偶尔会突然飙升至数秒,并会在几秒内自动恢复(抖动期间伴随着CPU占用100%、线程池大量扩容)。抖动大都集中在新代码上线后的前几天,会随着时间拉长逐渐减少。
二、前期排查(失败)
前期未排查到问题根因,也不知道如何去定位根因;只好从现象出发(CPU占用100% 和 线程池大量扩容),尝试通过解决表面现象,从而避免服务抖动。具体做了以下工作进行测试验证:
工作项 | 预期 | 结果 |
---|---|---|
固定线程池线程数 | 避免因线程创建销毁、线程上下文切换产生的CPU开销 | 抖动时的TP峰值降低,但抖动仍存在 |
监控线程CPU占用的shell脚本 | 捕获异常时刻到CPU占用高的线程 | 捕获到的线程比较多,有业务代码线程、C2编译器线程、GC线程... |
JIT调优(提高编译阈值、减少C2线程数...) | 降低CPU占用 | 效果不明显 |
试用JDK21的虚拟线程 | 避免因线程创建销毁、线程上下文切换产生的CPU开销 | 使用虚拟线程后,抖动时的TP峰值降低,但抖动仍存在 |
试用JDK21的结构化并发 | 避免部分业务线程查存储失败后,其他线程还在运行、持续占用CPU | 结构化并发也是基于虚拟线程的,效果和虚拟线程类似 |
试用分代ZGC | 降低GC线程的CPU占用 | 无效 |
这时候我们发现针对表面的现象可以做的猜想实在是太多了,对应的实验也太多了,很多时候也很难通过实验去完全地证伪这些猜想。
基于“抖动大都集中在新代码上线后的前几天”的现象,服务冷启动和JIT编译确实有很大的嫌疑,但是JIT编译真的会持续这么多天吗?我们并不能理解,开启了JIT编译日志打印也没看出什么。并且JIT参数调优我们也试了,效果也并不明显。
排查陷入了停滞...
三、使用神器JFR
1、JFR的简介与作用
JFR全程是Java Flight Recorder,即Java飞行记录器。借助JFR我们可以把Java服务的各种事件记录下来,如:各种JIT事件的发生时刻、原因等细节;新开线程的时间;各个时间点各线程对CPU的占用情况...这样就可以把服务异常时刻的各种指标记录下来,大大提升服务的可观测性。
详细了解推荐这位大佬的系列博客:Java 监控 JFR
2、JFR常用命令
# JVM参数开启JFR
-XX:StartFlightRecording=filename=/logs/flight.jfr,maxsize=10g
-XX:FlightRecorderOptions=repository=/logs/tmp #指定临时记录的目录
# 检查正在运行的JFR
jcmd JFR.check
# JFR不会自动导出记录,需要通过命令转储
# 转储所有的记录
jcmd <pid> JFR.dump filename=/logs/flight.jfr
# 转储最后n小时的记录
jcmd <pid> JFR.dump begin=-1h
jcmd <pid> JFR.dump maxage=1h
# 转储指定日期
jcmd <pid> JFR.dump begin=2024-01-01T13:00:00 end=2024-01-01T14:00:00 filename=/logs/flight.jfr
3、使用JFR定位问题根因
有了工具的加持,后面的问题排查就顺利了很多。我们很容易就发现了服务的抖动总是伴随着JIT的逆优化、再编译事件,并且逆优化的原因几乎都是C2激进的分支预测发生了失败,逆优化的代码集中在依赖的json库上。
四、学习JIT&思考解决方案
相关资料
JIT分层编译阈值策略
基本功 | Java即时编译器原理解析及实践 - 美团技术团队
(下图来自上文)
思考
- 由上述资料我们可以得知,JIT的level 4编译发生逆优化后,代码将发生解释运行
- 此时我们几乎可以猜测抖动就是来自于JIT逆优化后的解释运行(解释运行性能极差),所以解决方案的核心在于避免逆优化
- level 1编译不会发生逆优化,可以将分层编译固定在level 1,但是性能会比level 4差30%(实测性能发生了不小的下降,方案不够完美,但TP抖动确实消失了)
- 因为逆优化集中在json库,尝试更换其他json库(失败,没有效果)
- 修改分层编译的阈值,避免大量方法被level 2、3、4编译(失败,产生了连锁反应,抖动加剧)
- 再次陷入了僵局...
五、最终的解决方案
山重水复疑无路,柳暗花明又一村。灵光乍现+好运加成,终于被我找到了两个很有效的方案!
1、使用graal编译器
-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompile
压测的效果不错,压测了10小时抖动只发生的1~2次,差不多是原来的1/10。
猜测可能是graal对分支预测相关的逻辑有优化,避免了频繁的逆优化及代码的解释运行。
2、修改OpenJDK源码禁用C2的分支预测
-
openjdk编译流程:Building the JDK
-
openjdk源码下载:GitHub - openjdk/jdk: JDK main-line development https://openjdk.org/projects/jdk
-
openjdk源码修改,注释分支预测逻辑,直接返回PROB_FAIR(Fair probability 50/50,即各有一半的机会):
//-----------------------------branch_prediction------------------------------- float Parse::branch_prediction(float& cnt, BoolTest::mask btest, int target_bci, Node* test) { return PROB_FAIR; // float prob = dynamic_branch_prediction(cnt, btest, test); // // If prob is unknown, switch to static prediction // if (prob != PROB_UNKNOWN) return prob; // prob = PROB_FAIR; // Set default value // if (btest == BoolTest::eq) // Exactly equal test? // prob = PROB_STATIC_INFREQUENT; // Assume its relatively infrequent // else if (btest == BoolTest::ne) // prob = PROB_STATIC_FREQUENT; // Assume its relatively frequent // // If this is a conditional test guarding a backwards branch, // // assume its a loop-back edge. Make it a likely taken branch. // if (target_bci < bci()) { // if (is_osr_parse()) { // Could be a hot OSR'd loop; force deopt // // Since it's an OSR, we probably have profile data, but since // // branch_prediction returned PROB_UNKNOWN, the counts are too small. // // Let's make a special check here for completely zero counts. // ciMethodData* methodData = method()->method_data(); // if (!methodData->is_empty()) { // ciProfileData* data = methodData->bci_to_data(bci()); // // Only stop for truly zero counts, which mean an unknown part // // of the OSR-ed method, and we want to deopt to gather more stats. // // If you have ANY counts, then this loop is simply 'cold' relative // // to the OSR loop. // if (data == nullptr || // (data->as_BranchData()->taken() + data->as_BranchData()->not_taken() == 0)) { // // This is the only way to return PROB_UNKNOWN: // return PROB_UNKNOWN; // } // } // } // prob = PROB_STATIC_FREQUENT; // Likely to take backwards branch // } // assert(prob != PROB_UNKNOWN, "must have some guess at this point"); // return prob; }
压测的效果极好,抖动几乎完全消失,并且接口的AVG、TP9999指标并未发生明显下降。
六、总结
- 可观测性对计算机系统极其重要,良好的可观测性可以大大提高问题排查、性能优化的效率
- 工欲善其事,必先利其器。掌握各种性能分析、问题排查、效率提升工具的使用是很有必要的
- 先分析清楚问题的根因才可以解决问题,没找到正确方向的努力只会是隔靴搔痒
- 阅读第一手的文档资料(当然大都是英文的),才能得到最准确的信息(这里推荐一个浏览器插件“沉浸式翻译”,可以实现中文与原文的对照阅读)
- 对于不同的技术积累,解决问题的维度也是不一样的。熟悉底层技术/源码,能做出惊艳的效果。