介绍完 之后,很多小伙伴问我,你描述的这些知识结构看起来艰深晦涩高大上,实际工作中能有多大用途呢?今天我就简单举个例子。
众所周知,我们的 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) {
最终调用到了 的
函数,而这个函数中最重要的一步就是启动虚拟机:
- start
- JNIEnv* env;
- if (startVm(&mJavaVM, &env) != 0) {
- return;
- }
这个函数相当之长,不过都是解析虚拟机启动的参数,比如堆大小等等; 这篇文章对一些重要的参数做了说明,这些参数对虚拟机非常重要,后面我们会见到。解析参数完毕之后,最终调用
来真正创建 Java 虚拟机。这个接口是 Android 虚拟机定义的三个接口这一,dalvik 能切换到 art 很大程度上与这个有关。它的具体是现在 ;JNI_CreateJavaVM 这个函数在拿到虚拟机的相关参数之后,就直接创建了 Android 运行时:
- JNI_CreateJavaVM
- 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_initial 是堆的初始大小,heap_growth 是堆增长的最大限制,heap_min 以及 heap_max 是什么呢?详细的用途见 简单来说就是,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 以及 heap_max ,就能很大程度上影响 GC 的过程
如何调整这两个参数的大小呢? 这里稍微需要一点 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 版本某个类的内存布局,其中的成员变量的偏移量可能不同版本不同;其二,这个 min 以及 max 具体调整为多大,跟手机的物理内存,App 使用的内存,手机配置的初始堆大小等等因素密切相关;调整一个合适的参数需要花费一些时间,Android 机型如此之多,这里需要一些小技巧。
不知道上面这个例子有木有让你感受到深入系统底层,那种呼风唤雨无所不能的快感?可能很多人觉得我们都是写写 if else 而已,调节面改动画写业务已经够了;但我想说明的是,深入学习系统原理是非常有好处的,它可以赋予你在应用层永远无法拥有的能力。
另外留个作业,我们上面提到观察 GC 的次数,除了使用 debug 模式下用工具观察,能不能用代码监听到呢?本文主要说明了虚拟机运行时等 native 层的重要性,而这个答案可以在 Java Framework 中找到 ^_^
来源: