作者|秦兵兵 & 宋志阳
一、摘要
本文从飞书 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%。
四、分析
1.主仓打包和子仓发组件变成了单线程
子仓发组件指标和主仓打包指标,都在 06-17 劣化到了峰值,找了 06-17 主仓打包最慢的 10 次构建进行分析。
初步分析就有一个大发现:10 次构建都是单线程。
而之前正常的构建是并发的
子仓发组件的情况也一样,由并发发布变成了单线程发布。
2.并发变单线程和升级 JDK 有关
查了下并发构建相关的属性,org.gradle.parallel 一直为 true,并没有更改。然后对比机器信息,发现并发构建用的是JDK 8,可用核心数是 96;单线程构建用的是 JDK 11,可用核心数是 1。初步分析,问题应该就在这里,从 JDK 8 升到 JDK 11 后,由并发构建变成了单线程构建,导致耗时明显上升。而且升级 JDK 11 的修改是在 06-13 合入主干的,06-14 构建耗时明显上升,时间上吻合。
3.整体恢复了并发,但指标没下降
为了恢复并发构建,容易联想到另一个相关的属性 org.gradle.workers.max。
由于 PC 和服务器可用核心数有差异,为了不写死,就试着在 CI 打包时动态指定了 --max-workers 参数。设置参数后主仓打包恢复了并发构建,子仓发组件也恢复了并发。
但观察了一周大盘指标后,发现构建耗时并没有明显的回落,稳定在 25 m,远高于之前 17 m的水平。
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 后也没有好转。
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}
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
五、修复
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。
从源码看,修复该问题主要有两种办法:
设置 -XX:ActiveProcessorCount=[count],指定 JVM 的可用核心数
设置 -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 来提升构建性能。
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。
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)变更进行更充分的测试可以事前发现问题,完善的防劣化机制可以有效拦截问题,有区分度的监控报警可以及时发现劣化,强大的自动归因机制可以给分析问题提供更多输入,后面会持续完善这些方面来提供更好的研发体验。