飞书 Android 升级 JDK 11 引发的 CI 构建性能问题

百家 作者:51CTO技术栈 2022-09-17 09:03:04
本文从飞书 Android 升级 JDK 11 意外引发的 CI 构建性能劣化谈起,结合高版本 JDK 在 Docker 容器和 GC 方面的新特性,深挖 JVM 和 Gradle 的源码实现,抽丝剥茧地介绍了分析过程和修复方法,供其他升级 JDK 的团队参考。  


背景


最近飞书适配 Android 12 时把 targetSdkVersion 和 compileSdkVersion 改成了 31,改完后遇到了如下的构建问题。



在 StackOverflow 上有不少人遇到同样的问题,简单无侵入的解决方案是把构建用的 JDK 版本从 8 升到 11。



飞书目前用的 AGP 是 4.1.0,考虑到将来升级 AGP 7.0 会强制要求 JDK 11,而且新版 AS 已经做了铺垫,所以就把构建用的 JDK 版本也升到了 11。



问题

升级后不少同学反馈子仓发组件(即发布 AAR)很慢,看大盘指标确实上涨了很多。



除了子仓发组件指标明显上升,每周例行分析指标时发现主仓打包指标也明显上升,从 17m 上升到了 26m,涨幅约 50%。



分析

主仓打包和子仓发组件变成了单线程


子仓发组件指标和主仓打包指标,都在 06-17 劣化到了峰值,找了 06-17 主仓打包最慢的 10次构建进行分析。



初步分析就有一个大发现:10次构建都是单线程。



而之前正常的构建是并发的。



子仓发组件的情况也一样,由并发发布变成了单线程发布。




并发变单线程和升级 JDK 有关


查了下并发构建相关的属性,org.gradle.parallel 一直为 true,并没有更改。然后对比机器信息,发现并发构建用的是 JDK 8,可用核心数是 96;单线程构建用的是 JDK 11,可用核心数是 1。初步分析,问题应该就在这里,从 JDK 8 升到 JDK 11 后,由并发构建变成了单线程构建,导致耗时明显上升。而且升级 JDK 11 的修改是在 06-13 合入主干的,06-14 构建耗时明显上升,时间上吻合。




整体恢复了并发,但指标没下降


为了恢复并发构建,容易联想到另一个相关的属性 org.gradle.workers.max。



由于 PC 和服务器可用核心数有差异,为了不写死,就试着在 CI 打包时动态指定了 --max-workers 参数。设置参数后主仓打包恢复了并发构建,子仓发组件也恢复了并发。



但观察了一周大盘指标后,发现构建耗时并没有明显的回落,稳定在 25m,远高于之前 17m 的水平。



重点 Task 的耗时没下降


细化分析,发现 ByteXTransform(ByteX 是字节推出的基于 AGP Transform 的开源字节码处理框架,通过把多个串行执行重复 IO 的 Transform 整合成一个 Transform 和并发处理 Class 来优化 Transform 性能,详见相关资料)和 DexBuilder 的走势和构建整体的走势一致,06-21 后都维持在高位,没有回落。ByteXTransform 劣化了约 200s,DexBuilder 劣化了约 200s,而且这两个 Task 是串行执行,合在一起劣化了约 400s,接近构建整体的劣化9m。GC 情况在 06-21 后也没有好转。





获取 CPU 核心数的 API 有变化


进一步分析发现其他 Transform (由于历史原因,有些 Transform 还没有接入 ByteX)并没有劣化,只有 ByteXTransform 明显劣化了 200s。联想到 ByteXTransform 内部使用了并发来处理 Class,而其他 Transform 默认都是单线程处理 Class,排查的同学定位到了一行可能出问题的代码。



调试 DexBuilder 时发现核心逻辑 convertToDexArchive 也是并发执行。



再联想到虽然使用 --max-workers 恢复了并发构建,但 OsAvailableProcessors 字段仍然为 1,而这个字段在源码中是通过下面的 API 获取的

ManagementFactory.getOperatingSystemMXBean().getAvailableProcessors()



ManagementFactory.getOperatingSystemMXBean().getAvailableProcessors() 和Runtime.getRuntime().availableProcessors() 的效果一样,底层也是 Native 方法。综上推断,可能是 JDK 11 的 Native 实现导致了获取核心数的 API 都返回了 1,从而导致虽然构建整体恢复了并发,但依赖 API 进行并发设置的 ByteXTransform 和 DexBuilder 仍然有问题,进而导致这两个 Task 的耗时一直没有回落。



直接在 .gradle 脚本中调用这两个 API 验证上面的推断,发现返回的核心数果然从 96 变成了 1。




另外有同学发现并不是所有的 CI 构建都发生了劣化,只有用 Docker 容器的 CI 构建发生了明显的劣化,而 Linux 原生环境下的构建正常。所以获取核心数的 Native 实现可能和 Docker 容器有关。


GC 劣化推断也是同样的原因。下面用 -XX:+PrintFlagsFinal 打印所有的 JVM 参数来验证推断。可以看到单线程构建用的是 SerialGC,GC 变成了单线程,没能利用多核优势,GC 耗时占比高。并发构建用的是 G1GC,而且 ParallelGCThreads = 64,ConcGCThreads = 16(约是 ParallelGCThreads 的 1/4),GC 并发度高,兼顾 Low Pause 和 High Throughput,GC 耗时占比自然就低。


// 单线程构建时 GC 相关的参数值
bool UseG1GC              = false       {product} {default}
bool UseParallelGC        = false       {product} {default}
bool UseSerialGC          = true        {product} {ergonomic}
uint ParallelGCThreads    = 0           {product} {default}  
uint ConcGCThreads        = 0           {product} {default}      

左右滑动查看完整代码

// 并发构建时 GC 相关的参数值
bool UseG1GC              = true         {product} {ergonomic}
bool UseParallelGC        = false        {product} {default}
bool UseSerialGC          = false        {product} {default}
uint ParallelGCThreads    = 63           {product} {default}
uint ConcGCThreads        = 16           {product} {ergonomic}

左右滑动查看完整代码




Native 源码分析


下面分析下 JDK 8 和 JDK 11 获取可用核心数的 Native 实现,由于 AS 默认使用 OpenJDK,这里就用OpenJDK 的源码进行分析。


JDK 8 实现



JDK 11 实现



JDK 11 默认没有设置可用核心数并开启了容器化,所以可用核心数由 OSContainer::active_processor_count() 决定。


查询 Docker 环境下的 CPU 参数并代入计算逻辑,很容易得出可用核心数是 1,从而导致 Native 方法返回 1。


cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
cat /sys/fs/cgroup/cpu/cpu.shares



修复


设置相关的 JVM 参数


总结上面的分析可知,问题的核心是在 Docker 容器默认的参数配置下 JDK 11 获取核心数的 API 返回值有了变化。Gradle 构建时 org.gradle.workers.max 属性的默认值、ByteXTransform 的线程数、DexBuilder 设置的 maxWorkers、OsAvailableProcessors 字段、GC 方式都依赖了获取核心数的 API,用 JDK 8 构建时 API 返回 96,用 JDK 11 构建时返回 1,修复的思路就是让 JDK 11 也能正常返回 96。


从源码看,修复该问题主要有两种办法:



  • 设置 -XX:ActiveProcessorCount=[count],指定 JVM 的可用核心数

  • 设置 -XX:-UseContainerSupport,让 JVM 禁用容器化


(1)设置 -XX:ActiveProcessorCount=[count]



根据 Oracle 官方文档和源码,可以指定 JVM 的可用核心数来影响 Gradle 构建。


这个方法适用于进程常驻的场景,避免资源被某个 Docker 实例无限占用。例如 Web 服务的常驻进程,若不限制资源,当程序存在 Bug 或出现大量请求时,JVM 会不断向操作系统申请资源,最终进程会被 Kubernetes 或操作系统杀死。


(2)设置 -XX:-UseContainerSupport



根据 Oracle 官方文档和源码,通过显式设置 -XX:-UseContainerSupport 可以禁用容器化,不再通过 Docker 容器相关的配置信息来设置 CPU 数,而是直接查询操作系统来设置。


这个方法适用于构建任务耗时不长的场景,应最大程度调度资源快速完成构建任务。目前 CI 上均为短时间的构建任务,当任务完成后,Docker 实例会视情况进行缓存或销毁,资源也会被释放。


(3)选择的参数


对于 CI 构建,虽然可以查询物理机的可用核心数,然后设置-XX:ActiveProcessorCount。但这里根据使用场景,选择了设置更简单的 -XX:-UseContainerSupport 来提升构建性能。


怎么设置参数


(1)通过命令行设置


这个是最先想到的方法,但执行命令 "./gradlew clean, app:lark-application:assembleProductionChinaRelease -Dorg.gradle.jvmargs=-Xms12g -Xss4m -XX:-UseContainerSupport" 后有意外发现。虽然 OsAvailableProcessors 字段和 ByteXTransform 的耗时恢复正常;但构建整体仍然是单线程且 DexBuilder 的耗时也没回落。


这个和 Gradle 的构建机制有关。


  • 执行上面的命令时会触发 GradleWrapperMain#main 方法启动 GradleWrapperMain 进程(下面简称 wrapper 进程);

  • wrapper 进程会解析 org.gradle.jvmargs 属性,然后通过 Socket 传递给 Gradle Daemon 进程(下面简称 daemon 进程),所以上面的 -XX:-UseContainerSupport 只对 daemon 进行有效,对 wrapper 进程无效,同时 wrapper 进程也会初始化DefaultParallelismConfiguration#maxWorkerCount 然后传给 daemon 进程;

  • daemon 进程禁用了容器化,所以能通过 API 获取到正确的核心数,从而正确显示 OsAvailableProcessors 字段和并发执行 ByteXTransform;但 wrapper 进程没有禁用容器化,所以获取的核心数是 1 ,传给 daemon 进程后导致构建整体和 DexBuilder 都是单线程执行。





这里有个不好理解的点是 ByteXTransform 和 DexBuilder 都是 daemon 进程中执行的 Task,为什么 ByteXTransform 恢复正常了,而 DexBuilder 没有?


因为 ByteXTransform 内部主动调了 API ,能获取到正确的核心数,所以 ByteXTransform 可以并发执行;但 DexBuilder 受 Gradle Worker API (详见相关资料)的调度,执行时的 maxWorkers 是被动设置的(wrapper 进程传给 daemon 进程的)。如果通过 -XX:ActiveProcessorCount=[count] 给 wrapper 进程指定核心数,然后断点,会发现 maxWorkers = count 。所以当 wrapper 进程没有禁用容器化时,获取的核心数是 1,DexBuilder 会单线程执行,因而没有恢复正常。




上面引出来的一个点是既然构建整体和 DexBuilder 都受 Gradle Worker API 调度,为什么之前在 CI 上执行“./gradlew clean, app:lark-application:assembleProductionChinaRelease --max-workers=96”时,构建整体恢复了并发,但 DexBuilder 仍然没有恢复正常?


因为 DexBuilder 的并发度除了受 maxWorkers 影响,还受 numberOfBuckets 的影响。


对于 Release 包,DexBuilder 的输入是上游 MinifyWithProguard (不是MinifyWithR8,因为显式关闭了R8)的输出(minified.jar),minified.jar 会分成 numberOfBuckets 个 ClassBucket,每个 ClassBucket 会作为 DexWorkActionParams 的一部分设置给 DexWorkAction,最后把 DexWorkAction 提交给 WorkerExecutor 分配的线程完成 Class 到 DexArchive 的转换。





默认情况下,numberOfBuckets = DexArchiveBuilderTask#DEFAULT_NUM_BUCKETS = Math.max(12 / 2, 1) = 6



虽然通过 --max-workers 把 DexBuilder 的 maxWorkers 设置成了 12,但由于 daemon 进程默认开启了容器化,通过 Runtime.getRuntime().availableProcessors() 获取的可用核心数是 1,因此 numberOfBuckets 并不是预期的 6 而是 1,所以转 dex 时不能把 Class 分组然后并发处理,导致 DexBuilder 的耗时没有恢复正常。CI 上也是一样的逻辑,numberOfBuckets 从 48 变成了 1,极大的降低了并发度。



所以要让构建整体恢复并发,让 DexBuilder 的耗时恢复正常,还需要让 daemon 进程接收的 maxWorkers 恢复正常,即让 wrapper 进程获取到正确的核心数。通过给工程根目录下的 gradlew 脚本设置 DEFAULT_JVM_OPTS 可以达到这个效果。



所以最终执行如下构建命令时,wrapper 进程和 daemon 进程都能通过 API 获取到正确的核心数,从而让构建整体、ByteXTransform、DexBuilder、OsAvailableProcessors 字段显示都恢复正常。



但上面的命令在 CI Docker 容器中执行时正常,在本地 Mac 执行时会报无法识别 UseContainerSupport。通过判断构建机器和环境(本地 Mac,CI Linux 原生环境,CI Docker 容器)动态设置参数可以解这个问题,但显然比较麻烦。



(2)通过环境变量设置


后来发现环境变量 JAVA_TOOL_OPTIONS 在创建 JVM 时就会检测,简单设置后对 wrapper 进程和 daemon 进程都有效,也可以解决上面所有的问题。



(3)选择的设置方法


对比上面两种设置方法,这里选择了更简单的即通过环境变量来设置 -XX:-UseContainerSupport。


新老分支同时可用


由于飞书自身的业务特点,老分支也需要长期维护,老分支上存在和 JDK 11 不兼容的构建逻辑,为了新老分支都能正常出包,需要动态设置构建用的 JDK 版本。


另外 UseContainerSupport 是 JDK 8u191 引入的(也就是说高版本的 JDK 8 也有上面的问题,教育团队升 AGP 4.1.0 时把 JDK 升到了 1.8.0_332,就遇到上面的问题),直接设置给 JDK 1.8.0_131 会无法识别,导致无法创建 JVM。



所以飞书最终的解决方案是根据分支动态设置构建用的 JDK 版本,并且只在使用 JDK 11 时显式设置JAVA_TOOL_OPTIONS 为 -XX:-UseContainerSupport。对于其他团队,如果老分支用 JDK 11 也能正常构建,可以选择默认使用 JDK 11 且内置了该环境变量的 Docker 镜像,无需修改构建逻辑。


效果

06-30 22点以后合入了修改,07-01 的构建整体耗时明显下降,恢复到了 06-13(合入了 JDK 11 的升级)之前的水平,ByteXTransform 和 DexBuilder 的耗时也回落到了之前的水平,构建指标恢复正常,OsAvailableProcessors 字段也恢复正常,GC 情况恢复正常,世界又清静了。







总结


虽然最后解决了构建性能劣化的问题,但在整个引入问题-发现问题-分析问题的流程中还是有不少点可以改进。比如对基础构建工具(包括 Gradle、AGP、Kotlin、JDK)变更进行更充分的测试可以事前发现问题,完善的防劣化机制可以有效拦截问题,有区分度的监控报警可以及时发现劣化,强大的自动归因机制。


参考链接:

https://stackoverflow.com/questions/68344424/unrecognized-attribute-name-module-class-com-sun-tools-javac-util-sharednametab/69250502#69250502

https://developer.android.com/studio/releases/gradle-plugin#jdk-11

https://github.com/bytedance/ByteX

https://docs.oracle.com/en/java/javase/11/docs/api/java.management/java/lang/management/OperatingSystemMXBean.html

https://docs.oracle.com/cd/E40972_01/doc.70/e40973/cnf_jvmgc.htm#autoId2

https://www.oracle.com/technical-resources/articles/java/g1gc.html

https://docs.oracle.com/en/java/javase/11/gctuning/available-collectors.html#GUID-F215A508-9E58-40B4-90A5-74E29BF3BD3C

https://github.com/openjdk/jdk/blob/jdk8-b01/hotspot/src/os/linux/vm/os_linux.cpp

https://github.com/openjdk/jdk/blob/jdk-11%2B8/src/hotspot/os/linux/os_linux.cpp

https://github.com/openjdk/jdk/blob/jdk-11%2B8/src/hotspot/os/linux/osContainer_linux.cpp

https://www.oracle.com/java/technologies/javase/8u191-relnotes.html

https://mp.weixin.qq.com/s/AU-7IuMnRN5GQ6mx5Pht7Q

https://docs.oracle.com/en/java/javase/11/troubleshoot/environment-variables-and-system-properties.html


关注公众号:拾黑(shiheibook)了解更多

[广告]赞助链接:

四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/

公众号 关注网络尖刀微信公众号
随时掌握互联网精彩
赞助链接