极致性能优化 - 如何通过Java JIT优化实现数十倍性能提升

背景

Fury是一个基于JIT动态编译的高性能多语言序列化框架,其会在序列化运行时为大部分class动态生成序列化代码,减少虚方法调用、条件分支、Hash查找等开销,从而实现相比kryo 10~40倍高性能。

添加图片注释,不超过 140 字(可选)

在进行jmh benchmark的过程当中发现,发现在部分大对象的情况下,fury相比kryo的性能提升并没有数十倍以上,感觉跟JVM JIT代码编译和内联有关系,本文介绍我们是如何分析和优化,从而实现数十倍性能提升的。

分析步骤

整个分析验证过程分为两步:

  • 查看当前运行的JVM的JIT相关参数,确认关键参数是否符合线上真实环境配置

  • 分析JIT编译日志,确认方法是否可以被编译,查看是否有方法无法被编译和内联的相关日志

确认JIT相关参数

首先通过`java ${other_options} -XX:+PrintFlagsFinal -version`和`jcmd $pid VM.flags -all`查看JVM相关参数,确认当前使用的JIT编译器以及相关编译参数。

在我的macos以及JDK8环境下,JVM使用的是-server -XX:+TieredCompilation编译选项。事实上从JDK8开始,64位JDK默认使用的都是改编译选项。

内法内联参数大致如下:

图1

同时需要注意PrintFlagsFinal只能打印可以调整的参数,对于不可以调整的JVM参数无法打印,比如DesiredMethodLimit。对于这些参数,需要查看JDK源码,https://github.com/openjdk/jdk11u/blob/master/src/hotspot/share/runtime/globals.hpp.

比如DesiredMethodLimit的大小是8000,表示内联之后方法的字节码大小不能超过8000个byte。

分析编译日志

有了这些JIT参数信息,接下来我们就可以打开编译器日志,查看是否有比较可疑的地方导致编译去优化。

可以通过`-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining`选项打印JVM JIT编译日志。JVM JIT编译日志主要由五部分组成:

timestamp compilation_id attributes (tiered_level) method_name size deopt

这里我们重点关注tiered_level、size和deopt,因为我们使用的是C2编译器的分层模式,代码编译将分为五个level:

  • 0: Interpreted code

  • 1: Simple C1 compiled code

  • 2: Limited C1 compiled code

  • 3: Full C1 compiled code

  • 4: C2 compiled code

由于Fury基于类型推断对动态生成的代码做了大量优化,便于JIT编译和内联,因此Fury生成的代码最终将会是tier level 4,并且不会出现编译去优化,因此我们只需关注编译日志当中Fury tier level 4的部分。在编译日志当中出现的made not entrant and made zombie可以忽略,因为最终代码会被编译成更优的level4版本,然后保持不变。

对于编译日志术语的详细解释可以参考openjdk的官方文档:Server Compiler Inlining Messages - Server Compiler Inlining Messages - OpenJDK Wiki

确认方法是否可被编译

JVM方法编译主要由两个因素决定:

  • CodeCache。如果当前CodeCache以及满了,则JVM JIT会停止编译。在64位jdk8 C2编译模式情况下,CodeCache默认是240M,查看相关日志并没有发现JVM停止编译,因此排除是代码膨胀和CodeCache不够的原因。

  • 方法字节码大小:超过8000 bytecode的方法会被拒绝编译,查看日志并没有发现这样的方法。

确认方法是否可被内联

继续查看tiered compilation level4最终阶段的编译输出,确认生成的Fury相关代码是否被内联,发现两类有问题的日志:

  • 内联过程出现hot method too big,导致方法无法被内联

  • 内联过程当中出现 size > DesiredMethodLimit。即生成的方法太大,导致JIT内联内部调用的方法时,如果内联后的字节码达到了DesiredMethodLimit,就会停止后续方法调用的内联。

上述两类日志表明Fury生成的代码方法体过大,在内联过程中方法体超过8000个byte,从而导致无法继续在hot callsite将callee方法内联到caller方法,从而后续Fury代码存在部分方法调用开销。

方法动态分割优化方案

确认了是Fury生成的代码方法体过大导致方法无法内联后,解决思路就相当清晰了,只需要将Fury生成的代码进行拆分,生成多个小方法,然后在生成其它方法来调用这些方法即可。Fury的codgen过程主要是定义了一套表达式IR,然后基于类型信息动态构建序列化和反序列化的逻辑的表达式树,再将整个表达式树翻译成flat的代码,然后基于生成的代码创建和加载序列化class。为了将序列化相关代码进行拆分,这里就需要基于一套规则对整个表达式树进行切割,然后为每个子树生成单独的方法,再在表达式树的父类节点调用相关方法。这里就面临几个关键挑战:

  • 如何寻找最佳分割点进行表达式树的切分。这里Fury定义了一个表达式优化器,可以基于一套heuristic规则进行表达式拆分。

  • 如何切断表达式树的依赖关系避免重复代码生成。表达式直接存在依赖关系,表达式的代码生成是递归和lazy的。子表达式树和父表达式树属于不同的方法和作用域,因此对应两个不同的CodegenContext,直接在字表达树生成代码会导致父表达式树的代码重复生成在子表达式树对应的方法里面。因此需要一种方法切断表达式的依赖关系,这里主要是定义了一个Reference的表达式节点,该节点只有变量名称和类型,没有实际生成该变量的代码,这样就可以遍历子表达式树,将根节点重写为Reference,从而切断依赖关系。

  • 如何找到子表达式树的根节点。这里Fury采用了一个特别的编程技巧:将子树的构建过程放在一个serializable lambda里面,通过closure捕获作用域之外的变量,从而捕获依赖的表达式,然后再运行时通过SerializedLambda找到捕获的表达式,这些表达式就是整个子表达式树当中需要被重写的根节点。

优化效果

在实现表达式树切割和方法拆分内联优化之后,查看JIT日志发现最终编译的level4代码当中,几乎所有代码都已经被完全内联,达到了我们的预期:

在某些大对象场景,此类优化可以提升数十倍性能(图1是开启JIT动态方法拆分优化前,图2是开启JIT动态方法拆分优化后。纵轴越小越好):

参考

  1. 编译内联日志术语:Server Compiler Inlining Messages - Server Compiler Inlining Messages - OpenJDK Wiki

  2. java-jit-compiler-inlining:Java JIT compiler inlining | Julien's tech blog

加入我们

我们致力于将 Fury 打造为一个开放中立、追求极致与创新的社区项目,欢迎任何形式的参与,包括但不限于提问、代码贡献、技术讨论等。非常期待收到大家的想法和反馈,一起参与到项目的建设中来,推动项目向前发展,打造最先进的序列化框架。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值