作为C/C++开发人员,内存泄漏是最容易遇到的问题之一,这是由C/C++语言的特性引起的。C/C++语言与其他语言不同,需要开发者去申请和释放内存,即需要开发者去管理内存,如果内存使用不当,就容易造成段错误(segment fault)
或者内存泄漏(memory leak)
。
今天,借助此文,分析下项目中经常遇到的导致内存泄漏的原因,以及如何避免和定位内存泄漏。
主要内容如下:
背景
C/C++语言中,内存的分配与回收都是由开发人员在编写代码时主动完成的,好处是内存管理的开销较小,程序拥有更高的执行效率;弊端是依赖于开发者的水平,随着代码规模的扩大,极容易遗漏释放内存的步骤,或者一些不规范的编程可能会使程序具有安全隐患。如果对内存管理不当,可能导致程序中存在内存缺陷,甚至会在运行时产生内存故障错误。
内存泄漏是各类缺陷中十分棘手的一种,对系统的稳定运行威胁较大。当动态分配的内存在程序结束之前没有被回收时,则发生了内存泄漏。由于系统软件,如操作系统、编译器、开发环境等都是由C/C++语言实现的,不可避免地存在内存泄漏缺陷,特别是一些在服务器上长期运行的软件,若存在内存泄漏则会造成严重后果,例如性能下降、程序终止、系统崩溃、无法提供服务
等。
所以,本文从原因
、避免
以及定位
几个方面去深入讲解,希望能给大家带来帮助。
概念
内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
当我们在程序中对原始指针(raw pointer)使用new
操作符或者free
函数的时候,实际上是在堆上为其分配内存,这个内存指的是RAM,而不是硬盘等永久存储。持续申请而不释放(或者少量释放)内存的应用程序,最终因内存耗尽导致OOM(out of memory)
。
方便大家理解内存泄漏的危害,举个简单的例子。有一个宾馆,共有100间房间,顾客每次都是在前台进行登记,然后拿到房间钥匙。如果有些顾客不需要该房间了,既不去前台处登记退房,也不归还钥匙,久而久之,前台处可用房间越来越少,收入也越来越少,濒临倒闭。当程序申请了内存,而不进行归还,久而久之,可用内存越来越少,OS就会进行自我保护,杀掉该进程,这就是我们常说的OOM(out of memory)
。
分类
内存泄漏分为以下两类:
-
堆内存泄漏:我们经常说的内存泄漏就是堆内存泄漏,在堆上申请了资源,在结束使用的时候,没有释放归还给OS,从而导致该块内存永远不会被再次使用
-
资源泄漏:通常指的是系统资源,比如socket,文件描述符等,因为这些在系统中都是有限制的,如果创建了而不归还,久而久之,就会耗尽资源,导致其他程序不可用
本文主要分析堆内存泄漏,所以后面的内存泄漏均指的是堆内存泄漏
。
根源
内存泄漏,主要指的是在堆(heap)上申请的动态内存泄漏,或者说是指针指向的内存块忘了被释放,导致该块内存不能再被申请重新使用。
之前在知乎上看了一句话,指针是C的精髓,也是初学者的一个坎。换句话说,内存管理是C的精髓,C/C++可以直接跟OS打交道,从性能角度出发,开发者可以根据自己的实际使用场景灵活进行内存分配和释放。虽然在C++中自C++11引入了smart pointer,虽然很大程度上能够避免使用裸指针,但仍然不能完全避免,最重要的一个原因是你不能保证组内其他人不适用指针,更不能保证合作部门不使用指针。
那么为什么C/C++中会存在指针呢?
这就得从进程的内存布局说起。
进程内存布局
上图为32位进程的内存布局,从上图中主要包含以下几个块:
-
内核空间:供内核使用,存放的是内核代码和数据
-
stack:这就是我们经常所说的栈,用来存储自动变量(automatic variable)
-
mmap:也成为内存映射,用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系
-
heap:就是我们常说的堆,动态内存的分配都是在堆上
-
bss:包含所有未初始化的全局和静态变量,此段中的所有变量都由0或者空指针初始化,程序加载器在加载程序时为BSS段分配内存
-
ds:初始化的数据块
-
包含显式初始化的全局变量和静态变量
-
此段的大小由程序源代码中值的大小决定,在运行时不会更改
-
它具有读写权限,因此可以在运行时更改此段的变量值
-
该段可进一步分为初始化只读区和初始化读写区
-
-
text:也称为文本段
-
该段包含已编译程序的二进制文件。
-
该段是一个只读段,用于防止程序被意外修改
-
该段是可共享的,因此对于文本编辑器等频繁执行的程序,内存中只需要一个副本
-
由于本文主要讲内存分配相关,所以下面的内容仅涉及到栈(stack)和堆(heap)。
栈
栈一块连续的内存块,栈上的内存分配就是在这一块连续内存块上进行操作的。编译器在编译的时候,就已经知道要分配的内存大小,当调用函数时候,其内部的遍历都会在栈上分配内存;当结束函数调用时候,内部变量就会被释放,进而将内存归还给栈。
class Object {
public:
Object() = default;
// ....
};
void fun() {
Object obj;
// do sth
}
在上述代码中,obj就是在栈上进行分配,当出了fun作用域的时候,会自动调用Object的析构函数对其进行释放。
前面有提到,局部变量会在作用域(如函数作用域、块作用域等)结束后析构、释放内存。因为分配和释放的次序是刚好完全相反的,所以可用到堆栈先进后出(first-in-last-out, FILO)的特性,而 C++ 语言的实现一般也会使用到调用堆栈(call stack)来分配局部变量(但非标准的要求)。
因为栈上内存分配和释放,是一个进栈和出栈的过程(对于编译器只是一个指令),所以相比于堆上的内存分配,栈要快的多。
虽然栈的访问速度要快于堆,每个线程都有一个自己的栈,栈上的对象是不能跨线程访问的,这就决定了栈空间大小是有限制的,如果栈空间过大,那么在大型程序中几十乃至上百个线程,光栈空间就消耗了RAM,这就导致heap的可用空间变小,影响程序正常运行。
设置
在Linux系统上,可用通过如下命令来查看栈大小:
ulimit -s
10240
在笔者的机器上,执行上述命令输出结果是10240(KB)即10m,可以通过shell命令修改栈大小。
ulimit -s 102400
通过如上命令,可以将栈空间临时修改为100m,可以通过下面的命令:
/etc/security/limits.conf
分配方式
静态分配
静态分配由编译器完成,假如局部变量以及函数参数等,都在编译期就分配好了。
void fun() {
int a[10];
}
上述代码中,a占10 * sizeof(int)
个字节,在编译的时候直接计算好了,运行的时候,直接进栈出栈。
动态分配
可能很多人认为只有堆上才会存在动态分配,在栈上只可能是静态分配。其实,这个观点是错的,栈上也支持动态分配
,该动态分配由alloca()函数进行分配。栈的动态分配和堆是不同的,通过alloca()函数分配的内存由编译器进行释放,无需手动操作。
特点
-
分配速度快:分配大小由编译器在编译期完成
-
不会产生内存碎片:栈内存分配是连续的,以FILO的方式进栈和出栈
-
大小受限:栈的大小依赖于操作系统
-
访问受限:只能在当前函数或者作用域内进行访问
堆
堆(heap)是一种内存管理方式。内存管理对操作系统来说是一件非常复杂的事情,因为首先内存容量很大,其次就是内存需求在时间和大小块上没有规律(操作系统上运行着几十甚至几百个进程,这些进程可能随时都会申请或者是释放内存,并且申请和释放的内存块大小是随意的)。
堆这种内存管理方式的特点就是自由(随时申请、随时释放、大小