JIT逆优化引发的Java服务瞬时抖动 问题排查&解决方案

目录

一、背景

二、前期排查(失败)

三、使用神器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指标并未发生明显下降。

六、总结

  1. 可观测性对计算机系统极其重要,良好的可观测性可以大大提高问题排查、性能优化的效率
  2. 工欲善其事,必先利其器。掌握各种性能分析、问题排查、效率提升工具的使用是很有必要的
  3. 先分析清楚问题的根因才可以解决问题,没找到正确方向的努力只会是隔靴搔痒
  4. 阅读第一手的文档资料(当然大都是英文的),才能得到最准确的信息(这里推荐一个浏览器插件“沉浸式翻译”,可以实现中文与原文的对照阅读)
  5. 对于不同的技术积累,解决问题的维度也是不一样的。熟悉底层技术/源码,能做出惊艳的效果。
  • 29
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Java是一种广泛使用的面向对象的编程语言,由Sun Microsystems公司于1995年5月正式发布。它的设计目标是“一次编写,到处运行(Write Once, Run Anywhere)”,这意味着开发者可以使用Java编写应用程序,并在支持Java的任何平台上无需重新编译即可运行,这得益于其独特的跨平台性,通过Java虚拟机(JVM)实现不同操作系统上的兼容。 Java的特点包括: 面向对象:Java全面支持面向对象的特性,如封装、继承和多态,使得代码更易于维护和扩展。 安全:Java提供了丰富的安全特性,如禁止指针运算、自动内存管理和异常处理机制,以减少程序错误和恶意攻击的可能性。 可移植性:Java字节码可以在所有安装了JVM的设备上执行,从服务器到嵌入式系统,再到移动设备和桌面应用。 健壮性与高性能:Java通过垃圾回收机制确保内存的有效管理,同时也能通过JIT编译器优化来提升运行时性能。 标准库丰富:Java拥有庞大的类库,如Java SE(Java Standard Edition)包含基础API,用于开发通用应用程序;Java EE(Java Enterprise Edition)提供企业级服务,如Web服务、EJB等;而Java ME(Java Micro Edition)则针对小型设备和嵌入式系统。 社区活跃:Java有着全球范围内庞大的开发者社区和开源项目,持续推动技术进步和创新。 多线程支持:Java内建对多线程编程的支持,使并发编程变得更加简单直接。 动态性:Java可以通过反射、注解等机制实现在运行时动态加载类和修改行为,增加了程序的灵活性。 综上所述,Java凭借其强大的特性和广泛的适用范围,在企业级应用、互联网服务、移动开发等领域均扮演着举足轻重的角色,是现代软件开发不可或缺的重要工具之一。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值