引子
我们写的程序是如何一步一步运行起来的?
为什么虚拟内存大小可以比实际物理内存大
cpu是如何管理物理内存和映射内存的
……
内存泄漏是什么,如何定位和排查
内存泄漏的现象
在实际工作中,我们可能会遇到下面这些情况
- 伴随着服务器中的后台任务持续地运行,系统中可用内存越来越少;
- 应用程序正在运行时忽然被
OOM kill
掉了; - 进程看起来没有消耗多少内存,但是系统内存就是不够用了;
- ……
类似问题,很可能就是内存泄漏导致的。我们都知道,内存泄漏指的是内存被分配出去后一直没有被释放,导致这部分内存无法被再次使用,甚至更加严重的是,指向这块内存空间的指针都不存在了,进而再也无法访问这块内存空间。
应用程序的内存泄漏可能是堆内存(heap)的泄漏,也可能是内存映射区(Memory Mapping Region)的泄漏。这些不同类型的内存泄漏,它们的表现形式也是不一样的。那么什么是堆内存,什么是内存映射区,程序的内存模型又是怎样的呢,我们来一层一层的拨开迷雾。
内存布局
在32位机器上,每个进程都具有4GB的寻址能力。Linux系统会默认将高地址的1GB空间分配给内核,剩余的低3GB是用户可以使用的用户空间。下图是32位机器上Linux进程的一个典型的内存布局。
图片来自极客时间
从0地址开始的内存区域并不是直接就是代码段区域,而是一段不可访问的保留区。这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址。
- 代码段 .text,属性为只读
- 数据段 .data,属性为可读可写,保存有初始化的全局变量和初始化的静态变量
- BSS段 .bss,存放的是未初始化的全局变量和未初始化的静态变量,这里特别需要注意,未初始化的全局变量并不一定就是我们这里说的,直接保存在 BSS 段,后面我们会重点介绍。
- 堆 Heap,就是通过动态申请的内存,可以通过 malloc/new,或者系统调用 brk/sbrk/mmap 来申请的内存空间。这部分空间,由程序员手动申请和释放,也主要是内存泄漏可能发生的地方。堆的增长方向是从小到大。
- 文件映射和匿名映射区,一般就是动态库加载的内存区域
- 栈 stack,是由系统维护的内存空间,这部分的内存比较小,效率上也比堆要快很多。由系统进行申请和释放,不会发生内存泄漏,但是无限制使用栈空间,会导致栈溢出的错误发生。栈内存的增长方向与堆正好相反,从大到小。
64位系统理论的寻址范围是2^64,也就是16EB。但是,依据 Intel 64 架构里定义的 canonical form 标准,64 位系统目前只支持低 48 位的总线寻址,在第 48-63 位填充全 0 或者全 1。也就是说,64为系统的寻址能力为 2^48,即 256 TB。而且根据canonical address的划分,地址空间天然地被分割成两个区间,分别是0x0 - 0x00007fffffffffff
和0xffff800000000000 - 0xffffffffffffffff
两个 128 T 的空间。下面这张图展示了Intel 64机器上的Linux进程内存布局:
图片来自极客时间
我们用一张图,来表示进程的地址空间。图的左侧是说进程可以通过什么方式来更改进程虚拟地址空间,而中间就是进程虚拟地址空间是如何划分的,右侧则是进程的虚拟地址空间所对应的物理内存或者说物理地址空间。
图片来自极客时间
应用程序首先会调用内存申请释放相关的函数,比如 glibc 提供的 malloc(3)、 free(3)、calloc(3) 等;或者是直接使用系统调用 mmap(2)、munmap(2)、 brk(2)、sbrk(2) 等。(括号里面的数字,表示的是 man page 的章节,一般 1 表示 shell command,2 表示系统调用,3 及以上都表示库函数)
我们用一张表格来简单汇总下这些不同的申请方式所对应的不同内存类型。
图片来自极客时间
进程运行所需要的内存类型有很多种,总的来说,这些内存类型可以从是不是文件映射,以及是不是私有内存这两个不同的维度来做区分,也就是可以划分为上面所列的四类内存。
- 私有匿名内存。进程的堆、栈,以及
mmap(MAP_ANON | MAP_PRIVATE)
这种方式申请的内存都属于这种类型的内存。其中栈是由操作系统来进行管理的,应用程序无需关注它的申请和释放;堆和私有匿名映射则是由应用程序(程序员)来进行管理的,它们的申请和释放都是由应用程序来负责的,所以它们是***容易产生内存泄漏的地方***。 - 共享匿名内存。进程通过
mmap(MAP_ANON | MAP_SHARED)
这种方式来申请的内存,比如说 tmpfs 和 shm。这个类型的内存也是由应用程序来进行管理的,所以也***可能会发生内存泄漏***。父子进程之间通过共享内存进行通讯,就是通过这种方式来实现的。 - 私有文件映射。进程通过
mmap(MAP_FILE | MAP_PRIVATE)
这种方式来申请的内存,比如进程将共享库(Shared libraries)和可执行文件的代码段(Text Segment)映射到自己的地址空间就是通过这种方式。对于共享库和可执行文件的代码段的映射,这是通过操作系统来进行管理的,应用程序无需关注它们的申请和释放。而应用程序直接通过mmap(MAP_FILE | MAP_PRIVATE)
来申请的内存则是需要应用程序自己来进行管理,这也是***可能会发生内存泄漏的地方***。 - 共享文件映射。进程通过
mmap(MAP_FILE | MAP_SHARED)
这种方式来申请的内存,我们在上一个模块课程中讲到的File Page Cache就属于这类内存。这部分内存也需要应用程序来申请和释放,所以***也存在内存泄漏的可能性***。不同进程之间的通信,就可以通过共享文件映射的方式来实现。
NOTE: 进程虚拟地址空间是通过Paging(分页)这种方式来映射为物理内存的,进程调用malloc()或者mmap()来申请的内存都是虚拟内存,只有往这些内存中写入数据后(比如通过memset),才会真正地分配物理内存 。
引申:虚拟地址如何映射到物理地址空间?
那么,如果进程只是调用 malloc()
或者 mmap()
而不去写这些地址,即不去给它分配物理内存,是不是就不用担心内存泄漏了?答案是这依然需要关注内存泄露,因为这可能导致进程虚拟地址空间耗尽,即虚拟地址空间同样存在内存泄露的问题。
如何观察和判断是否发生了内存泄漏
我们常用来观察进程内存的工具,比如说pmap、ps、top等,都可以很好地来观察进程的内存。
首先我们可以使用top来观察系统所有进程的内存使用概况,打开top后,然后按g再输入3,从而进入内存模式就可以了。在内存模式中,我们可以看到各个进程内存的%MEM、VIRT、RES、CODE、DATA、SHR、nMaj、nDRT,这些信息都是从 /proc/[pid]/statm
和 /proc/[pid]/stat
这个文件里面读取的。
图片来自极客时间
通过 pmap 我们能够清楚地观察一个进程的整个的地址空间,包括它们分配的物理内存大小,这非常有助于我们对进程的内存使用概况做一个大致的判断。比如说,如果地址空间中 [heap] 太大,那有可能是堆内存产生了泄漏;再比如说,如果进程地址空间包含太多的 vma(可以把 maps 中的每一行理解为一个 vma),那很可能是应用程序调用了很多mmap 而没有 munmap;再比如持续观察地址空间的变化,如果发现某些项在持续增长,那很可能是那里存在问题。
举个例子
假设我们现在有下面这个程序
#include <iostream>
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[]) {
while (1) {
int* a = new int[102400];
memset(a, 0, 102400);
sleep(1);
}
return 0;
}
很明显,这个程序存在内存泄漏问题。假设运行其中的程序我们并不知道,首先我们通过 top 进行观察(程序需要运行一段时间以后,才会更加明显)
。
valgrind 的 memcheck 还能检测未初始化的变量,提示信息为 “Conditional jump or move depends on uninitialised value(s)”,但是根据这些信息分析出其根因(root cause) 却是一件非常困难的事情,可以通过 --track-origins=yes
选项来获取额外的辅助信息来帮助定位问题。
原理
我们发现,valgrind 使用的时候,最后一个参数,实际是在执行我们的可执行文件,也就说,valgrind 类似于 attach 的方式一直在监控我们的进程。实际上,valgrind 实现了一个仿真器的模拟环境,模拟了一个CPU 环境。进程执行过程中的cpu寄存器,内存访问等数据都被valgrind捕获并在仿真环境中进行模拟,就相当于进程是在 valgr