此文是对# Identify & Handle Android Builds’ Memory Issues的翻译,同时就文中个别名词增加了说明。
本人能力有限,如有错误还望指正。:)
前言
随着软件项目的业务和功能模块的不断迭代,作为一个Android(或其它端的)开发人员,随着时间的推移,或早或晚都会遇到内存问题
。Doni(作者) 将分享一些研究、优化及修复方面的经验,以备你不时之需。
Doni Winata是Android Infra团队的一名Android软件工程师,负责维护和提高Android的工程效率以及敏捷性,如优化构建速度、构建管道以及监督代码的可维护性。
正文
在安卓开发者的旅程中,项目会越来越大,构建时间也会越来越长。有时,由于内存不足(OOM)的错误,构建可能会停止并最终失败。
在这种情况下,开发者一般会直接增加Gradle构建时的堆
的大小(用-xmx参数),以跟上不断增加的内存占用量。但就结果来看,这样并不能解决问题,电脑的响应速度还会变慢,并最终死机。
如果这种情况听起来很熟悉,这篇文章可能有助于确定你的潜在内存问题。
分析内存使用量
为了确定根本问题,我们需要使用VisualVM这个开源软件,通过可视化的CPU和内存使用情况以及垃圾收集活动,对你的JVM(Java虚拟机)活动和构建进行分析。你也可以安装VisualGC插件作为VisualVM的插件,以记录和图形化显示GC
、类加载器
和HotSpot编译器
性能数据。
要将VisualGC插件添加到VisualVM的步骤如下:
这里我就不翻译了。
- Run VisualVM.
- Click ‘Tools’ > ‘Plugins’ > ‘Available Plugins’.
- Check ‘VisualGC’ on the list.
- Wait for the installation to complete.
- Relaunch VisualVM.
检查 JVM / Daemon 进程实例
译者注:关于后文提到的JVM / Daemon 或者jvm/守护进程, 可以理解为运行在守护进程上的虚拟机
了解一次构建使用了多少资源是很重要的。在JVM中执行的Gradle构建被称为守护进程。每个守护进程都会使用内存和CPU资源,这将影响你的构建性能。要查看这些正在运行的守护进程,在构建你的Android项目时(或之后)打开VisualVM,你会在左侧窗格中看到以下JVM实例,如下图1所示。
就上图,我来逐条说明一下(按照显示的顺序)。
AndroidStudio: Android Studio(JetBrains)使用的守护进程。这个守护进程不用于Gradle构建,而是辅助Android Studio的一些活动,如索引、代码生成和代码分析。如果你的Android Studio看起来很迟钝,你可以尝试调整其JVM设置。
GradleDaemon:这是个重要的
JVM/守护进程
,它执行Gradle任务来构建你的应用程序。大多数情况下,我们会对这个守护程序进行分析,以解决内存问题。GradleWrapperMain:它是一个由脚本拉起的进程,用于辅助Gradle项目,一般情况下消耗的内存很少(< 100MB)。
译者注: Gradle Wrapper 它是一个脚本,调用了已经声明的 Gradle 版本,并且我们编译时需要事先下载它。
所以,开发者能够快速的启动并且运行 Gradle 项目,不用再手动安装,从而节省了时间成本。
- KotlinCompileDaemon: 由安卓项目上的Kotlin编译器(如果项目上使用Kotlin/KAPT)使用。这个守护进程也对构建性能有影响。
我们还可以在MacOS的活动监视器(或Windows的任务管理器)中看到守护(程序)进程,如图所示2所示:
每个 "Java "进程都代表Gradle和KotlinCompile守护进程的实例。正如我们所看到的,每个都消耗了大量的内存和CPU。为这些守护进程分配大量的内存可能是一个问题,因为其他应用程序如Android Studio和Chrome浏览器通常也消耗大量的内存。另一方面,分配小的内存会导致你的构建中出现内存不足的错误。下文中的两条建议将用于解决这个问题。
避免重复创建 JVM/Daemon 进程实例
理想情况下,每个Gradle构建只使用GradleDaemon和KotlinCompileDaemon的一个实例。这两个守护进程将留在内存中,以备下一次的构建,如果3小时内未接收到任务,将会被杀死。在随后的构建中重复使用这些守护进程是必要的,因为:
产生一个新的守护进程是昂贵的(系统需要计算和分配足够的内存空间以从硬盘加载JVM数据和类,运行JIT编译器,等等)
重复使用之前构建中缓存的JVM是一种时间和资源的节约。
还有个别情况会导致守护进程不能复用,进而同时产生重复的守护进程,占用了大量内存,拖慢了机器,故而大大降低了构建性能。下面是一个例子,当多个守护进程为同一个项目运行时(图3):
为了弄清楚这些守护进程是否同属一个项目,你可以点击JVM实例,看到 "概览 "标签(图4):
这个标签包含了关于一个守护进程的详细信息,包括它是否属于同一个项目。如果一个项目有不同的属性(JDK版本、Android Gradle插件、Gradle版本,或从以前的构建中使用的JVM参数),那么它可能会产生多个守护进程(实例)。
在Android项目构建中,这个问题主要发生在Android Studio和终端(terminal)使用不同的守护进程构建时。解决方案是将它们都设置为使用相同的JDK路径。在这种情况下,建议使用Android Studio 内嵌SDK,以避免jdk8上的bug,该bug会阻止 Room Incremental Annotation进程。
按照以下步骤,将你的终端的JDK路径改为Android Studio的版本:
- 在Android Studio中,依次点击 File → Project Structure → SDK Location(图5)。
复制
JDK location
下方的路径(图5):/Applications/AndroidStudio.app/Contents/jre/jdk/Contents/Home
将环境变量
JAVA_HOME
的值修改为你刚复制的。对于Mac OS用户,打开你的终端应用程序,并通过输入:nano ~/.bash_profile
打开其shell的配置文件。(macos) 在.bash_profile文件的顶部,使用步骤2所复制的路径,初始化如下变量:
export JAVA_HOME=’/Applications/Android Studio.app/Contents/jre/jdk/Contents/Home’
保存.bash_profile文件并重新启动你的终端(或打开一个新的)。
在你的终端提上输入
echo $JAVA_HOME
,你应该看到你刚刚设置的路径。
现在你可以从终端和Android Studio进行构建,通过在Overview选项卡中找到JDK信息来验证构建是否使用了相同的守护/JVM进程。如果旧设置中的守护进程实例仍然存在,你可以运行这个命令来杀死所有守护进程(之后再从新的配置中重新运行构建):
# Gradle command to kill Daemon instance
./gradlew - stop
fork 守护进程
在某些情况下,我们构建过程中会产生子守护进程
,类似于上面图6中的图片。子守护进程
主要用于分担主守护进程
的任务以及内存使用量。与其他守护进程不同,如Android Studio守护进程
、Gradle wrapper守护进程
、KotlinDaemon
或mainDaemon
,fork的子守护进程
在任务执行完毕后,会被立即释放。
如果要使用子守护进程
,如用于分担任务量、获得额外内存空间、增加并行进程数等,你可以在build.gradle文件中添加以下代码:
# Put it on Root build.gradle → applied to all submodules
# Put it on specific module's build.gradle → applied only for specific module.
subprojects {
tasks.withType(JavaCompile) {
options.fork = true
options.forkOptions.memoryMaximumSize = "2G"
}
}
一次构建中可使用守护进程
的最大数量取决于gradle.properties
文件中max-workers
变量的指定值。例如,6个max-workers
可能允许多达5个子守护进程
同时并行工作。然而,请注意在使用子守护进程
时亦有一些利弊。
优点:
它在不同的守护进程上执行JavaCompile,以减少开销,从而减少
主
守护进程上的GC。任务完成后,它将消失。因此,如果javaCompile有内存泄漏,它将不会影响到后续构建的
主
守护进程。
弊端
启动一个新的守护进程(deamon worker)需要时间,可能有些进程不能被共享或隔离,从而严重影响整体构建性能。
它将占用额外的内存空间。如果你的
max-workers
设置的很大,意味着更多的守护进程并行运行,这将占用大量的内存和CPU资源。
对于单元测试,任务将在一个单独fork
出的守护进程中执行。你可以通过这个参数来增加它的fork
数量上限:
# Root build.gradle inside #subprojects
tasks.withType(Test) {
maxParallelForks = 4
}
请注意,并不是所有的构建都能从fork子守护进程
中受益。你应该首先对你的构建程序进行分析和基准测试,以评估其效用。在Traveloka,它帮助我们避免了OOM错误。但是,由于库的所有者已经修复了他们的内存泄漏问题,所以我们在一般构建下,已不在fork子守护进程
。目前,只有我们的单元测试使用forked daemon。
降低 JVM 上的GC时间
高频率的垃圾收集(GC)会使程序变的卡顿,并使你的Gradle构建速度大大降低。减少GC时间最常见的解决方案是增加你的heap
大小(gradle.properties文件中的-xmx值)。然而,如果你正在参与一个有很多模块或类的大项目,或者甚至是一个小项目,但产生了一个恰好有内存泄漏的守护进程,那么调整heap
的大小可能并不能解决问题。它可能会使问题变得更糟。更大的heap
意味着更多的对象需要在一个major GC
过程中被清除掉。在小内存的机器上,将会受到明显的影响,特别是在打开其他内存需求量大的程序时,如Android Studio或Chrome,这将迫使操作系统不断交换内存(驻留在硬盘中),并大大降低构建速度(连同其他计算活动)。
那么,你如何发现你的构建是否在GC上浪费了很多时间?
通过三个步骤,从我们的构建中识别内存问题。
Monitor Buildscans
在Traveloka,我们使用Gradle Enterprise监控来自工程师和CI的构建。从每个Buildscan(构建扫描)中,我们可以在性能标签上快速检查GC时间:
从这个标签中,我们可以看到,GC只需要1分钟多一点。请注意,这只是来自Main GradleDaemon
的GC(Gradle Buildscan还没有提供Kotlin daemon的统计数据)。理想情况下,GC时间越短越好。但是,如果GC花费的时间超过了构建时间的5%(例如,如果GC在17m的构建中花费了3m),我们将通过visualGC(VisualVM插件)
继续调查我们的内存是否有问题。
借助VisualGC进行深度分析
由上,我们知道在某些特定构建过程中发生了内存问题,我们可以使用VisualGC插件对此次构建进行分析。VisualGC会准确地告诉我们JVM何时开始GC
,这样我们便知道哪些任务消耗了大量内存。它还提供了更多关于GC活动
的细节,以及老年代
(Major GC)和新生代
(Minor GC)的总时间。这些信息将帮助我们专注于哪个任务导致GC过程延长。一些常见的罪魁祸首是Gradle
、Android Gradle插件
,或特定的注释处理器库
。如果是一个bug,那么我们可以把它报告给问题跟踪器(提个issue)。如果需要更深入的分析,我们可以通过dump heap
生成.hprof
文件,找到最终的支配者对象。
通过分析.hprof文件,找到支配者对象
译者注:支配对象,一个支配其它对象的对象。本质上还是引用,但是又有一些区别。
假设X 引用y,那么x被回收,y并不一定回收,因为还有a,b等引用它。
假设x 支配y,那么x被回收,y必然被回收。
我还推荐Eclipse内存分析器(MAT)来查找内存泄漏、支配者对象或堆支配
的注释处理器/进程。
在下一节中,我们将解释我们如何使用这些工具从我们的构建中识别内存问题。
实操...
现在,让我们通过一个例子,来看看是如何减少GC时间和解决一些内存问题。
找到一个最佳的堆大小值(-xmx)
在构建你的项目时,打开GradleDaemon或KotlinCompileDaemon的Monitor标签:
该图显示了任务执行时的CPU使用率(黄色)和GC时间(蓝色),以及它是在GradleDaemon还是KotlinCompileDaemon上执行的。例如,当 Kotlin Annotation Processor(KAPT)
的特定任务运行时,KotlinCompileDaemon的图表开始飙升,那么我们就知道该任务是在KotlinCompileDaemon内执行的。知道你的任务在哪里被执行,以及它是否在GC上花费了太多的时间,这一点很重要。
假设我们在gradle.properties
文件中为-xmx
的值设置了5GB:
# Set maximum heap space
org.gradle.jvmargs= -Xmx5g
在这种情况下,每个GradleDaemon
和KotlinCompileDaemon
将有5GB的最大堆大小,总共有10GB。在某些情况下,KotlinCompileDaemon
不需要这么大的堆。如果你设置了Gradle
的kapt.use.worker.api=true
,大部分的Kotlin工作将转移到GradleDaemon
。你会从VisualVM
中注意到,大部分的CPU活动也会被转移到GradleDaemon
中,从而缓解KotlinCompileDaemon
对内存的需求。因此,让我们把KotlinCompileDaemon
的堆大小从5GB减少到2GB(现在两个守护进程的最大堆总大小为7GB)。
# customize xmx for GradleDaemon and KotlinCompileDaemon
org.gradle.jvmargs= -Xmx5g -XX: -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options=-Xmx2g
为了确定xmx值是否为最佳值,你可以分析VisualVM图:
该图显示了构建一个样本项目时的堆情况,我们可以看到在上限为6gb的堆中,堆的使用量在1.5GB到3.5GB之间。构建完成后,堆的内存占用会缩减到1GB以下。由于内存使用量从未超过3.5GB,我们可以将堆空间上限减少到4GB而不是6GB。但在这样做之前,你还需要考虑不同的构建类型,如R8(启用最小化),其启用后,需要更大的堆空间。
译者注:D8是一款用于取代 DX、更快的 Dex 编译器,可以生成更小的 APK;
R8则将ProGuard和D8工具进行整合,目的是加速构建时间和减少输出apk的大小
指定-xms值(如果需要的话)。
在图10中,你可以看到堆的大小(橙色区域)在动态变化(这个过程叫做堆扩展,可能会影响你的构建性能),其取决于堆的使用大小(蓝色区域)。下面是我们指定-xms
的值后的结果。
正如我们所看到的,堆的大小(橙色区域)从构建开始到结束都保持不变,这意味着没有额外的成本,如时间或资源,用于堆的扩展,这可能会提高你的构建性能。
译者注:这里一次性分配内存空间,就可以避免在运行时,cpu再去动态的为进程扩展内存空间、表索引、
甚至发生内存交换等行为。
这里可以参考操作系统等书籍
通过GC plugin检查GC活动
GC活动极大地影响了构建性能,而且很难弄清楚它慢在哪里。为了检查GC活动的细节,我们可以使用VisualVM
的一个插件,叫做Visual GC
。
这个插件可以帮助我们从你的构建中检查GC
活动。你可以查看Java中的垃圾收集,来了解hotspot
的垃圾收集的运作原理。
Total GC time = Eden space + Old Gen GC (Minor GC + Major GC)
译者注: 这个就不翻译了
从图12中,可知总的GC时间是1m 49s(图中第三行),它来自于24s的Minor GC
(第四行)+1m 25s的Major GC
(第七行)。Major GC
发生在old-space
满的时候,而Minor GC
发生在Eden-space
满的时候。Eden-space
和old-space
的最大尺寸由JVM参数-xmx
定义。这些图形的值是实时变化的,所以它可以帮助我们准确地知道在哪个进程/任务中导致我们的内存激增。理想情况下,在构建完成后,我们可以看到大部分的空间将再次变空。否则,这表明堆中含有内存泄漏,严重影响下一次构建。
识别编译器上的内存泄漏
编译器上的内存泄漏对你的构建来说是非常昂贵的。泄露的对象不能被GC-ed
,导致更高的内存压力,最终可能导致Out of Memory错误。
对于Android构建,泄漏通常来自Gradle编译器
、Kotlin注释处理器(KAPT)
、Android Gradle插件(AGP)
、脚本
或我们使用的第三方Gradle插件
和注释处理器库
。
为了详细了解我们是如何识别内存泄漏,让我们看一下下面的案例:
Parceler Leak
我们的一个注解库
在主守护进程
上引起了泄漏。它持有了很多的Parceler库的对象导致GC无法回收。为了确定这个问题,我们在构建项目时(在构建完成之前)通过dump heap
生成.hprof
文件。
以下是问题复现步骤:
杀死守护进程:./gradlew - stop
启动一个全量构建:./gradlew app:assembleDebug - rerun-tasks
在构建完成之前(运行应用模块时),右键单击守护进程实例,选择
heap dump
。它应该产生一个.hprof文件
。对Gradle
和Kotlin Daemon
分别执行这个操作。从MAT中打开.hprof文件并运行
Leak Suspect Report
如果发生了内存泄漏,你就可以看到对应的泄漏报告,同时报告给问题跟踪器或库的所有者。
MAT分析器将告诉我们可能的泄漏点,以及该实例占用守护进程的堆大小。
在这个案例中,泄漏的发生是因为Transfuse库(Parceler编译器使用的依赖注入)持有一个生成的Parceler对象中的静态大对象。图14显示了这个泄漏对堆大小的影响:
在10:30AM
之后,我们可以看到堆的大小(橙色区域)显著增加,直到10:36AM
左右达到堆上限5GB。在这一点上,major GC
启动,并将内存减少到2.5GB(蓝色区域)。之后,一些major GC
不断发生,因为剩余的堆空间不断变小。从GC插件(图15),我们可以看到在该构建过程中,有5次major GC
(第七行)和612次minor GC
(第四行),总计3m9s(第三行)。
让我们来比较一下库的作者在Parceler 1.1.13上修复这个问题后的图表:
此时,GC过程
可以回收大部分的内存,并将堆的大小保持在1.5-3GB
之间,这样看来就比较合理了,因为它删除了之前泄露的大约2GB的对象。major GC
从3m9s
减少到1m39s
,major GC从5次降到1次,minor gc
从612次降到510次,这是一个巨大的改进。
Dagger 请求类型的泄漏 & Google service
我们还发现,在构件中由Dagger库
引起的泄漏。这种泄漏与Parceler
的泄漏不同,其所引起的泄漏,在构建完成后依然无法对对象回收。为了识别这种情况,你需要多次运行并在构建完成后进行dump heap
。
杀死守护进程:./gradlew - stop
-
启动一个全量构建:./gradlew app:assembleDebug - rerun-tasks。
需要多次运行
在构建完成后,右键单击守护进程实例,选择
heap dump
。它应该产生一个.hprof文件
。对Gradle
和Kotlin Daemon
分别执行这个操作。从MAT中打开.hprof文件并运行
Leak Suspect Report
如果发生了内存泄漏,你就可以看到对应的泄漏报告,同时报告给问题跟踪器或库的所有者。
在这种情况下,泄漏将在每次构建中不断积累(取决于哪个任务正在执行)。因此,这3.5GB的泄漏是在运行了Dagger
的同一守护进程
中构建5次而积累形成的。最终,在某时间点,你会捕捉到OOM
,或者守护进程会被卡住,因为现有的空间已经满了(图19中左边的绿色条)。
这种情况下,构建将停滞很长一段时间,因为GC
在试图回收内存。一段时间后,它将抛出Out-Of-Memory
错误,构建也将失败。大多数内存泄漏问题都不容易解决, 请确保将此issue
报告给库的所有者。
当你遇到无法解决的泄漏问题时,以下技巧可能有助于减少你在构建中的内存使用:
启用G1GC作为GC算法(如果你使用JDK 11,G1GC默认是启用的)。G1GC可以通过在gradle.properties文件的JVM参数来启用。根据我的个人经验,G1GC在不稳定的守护进程或高内存压力下会表现得更好。它可能比默认的要慢,但如果你发现你的构建在
堆满
后总是卡住的话,它可以作为一个临时的解决方案。你可以对Gradle主进程
和Kotlin守护进程
都使用这个方法。更多的并行
worker(守护进程)
会消耗更多的内存。如果守护进程在GC上花费了太多时间,可以考虑减少你的最大worker(守护进程)
数量。在java编译上,可以通过
fork子进程
来降低主Gradle进程的
开销。通过适当的分析,如果你能看到构建速度的提升,你就可以考虑fork子进程
。考虑减少
KotlinCompile守护进程
的堆大小。默认情况下,它使用的堆大小与Gradle的主守护进程
一样。但在大多数情况下,它只使用较少的内存。持续关注你的
注解库
、Gradle插件
、Android Gradle插件(AGP)版本
、Gradle
和Kotlin
的使用情况。你还需要关注最新版本和其问题跟踪器上的更新。
结语
在相对较大的Android项目中,内存问题是最常见的问题之一。用我们在网上找到的普通方法(例如,设置最大堆大小)来解决这个问题会导致其进一步复杂化,因为每个项目都有不同的代码大小、库、架构和机器规格。我们需要了解和确定根本原因,并通过试验和错误来找到最佳配置。
我们已经介绍了一些我们用来识别和优化内存使用的方法。这有助于我们在更新编译器和注释库时,预测由内存问题对构建速度所造成的影响。内存问题只是我们在优化构建速度时的问题之一。请期待本文的第二部分,涵盖我们如何在Traveloka提高我们的Android构建速度。