【翻译】识别&处理Android构建时的内存问题

1,499 阅读20分钟

此文是对# 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的步骤如下:

这里我就不翻译了。
  1. Run VisualVM.
  2. Click ‘Tools’ > ‘Plugins’ > ‘Available Plugins’.
  3. Check ‘VisualGC’ on the list.
  4. Wait for the installation to complete.
  5. Relaunch VisualVM.

检查 JVM / Daemon 进程实例

译者注:关于后文提到的JVM / Daemon 或者jvm/守护进程, 可以理解为运行在守护进程上的虚拟机

了解一次构建使用了多少资源是很重要的。在JVM中执行的Gradle构建被称为守护进程。每个守护进程都会使用内存和CPU资源,这将影响你的构建性能。要查看这些正在运行的守护进程,在构建你的Android项目时(或之后)打开VisualVM,你会在左侧窗格中看到以下JVM实例,如下图1所示。

0_Azngh6qg8sqN5ixZ.png 图1

就上图,我来逐条说明一下(按照显示的顺序)。

  1. AndroidStudio: Android Studio(JetBrains)使用的守护进程。这个守护进程不用于Gradle构建,而是辅助Android Studio的一些活动,如索引、代码生成和代码分析。如果你的Android Studio看起来很迟钝,你可以尝试调整其JVM设置。

  2. GradleDaemon:这是个重要的JVM/守护进程,它执行Gradle任务来构建你的应用程序。大多数情况下,我们会对这个守护程序进行分析,以解决内存问题。

  3. GradleWrapperMain:它是一个由脚本拉起的进程,用于辅助Gradle项目,一般情况下消耗的内存很少(< 100MB)。

译者注: Gradle Wrapper 它是一个脚本,调用了已经声明的 Gradle 版本,并且我们编译时需要事先下载它。
所以,开发者能够快速的启动并且运行 Gradle 项目,不用再手动安装,从而节省了时间成本。
  1. KotlinCompileDaemon: 由安卓项目上的Kotlin编译器(如果项目上使用Kotlin/KAPT)使用。这个守护进程也对构建性能有影响。

我们还可以在MacOS的活动监视器(或Windows的任务管理器)中看到守护(程序)进程,如图所示2所示:

2.png 图2

每个 "Java "进程都代表Gradle和KotlinCompile守护进程的实例。正如我们所看到的,每个都消耗了大量的内存和CPU。为这些守护进程分配大量的内存可能是一个问题,因为其他应用程序如Android Studio和Chrome浏览器通常也消耗大量的内存。另一方面,分配小的内存会导致你的构建中出现内存不足的错误。下文中的两条建议将用于解决这个问题。

避免重复创建 JVM/Daemon 进程实例

理想情况下,每个Gradle构建只使用GradleDaemon和KotlinCompileDaemon的一个实例。这两个守护进程将留在内存中,以备下一次的构建,如果3小时内未接收到任务,将会被杀死。在随后的构建中重复使用这些守护进程是必要的,因为:

  1. 产生一个新的守护进程是昂贵的(系统需要计算和分配足够的内存空间以从硬盘加载JVM数据和类,运行JIT编译器,等等)

  2. 重复使用之前构建中缓存的JVM是一种时间和资源的节约。

还有个别情况会导致守护进程不能复用,进而同时产生重复的守护进程,占用了大量内存,拖慢了机器,故而大大降低了构建性能。下面是一个例子,当多个守护进程为同一个项目运行时(图3):

3.png 图3

为了弄清楚这些守护进程是否同属一个项目,你可以点击JVM实例,看到 "概览 "标签(图4):

4.png 图4

这个标签包含了关于一个守护进程的详细信息,包括它是否属于同一个项目。如果一个项目有不同的属性(JDK版本、Android Gradle插件、Gradle版本,或从以前的构建中使用的JVM参数),那么它可能会产生多个守护进程(实例)。

在Android项目构建中,这个问题主要发生在Android Studio和终端(terminal)使用不同的守护进程构建时。解决方案是将它们都设置为使用相同的JDK路径。在这种情况下,建议使用Android Studio 内嵌SDK,以避免jdk8上的bug,该bug会阻止 Room Incremental Annotation进程

按照以下步骤,将你的终端的JDK路径改为Android Studio的版本:

  1. 在Android Studio中,依次点击 File → Project Structure → SDK Location(图5)。

5.png 图5

  1. 复制JDK location 下方的路径(图5): /Applications/AndroidStudio.app/Contents/jre/jdk/Contents/Home

  2. 将环境变量JAVA_HOME的值修改为你刚复制的。对于Mac OS用户,打开你的终端应用程序,并通过输入:nano ~/.bash_profile打开其shell的配置文件。

  3. (macos) 在.bash_profile文件的顶部,使用步骤2所复制的路径,初始化如下变量: export JAVA_HOME=’/Applications/Android Studio.app/Contents/jre/jdk/Contents/Home’

  4. 保存.bash_profile文件并重新启动你的终端(或打开一个新的)。

  5. 在你的终端提上输入echo $JAVA_HOME,你应该看到你刚刚设置的路径。

现在你可以从终端和Android Studio进行构建,通过在Overview选项卡中找到JDK信息来验证构建是否使用了相同的守护/JVM进程。如果旧设置中的守护进程实例仍然存在,你可以运行这个命令来杀死所有守护进程(之后再从新的配置中重新运行构建):

# Gradle command to kill Daemon instance 
./gradlew - stop

fork 守护进程

6.png 图6

在某些情况下,我们构建过程中会产生子守护进程,类似于上面图6中的图片。子守护进程主要用于分担主守护进程的任务以及内存使用量。与其他守护进程不同,如Android Studio守护进程Gradle wrapper守护进程KotlinDaemonmainDaemon,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个子守护进程同时并行工作。然而,请注意在使用子守护进程时亦有一些利弊。

优点:

  1. 它在不同的守护进程上执行JavaCompile,以减少开销,从而减少守护进程上的GC。

  2. 任务完成后,它将消失。因此,如果javaCompile有内存泄漏,它将不会影响到后续构建的守护进程。

弊端

  1. 启动一个新的守护进程(deamon worker)需要时间,可能有些进程不能被共享或隔离,从而严重影响整体构建性能。

  2. 它将占用额外的内存空间。如果你的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时间:

7.png

图7

从这个标签中,我们可以看到,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过程延长。一些常见的罪魁祸首是GradleAndroid Gradle插件,或特定的注释处理器库。如果是一个bug,那么我们可以把它报告给问题跟踪器(提个issue)。如果需要更深入的分析,我们可以通过dump heap生成.hprof文件,找到最终的支配者对象。

通过分析.hprof文件,找到支配者对象

译者注:支配对象,一个支配其它对象的对象。本质上还是引用,但是又有一些区别。
        假设X 引用y,那么x被回收,y并不一定回收,因为还有a,b等引用它。
        假设x 支配y,那么x被回收,y必然被回收。

8.png 图8

我还推荐Eclipse内存分析器(MAT)来查找内存泄漏、支配者对象或堆支配的注释处理器/进程。

在下一节中,我们将解释我们如何使用这些工具从我们的构建中识别内存问题。

实操...

现在,让我们通过一个例子,来看看是如何减少GC时间和解决一些内存问题。

找到一个最佳的堆大小值(-xmx)

在构建你的项目时,打开GradleDaemon或KotlinCompileDaemon的Monitor标签:

9-1.png

9-2.png 图9

该图显示了任务执行时的CPU使用率(黄色)和GC时间(蓝色),以及它是在GradleDaemon还是KotlinCompileDaemon上执行的。例如,当 Kotlin Annotation Processor(KAPT)的特定任务运行时,KotlinCompileDaemon的图表开始飙升,那么我们就知道该任务是在KotlinCompileDaemon内执行的。知道你的任务在哪里被执行,以及它是否在GC上花费了太多的时间,这一点很重要。

假设我们在gradle.properties文件中为-xmx的值设置了5GB:

 # Set maximum heap space 
 org.gradle.jvmargs= -Xmx5g 

在这种情况下,每个GradleDaemonKotlinCompileDaemon将有5GB的最大堆大小,总共有10GB。在某些情况下,KotlinCompileDaemon不需要这么大的堆。如果你设置了Gradlekapt.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图:

10.png 图10

该图显示了构建一个样本项目时的堆情况,我们可以看到在上限为6gb的堆中,堆的使用量在1.5GB到3.5GB之间。构建完成后,堆的内存占用会缩减到1GB以下。由于内存使用量从未超过3.5GB,我们可以将堆空间上限减少到4GB而不是6GB。但在这样做之前,你还需要考虑不同的构建类型,如R8(启用最小化),其启用后,需要更大的堆空间。

译者注:D8是一款用于取代 DX、更快的 Dex 编译器,可以生成更小的 APK;
        R8则将ProGuard和D8工具进行整合,目的是加速构建时间和减少输出apk的大小
        

指定-xms值(如果需要的话)。

在图10中,你可以看到堆的大小(橙色区域)在动态变化(这个过程叫做堆扩展,可能会影响你的构建性能),其取决于堆的使用大小(蓝色区域)。下面是我们指定-xms的值后的结果。

11.png 图11

正如我们所看到的,堆的大小(橙色区域)从构建开始到结束都保持不变,这意味着没有额外的成本,如时间或资源,用于堆的扩展,这可能会提高你的构建性能。

译者注:这里一次性分配内存空间,就可以避免在运行时,cpu再去动态的为进程扩展内存空间、表索引、
        甚至发生内存交换等行为。
        这里可以参考操作系统等书籍
        

通过GC plugin检查GC活动

GC活动极大地影响了构建性能,而且很难弄清楚它慢在哪里。为了检查GC活动的细节,我们可以使用VisualVM的一个插件,叫做Visual GC

12.png 图12

这个插件可以帮助我们从你的构建中检查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-spaceold-space的最大尺寸由JVM参数-xmx定义。这些图形的值是实时变化的,所以它可以帮助我们准确地知道在哪个进程/任务中导致我们的内存激增。理想情况下,在构建完成后,我们可以看到大部分的空间将再次变空。否则,这表明堆中含有内存泄漏,严重影响下一次构建。

识别编译器上的内存泄漏

编译器上的内存泄漏对你的构建来说是非常昂贵的。泄露的对象不能被GC-ed,导致更高的内存压力,最终可能导致Out of Memory错误。

对于Android构建,泄漏通常来自Gradle编译器Kotlin注释处理器(KAPT)Android Gradle插件(AGP)脚本或我们使用的第三方Gradle插件注释处理器库

为了详细了解我们是如何识别内存泄漏,让我们看一下下面的案例:

Parceler Leak

我们的一个注解库主守护进程上引起了泄漏。它持有了很多的Parceler库的对象导致GC无法回收。为了确定这个问题,我们在构建项目时(在构建完成之前)通过dump heap生成.hprof文件。

以下是问题复现步骤:

  1. 杀死守护进程:./gradlew - stop

  2. 启动一个全量构建:./gradlew app:assembleDebug - rerun-tasks

  3. 在构建完成之前(运行应用模块时),右键单击守护进程实例,选择heap dump。它应该产生一个.hprof文件。对GradleKotlin Daemon分别执行这个操作。

  4. 从MAT中打开.hprof文件并运行Leak Suspect Report

  5. 如果发生了内存泄漏,你就可以看到对应的泄漏报告,同时报告给问题跟踪器或库的所有者。

MAT分析器将告诉我们可能的泄漏点,以及该实例占用守护进程的堆大小。

13-1.png

13-2.png 图13

在这个案例中,泄漏的发生是因为Transfuse库(Parceler编译器使用的依赖注入)持有一个生成的Parceler对象中的静态大对象。图14显示了这个泄漏对堆大小的影响:

14.png 图14

10:30AM之后,我们可以看到堆的大小(橙色区域)显著增加,直到10:36AM左右达到堆上限5GB。在这一点上,major GC启动,并将内存减少到2.5GB(蓝色区域)。之后,一些major GC不断发生,因为剩余的堆空间不断变小。从GC插件(图15),我们可以看到在该构建过程中,有5次major GC(第七行)和612次minor GC(第四行),总计3m9s(第三行)。

15.png 图15

让我们来比较一下库的作者在Parceler 1.1.13上修复这个问题后的图表:

16.png 图16

此时,GC过程可以回收大部分的内存,并将堆的大小保持在1.5-3GB之间,这样看来就比较合理了,因为它删除了之前泄露的大约2GB的对象。major GC3m9s减少到1m39s,major GC从5次降到1次,minor gc从612次降到510次,这是一个巨大的改进。

17.png 图17

Dagger 请求类型的泄漏 & Google service

我们还发现,在构件中由Dagger库引起的泄漏。这种泄漏与Parceler的泄漏不同,其所引起的泄漏,在构建完成后依然无法对对象回收。为了识别这种情况,你需要多次运行并在构建完成后进行dump heap

  1. 杀死守护进程:./gradlew - stop

  2. 启动一个全量构建:./gradlew app:assembleDebug - rerun-tasks。

    需要多次运行

  3. 在构建完成后,右键单击守护进程实例,选择heap dump。它应该产生一个.hprof文件。对GradleKotlin Daemon分别执行这个操作。

  4. 从MAT中打开.hprof文件并运行Leak Suspect Report

  5. 如果发生了内存泄漏,你就可以看到对应的泄漏报告,同时报告给问题跟踪器或库的所有者。

18.png 图18

在这种情况下,泄漏将在每次构建中不断积累(取决于哪个任务正在执行)。因此,这3.5GB的泄漏是在运行了Dagger的同一守护进程中构建5次而积累形成的。最终,在某时间点,你会捕捉到OOM,或者守护进程会被卡住,因为现有的空间已经满了(图19中左边的绿色条)。

19.png 图19

这种情况下,构建将停滞很长一段时间,因为GC在试图回收内存。一段时间后,它将抛出Out-Of-Memory错误,构建也将失败。大多数内存泄漏问题都不容易解决, 请确保将此issue报告给库的所有者。

当你遇到无法解决的泄漏问题时,以下技巧可能有助于减少你在构建中的内存使用:

  1. 启用G1GC作为GC算法(如果你使用JDK 11,G1GC默认是启用的)。G1GC可以通过在gradle.properties文件的JVM参数来启用。根据我的个人经验,G1GC在不稳定的守护进程或高内存压力下会表现得更好。它可能比默认的要慢,但如果你发现你的构建在堆满后总是卡住的话,它可以作为一个临时的解决方案。你可以对Gradle主进程Kotlin守护进程都使用这个方法。

  2. 更多的并行worker(守护进程)会消耗更多的内存。如果守护进程在GC上花费了太多时间,可以考虑减少你的最大worker(守护进程)数量。

  3. 在java编译上,可以通过fork子进程来降低主Gradle进程的开销。通过适当的分析,如果你能看到构建速度的提升,你就可以考虑fork子进程

  4. 考虑减少KotlinCompile守护进程的堆大小。默认情况下,它使用的堆大小与Gradle的主守护进程一样。但在大多数情况下,它只使用较少的内存。

  5. 持续关注你的注解库Gradle插件Android Gradle插件(AGP)版本GradleKotlin的使用情况。你还需要关注最新版本和其问题跟踪器上的更新。

结语

在相对较大的Android项目中,内存问题是最常见的问题之一。用我们在网上找到的普通方法(例如,设置最大堆大小)来解决这个问题会导致其进一步复杂化,因为每个项目都有不同的代码大小、库、架构和机器规格。我们需要了解和确定根本原因,并通过试验和错误来找到最佳配置。

我们已经介绍了一些我们用来识别和优化内存使用的方法。这有助于我们在更新编译器和注释库时,预测由内存问题对构建速度所造成的影响。内存问题只是我们在优化构建速度时的问题之一。请期待本文的第二部分,涵盖我们如何在Traveloka提高我们的Android构建速度。

我们一直在探索新的技术来构建可扩展的系统。请查看Traveloka的职业页面,加入我们的冒险吧!

译者的其他文章

# Flutter&Flame在游戏上的实践——坦克大战

flutter_hybird_webview 跨进程渲染的实践技术分享

Flutter——原生View的Touch事件分发流程

Flutter在Android平台上启动时,Native层做了什么?

Flutter 仿同花顺自选股列表