1. 摘要

本文从飞书 Android 晋级 JDK 11 意外引发的 CI 构建功能劣化谈起,结合高版别 JDK 在 Docker 容器和 GC 方面的新特性,深挖 JVM 和 Gradle 的源码完结,抽丝剥茧地介绍了剖析过程和修正办法,供其他晋级 JDK 的团队参阅。

2. 背景

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

在 StackOverflow 上有不少人遇到相同的问题,简略无侵入的处理方案是把构建用的 JDK 版别从 8 升到 11。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书现在用的 AGP 是 4.1.0,考虑到将来晋级 AGP 7.0 会强制要求 JDK 11,并且新版 AS 已经做了衬托,所以就把构建用的 JDK 版别也升到了 11。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

3. 问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

4. 剖析

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

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

开始剖析就有一个大发现:10 次构建都是单线程。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

子仓发组件的状况也相同,由并发发布变成了单线程发布。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

查了下并发构建相关的特点,org.gradle.parallel 一向为 true,并没有更改。然后比照机器信息,发现并发构建用的是JDK 8,可用核心数是 96;单线程构建用的是 JDK 11,可用核心数是 1。开始剖析,问题应该就在这儿,从 JDK 8 升到 JDK 11 后,由并发构建变成了单线程构建,导致耗时显着上升。并且晋级 JDK 11 的修正是在 06-13 合入主干的,06-14 构建耗时显着上升,时间上吻合。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

4.3 全体康复了并发,但目标没下降

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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 后也没有好转。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

4.5 获取 CPU 核心数的 API 有变化

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

再联想到尽管运用 –max-workers 康复了并发构建,但 OsAvailableProcessors 字段仍然为 1,而这个字段在源码中是经过下面的 API 获取的ManagementFactory.getOperatingSystemMXBean().getAvailableProcessors()

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

ManagementFactory.getOperatingSystemMXBean().getAvailableProcessors() 和Runtime.getRuntime().availableProcessors() 的作用相同,底层也是 Native 办法。综上揣度,或许是 JDK 11 的 Native 完结导致了获取核心数的 API 都回来了 1,然后导致尽管构建全体康复了并发,但依靠 API 进行并发设置的 ByteXTransform 和 DexBuilder 仍然有问题,然后导致这两个 Task 的耗时一向没有回落。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

别的有同学发现并不是一切的 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相关的参数值
boolUseG1GC=false{product}{default}
boolUseParallelGC=false{product}{default}
boolUseSerialGC=true{product}{ergonomic}
uintParallelGCThreads=0{product}{default}
uintConcGCThreads=0{product}{default}
//并发构建时GC相关的参数值
boolUseG1GC=true{product}{ergonomic}
boolUseParallelGC=false{product}{default}
boolUseSerialGC=false{product}{default}
uintParallelGCThreads=63{product}{default}
uintConcGCThreads=16{product}{ergonomic}

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

4.6 Native 源码剖析

下面剖析下 JDK 8 和 JDK 11 获取可用核心数的 Native 完结,因为 AS 默许运用 OpenJDK,这儿就用OpenJDK 的源码进行剖析。

JDK 8 完结

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

JDK 11 完结

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

5. 修正

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。

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

设置 -XX:ActiveProcessorCount=[count]

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

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

设置 -XX:-UseContainerSupport

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

依据 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 都是单线程履行。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

这儿有个不好了解的点是 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 会单线程履行,因而没有康复正常。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

上面引出来的一个点是既然构建全体和 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 的转化

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

所以要让构建全体康复并发,让DexBuilder 的耗时康复正常,还需求让 daemon进程接纳的 maxWorkers 康复正常,即让wrapper 进程获取到正确的核心数。经过给工程根目录下的 gradlew 脚本设置 DEFAULT_JVM_OPTS 能够达到这个作用。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

所以终究履行如下构建命令时,wrapper 进程和 daemon 进程都能经过 API 获取到正确的核心数,然后让构建全体、ByteXTransform、DexBuilder、OsAvailableProcessors 字段显现都康复正常。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

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

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

经过环境变量设置

后来发现环境变量 JAVA_TOOL_OPTIONS 在创立 JVM 时就会检测,简略设置后对 wrapper 进程和 daemon 进程都有用,也能够处理上面一切的问题。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

挑选的设置办法

比照上面两种设置办法,这儿挑选了更简略的即经过环境变量来设置 -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。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

所以飞书终究的处理方案是依据分支动态设置构建用的 JDK 版别,并且只在运用 JDK 11 时显式设置JAVA_TOOL_OPTIONS 为 -XX:-UseContainerSupport。对于其他团队,如果老分支用 JDK 11 也能正常构建,能够挑选默许运用 JDK 11 且内置了该环境变量的 Docker 镜像,无需修正构建逻辑。

6. 作用

06-30 22点今后合入了修正,07-01 的构建全体耗时显着下降,康复到了 06-13(合入了 JDK 11 的晋级)之前的水平,ByteXTransform 和 DexBuilder 的耗时也回落到了之前的水平,构建目标康复正常,OsAvailableProcessors 字段也康复正常,GC 状况康复正常,国际又清静了。

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

飞书 Android 晋级 JDK 11 引发的 CI 构建功能问题

7. 总结

尽管终究处理了构建功能劣化的问题,但在整个引进问题–>发现问题–>剖析问题的流程中仍是有不少点能够改进。比如对基础构建工具(包含Gradle、AGP、Kotlin、JDK)变更进行更充沛的测验能够事前发现问题,完善的防劣化机制能够有用拦截问题,有区分度的监控报警能够及时发现劣化,强大的主动归因机制能够给剖析问题供给更多输入,后面会继续完善这些方面来供给更好的研制体验。

8. 致谢

在剖析和修正问题的过程中,有不少同学供给线索、提出疑问、讨论修正方向,正是这些沟通和讨论促成了全面深入的归因和更优的处理方案,在此特别感谢这些同学。一起也特别感谢投稿后 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…