1. 摘要
本文从飞书 Android 晋级 JDK 11 意外引发的 CI 构建功能劣化谈起,结合高版别 JDK 在 Docker 容器和 GC 方面的新特性,深挖 JVM 和 Gradle 的源码完结,抽丝剥茧地介绍了剖析过程和修正办法,供其他晋级 JDK 的团队参阅。
2. 背景
最近飞书适配 Android 12 时把 targetSdkVersion 和 compileSdkVersion 改成了 31,改完后遇到了如下的构建问题。
在 StackOverflow 上有不少人遇到相同的问题,简略无侵入的处理方案是把构建用的 JDK 版别从 8 升到 11。
飞书现在用的 AGP 是 4.1.0,考虑到将来晋级 AGP 7.0 会强制要求 JDK 11,并且新版 AS 已经做了衬托,所以就把构建用的 JDK 版别也升到了 11。
3. 问题
晋级后不少同学反馈子仓发组件(即发布 AAR)很慢,看大盘目标确实上涨了很多。
除了子仓发组件目标显着上升,每周例行剖析目标时发现主仓打包目标也显着上升,从 17m上升到了 26m,涨幅约 50%。
4. 剖析
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相关的参数值
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}
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. 修正
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。
从源码看,修正该问题主要有两种办法:
- 设置 -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 来提高构建功能。
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 镜像,无需修正构建逻辑。
6. 作用
06-30 22点今后合入了修正,07-01 的构建全体耗时显着下降,康复到了 06-13(合入了 JDK 11 的晋级)之前的水平,ByteXTransform 和 DexBuilder 的耗时也回落到了之前的水平,构建目标康复正常,OsAvailableProcessors 字段也康复正常,GC 状况康复正常,国际又清静了。
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…