内存泄漏-原因、避免以及定位

2022-09-19 00:00:00 函数 内存 泄漏 分配 释放

你好,我是雨乐!

作为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
}

相关文章