浅谈内存泄漏

引子

我们写的程序是如何一步一步运行起来的?

为什么虚拟内存大小可以比实际物理内存大

cpu是如何管理物理内存和映射内存的

……

内存泄漏是什么,如何定位和排查

内存泄漏的现象

在实际工作中,我们可能会遇到下面这些情况

  • 伴随着服务器中的后台任务持续地运行,系统中可用内存越来越少;
  • 应用程序正在运行时忽然被 OOM kill 掉了;
  • 进程看起来没有消耗多少内存,但是系统内存就是不够用了;
  • ……

类似问题,很可能就是内存泄漏导致的。我们都知道,内存泄漏指的是内存被分配出去后一直没有被释放,导致这部分内存无法被再次使用,甚至更加严重的是,指向这块内存空间的指针都不存在了,进而再也无法访问这块内存空间

应用程序的内存泄漏可能是堆内存(heap)的泄漏,也可能是内存映射区(Memory Mapping Region)的泄漏。这些不同类型的内存泄漏,它们的表现形式也是不一样的。那么什么是堆内存,什么是内存映射区,程序的内存模型又是怎样的呢,我们来一层一层的拨开迷雾。

内存布局

在32位机器上,每个进程都具有4GB的寻址能力。Linux系统会默认将高地址的1GB空间分配给内核,剩余的低3GB是用户可以使用的用户空间。下图是32位机器上Linux进程的一个典型的内存布局。

32bit memory prototype

图片来自极客时间

从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 - 0x00007fffffffffff0xffff800000000000 - 0xffffffffffffffff 两个 128 T 的空间。下面这张图展示了Intel 64机器上的Linux进程内存布局:

128 bit memory prototype

图片来自极客时间

我们用一张图,来表示进程的地址空间。图的左侧是说进程可以通过什么方式来更改进程虚拟地址空间,而中间就是进程虚拟地址空间是如何划分的,右侧则是进程的虚拟地址空间所对应的物理内存或者说物理地址空间。

memory structure of process

图片来自极客时间

应用程序首先会调用内存申请释放相关的函数,比如 glibc 提供的 malloc(3)、 free(3)、calloc(3) 等;或者是直接使用系统调用 mmap(2)、munmap(2)、 brk(2)、sbrk(2) 等。(括号里面的数字,表示的是 man page 的章节,一般 1 表示 shell command,2 表示系统调用,3 及以上都表示库函数)

我们用一张表格来简单汇总下这些不同的申请方式所对应的不同内存类型。

mmap private share annon file

图片来自极客时间

进程运行所需要的内存类型有很多种,总的来说,这些内存类型可以从是不是文件映射,以及是不是私有内存这两个不同的维度来做区分,也就是可以划分为上面所列的四类内存。

  • 私有匿名内存。进程的堆、栈,以及 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 这个文件里面读取的。

top and statm

图片来自极客时间

通过 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 进行观察(程序需要运行一段时间以后,才会更加明显)

![top mem](https://img-blog.csdnimg.cn/img_convert/c656d4fde6c2aadb763aa30054b72ed4.png

观察一段时间后发现,mem 中的 free 一直在不断变小,同时,有一个进程的虚拟内存VIRT 和物理内存 RSS 一直在变大,就是 pid 为 12117 的 a.out 进程。

我们通过 pidstat 对其进行持续观察,执行 一下命令

watch -d pidstat -r -p 12117

watch 命令用于监控 pidstat 命令的结果,-d 选项能够现实差异部分

pidstat 用于跟踪和分析进程的详细信息,-p attach 到某个具体的进程,-r 显示进程的内存使用情况。
pidstat
每两秒,VSZ 也就是虚拟内存和 RSS 物理内存就会变化一次,而且是在持续的不断增大,可能这个程序就发生了内存泄漏。我们可以通过 top + c 的命令查看 a.out 的具体执行程序,可以通过 proc 文件系统来观察,

cat /proc/12117/maps

查看该进程的内存映射
proc/maps
这个程序就是我们刚才执行的测试进程。

既然知道是哪个进程,下面就是对程序进行分析,定位程序中可能发生内存泄漏的地方了。

内存泄漏常见分析和定位方法

valgrind

valgrind 提供了一套工具集,可以用于程序调试和性能分析。

The Valgrind tool suite provides a number of debugging and profiling tools that help you make your programs faster and more correct. The most popular of these tools is called Memcheck. It can detect many memory-related errors that are common in C and C++ programs and that can lead to crashes and unpredictable behavior.

Memcheck 工具就是专门用来检测内存泄漏的。常见的使用参数如下

valgrind --trace-children=yes --leak-check=full --show-reachable=yes --track-origins=yes your_prog

用 valgrind 检测我们上述那个例子,

 valgrind --trace-children=yes --leak-check=full --show-reachable=yes --track-origins=yes ./a.out

valgrind memcheck
第一行信息就发现,valgrind 的 memcheck 工具检测到了一个错误 error。

  • 17205 表示的执行的 a.out 这个进程的 pid
  • HEAP SUMMARY 下面的每一个小段内容,就是 valgrind 检测出来的可能发生内存泄漏的区域,上述线索提示都是new 的堆没有释放导致,发生在 memleak.cp 的第 7 行。

继续往下看,我们来看一下 LEAK SUMMARY 中的错误提示

  • definitely lost,表示肯定发生了内存泄漏,也就是必须要修复的错误
  • indirectly lost,表示发生了内存泄漏,比如将堆内存的执行偏移到了堆的中间部位。这一部分可能是程序员的某种操作故意为之的。
  • possible lost,可能发生了内存泄漏,需要关注。

其中只有 definitely lost 是一定发生了内存泄漏,必须要修复。(这种说法有点类似于编译器中的 MAY 和 MUST 算法,也就是说 valgrind 是 MAY 算法,肯定会报出所有可能出现的错误,这之间的错误信息有一些就是误报。但是又有 MUST 算法的意思在里面,也就是说,definitely lost 的错误,一旦检测到就一定发生了内存泄漏,但是可能还有其他的内存泄漏的错误没有检测出来,也就是可能出现漏报的情况。这里似乎两者兼而有之,也说明了这款工具的强大)。

valgrind 的 memcheck 还能检测未初始化的变量,提示信息为 “Conditional jump or move depends on uninitialised value(s)”,但是根据这些信息分析出其根因(root cause) 却是一件非常困难的事情,可以通过 --track-origins=yes 选项来获取额外的辅助信息来帮助定位问题。

原理

我们发现,valgrind 使用的时候,最后一个参数,实际是在执行我们的可执行文件,也就说,valgrind 类似于 attach 的方式一直在监控我们的进程。实际上,valgrind 实现了一个仿真器的模拟环境,模拟了一个CPU 环境。进程执行过程中的cpu寄存器,内存访问等数据都被valgrind捕获并在仿真环境中进行模拟,就相当于进程是在 va

  • 1
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猫步旅人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值