- Android性能优化之道:从底层原理到一线实践
- 赵子健
- 5275字
- 2025-04-25 18:41:20
1.1 虚拟内存
1.1.1 为什么需要虚拟内存
我们日常使用的操作系统都是支持同时运行多个程序的,从技术的角度来看,这并不是一件容易做到的事情,想要支持这一特性,需要解决很多问题,笔者在这里列举几个最典型的问题。
1.内存地址隔离问题
对于操作系统来说,应用程序是不能直接访问真实的内存(也称为物理内存)的。如果应用程序有这样的权限,那么不同的应用程序所使用的内存地址便无法相互隔离,此时恶意或非恶意的程序都可以很容易地改写其他程序的内存数据,导致内存数据被改写的程序产生数据安全或程序崩溃等严重问题。因此操作系统禁止了应用程序直接访问物理内存,并且给每个应用程序的进程创建一个“中间层”,每个进程都只能在其独有的“中间层”中读写数据,然后再由系统将“中间层”的数据映射到物理内存中,这样不同的进程便有自己独立的内存地址空间,可以独立运行而不互相干扰,从而实现各个程序间的内存地址隔离。
2.内存使用效率问题
为了确保程序运行的效率,程序被装载到内存中时地址空间都是连续的,但是内存的容量是有限的,所以很可能在加载几个程序后,就没有连续的大块内存给下一个程序使用了。这个时候,如果我们想继续执行新的程序,就只能将之前程序的数据暂时写回磁盘里,等到后面需要用到的时候再读回来。这个过程中有大量的数据被换入换出,程序的执行效率及性能自然就十分低下。所以想要提升内存的使用效率,就需要程序可以使用非连续的内存地址,而这又与程序的运行效率相冲突。操作系统的做法是创建一个地址连续的“中间层”,程序的数据都加载在这个连续的“中间层”中,然后由系统将“中间层”的连续地址映射到非连续的物理内存地址中。
3.地址稳定性问题
程序在运行过程中想要执行某个函数,首先需要知道这个函数在内存中的地址,如果程序是直接加载在物理内存中的,那么它很可能不是从地址0开始加载的,而是从中间某一个地址开始,所以函数的地址是不确定的。
解决函数地址不确定问题的方法依然是给每个进程创建一个独立的“中间层”,并且这个“中间层”的地址都是从0开始的。由于程序只能加载进这个“中间层”,我们就能确保程序一定是从地址0开始加载的,这样函数的地址不会发生改变,并能在编译时确定。
通过上文可以看到,操作系统都是通过创建一个“中间层”来解决几个典型问题的,这个“中间层”就是虚拟内存,可以说虚拟内存是现代操作系统中最重要的技术之一。那么什么是虚拟内存呢?虚拟内存又是如何解决上文提到的各种问题的呢?我们接着往下看。
1.1.2 什么是虚拟内存
虚拟内存技术相当于给每个进程分配一块独占且连续的内存,只不过这个内存是虚拟的。虚拟内存的简化模型如图1-1所示,从简化的模型可以看到,每个进程都独享一块唯一的虚拟内存,由内核空间和用户空间组成。其中内核空间存放的是操作系统的数据,这部分数据在所有进程中都是同一份,都映射到同一段物理内存;用户空间存放的是应用程序的数据,当某个应用程序向其对应的虚拟内存地址中写入数据时,操作系统会将该虚拟内存地址映射到真正的物理内存地址中,映射之后就能写入数据了。

图1-1 虚拟内存的简化模型
虚拟内存的大小在32位操作系统下是232B,即4GB;在64位操作系统下是248B,即16TB。之所以不是264B,是因为248B已经足够大了,264B的空间只会导致系统损耗更多的资源来维护和管理这些空间。虚拟内存到物理内存是按照页来进行管理和映射的,一页的大小为4KB。
假设有一个32位操作系统,其物理内存只有2GB,下面以这个系统的虚拟内存和物理内存的映射模型为例来帮助读者加强对虚拟内存的理解,该场景如图1-2所示。4GB大小的虚拟内存被分成1 048 576个大小为4KB的页,当虚拟内存的某一页需要写入数据时,系统便会映射一块4KB大小的物理内存,如果虚拟内存中的页没有写入数据,系统则不会进行映射。虚拟内存到物理内存的地址映射由计算机的内存管理单元(MMU)完成,它属于硬件而不是系统软件,所以映射速度很快。

图1-2 32位操作系统下虚拟内存和物理内存的映射模型
1.1.3 ELF文件
我们已经知道了虚拟内存由用户空间和内核空间两部分组成。内核空间存放的是操作系统的数据,这一块空间对于应用程序来说是没有权限进行操作的,应用程序能操作的只有用户空间,所以本节对用户空间做进一步介绍。
操作系统能执行的文件都要符合一定的格式,比如Windows系统的.exe程序文件、.dll库文件都是PE(Portable Executable,可移植可执行)文件。Linux系统中可执行的文件(包括.o可重定位文件、.so库文件等)都是ELF(Executable and Linkable Format,可执行和链接格式)文件。系统要执行某个程序,首先要将程序中的数据加载进用户空间的虚拟内存块中,所以想要了解用户空间中有哪些数据,就需要先了解ELF文件格式。ELF文件格式如图1-3所示。

图1-3 ELF文件格式
ELF文件一般由ELF头(ELF Header)、数据段(Section)、段头表(Section Header Table)和程序头表(Program Header Table)组成,它们的解释见表1-1。
表1-1 ELF文件的组成部分解释

接着,我们再详细了解一下数据段和程序段。
1.数据段
通过Android NDK中提供的readelf工具,执行readelf-S libart.so命令来读取libart库的数据段信息,如图1-4所示,可以看到art虚拟机库文件有30多个数据段。

图1-4 libart库的数据段信息
由于Section段的数目比较多,这里仅对一些常见的数据段进行介绍。
❑代码段(.text):包含了可执行程序的机器指令。在运行时,该段的内容被加载到内存中,并由处理器执行。代码段通常具有可执行和只读权限。
❑数据段(.data):包含了程序的全局和静态变量的初始化值。在运行时,该段的内容被加载到内存中可以进行读写的区域,因此数据段通常具有可读写权限。
❑BSS段(.bss,Block Started by Symbol):用于存储程序中未初始化的全局和静态变量。在运行时,该段的内容会被初始化为0或空值。BSS段通常具有可读写权限。
❑只读数据段(.rodata):包含了程序中的只读常量数据,如字符串常量、常量表等。在运行时,该段的内容被加载到内存中,并具有只读权限。
❑调试信息段(.debug):包含了用于调试和符号解析的信息,如源码行号、变量名、函数名等。该段通常在发布版本中被剥离,以减小文件大小(基于安全、体积等因素考虑,线上的so文件中一般都会剔除debug段,所以图1-4中未见到debug段)。
❑动态段(.dynamic):该段主要包含了外部依赖库的信息,比如外部库的名称、外部库函数的地址等。
❑符号段(.symtab):该段主要包含了程序中的符号信息。符号信息包括符号的名称、类型、大小、值、段等,它们可以用于调试、链接、反汇编等。后文要介绍的一些
技术方案会用到符号,所以这里重点讲解一下什么是符号。编译器在将C++源代码编译成目标文件时,会对函数和变量的名字进行修饰,并生成对应的符号名。编译器不同,生成的符号也不一样,通过GCC编译器编译示例函数生成的对应符号见表1-2。
表1-2 示例函数及其对应的符号

这里以int Test::func(int)函数为例来进行讲解。GCC在生成方法的符号时,都以_Z开头,对于嵌套的名字后面紧跟N,然后是各个名称空间和类的名称长度及名称,所以是4Test4func,嵌套的方法以E表示结尾,非嵌套的方法则不需要用E表示结尾,最后是入参类型,所以这个函数的符号连起来就是_ZN4Test4funcEi。这些符号信息会绑定对应的类型、信息、地址等属性,形成符号条目并存放在符号表中。符号表可以帮助我们调试和定位程序运行中的问题。但出于对包体积和安全的考虑,线上运行时我们往往会把so库中的符号表移除。
2.程序段
我们再通过readelf工具执行readelf-l libart.so命令来读取libart库的程序段信息,如图1-5所示,可以看到art虚拟机库文件将31个数据段(Section)组织成了9个程序段(Program)。

图1-5 libart库的程序段信息
3.虚拟内存的结构
系统执行ELF格式的程序文件时,会将ELF文件中的数据段按照程序头组织的顺序加载进虚拟内存中,并放在低地址区域,即虚拟内存地址从0开始的区域。
当ELF文件中的数据在虚拟内存中存放好后,就要用到栈空间和堆空间了。其中,栈空间由编译器自动分配和释放,用于在函数执行时存放函数的参数值、局部变量、执行指令等;堆空间用于内存的动态分配,可以由开发者自己分配和释放,主要由malloc和free函数实现。但通过Java或者Kotlin进行Android开发时,不需要我们手动分配和释放堆空间的内存,因为虚拟机程序已经帮我们做了。堆内存的地址分配是从下到上的,栈内存的地址分配是从上到下的,这种相向的分配方式可以充分利用内存空间,因为如果堆空间和栈空间同时向一个方向分配内存,那么堆空间就必然要限制在一个固定的大小,以防止堆空间申请内存时的地址越界到栈空间。
栈空间再往上便是内核空间,用于存放操作系统的数据。图1-6是32位操作系统下ELF文件与虚拟内存的结构模型,通过该模型,我们可以对虚拟内存的结构有更加清晰的理解。

图1-6 ELF文件与虚拟内存的结构模型
1.1.4 虚拟内存申请和释放
应用程序是无法直接操作物理内存的,也无法感知到物理内存,应用程序面向的内存只有虚拟内存,所以我们在程序开发的过程中,分配或申请的内存实际上都是虚拟内存。那么要如何申请虚拟内存呢?其中又经历了哪些流程呢?我们接着往下看。
在进行Android应用开发时,如果我们是做Native开发的,就需要手动申请或释放内存。而如果只是写Java层的代码,那么不需要我们自己去申请内存,在创建对象和声明变量、常量等操作时,虚拟机会自动为这些数据申请内存。并且在使用完毕后,我们也不需要自己去做内存释放,虚拟机会自动释放这些内存。虚拟机申请和释放内存的方式,和我们做Native开发时申请和释放内存的方式是一样的,都是使用malloc函数在堆空间上为数据申请合适的内存,并在数据使用结束后使用free函数释放内存。
1.malloc函数
我们先看看malloc函数,该函数很简单,调用时我们只需要传入要申请的内存大小即可。如果分配成功,函数会返回void*指针地址,失败则返回NULL。

malloc函数是一个C语言库的函数,所以它分配内存最终还是得调用Linux系统提供的函数,让Linux内核去帮我们申请内存。而内核会根据申请的内存大小来执行不同的申请策略,主要有以下两种策略。
1)如果申请的内存小于或等于128KB,则内核会调用brk()函数来申请内存。sbrk()会将堆顶指针向高地址移动,获得新的虚拟内存空间,这种方式在申请和释放内存时会更加简单高效。
2)如果申请的内存大于128KB,则内核会调用mmap()函数,在堆中分配我们所需大小的内存空间。在申请内存时,这种方式可以对较大的内存进行内存对齐,提高访问效率。
2.mmap函数
mmap函数是一个很重要的函数,后面会反复用到,所以我们在这里对这个函数进行一定的讲解。mmap函数有两种用法:第一种是将一个文件映射到进程的虚拟内存中,进程可以通过内存访问的方式来读写对象;第二种是不映射文件,而是直接在虚拟内存中申请一块空的内存空间。mmap函数如下:

mmap函数的每个入参解释如下:
❑参数addr指向欲映射的内存起始地址,通常设为NULL,代表让系统自动选定地址,并在映射成功后返回该地址。
❑参数length表示映射到内存中的数据大小。
❑参数prot指定映射区域的读写权限。
❑参数flags指定映射时的特性,如是否允许其他进程映射这段内存等。
❑参数fd指定映射进内存的文件的描述符。
❑参数offset指定映射位置的偏移量,一般为0。
对于入参fd,我们可以传入想要映射进用户空间的文件地址,也可以不映射文件,这两种用法的解释如下:
1)如果想要映射磁盘文件到用户空间中,fd会传入我们要映射的文件。这种用法可以让我们读写文件的效率更高,可以用来实现数据的跨进程传输,比如Android共享内存机制、Binder通信都是通过mmap文件映射来实现的。
2)入参fd置为-1,表示不映射磁盘文件,而是在堆空间中申请一块内存。虚拟机的malloc函数使用的就是这种用法,它会直接在Java堆空间中申请一块内存。malloc函数申请的内存是虚拟内存,并且不会分配和映射真正的物理内存,只有当我们真正要往这块虚拟内存区域中写入数据,操作系统检查到对应的虚拟内存没有映射到物理内存而发生缺页中断时,才会分配一块同样大小的物理内存,并建立映射关系。这是一种懒加载技术,可以提升内存的使用效率。
3.free函数
内存的释放则是调用free函数,我们只需要传入要释放的首地址,这个地址是调用malloc函数后返回的地址。我们不需要传入要释放的内存大小,因为内存管理机制已经记录了这个地址分配的内存大小信息。当申请的内存不再使用时,一定要记得调用free函数释放掉这部分内存,不然会发生内存泄漏。

1.1.5 虚拟内存到物理内存
调用malloc函数只会申请一个虚拟内存空间,这个虚拟内存空间中没有任何数据,也不会占用真正的物理内存,只有当我们往这块虚拟内存中写入数据时,才会消耗物理内存的空间。大家可以通过下面的代码了解申请内存、写入数据、释放内存的整个流程。

当我们往申请的内存空间中写入数据时,流程如下:
1)触发缺页中断:当往指定的内存地址中写入数据时,如果此时该地址的页没有映射到物理内存页,就会触发缺页中断(Page Fault),操作系统接着会捕捉到这个中断异常。
2)分配物理内存:当操作系统捕捉到中断异常后,首先检查访问的虚拟内存页是否合法,即是否在进程的地址空间范围内。如果是合法的,操作系统就会为该虚拟内存页分配一个物理内存页。如果物理内存已经满了,操作系统可能会触发页面置换算法,将某些不常用的物理内存页换到磁盘上,从而腾出空间来分配新的物理内存页。
3)更新页表:一旦物理内存页被分配,操作系统就会更新该进程的页表,将该进程中的虚拟内存页与新分配的物理内存页进行映射。
4)写入数据:操作系统完成页表更新后,程序会继续执行,此时上面的代码就可以继续完成数据的写入操作了。