对于普通进程,看到的是内核提供的虚拟内存,这些虚拟内存还需要通过页表,由系统映射为物理内存。
当进程通过malloc()申请虚拟内存后,系统并不会立即为其分配物理内存,而是首次访问时,才通过缺页异常陷入内核中分配内存。
对于应用程序来说,动态内存的分配和回收,是既核心又复杂的一个逻辑功能模块。管理内存的过程中,也很容易发生各种各样的事故。如:
- 没正确回收分配后的内存,导致了泄露
- 访问的是已分配内存边界外的地址,导致程序异常退出,等
一、内存的分配和回收
用户空间内存包括多个不同的内存段,比如:只读段、数据段、堆、栈以及文件映射段等,这些内存段正是应用程序使用内存的基本方式。
举例:程序中定义了一个局部变量,整数数组int data[64],就定义了一个可以存储64个整数的内存段。由于这是一个局部变量,它会从内存空间的栈中分配内存。
栈内存由系统自动分配和管理。一旦程序运行超出了这个局部变量的作用域,栈内存就会被系统自动回收,所以不会产生内存泄露的问题。
再如:事先并不知道数据大小,所以就需要用标准库函数malloc()__
在程序中动态分配内存。此时,系统会从内存空间中的堆中分配内存。
堆内存由应用程序自己来分配和管理。除非程序退出,这些堆内存并不会被系统自动释放,而是需要应用程序明确调用函数free()
来释放它们。如果应用程序没有正确释放堆内存,就会造成内存泄露。
疑问:其他数据段是否会导致内存泄露?
- 只读段:包括程序的代码和常量,由于是只读的,不会再去分配新的内存,所以也不会产生内存泄漏。
- 数据段:包括全局变量和静态变量,这些变量在定义时就已经确定了大小,所以也不会产生内存泄漏。
- 内存映射段:包括动态链接库和共享内存,其中共享内存由程序动态分配和管理。所以,如果程序在分配后忘了回收,就会导致跟堆内存类似的泄漏问题。
小结:可能导致内存泄露的内存段:堆内存、内存映射段
内存泄漏的危害非常大,这些忘记释放的内存,不仅应用程序自己不能访问,系统也不能把它们再次分配给其他应用。内存泄漏不断累积,甚至会耗尽系统内存。
虽然,系统最终可以通过 OOM (Out of Memory)机制杀死进程,但进程在 OOM 前,可能已经引发了一连串的反应,导致严重的性能问题。
比如,其他需要内存的进程,可能无法分配新的内存;内存不足,又会触发系统的缓存回收以及 SWAP 机制,从而进一步导致 I/O 的性能问题等等。
案例说明:
下面用一个计算斐波那契数列的案例,来看看内存泄漏问题的定位和处理方法。
斐波那契数列是一个这样的数列:0、1、1、2、3、5、8…,也就是除了前两个数是 0 和 1,其他数都由前面两数相加得到,用数学公式来表示就是 F(n)=F(n-1)+F(n-2),(n>=2),F(0)=0, F(1)=1。
二、案例
- 环境准备
- Ubuntu 18.04,适用其他Linux系统
- 机器配置:2CPU、8GB内存
- 预先安装:sysstat、docker以及bcc软件包
- BCC软件包它提供了一系列的 Linux 性能分析工具,常用来动态追踪进程和内核的行为。
bcc-tools 需要内核版本为 4.1 或者更高,如果你使用的是 CentOS7,或者其他内核版本比较旧的系统,那么你需要手动升级内核版本后再安装。 - 上述安装完成后,执行如下命令运行案例
- 确认案例是否正常启动,正常可以看到如下输出:
输出中,可以发现,这个案例会输出斐波那契数列的一些列数值。实际上,这些数值每隔1秒输出一次。
分析:知道了这些,我们应该怎么检查内存情况,判断有没有泄漏发生呢?我们首先想到的可能是 top 工具,不过,top 虽然能观察系统和进程的内存占用情况,但今天的案例并不适合。内存泄漏问题,我们更应该关注内存使用的变化趋势。
- 终端一:运行下面的 vmstat ,等待一段时间,观察内存的变化情况。
从输出中可以看到,内存的 free 列在不停的变化,并且是下降趋势;而 buffer 和 cache 基本保持不变。
未使用内存在逐渐减小,而 buffer 和 cache 基本不变,这说明,系统中使用的内存一直在升高。但这并不能说明有内存泄漏,因为应用程序运行中需要的内存也可能会增大。比如说,程序中如果用了一个动态增长的数组来缓存计算结果,占用内存自然会增长。
疑问:
那么该如何确定是否内存泄露呢?有没有简单方法找出让内存增长的进程,并定位增长内存用在哪儿呢?
应该想到可以用 top 或 ps 来观察进程的内存使用情况,然后找出内存使用一直增长的进程,最后再通过 pmap 查看进程的内存分布。
但这种方法并不太好用,因为要判断内存的变化情况,还需要你写一个脚本,来处理 top 或者 ps 的输出。
专门检测内存泄露的工具:memleak
memleak 可以跟踪系统或指定进程的内存分配、释放请求,然后定期输出一个未释放内存和相应调用栈的汇总情况(默认 5 秒)。
- 运行命令
从 memleak 的输出可以看到,案例应用在不停地分配内存,并且这些分配的地址没有被回收。
这里有一个问题,Couldn’t find .text section in /app,所以调用栈不能正常输出,最后的调用栈部分只能看到 [unknown] 的标志。
为什么会有这个错误?实际上,这是由于案例应用运行在容器中导致的。memleak 工具运行在容器之外,并不能直接访问进程路径 /app。
终端运行ls命令,会发现,这个路径不存在:
简单的解决方法:在容器外部构建相同路径的文件以及依赖库。这个案例只有一个二进制文件,所以只要把案例应用的二进制文件放到 /app 路径中,就可以修复这个问题。
说明:上述问题,容器外部/app目录也没有,但依然能查看调用栈
可以运行下面命令,把app二进制文件从容器中复制出来,然后重新运行memleak工具:
这一次,我们终于看到了内存分配的调用栈,原来是 fibonacci() 函数分配的内存没释放。
定位了内存泄漏的来源,下一步自然就应该查看源码,想办法修复它。查看案例源码app.c
你会发现, child() 调用了 fibonacci() 函数,但并没有释放 fibonacci() 返回的内存。所以,想要修复泄漏问题,在 child() 中加一个释放函数就可以了,比如:
修复后的代码app-fix.c,可以运行下面命令,验证内存泄露是否修复:
现在,我们看到,案例应用已经没有遗留内存,证明我们的修复工作成功完成。
三、小结
应用程序可以访问的用户内存空间,由只读段、数据段、堆、栈以及文件映射段等组成。其中,堆内存和文件映射段,需要应用程序来动态管理内存段,所以我们必须小心处理。不仅要会用标准库函数 malloc() 来动态分配内存,还要记得在用完内存后,调用库函数 free() 来释放它们。
- malloc() 和 free() 通常并不是成对出现,而是需要你,在每个异常处理路径和成功路径上都释放内存 。
- 在多线程程序中,一个线程中分配的内存,可能会在另一个线程中访问和释放。
- 更复杂的是,在第三方的库函数中,隐式分配的内存可能需要应用程序显式释放。
所以,为了避免内存泄漏,最重要的一点就是养成良好的编程习惯,比如分配内存后,一定要先写好内存释放的代码,再去开发其他逻辑。有借有还,才能高效运转,再借不难。