Android性能优化之道:从底层原理到一线实践
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.2 内存数据的组成

Android系统的核心是Linux系统,所以在Android系统下进程的内存空间模型和Linux系统是一样的。站在Linux系统的角度来看,每一个进程所承载的程序实际上都是ART虚拟机程序,虚拟机程序再从堆空间中申请内存空间给真正运行在这个虚拟机上的程序使用。因此,在Android系统中进程的内存组成实际就是虚拟机以及运行在虚拟机上的程序两部分。作为Android开发者,我们更关心的是虚拟机为程序申请的内存区域,下面就来介绍内存区域的数据组成。

1.2.1 maps文件

为了清晰地了解Android系统中进程的内存数据组成,我们首先需要了解maps文件。在Linux系统中,/proc/{pid}/maps路径下的文件记录了每个进程的虚拟内存所映射的数据信息,其中{pid}是进程的id。

对于root的手机,我们可以通过cat/proc/xxx/maps命令直接查看某个进程的maps文件,图1-7展示了Android系统中某个程序的部分maps文件数据。

图1-7 部分maps文件数据

以图1-7中的第一行数据为例,从左至右对各个数据段的解释如下:

❑12c00000-32c00000(address,地址):本段内存映射的虚拟地址空间范围。

❑rw-p(perms,权限):该内存区域的访问权限。

❑00000000(offset,偏移值):本段映射地址在文件中的偏移。

❑00:00(dev,设备号):映射文件所属设备的设备号,由主设备号和次设备号两部分组成。主设备号用于标识设备的类型,如字符设备或块设备;次设备号用于标识同一类型设备中的具体设备。如果是匿名映射,如堆、栈等空间,设备号则为00:00。

❑0(inode,索引):映射文件的索引节点号。inode可以用来识别文件的内容和属性,而不依赖文件名。文件名只是inode的别名,可以有多个文件名指向同一个inode。如果是匿名映射,inode则为0。

❑[anon:dalvik-main space(region space)](路径名,pathname):映射文件的路径名。如果是匿名映射,路径名则为空。

1.2.2 Java堆内存

了解了maps文件中各数据段的含义后,我们再来看看每一列的数据,图1-7中出现的dalvik-main space、boot.art等都是什么数据呢?实际上,当Android虚拟机启动时,便会创建Java堆空间,所以maps文件前面很多列记录的映射数据都属于Java堆的数据。

1.堆内存的组成

当Android虚拟机启动时便会创建Java堆,后续所有Java对象所需要的内存都会从这个堆中分配,所以我们先来了解一下Java堆的组成。Java堆由MainSpace、ImageSpace、ZygoteSpace、NonMovingSpace、LargeObjectSpace 5个部分组成,下面是对每个组成部分的说明。

❑MainSpace:程序中除大对象以外的Java对象数据都会存放在这块空间中,它是程序运行时的核心存储区域。

❑ImageSpace:用来存放系统库的对象,如java.lang包下的对象、android.jar的对象等。该空间的大小不固定。

❑ZygoteSpace:该空间和ImageSpace相邻,用来存放进程启动时所需要的基本资源和对象,这些对象不会被GC机制回收。当通过fork操作从Zygote进程创建一个新的应用进程时,该应用进程会继承ZygoteSpace中的资源,这样可以提高应用程序的启动效率,因为不需要重新加载这些资源。ZygoteSpace在Zygote进程中的大小为64MB,但是在非Zygote进程中,便只会保留2MB左右,因为非Zygote进程用到的资源只需要2MB,所以可以留出更多空间给其他资源使用。

❑NonMovingSpace:非Zygote进程启动时,会将ZygoteSpace切分出62MB,只保留需要用到的2MB大小的空间,剩下的空间称作NonMovingSpace,用来存放一些生命周期较长的对象。

❑LargeObjectSpace:用来存放大对象,即大于12 KB的基本类型数组和String对象。

通过图1-7中的maps文件数据可知,12c00000到32c00000的地址范围刚好是512MB,属于MainSpace。从6fe2e000到726e0000的地址范围则属于ImageSpace,共40MB,用于存放各个系统相关的库。紧跟着ImageSpace的便是ZygoteSpace、NonMovingSpace和LargeObjectSpace,具体的堆空间组成如图1-8所示。

图1-8 堆空间组成

2.堆的创建

了解了Java堆的组成就可以通过阅读源码来了解Java堆的创建流程了。该流程的源码位于heap.cc文件中。为了方便阅读,笔者对源码进行了精简,并将整个流程拆分成了6个部分。

我们先看第一部分,代码如下所示。该部分的代码主要用于创建ImageSpace,该空间主要用来加载boot.oat库,该库是ART虚拟机的一部分。

第二部分主要用于创建ZygoteSpace,代码如下。

第三部分主要用于创建MainSpace,根据代码逻辑可知,如果前台GC机制的类型(foreground_collector_type_)不是并发复制(Concurrent Copying)回收,即不是kCollectorTypeCC,操作系统会创建名为main space的空间,大小为capacity_。capacity_的值等同于system目录下的build.prop配置文件中dalvik.vm.heapsize项的值,大部分设备都为512MB。如果前台或后台的GC机制是半空间GC(kCollectorTypeSS),操作系统则创建名为main space 1的空间,只有Android 5到Android 7的操作系统下的GC机制符合这两个条件,因此在这些操作系统中会创建名为main space和main space 1的空间。

第四部分的代码会通过DlMallocSpace来管理前面创建的ZygoteSpace,如下所示。

第五部分的代码会判断前台GC机制的类型是否为并发复制回收,若是则创建名为main space(region space)并且大小为capacity_×2(即总共1 GB)的空间。注意,有些设备中将capacity_调整成了256MB,在这种情况下空间总共就只有512MB的大小,并直接放入RegionSpace中进行管理。因为只有Android 8及以上版本的操作系统中前台GC机制的类型才是并发复制回收,所以该空间只会在Android 8及以上版本的操作系统中存在。通过图1-7中maps文件数据的第一行可知,main space(region space)的大小为512MB。

如果前台GC机制的类型不是并发复制回收,也就是在Android 8以下版本的操作系统中,会先判断前台GC机制的类型是否为移动式GC(MovingGc),如果是则将第三部分代码创建的main space和main space 1两个空间分别放入两个BumpPointerSpace中进行管理,其他情况下则将main space和main space 1两个空间放入MallocSpace中进行管理。

最后一部分代码主要用于创建LargeObjectSpace,实现过程如下。

通过上面对堆创建源码的解读,我们可以知道Android 5到Android 7的系统中,会创建名为main space和main space 1、大小都为512MB的空间,并且main space和main space 1会通过MallocSpace进行维护和管理。在实际工作时,系统只会使用其中一个空间,只有当执行GC的时候,另一个空间才会派上用场,此时GC会将前面使用的空间中的存活对象全部移动到另一个空间中。在Android 8.0及以上版本的系统中创建的main space(region space)则会通过RegionSpace进行维护和管理。

在Java堆的创建流程中,所有内存空间都会先通过mmap申请一块虚拟内存,然后再将这块内存放入对应的空间中进行管理。表1-3列举了用来管理内存的空间。

表1-3 管理内存的空间

不同的GC算法对空间的要求不一样,比如标记清除算法只需要1个空间,但是复制回收算法就需要2个空间。不同性质的对象也会对空间有不同的要求,比如系统对象的存活周期非常长,需要放在一些生命周期较长的空间中;而有些应用对象的存活周期非常短,需要统一放在生命周期较短的空间中。所以在创建Java堆的过程中会出现很多空间,这些空间的内存申请和释放机制都不一样,GC算法也不一样,系统会根据不同的场景选择合适的空间。

3.Java对象申请及释放

虽然Java堆的组成空间很多,但实际上应用代码中的Java对象几乎只会存放在MainSpace和LargeObjectSpace这两个空间中,其他的空间都是给系统库使用的,所以下面我们就来看看Java对象所需的内存是如何在MainSpace和LargeObjectSpace中进行申请和释放的。

(1)申请流程

在Java中创建并加载一个对象有两种方式,分别是显式加载和隐式加载。显式加载使用Class.forName或者ClassLoader.loadClass方法加载对象;隐式加载使用new关键字、反射、访问静态变量等方式加载对象。这两种方式到最后都会调用AllocObjectWithAllocator方法到Java堆中申请内存,该方法位于heap-inl.h文件中,下面是AllocObjectWithAllocator方法的简化逻辑代码。

通过上述代码可以看到,如果申请内存的Java对象是大对象,则会调用AllocLargeObject在LargeObjectSpace中申请内存;如果不是,则调用TryToAllocate在MainSpace中申请内存。如果申请失败,系统就会在执行GC后继续申请。

什么是大对象呢?通过下面的ShouldAllocLargeObject判断接口的代码可以看到,如果申请的内存大于等于large_object_threshold_(该值为12 KB),且对象是基本类型数组或者字符串,便认为其是大对象。

(2)释放流程

了解了对象的申请流程,我们再来看对象的释放流程。在Java堆中申请内存时,如果申请失败或者申请完毕后内存的总大小超过了阈值,系统就会执行GC。在上面的申请流程中我们可以看到,申请内存失败后,系统会调用AllocateInternalWithGc接口去重新申请,这个接口会调用位于heap.cc文件中的CollectGarbageInternal方法来执行GC,代码如下所示。

AllocateInternalWithGc接口的逻辑比较简单,主要做下面两件事:

❑选择合适的垃圾回收器,并设置好这个垃圾回收器的环境,如半空间回收(kCollector-TypeSS)会设置好源空间(FromSpace)和目的空间(ToSpace)。

❑调用执行collector->Run接口,执行该垃圾回收器的回收策略。

不同的垃圾回收器对应了不同的GC算法,这一部分的知识比较多,超出了本章的主题范围,就不做详细介绍了,这里仅介绍垃圾回收器是如何判断一个对象是否为可回收的,它能帮助我们更好地理解内存方面的优化流程。

ART虚拟机的垃圾回收器是通过可达性分析来判断一个对象是否可以被回收的。垃圾回收器会对空间中的每一个对象的引用链进行分析,如果这个对象的引用链最终被GC Root持有,就说明这个对象不可回收;否则,就可以回收。如图1-9所示,对象6、对象7虽然被对象5持有,但对象5并没有被GC Root持有,因此垃圾回收器判定对象5、6、7都是可被清除回收的对象,而对象1、2、3、4的引用链被GC Root持有,因此无法被垃圾回收器清除回收。

图1-9 GC可达性判断

GC Root主要有下面几项:

栈中引用的对象:每个线程在执行时都会开辟一个线程栈,因此只要在这个栈中引用了某个对象,那么这个对象在该线程退出前就不会被释放。

静态变量、常量引用的对象:被静态变量引用的对象也属于GC Root可达,只有我们手动置为空才能释放这个对象。

Native方法引用的对象:通过JNI(Java Native Interface)调用,传递到Native层并被Native的函数引用的对象也无法释放。

1.2.3 Native内存

相比于Java堆内存,Native内存主要由两个部分组成:一是Bitmap占用的内存,从Android 8版本开始,Bitmap的内存都算在Native上,Bitmap的内存空间实际上也是要通过malloc函数来申请的;二是so库中通过malloc、calloc、realloc、mmap等内存申请函数所申请的内存。Native内存虽然组成较简单,但是治理起来却比Java堆内存难很多,后面实战部分会进一步介绍Native内存及其治理。