Android性能优化之虚拟机调优

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

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

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.cppstart函数,而这个函数中最重要的一步就是启动虚拟机:

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

这个函数相当之长,不过都是解析虚拟机启动的参数,比如堆大小等等;探究largeHeap 这篇文章对一些重要的参数做了说明,这些参数对虚拟机非常重要,后面我们会见到。解析参数完毕之后,最终调用JNI_CreateJavaVM来真正创建Java虚拟机。这个接口是Android虚拟机定义的三个接口这一,dalvik能切换到art很大程度上与这个有关。它的具体是现在 jni_internal.cc;JNI_CreateJavaVM 这个函数在拿到虚拟机的相关参数之后,就直接创建了Android运行时:

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

Runtime的创建非常复杂,其中,跟GC相关的是,App的堆空间被创建出来了;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_initialsize 是堆的初始大小,heap_growthlimit是堆增长的最大限制,heap_minfree以及heap_maxfree 是什么呢?详细的用途见 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_minfree 以及 heap_maxfree,就能很大程度上影响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;
    size_t* max_free_ = (size_t*) max_free_offset;
    
    *min_free_ = 1024 * 1024 * 2;
    *max_free_ = 1024 * 1024 * 8;
}

修改之后启动过程中内存占用如下,可以看到我们的目的已经达到:

顺便说明一下,上面的代码没有考虑任何的可移植性和适配性,只起演示作用。真正投入使用是一个体力活:其一,我们依赖了某特定Android版本某个类的内存布局,其中的成员变量的偏移量可能不同版本不同;其二,这个 minfree 以及 maxfree 具体调整为多大,跟手机的物理内存,App使用的内存,手机配置的初始堆大小等等因素密切相关;调整一个合适的参数需要花费一些时间,Android机型如此之多,这里需要一些小技巧。

不知道上面这个例子有木有让你感受到深入系统底层,那种呼风唤雨无所不能的快感?可能很多人觉得我们都是写写if else而已,调节面改动画写业务已经够了;但我想说明的是,深入学习系统原理是非常有好处的,它可以赋予你在应用层永远无法拥有的能力。

另外留个作业,我们上面提到观察GC的次数,除了使用debug模式下用工具观察,能不能用代码监听到呢?本文主要说明了虚拟机运行时等native层的重要性,而这个答案可以在Java Framework中找到 ^_^

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏沈唁志

PHP新手必须认识的一些建议

1542
来自专栏小巫技术博客

推荐:Mac下高效静态代码分析神器Unstand详解

1051
来自专栏FreeBuf

滥用Edge浏览器的“恶意站点警告”特性,实现地址栏欺骗

前言 在过去的几个月里,我们看到使用这种以技术支撑的骗术日益增多,用户的浏览器会被辣眼睛的红屏以及类似”你的电脑可能存在风险”的提示消息”锁定”。当然,这种情形...

2309
来自专栏安富莱嵌入式技术分享

【RL-TCPnet网络教程】第18章 BSD Sockets基础知识

本章节为大家讲解BSD Sockets,需要大家对BSD Sockets有个基础的认识,方便后面章节Socket实战操作。

1163
来自专栏农夫安全

浅说驱动程序的加载过程

在开始之前,首先简要介绍一下本文的主题,这篇文章是关于将内核模块加载到操作系统内核的方法的介绍。所谓“内核模块”,指的便是通常所说的驱动程序。不过因为加载到内核...

3389
来自专栏老安的博客

zabbix 自动发现tomcat的war包并实现监控

1282
来自专栏北京马哥教育

Linux内存被吃掉了,它去哪里了?

在Windows下资源管理器查看内存使用的情况,如果使用率达到80%以上,再运行大程序就能感觉到系统不流畅了,因为在内存紧缺的情况下使用交换分区,频繁地从磁盘上...

1332
来自专栏FreeBuf

PHP代码审计实战思路浅析

对于面向过程写法的程序来说,最快的审计方法可能时直接丢seay审计系统里,但对于基于mvc模式的程序来说,你直接丢seay审计系统的话,那不是给自己找麻烦吗?

1193
来自专栏生信宝典

上传高通量测序原始文件

在我们发表高通量测序文章之前通常要上传测序数据到GEO数据库,现总结流程如下。 注册账户、填写MetaSheet 在NCBI GEO官网注册一个账号,然后登陆。...

2719
来自专栏枕边书

代码迁移之旅(二)- 渐进式迁移方案

说在前面 这是代码迁移的第二篇文章,也是最后一篇了,由于个人原因,原来的迁移我无法继续参与了,但完整的方案我已经准备好了,在测试环境也已经可以正常进行了。 上篇...

2259

扫码关注云+社区

领取腾讯云代金券