C++进阶与拔高(九)(C++内存管理)(智能指针与内存泄漏)

第四章 C++内存管理

       C++内存管理几乎存在于程序设计的方方面面,内存泄漏在每个C++程序中都有可能发生。参考网上大佬的博客,我们在这章对C++内存管理有一个大致的认识。本章包括内存管理,内存泄漏以及内存回收。C++测试岗位和开放岗位的面试很看重这一部分,因此很有必要说一下。本章讲解的内容均来自于博客:

       http://cnblogs.com/qiubole/archive/2008/03/07/1094770.html。本文是对上述博客的一种精炼。

4.1 内存管理

4.1.1 C++内存分配方式

       在C++中,内存分为5个区,分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

就是那些由new分配的内存块,一般一个new对应一个delete,如果程序在过程中没有释放,则在程序结束后系统自动释放。

在执行函数时,函数内部局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元被自动释放。

自由存储区

由malloc等分配的内存块,和堆类似,由free释放。

全局/静态存储区

全局变量和静态变量被分配到同一块内存中。

常量存储区

比较特殊的存储区,存放常量,且不允许修改。

一、堆与栈的区别

       void f() {int *p=new int[5];}

       上述短短一句话就包含了堆和栈,看到new我们知道这个内存是堆。指针p是函数内部的局部变量,因此它的内存应该是栈。于是上述语句可以这么解释,在栈内存中存储了一个指向一维堆内存的指针p。

       堆和栈的区别总结如下:

管理方式不同

堆:释放工作由程序员控制,容易产生memory leak

栈:编译器自动管理。

空间大小不同

堆:32位系统下为4G。

栈:默认是1M,但是可以手动修改。

能否产生碎片不同

堆:频繁的new/delete势必会造成空间的不连续,造成大量碎片。

栈:不存在该问题。

生长方向不同

堆:向上生长,即向着内存地址增加的方向。

栈:向下生长,即向着内存地址减小的方向。

分配方式不同

堆:都是动态分配的,不存在静态分配。

栈:静态分配有编译器完成,比如局部变量的分配;动态分配由alloca函数分配。但是栈的动态分配由编译器释放,无需程序员操作。

分配效率不同

堆:C/C++函数库提供,库函数按照指定算法搜索大小够用的空间。

栈:计算机提供的数据结构,计算机分配专门的寄存器存放栈的地址,入栈出栈都有专门的指令执行。

栈的效率明显高于堆。

       但是堆有一个更加显眼的好处,它比栈更加灵活,且可以分配更多的内存。

二、控制C++的内存分配

       比较好的做法是用全局的new和delete来代替系统的内存分配符;或者一个一个类的重载new和delete,这样可以灵活地控制对象的内存分配。

重载全局的new和delete操作符重载

对单个类的new和delete操作符重载

void * operator new(size_t size)

{

  void *p=malloc(size);

  return p;

}

void * operator delete(void *p)

{

  free(p);

}

class TestClass

{

  public:

void *operator new(size_t size);

void *operator delete(void *p);

……

};

void *TestClass:: operator new(size_t size)

{

  void *p=malloc(size);

  return p;

}

void *TestClass:: operator delete(void *p)

{

  free(p);

}

       必须小心的处理对象数组的分配,需要重载new[ ]和delete[ ]。

4.1.2 常见的内存错误及对策

问题

对策

内存未分配成功,却使用了它。

在使用内存之前检查指针是否为NULL。

如果指针p是函数的参数,则在函数入口处使用assert(p!=NULL)进行检查;如果是用malloc或者new申请内存,则应该用if(p==NULL)进行预防错处理。

内存分配成功,但尚未初始化就引用了它。

犯这种错误主要由2个原因,一是没有初始化;二是误以为内存的缺省初值全为0,导致引用初值错误。

对策是在初始化时赋初值。

内存分配成功且已初始化,但是操作越过了内存的边界。

比如使用数组时,下标多1或者少1操作;for循环语句中的循环次数。

忘记释放内存,造成内存泄漏。

含有这种错误的函数每被调用一次就会丢失一块内存,刚开始时可能会正常运行,但是中间有可能程序突然死掉,系统提示:内存耗尽。

对策是:动态内存的申请与释放必须配对,程序中malloc与free使用次数一定要相同,new和delete同理。

释放了内存却还在使用它。

产生这种错误的情况有3种:

  1. 程序中的对象调用关系过于复杂,难以搞清楚某个对象是否已经释放了内存。此时需要重新设计数据结构。
  2. 函数的return语句写错了,注意不要返回指向“栈内存”的指针或引用。
  3. 使用free和delete释放内存后,并没有将指针值设为NULL,导致产生野指针。

       总结上表可以得出以下规则,我们需要在编写代码时注意。

  • 用malloc和new申请内存之后,应立刻检查指针值是否为NULL。防止使用指针值为NULL的内存。
  • 不要忘记为数组或动态内存分配初值,防止未被初始化的内存直接作为右值使用。
  • 避免数组或指针的下标越界。
  • 动态内存的申请与释放必须配对,防止内存泄漏。
  • 使用free或delete释放了内存之后,应立即将指针设置为NULL,防止野指针。

4.1.3 指针与数组的对比

       C/C++中,指针和数组在很多地方是可以互相替换的,但是这2者可不是等价的。

       数组要么在静态存储区创建,要么在栈上创建。数组名对应者一块内存(不是指向),其地址与容量在生命周期内保持不变,只有数组内容可以改变。

       指针可以随时指向任意类型的内存块,由于它是“可变的”,因此我们常用指针来操作动态内存。

例 4.1.1 下面代码有什么错误。

#include<iostream>
#include<string>
using namespace std;
int main()
{
  char a[]=hello;
  a[0]=’X’;
  cout<<a<<endl;
  char *p=”world”;//p指向字符串常量
  p[0]=’X’;  //编译器不会发现该错误
  cout<<p<<endl;
}

       上面的代码中,字符数组a的容量为6,a的内容可以改变。指针p指向字符串常量”world”,它存储在静态存储区。从语法上看,编译器并不觉得p[0]有什么不妥,但是该句企图修改字符串常量的内容,于是报错。

       内容的复制与比较:不能直接对数组名进行复制(a=b)与比较(a==b),分别需要调用strcpy和strcmp函数。

       计算内存容量:用运算符sizeof()计算数组的容量,若有字符数组a和指向它的指针p,则sizeof(a)和sizeof(p)输出是不一样的。sizeof(p)相当于sizeof(char *)。但是当a[]作为函数的参数进行传递时,sizeof(a)等于sizeof(char *)。

       如果函数的参数是一个指针,则不能用该指针申请动态内存,参考下例:

例 4.1.2 下面的代码有什么错误

void GetMemory(char *p, int num)
{
  p=(char *)malloc(sizeof(char) *num);//用malloc函数为p申请一块容量为num的类型为字符的内存
}
void Test(void)
{
  char *str=NULL;
  GetMemory(str,100);//str仍为NULL
  strcpy(str,”hello”);//运行错误
}

       错误出现在GetMemory()中。编译器在处理自定义函数时,总是为函数的每一个参数制作一个临时副本,指针参数p的副本是_p,编译器处理时使_p=p。如果函数体内的程序修改了_p的内容,就会导致参数p的内容做出相应的修改。本例中,_p申请了新内存,只是把_p指向的内存地址改变了,但是p没有改变。且每执行一次GetMemory都会泄露一块内存,因为没有用free释放。如果非要用指针参数申请内存,则应用指向指针的指针。

void GetMemory2(char **p, int num)
{
  *p=(char *)malloc(sizeof(char) *num);//用malloc函数为p申请一块容量为num的类型为字符的内存
}
void Test2(void)
{
  char *str=NULL;
  GetMemory2(&str,100);//注意参数是&str
  strcpy(str,”hello”);
}

4.1.4 杜绝野指针

       野指针不是NULL指针,是指向”垃圾”内存的指针。一般NULL指针不易用错,用if语句就可以很容易地判断,但是这个方法对野指针是无效的。产生野指针的原因有以下3种:

  1. 指针变量没有初始化。

char *p=NULL’

char *str=(char *)malloc(100);

2、指针p被free或者delete后没有置为NULL,让人误以为p是一个合法的指针。

 

3、指针操作超越了变量的作用范围。

 

4.1.5 malloc/free和new/delete的区别

       malloc与free是C++的标准库函数,new和delete是C++的运算符。它们都可用于申请动态内存以及释放内存。对于非内部数据类型的对象而言,光用malloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,在消亡时自动执行析构函数。由于malloc/free是库函数而不是运算符,因此不在编译器的控制权限之内,不能把执行构造函数和析构函数的任务加在malloc/free上。因此C++使用运算符new完成动态内存的分配和初始化工作,用运算符delete完成清理与释放内存的工作。即不能使用malloc/free完成动态对象的内存管理

函数malloc的原型

void * malloc(size_t size)

malloc的返回值类型是void *,所以在调用malloc时要显示地进行类型转换,将void *转化为所需要的指针类型。

用malloc申请一块长度为length的整形类型的内存

int *p=(int *)malloc(sizeof(int) *length)

malloc函数本身并不需要识别申请的内存是什么类型,只关心内存的总字节数,因此用sizeof求解

使用new完成同样的操作

int *p=new int[length];

形式简单是因为new内置了sizeof,类型转换和类安全检查功能。

函数free的原型

void free(void * memblock)

p的类型和它所指向的内存的容量都是已知的。

delete释放对象数组

delete []a;

不要忘了加方括号。

4.1.6 内存耗尽

       如果在动态申请内存时找不到足够大的内存块,malloc和new将返回NULL指针,表示内存申请失败。解决内存耗尽通常有3种方法。

1、判断指针是否为NULL,若是则马上用return语句终止本函数。代码如下:

void Func(void)
{
  A *a=new A;
  if (a==NULL) return;
}

2、判断指针是否为NULL,若是则立马用exit(1)终止整个程序的运行。

void Func(void)
{
  A *a=new A;
  if (a==NULL) 
{
cout<<”Memory Exhausted!”<<endl;
exit(1);
}

3、为new和malloc设置异常处理函数

4.2 智能指针

4.2.1 智能指针的思想

例 4.2.1 从一个简单的例子入手,了解智能指针。

void remodel(std::string & str)
{
    std::string * ps = new std::string(str);
    ...
    if (weird_thing())
        throw exception();
    str = *ps; 
    delete ps;
    return;
}

       当出现异常时,即weird_thing()为真时,将不会执行delete,这样会导致内存泄露。解决方法很简单,就是在throw语句之前加delete ps。但是在比较大的文件工程中,这样做明显很难。如果当remodel函数终止时,本地变量都将自动从栈中删除,因此指针ps所占据的内存也被释放,这样就省了很多事。如果ps是一个类对象指针,我们可以通过调用析构函数来释放它的内存。但是ps只是一个常规指针,不是具有析构函数的对象指针。

       这正是auto_ptr,unique_ptr和shared_ptr这几个智能指针的设计思想。即将基本类型指针封装为类对象指针,并在析构函数里编写delete语句删除指针所指向的内存空间。因此,使用auto_ptr修改上述代码后如下:

  • 包含头文件memory(只能指针所在的头文件)
  • 将指向string的指针替换为指向string的智能指针对象
  • 删除delete语句

例 4.2.2

# include <memory>
void remodel (std::string & str)
{
    std::auto_ptr<std::string> ps (new std::string(str));
    ...
    if (weird_thing ())
        throw exception(); 
    str = *ps; 
    // delete ps; NO LONGER NEEDED
    return;
}

4.2.2 智能指针的作用

       说到智能指针,就不得不说一下栈。我们知道,栈上的资源由系统管理,申请和释放资源由栈的策略来进行。智能指针就是行为类似指针的栈对象,并非指针类型。在栈对象生命周期结束时,智能指针通过析构函数释放由它管理的堆内存。

       智能指针不是指针类型,是个栈对象

       智能指针包含了reset()方法,如果不传递参数,则智能指针会释放当前管理的内存。如果传递一个对象,则智能指针会释放当前对象,来管理新传入的对象。STL一共提供了四种智能指针:auto_ptr,unique_ptr,shared_ptr和weak_ptr。下面将分别介绍它们。

一、auto_ptr

       auto_ptr是C++ 98标准库中一个轻量级的智能指针。其就是在内部使用一个成员变量,指向一块内存资源,并在析构函数中释放内存资源。但是auto_ptr不支持复制和赋值,即拷贝物与被拷贝物之间并不等价,这就很坑了。这一点可以从源码得到答案,auto_ptr的拷贝构造函数和拷贝赋值操作符函数所接受的参数类型都是非const 的引用类型(auto_ptr<_Ty>&即我们可以且需要修改源对象),而不是我们一般应该使用的const引用类型。

auto_ptr(auto_ptr<_Ty>& _Right) _THROW0():_Myptr(_Right.release())
{   // construct by assuming pointer from _Right auto_ptr
}
template<class _Other>auto_ptr<_Ty>& operator=(auto_ptr<_Other>& _Right) _THROW0()
{   // assign compatible _Right (assume pointer)
    reset(_Right.release());
    return (*this);
}

二、unique_ptr

       unique_ptr是用于取代auto_ptr的产物,其无法进行复制构造,也无法进行复制、赋值操作,即无法使2个unique_ptr指向同一个对象(同一时刻只能有一个unique_str指向给定对象)。但是可以使用移动move()来进行移动构造和移动赋值操作。此外它可以保存指向某个对象的指针,当它本身被删除或释放时,会删除它所指向的对象(都删除了)。

       但是相比较与auto_ptr最大的不同是它可以在容器中保存。它可以为动态申请的内存提供异常安全。

Eg:

#include<iostream>
#include<memory>
int main()
{
{
  std::unique_ptr<int> uptr(new int(10));//绑定动态对象
  std::unique_ptr<int> uptr2=uptr;//错误,不能赋值
    std::unique_ptr<int> uptr2(uptr);//错误,不能拷贝
  std::unique_ptr<int> uptr2=std::move(uptr);//转化所有权
    uptr2.release();//释放所有权
  }
  //超过uptr作用域,内存释放
}

三、shared_ptr

       从名字可以看出,资源可以被多个shared_ptr指针共享,它使用计数机制来表明资源被几个指针共享。可以通过成员函数use_count()来查看资源的所有者个数。每使用它一次,内部的计数都会加一,当调用release()时,当前指针会释放资源所有权,计数减一。当计数等于0时,资源会被释放。

       shared_ptr和前两者最大的区别是:可以直接赋值或调用拷贝构造函数,且不会清空原本的智能指针。

       shared_ptr最大的陷阱时循环引用,循环引用会导致堆内存无法释放,导致内存泄露。

例 4.2.3

#include<iostream>
#include<memory>
int main()
{
  {
int a=10;
std::shared_ptr<int> ptra=std::make_shared<int>(a);
//make_shared函数的主要功能是在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr
std::shared_ptr<int> ptra2(ptra);//拷贝
std::cout<<ptra.use_count()<<std::endl;

int b=20;
int *pb=&a;
std::shared_ptr<int> ptrb=pb;//错误,不能使用一个原始指针初始化多个shared_ptr
std::shared_ptr<int> ptrb=std::make_shared<int>(b);
ptra2=ptrb;//赋值
pb=ptrb.get();//获取原始指针
std::cout<<ptra.use_count()<<std::endl;
std::cout<<ptrb.use_count()<<std::endl;
  }
}

四、weak_ptr

       weak_ptr是用来解决shared_ptr循环引用产生的死锁问题的,如果2个shared_ptr互相引用,那么这2个指针的引用计数器永远不会下降为0,即资源永远不会释放。weak_ptr是对对象的一种弱引用,不会增加对象的引用计数,且它和shared_ptr之间可以相互转化。如果一块内存同时被shared_ptr和weak_ptr所引用,则当所有shared_ptr析构以后,不管weak_ptr有没有引用该内存,内存都会被释放。所以weak_ptr并不能保证它指向的内存是有效的,在使用前需要检查它是否为空指针。

       weak_ptr不像其余3种智能指针那样,可以通过构造函数直接分配内存,它必须通过shared_ptr来共享内存。并且由于它没有重载”*”和”->”操作符,即便分配到对象它也无法调用。weak_ptr可以通过使用成员函数use_count()查看当前引用计数。

4.3 内存泄漏

4.3.1 内存泄漏的概念和分类

       一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的,使用完之后必须显示释放的内存。应用程序一般使用malloc,new等函数从堆中分到一块内存,使用完之后,程序必须负责相应的调用free或delete释放该内存块,否则这块内存就不能再次使用,这就是内存泄漏。

       程序在函数入口处分配内存,在出口处释放内存,但是如果函数中有一个语句可以使函数在中途任何地方退出,那么这个函数就会发生内存泄漏。按发生方式来说,内存泄漏分为以下4类:

常发性内存泄漏

发生内存泄漏的代码被多次执行,每次被执行都会导致一块内存泄漏。

偶发性内存泄漏

发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。

一次性内存泄漏

发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。

隐式内存泄漏

程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。

4.3.2 检测内存泄漏

       检测内存泄漏的关键是要能截获住对分配内存和释放内存的函数的调用。截获住这两个函数,我们就能跟踪每一块内存的生命周期,比如,每当成功的分配一块内存后,就把它的指针加入一个全局的list中;每当释放一块内存,再把它的指针从list中删除。这样,当程序结束的时候,list中剩余的指针就是指向那些没有被释放的内存。这里只是简单的描述了检测内存泄漏的基本原理。

      如果要检测堆内存的泄漏,那么需要截获住malloc/realloc/free和new/delete就可以了(其实new/delete最终也是用malloc/free的,所以只要截获前面一组即可)。对于其他的泄漏,可以采用类似的方法,截获住相应的分配和释放函数。

       在Windows平台下,检测内存泄漏的工具常用的一般有三种,MS C-Runtime Library内建的检测功能;外挂式的检测工具,诸如,Purify,BoundsChecker等;利用Windows NT自带的Performance Monitor。这三种工具各有优缺点,MS C-Runtime Library虽然功能上较之外挂式的工具要弱,但是它是免费的;Performance Monitor虽然无法标示出发生问题的代码,但是它能检测出隐式的内存泄漏的存在,这是其他两类工具无能为力的地方。

猜你喜欢

转载自blog.csdn.net/Lao_tan/article/details/81292676