大力学习灯APP编译内存治理

大力学习灯APP编译内存治理
研发 @ 字节跳动

大力智能技术团队-客户端 大不猎奇

背景

随着大力学习灯业务的蓬勃发展,大力客户端的编译情况劣化越来越严重。sync一次项目需要长达五分钟,本地编译耗时也极长,还会经常出现GC over limit 错误,严重影响开发效率。CI编译时长经常超过20分钟,严重影响合码效率。

以上劣化已经严重影响到日常研发工作,急切需要改善。

前期调研

针对上述情况,我们首先对本地编译情况做了具体的调研。

本地首次全量编译,耗时10分钟。接着不做任何修改,尝试第二次增量编译,耗时长达15分钟。第三次增量编译直接报GC over limit 错误。如果每次编译过后,清理掉Java进程,就不会有这个问题。查看每次编译的Java进程,内存都是打满到8G。

本地sync一次项目,耗时长达9分钟。第二次sync,10分钟后才结束。第三次直接报GC over limit 错误。同样的,每次sync后清理掉Java进程,就不会有卡死问题。

同时发现,由于本地Java进程占用内存过多,导致电脑会有明显的发热以及卡顿现象,非常影响开发体验。

思路分析

首先,我们可以从本地编译中,很容易观察到内存泄漏情况。每次sync之后,内存占用都是成倍增加,这说明其中存在很严重的内存泄漏问题。解决这些问题,可以有效缓解本地编译问题。

内存治理

variantFilter 过滤多余configuration

一开始我们只猜测是某些插件有内存泄漏问题,具体是哪些插件,为什么会有内存泄漏,我们也没什么思路。 在前期调研时,发现sync占用内存极多,且内存泄漏严重(多次sync会卡死报错)。决定先从sync场景入手,来进行内存治理。

后续复盘发现,这是一个非常正确的决定。当我们处理问题没有思路时,最好是找到一个简单的场景去深入分析。这里的重点就是找到sync这个简单的场景,相比较build编译,sync任务更为简单,更好给我们去复现问题。

首先我们需要知道,sync过后,内存占用情况。使用 VisualVM 获取sync实时的内存情况。这里是用 VisualVM 对新创建的Java进程进行实时监控,这个Java进程也就是gradle创建的deamon进程。

1

查看sync过程中内存变化情况,最终sync完成后,堆内存打满到8G,实际占用内存高达6.3G。dump出来hprof文件,我们使用MAT来分析当前内存情况。

分析dump文件,看Top Consumers中,6.3G的内存中,DefaultConfiguration_Decorated实例占了83%。我也不知道这个现象是不是正常的,这时候正好看到公司一篇文档中介绍如何解决编译中OOM问题,文档中提到,configuration数量由 模块数 * flavor 数 * buildType 数 * 每个 variant 对应的 Configurations 数决定。

我们项目中有三个flavor(其中有一个是最近新增的,这也能解释为什么劣化这么严重),主仓中有80+module,再加上debug、release两个buildType,Android Studio sync时会加载所有flavor以及buildType情况,这样可以在build variants中给我们提供所有选项。这也导致我们项目一次sync configuration内存占用高达5G。

这里我们可以参考Android官网关于variantFilter的使用,将当前flavor之外另外两个flavor给屏蔽掉。这样可以减少sync和开发过程中,内存占用,也可以减少configuration的时间。在项目的build.gradle 下面增加如下代码:

if (!project.rootProject.ext.BuildContext.isCI) {

    // 本地开发减少flavor的configuration配置

    afterEvaluate {

        if (it.plugins.hasPlugin('com.android.library') || it.plugins.hasPlugin('com.android.application')) {

            def flavorName = DEFAULT_FLAVOR_NAME

            def mBuildType = DEFAULT_BUILD_TYPE

            boolean needIgnore = false

            for(String s : gradle.startParameter.taskNames){

                s = s.toLowerCase()

                println("variantFilter taskName =  ${s}")

                //当涉及到组件升级或者组件检查时,不使用variantFilter

                if(s.contains("publish") || s.contains("checkchanged")){

                    needIgnore = false

                }

                if(s.contains("release")){

                    mBuildType = "release"

                }

                if(s.contains("flavor1")){

                    flavorName = "flavor1"

                    break

                }else if(s.contains("flavor2")){

                    flavorName = "flavor2"

                    break

                }else if(s.contains("flavor3")){

                    flavorName = "flavor3"

                    break

                }

            }

            if(needIgnore){

                println("variantFilter flavorName =  ${flavorName},mBuildType = ${mBuildType}")

                android {

                    variantFilter { variant ->

                        def names = variant.flavors*.name

                        if (!names.empty && !names.contains(flavorName)) {

                            setIgnore(true)

                            println("ignore variant ${names}")

                        }

                        def buildType = variant.getBuildType().getName()

                        if (buildType != mBuildType) {

                            setIgnore(true)

                            println("ignore variant ${buildType}")

                        }

                    }

                }

            }

        }

    }

}
复制代码

在gradle.properties中设置默认的flavor和build type,在开发过程中如果需要切换flavor,可以在此切换。

# flavor default setting

DEFAULT_FLAVOR_NAME = flavor1

DEFAULT_BUILD_TYPE = debug
复制代码

过滤之后,我们查看一下sync时内存情况:

堆大小5.5G,内存实际占用3.2G 。我们通过添加variant 过滤,减少3G的内存占用。

sync内存泄漏治理

上面一个过滤就减少了3G的内存占用,这是一个好消息。我们开始继续排查多次sync会GC over limit问题。这时候尝试再次sync,内存又增加了3.2G,内存占用直接翻倍。

这时候有两个猜想:

1.上一次sync时的内存占用,没有成功回收,形成内存泄漏。

2.第二次sync本应该使用第一次sync的缓存,由于某些原因,它没有复用反而自行创建新的缓存。

这时候我们还是先dump heap,来分析一下堆内存情况。这里直接抓取两次sync后的内存情况,看看是哪里有泄漏。

可以看到,两次sync后,其中configuration增加了一倍。到底是什么原因呢?其实这时候,我还是不太会使用这个软件,去搜索了MAT的正确使用方式,发现其中leak suspects功能会自动帮我们找出内存中可能存在的内存泄漏情况。

Leak suspects提示有两个内存泄漏可疑点,这里针对问题a,发现是defaultConfiguration_Decorated都是被 seer中的dependencyManager引用。到这个时候,我还是不确定是内存泄漏,还是内存没有复用导致的问题。其实后面复盘发现,MAT已经很明确给出了内存泄漏的建议,这时候问题应该已经很明朗了。但还是由于对gradle sync机制不够了解,仍然身处迷雾中。

这时候查看了一下上面VisitableURLClassLoader的path2GC(也就是查看它到GCroots的引用链),发现是build scan包中的一个线程对其有引用,导致其内存泄漏。而且在sync两次后这个线程从一个变成了两个!

通过这一步分析,我们可以确定,这就是泄漏问题。GCRoot来自我们接入的公司插件。找插件的维护人解决上述问题后,再次连续执行两次sync,内存还是翻倍了。

这时候我也学会如何使用MAT来分析内存泄漏问题了,直接查看一次sync之后的hprof文件。查看leak suspects,第一个问题变成了ActionRunningListener,第二个问题是configuration。

第二个的内存泄漏是大头,总共都有1.1G,而第一个只有280M,我们先分析第一个。

我们可以看到这里有两个相同的listener对象,我们直接看其中一个listener的path2GC,找到内存泄漏的GCROOTS。

这里可以发现,GCROOT来自另一个插件中的KVJsonHelper类。查看了一下它的源码,KVJsonHelper里面使用了一个static 变量引用了gradle。

这时候我也想搞清楚,为什么这里会是内存泄漏。我们两次sync,都使用的同一个gradle进程,静态变量在一个进程中,不是只会存在一个吗?查了相关资料,也阅读了公司相应文档,总算找到了原因。

gradle对象在每次编译或者sync都会重新创建(不会也不需要缓存),而这个重新创建,是会创建新的classloader,那么gradle对象也就不一样了。原有的gradle是一个GCroots,其中引用到了ActionRunningListener, 导致内存泄漏。这里涉及到gradle的类加载机制,具体原理可以查看gradle相关文档,这里就不赘述了。

找相关同学说明上下文,协助我们解决了这个问题。这时候再次sync发现内存还是翻倍。

看来这种问题还不少。接下来的问题排查与上面相似,就不赘述了。我们后续相继排查出另外几个插件中都有同样的问题。都是有GCROOT直接或者间接引用了gradle,导致gradle对象无法被回收。而gradle对象以及它所引用的对象,高达3G。

这里其实还有个小插曲,当我们解决完所有内存泄漏后,再次sync,发现内存还是翻倍。这时候准备dump heap出来分析时,发现内存占用都被回收了。原来VisualVM 的dump功能会先执行FULL GC ,而我们项目sync完成后也会执行full GC,但是由于mbox插件会在sync之后执行一次buildSrc ,导致这次fullGC没有回收成功,等插件任务执行完后,没有后续GC操作,所以内存依然存在。

这时候,内存泄漏已经完全解决了。我们总共帮助5个插件解决了内存泄漏问题,将本地内存占用从3G降低到了100M 。这时候还有一个遗留问题,为什么GC过后,实际内存占用100M,而堆大小还是6G呢?这就需要下面的,gradle JVM调优了。

Gradle JVM 调优

sync时的内存确实降下去了,但是build编译时间还是很长,CI上release编译也被同学疯狂吐槽太慢了。这该如何是好?查看了一下这时候CI上编译时长,都超过20Mins了。

挑了一个时间长的编译任务,看了看其中的耗时task。

整体编译时长24分钟,R8任务占了18mins。这时候点到内存分析,GC时间竟然逼近12mins。都占了整体时长的一半了。

查看了一下内存情况,发现到编译后期,内存几乎打满,导致一直GC。

看来是我们设置的8G最大堆内存不够用,决定将其增加到16G。在gradle.properties中,修改gradle进程Java堆的最大值。

org.gradle.jvmargs=-Xmx16384M -XX:MaxPermSize=8192m -Dkotlin.daemon.jvm.options="-Xmx8192M" 
复制代码

上面参数将gradle进程内存最大值增加到16G,kotlin gradle进程内存最大值8G。本地尝试了一下,发现编译速度确实快了很多。在CI上编译release包,编译时间从之前的20分钟,缩减到了10分钟,大大超出了我们的预期。

主要原因是,我们编译时间有大部分都消耗在GC时间上(占比百分之50+),我们提升了进程内存的最大值,GC时间大大降低,编译时间也就相应降低。

这时候发现一个新的问题,我们编译过程中,随着内存占用的增多,堆越来越大,后面一度到达13G 。但是当编译完成后,内存被回收到1G,堆还是13G ,有12G的空余内存。这不是浪费空间吗?

这个问题跟上面sync的遗留问题相似,我们开始尝试减少空余空间的比例。给gradle的进程增加新参数:-XX:MaxHeapFreeRatio=60 -XX:MinHeapFreeRatio=40,设置这两个参数,是用来控制堆中空闲内存的最大比例和最小比例的。

其实上面图中,就是设置过这个参数的测试结果。并不可行。这是为什么呢?在这个问题上排查了很久,搜到一些答案是说,现在GC并不会实时去更改堆的内存大小。

那这个空余内存,该怎么处理呢?这里我做了多种尝试,发现gradle对自己的deamon进程已经做过很好的优化了。我所尝试的新增参数做优化,可能适得其反。

这个时候转换思路,我们不需要在意是否有这么多空余内存占用,我们只需要确保,这个Java进程不会影响到我们日常电脑使用就OK。

deamon进程有一个参数可以设置保活时间,这个保活时间的意义是,当进程超过这个时间还没有新的任务时,会自动结束。保活时间默认3个小时,这里我们可以将其设置为一个小时,避免因为长时间占用电脑内存,影响其他工作。

优化结果

至此,我们的内存治理就告一段落了。

  • 我们治理了项目编译过程中的内存泄漏问题,多次编译内存占用只会缓慢上升,彻底杜绝了GC over limit 导致的编译错误。同时也将sync时间,从8分钟优化到1.5分钟,提升了本地研发效率。
  • 我们提升了项目gradle进程内存占用最大值,将编译过程中GC占用时间从50% 降低到了5%,将CI编译时间从20分钟缩减到10分钟,大大提升了研发合码效率。

内存治理,效果十分显著,既解决了本地编译的难题,也提升了CI编译速度。

总结

通过上述内容,我们总结了如下几条经验:

  1. 在多flavor项目中,我们可以通过使用variantFilter过滤非必须的variant资源,降低编译过程中内存占用。
  2. 我们在写gradle插件时,也应该注意,不要直接使用静态变量引用gradle对象,避免不必要的内存泄漏。
  3. 合理配置项目gradle daemon进程阈值,减少项目编译过程中,GC时长占用比例。
分类:
Android
分类:
Android