你好,我是雨乐!
作为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)
。
内存泄漏分为以下两类:
本文主要分析堆内存泄漏,所以后面的内存泄漏均指的是堆内存泄漏
。
内存泄漏,主要指的是在堆(heap)上申请的动态内存泄漏,或者说是指针指向的内存块忘了被释放,导致该块内存不能再被申请重新使用。
之前在知乎上看了一句话,指针是C的精髓,也是初学者的一个坎。换句话说,内存管理是C的精髓,C/C++可以直接跟OS打交道,从性能角度出发,开发者可以根据自己的实际使用场景灵活进行内存分配和释放。虽然在C++中自C++11引入了smart pointer,虽然很大程度上能够避免使用裸指针,但仍然不能完全避免,最重要的一个原因是你不能保证组内其他人不适用指针,更不能保证合作部门不使用指针。
那么为什么C/C++中会存在指针呢?
这就得从进程的内存布局说起。
上图为32位进程的内存布局,从上图中主要包含以下几个块:
由于本文主要讲内存分配相关,所以下面的内容仅涉及到栈(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()函数分配的内存由编译器进行释放,无序手动操作。
堆(heap)是一种内存管理方式。内存管理对操作系统来说是一件非常复杂的事情,因为首先内存容量很大,其次就是内存需求在时间和大小块上没有规律(操作系统上运行着几十甚至几百个进程,这些进程可能随时都会申请或者是释放内存,并且申请和释放的内存块大小是随意的)。
堆这种内存管理方式的特点就是自由(随时申请、随时释放、大小块随意)。堆内存是操作系统划归给堆管理器(操作系统中的一段代码,属于操作系统的内存管理单元)来管理的,堆管理器提供了对应的接口_sbrk、mmap_等,只是该接口往往由运行时库进行调用,即也可以说由运行时库进行堆内存管理,运行时库提供了malloc/free函数由开发人员调用,进而使用堆内存。
正如我们所理解的那样,由于是在运行期进行内存分配,分配的大小也在运行期才会知道,所以堆只支持动态分配
,内存申请和释放的行为由开发者自行操作,这就很容易造成我们说的内存泄漏。
理解堆和栈的区别,对我们开发过程中会非常有用,结合上面的内容,总结下二者的区别。
对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak
进栈和出栈
截止到这里,栈和堆的基本特性以及各自的优缺点、使用场景已经分析完成,在这里给开发者一个建议,能使用栈的时候,就尽量使用栈,一方面是因为效率高于堆,另一方面内存的申请和释放由编译器完成,这样就避免了很多问题。
终于到了这一小节,其实,上面讲的那么多,都是为这一小节做铺垫。
在前面的内容中,我们对比了栈和堆,虽然栈效率比较高,且不存在内存泄漏、内存碎片等,但是由于其本身的局限性(不能多线程、大小受限),所以在很多时候,还是需要在堆上进行内存。
我们先看一段代码:
#include <stdio.h> #include <stdlib.h> int main() { int a; int *p; p = (int *)malloc(sizeof(int)); free(p); return 0; }
上述代码很简单,有两个变量a和p,类型分别为int和int *,其中,a和p存储在栈上,p的值为在堆上的某块地址(在上述代码中,p的值为0x1c66010),上述代码布局如下图所示:
以产生的方式来分类,内存泄漏可以分为四类:
产生内存泄漏的代码或者函数会被多次执行到,在每次执行的时候,都会产生内存泄漏。
与常发性内存泄漏
不同的是,偶发性内存泄漏函数只在特定的场景下才会被执行。
笔者在19年的时候,曾经遇到一个这种内存泄漏。有一个函数专门进行价格加密,每次泄漏3个字节,且只有在竞价成功的时候,才会调用此函数进行价格加密,因此泄漏的非常不明显。当时发现这个问题,是上线后的第二天,帮忙排查线上问题,发现内存较上线前上涨了点(大概几百兆的样子),了解glibc内存分配原理的都清楚,调用delete后,内存不一定会归还给OS,但是本着宁可信其有,不可信其无的心态,决定来分析是否真的存在内存泄漏。
当时用了个比较傻瓜式的方法,通过top
命令,将该进程所占的内存输出到本地文件,大概几个小时后,将这些数据导入Excel中,内存占用基本呈一条斜线,所以基本能够确定代码存在内存泄漏,所以就对新上线的这部分代码进行重新review
,定位到泄漏点,然后修复,重新上线。
这种内存泄漏在程序的生命周期内只会泄漏一次,或者说造成泄漏的代码只会被执行一次。
有的时候,这种可能不算内存泄漏,或者说设计如此。就以笔者现在线上的服务来说,类似于如下这种:
int main() { auto *service = new Service; // do sth service->Run();// 服务启动 service->Loop(); // 可以理解为一个sleep,目的是使得程序不退出 return 0; }
这种严格意义上,并不算内存泄漏,因为程序是这么设计的,即使程序异常退出,那么整个服务进程也就退出了,当然,在Loop()后面加个delete更好。
程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。
比较常见的隐式内存泄漏有以下三种: