Android性能优化之虚拟机调优

Android性能优化之虚拟机调优

介绍完 深入学习Android:虚拟机 & 运行时 之后,很多小伙伴问我,你描述的这些知识结构看起来艰深晦涩高大上,实际工作中能有多大用途呢?今天我就简单举个例子。

众所周知,我们的Android App运行在Java虚拟机之上,而Java是一门带GC的语言。在虚拟机进行垃圾回收的时候,要做一件很形象的事叫做STW(stop the world);也就是说,为了回收那些不再使用的对象,虚拟机必须要停止所有的线程来进行必要的工作。虽说这一点在ART运行时上得到了很大的改善,但是GC的存在对App运行时的性能始终有着微妙的影响。如果你观察过手机输入的日志,一定会看到类似如下的内容:

12-23 18:46:06.470 28643-28658/? I/art: Background sticky concurrent mark sweep GC freed 14069(1392KB) AllocSpace objects, 7(112KB) LOS objects, 4% free, 32MB/33MB, paused 5.032ms total 49.071ms at GCDaemon thread CareAboutPauseTimes 1

12-23 18:46:07.300 28643-28658/? I/art: Background sticky concurrent mark sweep GC freed 15442(1400KB) AllocSpace objects, 8(128KB) LOS objects, 4% free, 32MB/33MB, paused 10.356ms total 53.023ms at GCDaemon thread CareAboutPauseTimes 1

12-23 18:46:12.250 28643-28658/? I/art: Background partial concurrent mark sweep GC freed 28723(1856KB) AllocSpace objects, 6(92KB) LOS objects, 11% free, 31MB/35MB, paused 2.380ms total 108.502ms at GCDaemon thread CareAboutPauseTimes 1

上面的日志反映一个事实:GC是有代价的。有很多有关性能优化的文章提到GC,会花长篇大论讲述垃圾回收的过程以及原理,但所做的策略无非就是「不要创建不必要的对象」,「避免内存泄漏」最终就提到MAT,LeakCanary等工具的使用上去了;我只能说这很苍白无力——写出这样的代码、学会使用工具应该是基本要求。

虽说Android也支持NDK开发,但是我们不可能把所有代码全用C++重写吧?那么,我们有没有办法能 影响GC的策略 ,使得GC尽量减少呢?答案是肯定的。原理在于Android的进程机制——每一个App都有一个单独的虚拟机实例,在App自己的进程空间,我们有相当大的主动权。

我举个简单的例子。(下面的内容基于Android 5.1系统,所有的原理以及代码不保证能在其他系统版本甚至某些ROM上工作)

Android上所有的App进程都从Zygote进程fork而来,App子进程采用copy on write机制共享了Zygote进程的进程空间;其中Android虚拟机以及运行时的创建在Android系统启动、创建Zygote进程的时候已经完成了。垃圾回收机制是虚拟机的一部分,因此,我们先从Zygote进程的启动过程谈起。

我们知道,Android系统是基于Linux内核的,而在Linux系统中,所有的进程都是init进程的子孙进程,Zygote进程也不例外——它是在系统启动的过程,由init进程创建的。在系统启动脚本system/core/rootdir/init.rc文件中,我们可以看到启动Zygote进程的脚本命令:

service zygote /system/bin/app_process -Xzygote /system/bin --zygote --start-system-server

也就是说init进程通过执行 /system/bin/app_process 这个可执行文件来创建zygote进程;app_process的源码可见 这里 ;在main函数的最后有这么一句话:

if (zygote) {
    runtime.start("com.android.internal.os.ZygoteInit", args);
} else if (className) {

最终调用到了 AndroidRuntime.cpp 的 `start` 函数,而这个函数中最重要的一步就是启动虚拟机:

JNIEnv *env;
if (startVm(&mJavaVM, &env) != 0) {
    return;

这个函数相当之长,不过都是解析虚拟机启动的参数,比如堆大小等等;这些参数的来源最终是从/system/build.prop中读取的。 探究android:largeHeap - 技术小黑屋 这篇文章对一些重要的参数做了说明,这些参数对虚拟机非常重要,后面我们会见到。解析参数完毕之后,最终调用 `JNI_CreateJavaVM` 来真正创建Java虚拟机。这个接口是Android虚拟机定义的三个接口这一;它的具体是现在 jni_internal.cc ;JNI_CreateJavaVM 这个函数在拿到虚拟机的相关参数之后,就直接创建了Android运行时:

 if (!Runtime::Create(options, ignore_unrecognized)) {
    return JNI_ERR;

Runtime的创建非常复杂,其中,跟GC相关的是,App的堆空间Heap对象被创建出来了;Heap的构造函数接受了一大堆参数,这些参数对于GC有着重大的影响,如果要调整GC的策略,从这里入手,是比较靠谱的。

heap_ = new gc::Heap(options->heap_initial_size_,
                     options->heap_growth_limit_,
                     options->heap_min_free_,
                     options->heap_max_free_,
                     options->heap_target_utilization_,
                     options->foreground_heap_growth_multiplier_,
                     options->heap_maximum_size_,

其中 heap_initial_size_ 是堆的初始大小,heap_growth_limit_是堆增长的最大限制,heap_min_free_以及heap_max_free_ 是什么呢?详细的用途见 Android ART GC之GrowForUtilization的分析 简单来说就是,Android系统为了保证堆的利用效率,减少堆中的内存碎片;每次执行GC回收到一些内存之后,会对堆大小进行调整。比如说你进入了一个图片非常多的页面,这时候申请了100M内存,当你退出这个页面的时候,这100M自然就被回收了,成为了空闲内存;但是系统为了防止浪费,并不会把这100M的空闲内存全部留给你,而是做一个调整。而具体调整到多大,则与 `heap_min_free_`, `heap_max_free_` 以及 `heap_target_utilization_` 相关。

说到这里,原理性的部分已经解释完了;除了流程稍微复杂,也没有什么难点。那么这个堆,跟我们的启动性能优化有什么关系呢?

在Android App的启动过程中,进程占用的内存在一段时间内是持续上涨的;假设堆的初始大小为8M,启动过程中的占用内存峰值30M;启动过程的进行中,伴随着大量临时对象的创建,它们朝生夕死,不久就被回收掉:

如上图,这是某次启动过程中某App的内存占用情况;我们看到了有很多小折线,专业术语叫做内存抖动;原因呢,也很明显——有大量的临时对象被创建。怎么解决?有人说,不要创建大量的临时对象。道理我都懂,可是做不到。对于很多大型App来说,启动的过程是相当复杂的,而很多操作也不能简单滴去掉。那么问题来了,30M并不是一个很大的数字,为什么系统如此恐慌,还需要不停滴回收内存呢?

有一种冷,叫做你妈妈觉得你冷。垃圾回收并不是说有垃圾了才去回收,而是只要系统觉得你需要回收垃圾就会进行。

那么,能不能在启动过程中让堆保持持续增长而不进行GC?毕竟,30M并不会造成什么OOM。是什么原因导致系统没有这么做?答案是空闲内存。比如说一开始堆有8M,随着启动过程的进行,堆增长到了24M;这时候执行了一次GC,回收掉了8M内存,也是堆回到了16M;我们还有8M的空闲内存。系统就会说,小伙子,你占这么多空闲内存干嘛呀?来妈妈帮你保管,于是你就只剩下2M的空闲内存了。但显然App使用的堆内存很快就会超过18M,于是又引发一系列GC以及堆大小调整,周而复始直至启动完成内存平稳。至此,我们的结论已经很明显:

如果我们能够调整 heap_min_free_ 以及 heap_max_free_,就能很大程度上影响GC的过程。

如何调整这两个参数的大小呢? 拿到Heap对象的指针,找到这两个参数的偏移量,直接修改内存即可。 这里稍微需要一点C++内存布局的知识;至于如何拿到Heap对象的指针,只有去源码里面寻找答案了。这里我给出最终的实现代码:

void modifyHeap(unsigned size) {
    // JavaVMExt指针 可以从JNI_OnLoad中拿到 
    JavaVMExt *vmExt = (JavaVMExt *) g_javaVM;
    if (vmExt->runtime == NULL) {
        return;
    char *runtime_ptr = (char *) vmExt->runtime;
    void **heap_pp = (void **) (runtime_ptr + 188);
    char *c_heap = (char *) (*heap_pp);
    char *min_free_offset = c_heap + 532;
    char *max_free_offset = min_free_offset + 4;
    char *target_utilization_offset = max_free_offset + 4;
    size_t *min_free_ = (size_t *) min_free_offset;