我们知道,手机的内存是有限的,如果应用内存占用过大,轻则引起卡顿,重则导致应用崩溃或被系统强制杀掉,更严重的情况下会影响应用的留存率。因此,内存优化是性能优化中非常重要的一部分。但是,很多开发者对内存的认识还停留在应用开发这一层,平时只是参考网上的方案,对内存进行比较浅显的优化。想要深入进行内存优化,我们需要从操作系统的层面了解内存是怎么管理的,又是如何被使用的。
可能会有人疑问:“为什么做个内存优化需要从操作系统层了解内存呢?”我们确实可以在网上搜到很多内存优化的文章,但它们都是从上层应用出发进行优化的,而不同的应用因为环境不一样、业务不一样,很多优化方法都不能通用。因此,只有当我们从底层掌握了内存的原理,从下而上地制定优化方案,才能适用于任何业务,甚至当我们转型到 iOS、前端或者后端都能通用。
接下来,我们就从操作系统底层出发,重新认识内存。
我们先将目光放到操作系统的早期,在这个环境下,程序都是直接操作物理内存的。比如一个程序执行如下指令:
MOV REGISTER1,0
计算机会将位置为 0 的物理内存中的内容移到 REGISTER1 的寄存器中。在这种情况下,如果第二个程序在 0 的位置写入一个新的值,就会擦掉第一个程序存放在相同位置上的所有内容,导致第一个程序崩溃。
正因为应用程序可以直接操作物理内存,所以我们完全可以修改其他程序在内存中的数据,导致程序崩溃或者产生安全问题。因此,对当时的操作系统来说,同时运行多个程序很困难。
为了解决这个问题,我们自然而然会想到:不允许应用程序直接操作物理内存。于是虚拟内存的技术诞生了。
为了更好地了解什么是虚拟内存,我们先看看早期直接操作物理内存系统下的内存模型长什么样。从下面内存模型的简化图中我们可以看到,物理内存中存在两块数据,一个是操作系统的数据,一个是应用程序的数据。除此之外,其实还会有设备驱动程序的数据,它们不是我们了解的重点就先不列上去了。
什么是虚拟内存?
虚拟内存技术相当于给每个程序一个独占且连续的内存,比如 32 位系统下是 4G(2^32),只不过这个内存是虚拟的。同时,虚拟内存需要能够映射到真实的物理内存。简化的内存模型如下
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-haHe4vHL-1670148923898)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/edd00859a2094eaa964f8ce588df1f18~tplv-k3u1fbpfcp-watermark.image?)]
从上图的简化内存模型,我们可以看到,每个程序都可以独享一块虚拟内存。在 Linux 系统上,一个进程代表着一个程序,这里我们可以理解成每个进程都独享一块虚拟内存,这块虚拟内存在 32 位系统下是 4G(2^32),64 位系统下是 2^48,即 256TB(这里不是 2^64,是因为 256TB 已经足够大了,如果用 2^64,会有大量的寻址空间浪费)。
其次,虚拟内存都由应用程序和操作系统这两部分组成。其中,应用程序这部分虚拟内存是应用独占的,操作系统这部分虚拟内存则由所有进程共享,而所有进程的操作系统这部分虚拟内存都指向了同一段物理内存。虚拟内存到物理内存的映射由操作系统来实现,操作系统在做映射操作时会寻找可用的物理内存,不可能出现覆盖其他数据的情况,这让同时运行多个程序成为了可能。
上图的虚拟内存是一个简化的模型。实际上,虚拟内存和物理内存都是按照页来管理和映射的,一页的大小为 4KB,我们来看一个 32 位 Android 系统、物理内存为 2G 的设备的内存模型。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ysAU5ah2-1670148923899)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/efb87e2370624ccdb09642d2018c0581~tplv-k3u1fbpfcp-watermark.image?)]
可以看到,物理内存和虚拟内存是通过 4K 大小的页一一对应的。这个时候,如果我们再用文章最开头的那个指令:
MOV REGISTER1,0
在虚拟内存技术的加持下,此时计算机就不会直接将物理地址为 0 的内存移到 REGISTER1 寄存器了,而是先寻找虚拟地址 0 对应的物理地址 4096,然后将物理地址为 4096 的内容移到 REGISTER1 寄存器中
MOV REGISTER1,4096
虚拟地址转换成物理地址是由计算机的内存管理单元(MMU)完成的,它属于硬件部分而不是系统软件部分,所以转换速度很快。
虚拟内存的内存模型
知道了虚拟内存由操作系统和应用程序两部分组成,并且虚拟内存都由页来维护和管理之后,我们再深入了解一下 Linux 系统中虚拟内存的内存模型,它需要和 Linux 系统的可执行文件,也就是 ELF 文件一起配合来看。
为了让你理解起来更简单,这里的 ELF 文件格式也被我简化了,后面用到的时候再进行深入介绍。在 Linux 系统中,存放操作系统的虚拟内存区域被称为内核空间,剩下的存放应用的虚拟内存区域称为用户空间。 内核空间占用了 1G,位于虚拟地址的高地址区域,而 ELF 文件的一些数据,是存放在低地址的区域(即从地址 0 开始)。下面我详细解释一下内存模型中的几个区域
-
栈:由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。
-
堆:动态内存分配,可以由开发者自己分配和释放(malloc 和 free 函数实现),Android 开发时不需要我们手动分配和释放,因为虚拟机程序已经帮我们做了。堆的开始地址由变量 start_brk 描述,堆的当前地址由变量 brk 描述。
-
BSS:存放全局未初始化,静态未初始化数据。
-
数据段:存放全局初始化,静态初始化数据。
-
程序代码区:存放的是 ELF 文件代码段。
可以看到,栈内存的分配是从上到下的,而堆内存的分配是从下到上的,这种方式可以最大程度利用虚拟内存的空间。
虚拟内存分配
前面我们已经知道,应用是没法直接操作物理内存的,所以我们在开发 App 时分配的内存实际都是虚拟内存。那么我们怎么申请虚拟内存呢?
开发 Android 应用时,并不需要我们自己去分配内存,直接 new 一个对象,声明一个变量或者常量即可,也不需要我们自己去做释放,但所有的数据都需要内存,这些都是虚拟机帮我们做。虚拟机分配申请内存主要使用的是 malloc() 函数,它是 C 语言库的一个标准函数。
void *malloc(size_t size)
malloc 函数是一个 C 语言库的函数,所以它分配内存最终还是得调用 Linux 系统提供的函数,让 Linux 内核去帮我们申请一块内存。内核会调用 mmap() 函数,在堆中分配我们想要的内存空间大小。 mmap() 函数是 Linux 系统一个很重要的函数,我们需要深刻认识它。
void *mmap(void *addr,size_t length,int prot,int flags,int fd, off_t offset);
- 参数 addr 指向欲映射的内存起始地址,通常设为 NULL,代表让系统自动选定地址,映射成功后返回该地址;
- 参数 length 表示将文件中多大的部分映射到内存;
- 参数 prot 指定映射区域的读写权限;
- 参数 flags 指定映射时的特性,如是否允许其他进程映射这段内存;
- 参数 fd 指定映射内存的文件描述符;
- 参数 offset 指定映射位置的偏移量,一般为 0。
mmap 函数有 2 种用法:
- 映射磁盘文件到用户空间中;
- 匿名映射,不映射磁盘文件,而是向映射区申请一块内存,此时的 fd 入参传 -1。
第 1 种用法可以让我们读文件的效率更高(比如 Android 读取 dex 文件就是通过 mmap 来提高读取速度),也可以用来实现数据跨进程传输(比如 Android 共享内存机制、Binder 通信都是通过 mmap 来实现的)。malloc() 函数使用了 mmap 函数的第 2 种用法,即在 Heap 区域中申请一块内存。
需要注意的,这里申请的内存都是虚拟内存,并且这个时候并不会分配真正的物理内存,只有当我们真正要往这块虚拟内存区域写入数据时,操作系统检查到对应的虚拟内存没有映射到物理内存,便会发生缺页中断,然后分配一块同样大小的物理内存,并建立映射关系。这是一种懒加载技术,也是内存优化的方案之一。
malloc() 函数在申请内存小于 128k 时会使用 sbrk() 函数,sbrk() 会将堆顶指针(即前面提到的 brk)向高地址移动,获得新的虚存空间,这些策略都是基于性能考虑的。比如 Android 虚拟机在分配大对象时,也会专门放在 LargeObjectSpcace 中,这些就不展开讲了。至于 Linux 系统是如何发生缺页中断,如何分配物理内存,如何建立映射关系的,都属于 Linux 系统相关知识了,更详细的知识点会在后面的篇章中结合实战项目穿插着讲解。
小结
事实上,直接操作物理内存的操作系统并没有消失,我们现在的嵌入式设备,如冰箱,微波炉等等都能直接操作物理内存。这其实也符合它们的使用场景,直接操作物理内存会让性能开销更小,操作也更方便。但需要同时运行多个软件的系统都有虚拟内存,可以说虚拟内存是现代操作系统最重要的发明之一了。
当我们重新认识了内存后,我们再来看内存优化,它其实分为两部分。
- 一是物理内存的优化:也就是这个程序实际消耗的物理内存。
- 二是虚拟内存的优化:在前面我们也知道了 32 位机只有 3G 的虚拟内存可用,所以一个比较大的Android 程序,很容易就会出现虚拟内存不足的情况(64 位系统就完全不用担心这个问题)。
在后面的章节中,我会针对这两部分,总结出体系的优化方法论,再搭配讲解一些优化实践。