检测和防范C++的内存泄漏的手段

143 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第13天,点击查看活动详情

内存泄漏

在C++中,内存是由我们开发人员来分配和释放的,和Java、Golang等拥有GC的语言不同的是,C++中,若是发生了内存泄漏,则可能导致我们的程序crash掉。

内存泄漏可以大致分为 忘记释放错误释放的情况 (并不严谨,这是我个人的分类方法

忘记释放

对于忘记释放来说,可能是 new 了内存,不释放,一点点的积累起来之后,慢慢的,我们的操作系统就无内存可分配了,还可能导致频繁的缺页,最后被OOM kill 掉我们的进程。

忘记释放内存

int main() {
  while (true)
  for (int i = 0; i < 10000; i ++) {
    new int;
  }
  return 0;
}

错误释放

对于错误释放来说,可能是用 new 分配的内存,然后我们则用 delete[] 去释放掉了这块内存,反之亦然。new 和 delete的不同步使用会给我们带来大麻烦,或者说,也许你很小心,每一个new 都配对了对应的delete。可是,比如一个类没有定义虚析构函数,然后你却使用一个父类指针(子类的内存)去delete之后,这样释放显然并没有释放完内存,这都属于在错误释放第一类中。

new delete 不配对

int main() {
  auto t = new int;
  delete[] t;
  t = new int[10];
  delete t;
  return 0;
}

未释放完

class Base {
public:
  ~Base() { std::cout << "delete base" << std::endl; }
};

class T : public Base {
  int arr[100];

public:
  ~T() { std::cout << "T base" << std::endl; }
};

int main() {
  Base *b = new T();
  delete b;
  return 0;
}

错误释放的第二类则是在于我们的重复释放一块内存,new了一次,结果却delete了两次;这种情况什么时候有呢?假设你的类中有一个成员有一块动态内存,是动态分配的,可是你并没有实现拷贝函数,结果导致了多个对象共享一块动态内存。然后在析构时,就发生的多次释放。

多次释放

class A {
  int *p;

public:
  A() : p(new int[100]) {}
  ~A() { std::cout << "delete to: " << p << std::endl; }
};

int main() {
  A a;
  A b = a;
  return 0;
}

上述举的几个例子都是很基础的小例子,真实的内存泄漏,情况比这个复杂的多

那么,我们如何去检测有没有内存泄漏呢?

检测内存泄漏

自己动手

自己动手,丰衣足食(bushi

那么,作为一个C++程序员,当然想的是去造一遍已经有的轮子了(刻板印象

我们来想一想,如何才能做到知道我们分配了多少内存呢?

我们分配内存都是通过 newdelete 来分配的 ( c++ 中

c++ 亲切的为我们提供了 newdelete 关键字

它会调用 operator new(size) 和 operator delete(void*) 来对我们进行内存管理

我们可以使用重载的方式,重载全局的new 和delete来实现我们的操作

在c++中,如果我们没有提供 new 和 delete,那么,系统会内建帮我们生成一个new 和 delete的

但是,我们重载之后,就不会再有了

可以重载 new 和 delete 之后,我们就可以玩出许多花样了

void *operator new(std::size_t size) {
  std::cout << "new\n";
  return malloc(size);
}
void operator delete(void *ptr) {
  std::cout << "delete\n";
  return free(ptr);
}

比如说,我们可以 重载 new(size_t),那么在其后面加一点参数不过分吧!

void *operator new(size_t size, const char *file_name, long line) {
  std::cout << file_name << " " << line << std::endl;
  return malloc(size);
}

欸,现在我就可以通过调用 new (__FILE__, __LINE__)new (__FILE__, __LINE__) xxx 这样的方式,来实现调用的行号定位了

我们还可以通过 宏定义 #define new new(__FILE__, __LINE__)的形式,我们就可以使用少量的代码,去侵入式的统计我们的内存泄漏问题

当然,这个对于C++来说还是有些不够用,因为,比如你用某一个库、或者某一个其他的调用,其中的内存统计,你是统计不了的,因为malloc和free不是受你所控制的结果

那么,我们其实可以hook掉malloc和free来试试,这是一个思路,我们去动系统的动态链接库,然后搞一点我们自己的统计代码进去,这样,我们就可以实现无缝的统计一些信息了

上述的方式是在自己的角度来考虑的这一件事

工具

那么,其实,我们有一些好用的工具来帮助我们检测内存泄漏问题

就拿Linux 平台来说

valgrind 提供了一些 debug 和优化的工具的工具箱,可以使得你的程序减少内存泄漏或者错误访问

valgrind 会默认使用 memcheck 去检查内存问题。

valgrind 的使用也是比较简单的一个工具,就如下图使用一般,还是比较清晰明了的

> g++ -g -o test test.cpp
> valgrind --tool=memcheck ./test

还有一些其实的内存检测软件,比如 debug_new(Windows)、Visual Leak Detecter (Windows)、Bounds Checker(Windows)、mtrace (Linux)、memwatch(Linux)

防范内存泄漏

那么,我们如何去防止内存泄漏呢?

GC?

的确,垃圾回收是个好东西,让别人帮我操心内存回收这一回事

我们不管内存回收,这个语言一下子就简洁了不少(我没内涵Java!

其实rust的所有权机制也蛮不错的,可以保证内存安全,就是写链表有点麻烦(

那么,在C++中,C++11为我们提供了几个管理内存的工具

其核心是引用计数

对一块内存的引用计数为0了,那么这块内存就应该被回收

那么,C++中拥有RAII机制,可以方便快捷使我们不用在意栈上对象的析构

离开作用域会自动帮我们析构

那么,利用引用计数的思想,其实,就可以大致解决内存问题

目前,C++ 11Boost库中提取了三个管理内存的类来用

分别是 shared_ptr, unique_ptr, weak_ptr

对于这三个来说,unique_ptr就像rust中的所有权一样,我只能移动转移出去,并且没有拷贝语义

shared_ptr的话,就是一个引用计数的例子,可以有多个对象拥有一个实例

直到最后才会释放掉内存

引用计数也是有所缺陷的,有循环计数的情况,即互相引用

struct B;
struct A {
  std::shared_ptr<B> b;
  ~A() { std::cout << "delete" << std::endl; }
};
struct B {
  std::shared_ptr<A> a;
  ~B() { std::cout << "delete" << std::endl; }
};
int main() {
  auto a = std::make_shared<A>();
  auto b = std::make_shared<B>();
  a->b = b;
  b->a = a;
  return 0;
}

可以自己试试看,上述代码并不会在离开作用域后自己析构,内存并没有释放

所有,拥有了引用计数,但并不是完美的,也要时时刻刻注意

这个时候就引入了我们的weak_ptr,这个指针不会增加引用,不会出现问题