作者|秦冰冰&宋志扬1.GC的抽象和新特性,深入挖掘JVM和Gradle的源码实现,介绍分析过程和修复方法简述,供其他升级JDK的团队参考。2.背景近期,飞书在适配Android12时将targetSdkVersion和compileSdkVersion改为31,修改后遇到如下构建问题。StackOverflow上很多人都遇到过同样的问题,简单无侵入的解决方法是将构建使用的JDK版本从8升级到11。飞书目前使用的AGP是4.1.0。考虑到以后升级到AGP7.0需要JDK11,而新版本的AS已经铺好了,所以搭建使用的JDK版本也升级到了11。3.问题升级后,很多同学反映子仓成分(即AAR的释放)非常缓慢,市场指标确实上涨了很多。除了分仓的组件指标明显增加外,每周例行指标分析发现,主仓的包装指标也有明显增加,从17m增加到26m,增幅约50%。四、分析1、主仓打包和分仓发货组件变成单线程分仓发货组件指标和主仓打包指标,均在06-17年恶化到峰值,发现最慢的10个主仓打包在06-17构建进行分析。初步分析揭示了一个重大发现:所有10个构建都是单线程的。之前正常并发构建的子仓组件也是如此,并发发布变成了单线程发布。2.并发改为单线程,升级JDK查看了并发构建相关的属性,org.gradle.parallel一直为真,并没有改变。然后对比机器信息,发现并发构建使用的是JDK8,可用核心数为96;单线程构建使用的是JDK11,可用核数为1,初步分析应该是这里的问题。从JDK8升级到JDK11后,并发构建变成了单线程构建,导致耗时明显增加。而且升级JDK11的修改在06-13年合并到主干,06-14年的构建时间明显增加,时间吻合。3、整体并发恢复了,但是索引没有下降。为了恢复并发构建,很容易想到另一个相关属性org.gradle.workers.max。由于PC和服务器的可用核数不同,为了不被硬编码,我在打包CI时尝试动态指定--max-workers参数。设置好参数后,主仓打包恢复并发构建,子仓组件也恢复并发构建。但观察市场指标一周后发现,施工时间并没有明显下降,稳定在25m,远高于此前17m的水平。4、详细分析关键Task的耗时并没有减少,发现ByteXTransform(ByteX是Byte推出的一个基于AGPTransform的开源字节码处理框架,通过集成多个串行执行重复IO的Transform合并为一个Transform和concurrentProcessClass,以优化Transform性能,详见相关资料)与DexBuilder的整体构建趋势一致。2006-21年后,一直保持在高位,没有回落。ByteXTransform退化约200s,DexBuilder退化约200s,这两个Task是串行执行的,一起退化约400s,接近整体退化9m。GC情况在06-21之后也没有改善。5、获取CPU核数的API发生了变化。进一步分析发现其他Transform(由于历史原因,部分Transform没有接入ByteX)并没有退化,只有ByteXTransform明显退化了200s。联想到ByteXTransform内部使用并发来处理Classes,而其他Transforms默认是单线程处理Classes。检查的同学定位到一行可能有问题的代码。在调试DexBuilder时,发现核心逻辑convertToDexArchive也被并发执行。再想想,虽然使用--max-workers恢复并发构建,但是OsAvailableProcessors字段还是1,而这个字段是通过源码中的如下API获取的:ManagementFactory.getOperatingSystemMXBean().getAvailableProcessors()ManagementFactory.getOperatingSystemMXBean().getAvailableProcessors()和Runtime.getRuntime().availableProcessors()的作用是一样的,底层也是一个Native方法。综上所述,可能是JDK11的Native实现导致获取核心数的API返回了1,导致虽然整体的构建并发恢复了,但依赖的ByteXTransform和DexBuilder并发设置的API,还是有问题,进而导致了这两个问题。一个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高并发,兼顾LowPause和HighThroughput,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}6.Native源码分析下面分析JDK8和JDK11的Native实现,获取可用数量核心。由于AS默认使用OpenJDK,这里使用OpenJDK的源码进行分析。JDK8implementsJDK11implementsJDK11。默认情况下,不设置可用核数,启用容器化,因此可用核数由OSContainer::active_processor_count()决定。查询Docker环境下的CPU参数代入计算逻辑,很容易发现可用核数为1,导致Native方法返回1cat/sys/fs/cgroup/cpu/cpu.cfs_quota_uscat/sys/fs/cgroup/cpu/cpu.cfs_period_uscat/sys/fs/cgroup/cpu/cpu.shares5.修复1.设置相关JVM参数总结以上分析,问题的核心是获取核心的APIDocker容器默认参数配置下的JDK11返回值发生了变化。构建Gradle时org.gradle.workers.max属性的默认值、ByteXTransform的线程数、DexBuilder设置的maxWorkers、OsAvailableProcessors字段、GC方法都依赖于获取核数的API。用JDK8构建时API返回96,使用JDK11构建时返回1,修复的思路是让JDK11也正常返回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.如何设置参数。通过命令行设置这个是第一个想到的方法,但是执行命令“./gradlewclean,app:lark-application:assembleProductionChinaRelease-Dorg.gradle.jvmargs=-Xms12g-Xss4m-XX:-UseContainerSupport”之后,有一个意外的发现。OsAvailableProcessors字段和ByteXTransform的耗时虽然恢复正常;但整体构建还是单线程的,DexBuilder的耗时并没有回落。这与Gradle的构建机制有关。执行上述命令时,会触发GradleWrapperMain#main方法,启动GradleWrapperMain进程(以下简称wrapper进程)。wrapper进程会解析org.gradle.jvmargs属性,然后通过Socket传递给GradleDaemon进程(以下简称daemon进程),所以上面的-XX:-UseContainerSupport只对daemon有效,不适用于包装过程。同时wrapper进程也会初始化DefaultParallelismConfiguration#maxWorkerCount,然后传递给daemon进程。守护进程禁用容器化,因此可以通过API获取正确的核数,从而可以正确显示OsAvailableProcessors字段和ByteXTransform的并发执行;但是wrapper进程并没有关闭容器化,所以获得的核数为1,传递给daemon进程后,整体build和DexBuilder都是单线程的。这里比较难理解的一点是,ByteXTransform和DexBuilder都是在daemon进程中执行的任务。为什么ByteXTransform恢复正常,而DexBuilder却没有?因为ByteXTransform内部主动调整API,可以获得正确的核数,所以ByteXTransform可以并发执行;但是DexBuilder是由GradleWorkerAPI调度的(详见相关资料),执行时的maxWorkers是被动设置的(由wrapper进程传递给daemon进程)。如果通过-XX:ActiveProcessorCount=[count]指定wrapper进程的核数,然后打断点,会发现maxWorkers=count。所以当wrapper进程不关闭容器化时,获得的核数为1,DexBuilder会单线程执行,所以不会恢复正常。上面提出的一个问题是,既然整体构建和DexBuilder都是由GradleWorkerAPI调度的,为什么构建整体Concurrency恢复了,而DexBuilder还是没有恢复正常?因为DexBuilder的并发度不仅受maxWorkers的影响,还受numberOfBuckets的影响。对于Release包,DexBuilder的输入是上游MinifyWithProguard的输出(minified.jar)(不是MinifyWithR8,因为R8是明确关闭的),这个minified.jar会被分成numberOfBucketsClassBuckets,每个ClassBucket都会被设置DexWorkAction作为DexWorkActionParams的一部分,最后将DexWorkAction提交给WorkerExecutor分配的线程,完成Class到DexArchive的转换。默认情况下,numberOfBuckets=DexArchiveBuilderTask#DEFAULT_NUM_BUCKETS=Math.max(12/2,1)=6虽然DexBuilder的maxWorkers设置为12,但是由于daemon进程默认开启了容器化,通过Runtime获取可用核数。getRuntime().availableProcessors()为1,所以numberOfBuckets不是预期的6而是1,所以Class在转dex的时候不能分组,然后并发处理,导致DexBuilder的耗时没有恢复正常。同样的逻辑也适用于CI。numberOfBuckets从48变成了1,大大降低了并发度。因此,为了让构建的整体并发恢复,让DexBuilder的耗时恢复正常,还需要让daemon进程接收到的maxWorkers恢复正常,即让wrapper进程获得正确的核心数。这个效果可以通过在项目根目录下的gradlew脚本中设置DEFAULT_JVM_OPTS来实现。因此,当最终执行下面的build命令时,wrapper进程和daemon进程都可以通过API获取到正确的core数,从而使整体build、ByteXTransform、DexBuilder、OsAvailableProcessors字段的显示恢复正常。但是上面的命令在CIDocker容器中执行是正常的,但是在本地Mac上执行会报无法识别UseContainerSupport。这个问题可以通过判断构建机器和环境(本地Mac、CILinux原生环境、CIDocker容器)动态设置参数来解决,但显然比较麻烦。设置环境变量后,发现创建JVM时会检测到环境变量JAVA_TOOL_OPTIONS。经过简单的设置,对wrapper进程和daemon进程都有效,也可以解决以上所有问题。与上面两种设置方式相比,选择的设置方式更为简单,即通过环境变量设置-XX:-UseContainerSupport。3.新老分行同时可用。由于飞书自身的业务特点,老分店也需要长期维护。旧分支上存在与JDK11不兼容的构建逻辑。JDK的版本。另外UseContainerSupport是JDK8u191引入的(也就是说JDK8的高版本也有以上问题,教育组在升级AGP4.1.0时将JDK升级到1.8.0_332时遇到了以上问题),直接设置为JDK1.8.0_131将无法识别,导致创建JVM失败。所以飞书最终的解决方案是根据分支动态设置用于构建的JDK版本,只在使用JDK11时显式设置JAVA_TOOL_OPTIONS为-XX:-UseContainerSupport。对于其他团队,如果旧分支可以使用JDK11正常构建,可以选择默认使用JDK11且内置此环境变量的Docker镜像,无需修改构建逻辑。6.效果06-3022:00后合并修改,07-01整体构建时间明显减少,恢复到06-13之前的水平(纳入JDK11升级)。ByteXTransform和DexBuilder的耗时也有所减少。又回落到之前的水平,构建指标恢复正常,OsAvailableProcessors字段也恢复正常,GC情况恢复正常,世界又恢复了平静。7.总结虽然最终解决了构建性能下降的问题,但是在引入问题->发现问题->分析问题的整个过程中,还有很多可以改进的地方。例如,对基础构建工具(包括Gradle、AGP、Kotlin、JDK)的变更进行更充分的测试可以提前发现问题,完善的防劣化机制可以有效拦截问题,差异化的监控告警可以及时发现劣化,以及强大的自动归因机制,可以为分析问题提供更多的输入,未来我们会在这些方面不断完善,提供更好的研发体验。
