5

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

 2 years ago
source link: https://juejin.cn/post/7138670703682781214
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

2022年09月02日 06:48 ·  阅读 172
飞书 Android 升级 JDK 11 引发的 CI 构建性能问题

本文从飞书 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%。

图片

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

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

图片

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

图片

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

图片

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

图片
图片

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

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

图片
图片

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

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

图片

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

图片

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

图片

4.4 重点 Task 的耗时没下降

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

图片
图片
图片

4.5 获取 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}
复制代码

4.6 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
复制代码

5.1 设置相关的 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。

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

图片
  1. 设置 -XX:ActiveProcessorCount=[count],指定 JVM 的可用核心数
  2. 设置 -XX:-UseContainerSupport,让 JVM 禁用容器化

设置 -XX:ActiveProcessorCount=[count]

图片

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

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

设置 -XX:-UseContainerSupport

图片

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

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

选择的参数

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

5.2 怎么设置参数

通过命令行设置

这个是最先想到的方法,但执行命令 "./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 容器)动态设置参数可以解这个问题,但显然比较麻烦。

图片

通过环境变量设置

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

图片

选择的设置方法

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

5.3 新老分支同时可用

由于飞书自身的业务特点,老分支也需要长期维护,老分支上存在和 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)变更进行更充分的测试可以事前发现问题,完善的防劣化机制可以有效拦截问题,有区分度的监控报警可以及时发现劣化,强大的自动归因机制可以给分析问题提供更多输入,后面会持续完善这些方面来提供更好的研发体验。

在分析和修复问题的过程中,有不少同学提供线索、提出疑问、探讨修复方向,正是这些沟通和讨论促成了全面深入的归因和更优的解决方案,在此特别感谢这些同学。同时也特别感谢投稿后 Leader 的审核、评委的建议、技术学院和 PR 相关同学的支持。

9. 相关资料

stackoverflow.com/questions/6…

developer.android.com/studio/rele…

github.com/bytedance/B…

docs.oracle.com/en/java/jav…

docs.oracle.com/cd/E40972_0…

www.oracle.com/technical-r…

docs.oracle.com/en/java/jav…

github.com/openjdk/jdk…

github.com/openjdk/jdk…

github.com/openjdk/jdk…

www.oracle.com/java/techno…

mp.weixin.qq.com/s/AU-7IuMnR…

docs.oracle.com/en/java/jav…


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK