C++内存溢出,看高级程序员的解决办法是什么

C/C++语言中,内存的分配与回收都是由开发人员在编写代码时主动完成的,好处是内存管理的开销较小,程序拥有更高的执行效率;弊端是依赖于开发者的水平,随着代码规模的扩大,极容易遗漏释放内存的步骤,或者一些不规范的编程可能会使程序具有安全隐患。如果对内存管理不当,可能导致程序中存在内存缺陷,甚至会在运行时产生内存故障错误。

内存泄漏是各类缺陷中十分棘手的一种,对系统的稳定运行威胁较大。当动态分配的内存在程序结束之前没有被回收时,则发生了内存泄漏。由于系统软件,如操作系统、编译器、开发环境等都是由C/C++语言实现的,不可避免地存在内存泄漏缺陷,特别是一些在服务器上长期运行的软件,若存在内存泄漏则会造成严重后果,例如性能下降、程序终止、系统崩溃、无法提供服务等。

所以,本文从原因避免以及定位几个方面去深入讲解,希望能给大家带来帮助。

概念

内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

当我们在程序中对原始指针(raw pointer)使用new操作符或者free函数的时候,实际上是在堆上为其分配内存,这个内存指的是RAM,而不是硬盘等永久存储。持续申请而不释放(或者少量释放)内存的应用程序,最终因内存耗尽导致OOM(out of memory)

方便大家理解内存泄漏的危害,举个简单的例子。有一个宾馆,共有100间房间,顾客每次都是在前台进行登记,然后拿到房间钥匙。如果有些顾客不需要该房间了,既不去前台处登记退房,也不归还钥匙,久而久之,前台处可用房间越来越少,收入也越来越少,濒临倒闭。当程序申请了内存,而不进行归还,久而久之,可用内存越来越少,OS就会进行自我保护,杀掉该进程,这就是我们常说的OOM(out of memory)

C++内存溢出,看高级程序员的解决办法是什么_第1张图片

 上图为32位进程的内存布局,从上图中主要包含以下几个块:

  • 内核空间:供内核使用,存放的是内核代码和数据
  • stack:这就是我们经常所说的栈,用来存储自动变量(automatic variable)
  • mmap:也成为内存映射,用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系
  • heap:就是我们常说的堆,动态内存的分配都是在堆上
  • bss:包含所有未初始化的全局和静态变量,此段中的所有变量都由0或者空指针初始化,程序加载器在加载程序时为BSS段分配内存
  • ds:初始化的数据块
    • 包含显式初始化的全局变量和静态变量
    • 此段的大小由程序源代码中值的大小决定,在运行时不会更改
    • 它具有读写权限,因此可以在运行时更改此段的变量值
    • 该段可进一步分为初始化只读区和初始化读写区
  • text:也称为文本段
    • 该段包含已编译程序的二进制文件。
    • 该段是一个只读段,用于防止程序被意外修改
    • 该段是可共享的,因此对于文本编辑器等频繁执行的程序,内存中只需要一个副本

由于本文主要讲内存分配相关,所以下面的内容仅涉及到栈(stack)和堆(heap)。

 

虚析构

记得08年面谷歌的时候,有一道题,面试官问,std::string能否被继承,为什么?

当时没回答上来,后来过了没多久,进行面试复盘的时候,偶然看到继承需要父类析构函数为virtual,才恍然大悟,原来考察点在这块。

下面我们看下std::string的析构函数定义:

~basic_string() { 
  _M_rep()->_M_dispose(this->get_allocator()); 
}

这块需要特别说明下,std::basic_string是一个模板,而std::string是该模板的一个特化,即std::basic_string。

typedef std::basic_string<charstring;

现在我们可以给出这个问题的答案:不能,因为std::string的析构函数不为virtual,这样会引起内存泄漏

仍然以一个例子来进行证明。

class Base {
 public:
  Base(){
    buffer_ = new char[10];
  }

  ~Base() {
    std::cout << "in Base::~Base" << std::endl;
    delete []buffer_;
  }
private:
  char *buffer_;

};

class Derived : public Base {
 public:
  Derived(){}

  ~Derived() {
    std::cout << "int Derived::~Derived" << std::endl;
  }
};

int main() {
  Base *base = new Derived;
  delete base;
  return 0;
}

上面代码输出如下:

in Base::~Base

可见,上述代码并没有调用派生类Derived的析构函数,如果派生类中在堆上申请了资源,那么就会产生内存泄漏

为了避免因为继承导致的内存泄漏,我们需要将父类的析构函数声明为virtual,代码如下(只列了部分修改代码,其他不变):

~Base() {
    std::cout << "in Base::~Base" << std::endl;
    delete []buffer_;
  }

然后重新执行代码,输出结果如下:

int Derived::~Derived
in Base::~Base

借助此文,我们再次总结下存在继承情况下,构造函数和析构函数的调用顺序。

派生类对象在创建时构造函数调用顺序:

  1. 调用父类的构造函数
  2. 调用父类成员变量的构造函数
  3. 调用派生类本身的构造函数

派生类对象在析构时的析构函数调用顺序:

  1. 执行派生类自身的析构函数
  2. 执行派生类成员变量的析构函数
  3. 执行父类的析构函数

为了避免存在继承关系时候的内存泄漏,请遵守一条规则:无论派生类有没有申请堆上的资源,请将父类的析构函数声明为virtual

循环引用

在C++开发中,为了尽可能的避免内存泄漏,自C++11起引入了smart pointer,常见的有shared_ptr、weak_ptr以及unique_ptr等(auto_ptr已经被废弃),其中weak_ptr是为了解决循环引用而存在,其往往与shared_ptr结合使用。

下面,我们看一段代码:

class Controller {
 public:
  Controller() = default;

  ~Controller() {
    std::cout << "in ~Controller" << std::endl;
  }

  class SubController {
   public:
    SubController() = default;

    ~SubController() {
      std::cout << "in ~SubController" << std::endl;
    }

    std::shared_ptr controller_;
  };

  std::shared_ptr sub_controller_;
};

int main() {
  auto controller = std::make_shared();
  auto sub_controller = std::make_shared();

  controller->sub_controller_ = sub_controller;
  sub_controller->controller_ = controller;
  return 0;
}

编译并执行上述代码,发现并没有调用Controller和SubController的析构函数,我们尝试着打印下引用计数,代码如下:

int main() {
  auto controller = std::make_shared();
  auto sub_controller = std::make_shared();

  controller->sub_controller_ = sub_controller;
  sub_controller->controller_ = controller;

  std::cout << "controller use_count: " << controller.use_count() << std::endl;
  std::cout << "sub_controller use_count: " << sub_controller.use_count() << std::endl;
  return 0;
}

编译并执行之后,输出如下:

controller use_count: 2
sub_controller use_count: 2

通过上面输出可以发现,因为引用计数都是2,所以在main函数结束的时候,不会调用controller和sub_controller的析构函数,所以就出现了内存泄漏

上面产生内存泄漏的原因,就是我们常说的循环引用

为了解决std::shared_ptr循环引用导致的内存泄漏,我们可以使用std::weak_ptr来单面去除上图中的循环。

class Controller {
 public:
  Controller() = default;

  ~Controller() {
    std::cout << "in ~Controller" << std::endl;
  }

  class SubController {
   public:
    SubController() = default;

    ~SubController() {
      std::cout << "in ~SubController" << std::endl;
    }

    std::weak_ptr controller_;
  };

  std::shared_ptr sub_controller_;
};

在上述代码中,我们将SubController类中controller_的类型从std::shared_ptr变成std::weak_ptr,重新编译执行,结果如下:

controller use_count: 1
sub_controller use_count: 2
in ~Controller
in ~SubController

从上面结果可以看出,controller和sub_controller均以释放,所以循环引用引起的内存泄漏问题,也得以解决。

可能有人会问,使用std::shared_ptr可以直接访问对应的成员函数,如果是std::weak_ptr的话,怎么访问呢?我们可以使用下面的方式:

std::shared_ptr controller = controller_.lock();

即在子类SubController中,如果要使用controller调用其对应的函数,就可以使用上面的方式。

避免

避免在堆上分配

众所周知,大部分的内存泄漏都是因为在堆上分配引起的,如果我们不在堆上进行分配,就不会存在内存泄漏了(这不废话嘛),我们可以根据具体的使用场景,如果对象可以在栈上进行分配,就在栈上进行分配,一方面栈的效率远高于堆,另一方面,还能避免内存泄漏,我们何乐而不为呢。

手动释放

  • 对于malloc函数分配的内存,在结束使用的时候,使用free函数进行释放
  • 对于new操作符创建的对象,切记使用delete来进行释放
  • 对于new []创建的对象,使用delete[]来进行释放(使用free或者delete均会造成内存泄漏)

避免使用裸指针

尽可能避免使用裸指针,除非所调用的lib库或者合作部门的接口是裸指针。

int fun(int *ptr) {// fun 是一个接口或lib函数
  // do sth
  
  return 0;
}

int main() {}
  int a = 1000;
  int *ptr = &a;
  // ...
  fun(ptr);
  
  return 0;
}

在上面的fun函数中,有一个参数ptr,为int *,我们需要根据上下文来分析这个指针是否需要释放,这是一种很不好的设计

使用STL中或者自己实现对象

在C++中,提供了相对完善且可靠的STL供我们使用,所以能用STL的尽可能的避免使用C中的编程方式,比如:

  • 使用std::string 替代char *, string类自己会进行内存管理,而且优化的相当不错
  • 使用std::vector或者std::array来替代传统的数组
  • 其它适合使用场景的对象

智能指针

自C++11开始,STL中引入了智能指针(smart pointer)来动态管理资源,针对使用场景的不同,提供了以下三种智能指针。

unique_ptr

unique_ptr是限制最严格的一种智能指针,用来替代之前的auto_ptr,独享被管理对象指针所有权。当unique_ptr对象被销毁时,会在其析构函数内删除关联的原始指针。

unique_ptr对象分为以下两类:

  • unique_ptr该类型的对象关联了单个Type类型的指针

    std::unique_ptr   p1(new Type)// c++11
    auto p1 = std::make_unique(); // c++14
    
  • unique_ptr 该类型的对象关联了多个Type类型指针,即一个对象数组

    std::unique_ptr p2(new Type[n]())// c++11
    auto p2 = std::make_unique(n); // c++14
    
  • 不可用被复制

    unique_ptr<inta(new int(0));
    unique_ptr<int> b = a;  // 编译错误
    unique_ptr<int> b = std::move(a); // 可以通过move语义进行所有权转移
    

根据使用场景,可以使用std::unique_ptr来避免内存泄漏,如下:

void fun() {
  unique_ptr<inta(new int(0));
  // use a
}

在上述fun函数结束的时候,会自动调用a的析构函数,从而释放其关联的指针。

shared_ptr

与unique_ptr不同的是,unique_ptr是独占管理权,而shared_ptr则是共享管理权,即多个shared_ptr可以共用同一块关联对象,其内部采用的是引用计数,在拷贝的时候,引用计数+1,而在某个对象退出作用域或者释放的时候,引用计数-1,当引用计数为0的时候,会自动释放其管理的对象。

void fun() {
  std::shared_ptr a; // a是一个空对象
  {
    std::shared_ptr b = std::make_shared(); // 分配资源
    a = b; // 此时引用计数为2
    {
      std::shared_ptr c = a; // 此时引用计数为3
    } // c退出作用域,此时引用计数为2
  } // b 退出作用域,此时引用计数为1// a 退出作用域,引用计数为0,释放对象
weak_ptr

weak_ptr的出现,主要是为了解决shared_ptr的循环引用,其主要是与shared_ptr一起来私用。和shared_ptr不同的地方在于,其并不会拥有资源,也就是说不能访问对象所提供的成员函数,不过,可以通过weak_ptr.lock()来产生一个拥有访问权限的shared_ptr。

std::weak_ptr a;
{
  std::shared_ptr b = std::make_shared();
  a = b
} // b所对应的资源释放

RAII

RAIIResource Acquisition is Initialization(资源获取即初始化)的缩写,是C++语言的一种管理资源,避免泄漏的用法。

利用的就是C++构造的对象最终会被销毁的原则。利用C++对象生命周期的概念来控制程序的资源,比如内存,文件句柄,网络连接等。

RAII的做法是使用一个对象,在其构造时获取对应的资源,在对象生命周期内控制对资源的访问,使之始终保持有效,最后在对象析构的时候,释放构造时获取的资源。

简单地说,就是把资源的使用限制在对象的生命周期之中,自动释放。

举个简单的例子,通常在多线程编程的时候,都会用到std::mutex,如下代码:

std::mutex mutex_;

void fun() {
  mutex_.lock();
  
  if (...) {
    mutex_.unlock();
    return;
  }
  
  mutex_.unlock()
}

在上述代码中,如果if分支多的话,每个if分支里面都要释放锁,如果一不小心忘记释放,那么就会造成故障,为了解决这个问题,我们使用RAII技术,代码如下:

std::mutex mutex_;

void fun() {
  std::lock_guard<std::mutex> guard(mutex_);

  if (...) {
    return;
  }
}

在guard出了fun作用域的时候,会自动调用mutex_.lock()进行释放,避免了很多不必要的问题。

定位

在发现程序存在内存泄漏后,往往需要定位泄漏点,而定位这一步往往是最困难的,所以经常为了定位泄漏点,采取各种各样的方案,甭管方案优雅与否,毕竟管他白猫黑猫,抓住老鼠才是好猫,所以在本节,简单说下笔者这么多年定位泄漏点的方案,有些比较邪门歪道,您就随便看看就行。

日志

这种方案的核心思想,就是在每次分配内存的时候,打印指针地址,在释放内存的时候,打印内存地址,这样在程序结束的时候,通过分配和释放的差,如果分配的条数大于释放的条数,那么基本就能确定程序存在内存泄漏,然后根据日志进行详细分析和定位。

char * fun() {
  char *p = (char*)malloc(20);
  printf("%s, %d, address is: %p", __FILE__, __LINE__, p);
  // do sth
  return p;
}

int main() {
  fun();
  
  return 0;
}

统计

统计方案可以理解为日志方案的一种特殊实现,其主要原理是在分配的时候,统计分配次数,在释放的时候,则是统计释放的次数,这样在程序结束前判断这俩值是否一致,就能判断出是否存在内存泄漏。

此方法可帮助跟踪已分配内存的状态。为了实现这个方案,需要创建三个自定义函数,一个用于内存分配,第二个用于内存释放,最后一个用于检查内存泄漏。代码如下:

static unsigned int allocated  = 0;
static unsigned int deallocated  = 0;
void *Memory_Allocate (size_t size)
{
    void *ptr = NULL;
    ptr = malloc(size);
    if (NULL != ptr) {
        ++allocated;
    } else {
        //Log error
    }
    return ptr;
}
void Memory_Deallocate (void *ptr) {
    if(pvHandle != NULL) {
        free(ptr);
        ++deallocated;
    }
}
int Check_Memory_Leak(void) {
    int ret = 0;
    if (allocated != deallocated) {
        //Log error
        ret = MEMORY_LEAK;
    } else {
        ret = OK;
    }
    return ret;
}

工具

在Linux上比较常用的内存泄漏检测工具是valgrind,所以咱们就以valgrind为工具,进行检测。

我们首先看一段代码:

#include 

void func (void){
    char *buff = (char*)malloc(10);
}

int main (void){
    func(); // 产生内存泄漏
    return 0;
}
  • 通过gcc -g leak.c -o leak命令进行编译
  • 执行valgrind --leak-check=full ./leak

在上述的命令执行后,会输出如下:

==9652== Memcheck, a memory error detector
==9652== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==9652== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==9652== Command: ./leak
==9652==
==9652==
==9652== HEAP SUMMARY:
==9652==     in use at exit: 10 bytes in 1 blocks
==9652==   total heap usage: 1 allocs, 0 frees, 10 bytes allocated
==9652==
==9652== 10 bytes in 1 blocks are definitely lost in loss record 1 of 1
==9652==    at 0x4C29F73: malloc (vg_replace_malloc.c:309)
==9652==    by 0x40052E: func (leak.c:4)
==9652==    by 0x40053D: main (leak.c:8)
==9652==
==9652== LEAK SUMMARY:
==9652==    definitely lost: 10 bytes in 1 blocks
==9652==    indirectly lost: 0 bytes in 0 blocks
==9652==      possibly lost: 0 bytes in 0 blocks
==9652==    still reachable: 0 bytes in 0 blocks
==9652==         suppressed: 0 bytes in 0 blocks
==9652==
==9652== For lists of detected and suppressed errors, rerun with: -s
==9652== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

valgrind的检测信息将内存泄漏分为如下几类:

  • definitely lost:确定产生内存泄漏
  • indirectly lost:间接产生内存泄漏
  • possibly lost:可能存在内存泄漏
  • still reachable:即使在程序结束时候,仍然有指针在指向该块内存,常见于全局变量

主要上面输出的下面几句:

==9652==    by 0x40052E: func (leak.c:4)
==9652==    by 0x40053D: main (leak.c:8)

提示在main函数(leak.c的第8行)fun函数(leak.c的第四行)产生了内存泄漏,通过分析代码,原因定位,问题解决。

valgrind不仅可以检测内存泄漏,还有其他很强大的功能,由于本文以内存泄漏为主,所以其他的功能就不在此赘述了,有兴趣的可以通过valgrind --help来进行查看

对于Windows下的内存泄漏检测工具,笔者推荐一款轻量级功能却非常强大的工具UMDH,笔者在十二年前,曾经在某外企负责内存泄漏,代码量几百万行,光编译就需要两个小时,尝试了各种工具(免费的和收费的),最终发现了UMDH,如果你在Windows上进行开发,强烈推荐。

经验之谈

在C/C++开发过程中,内存泄漏是一个非常常见的问题,其影响相对来说远低于coredump等,所以遇到内存泄漏的时候,不用过于着急,大不了重启嘛。

在开发过程中遵守下面的规则,基本能90+%避免内存泄漏:

  • 良好的编程习惯,只有有malloc/new,就得有free/delete
  • 尽可能的使用智能指针,智能指针就是为了解决内存泄漏而产生
  • 使用log进行记录
  • 也是最重要的一点,谁申请,谁释放

对于malloc分配内存,分配失败的时候返回值为NULL,此时程序可以直接退出了,而对于new进行内存分配,其分配失败的时候,是抛出std::bad_alloc,所以为了第一时间发现问题,不要对new异常进行catch,毕竟内存都分配失败了,程序也没有运行的必要了。

如果我们上线后,发现程序存在内存泄漏,如果不严重的话,可以先暂时不管线上,同时进行排查定位;如果线上泄漏比较严重,那么第一时间根据实际情况来决定是否回滚。在定位问题点的时候,可以采用缩小范围法,着重分析这次新增的代码,这样能够有效缩短问题解决的时间。

结语

C/C++之所以复杂、效率高,是因为其灵活性,可用直接访问操作系统API,而正因为其灵活性,就很容易出问题,团队成员必须愿意按照一定的规则来进行开发,有完整的review机制,将问题暴露在上线之前。这样才可以把经历放在业务本身,而不是查找这些问题上,有时候往往一个小问题就能消耗很久的时间去定位解决,所以,一定要有一个良好的开发习惯

好了,本期的文章就到这,我们下期见。

你可能感兴趣的:(c++)