C++的new、delete、new[]、delete[]分析讨论

部分转自http://blog.csdn.net/songthin/article/details/1703966

部分来自网上的某个边边角角吧……

 

new”是C++的一个关键字,同时也是操作符。关于new的话题非常多,因为它确实比较复杂,也非常神秘,下面我将把我了解到的与new有关的内容做一个总结。

 

new的过程


当我们使用关键字new在堆上动态创建一个对象时,它实际上做了三件事:获得一块内存空间、调用构造函数、返回正确的指针。当然,如果我们创建的是简单类型的变量,那么第二步会被省略。假如我们定义了如下一个类A:

class A
{
   int i;
public:
   A(int _i) :i(_i*_i) {}
   void Say()  { printf("i=%d/n", i); }
};
//调用new:
A* pa = new A(3);

那么上述动态创建一个对象的过程大致相当于以下三句话(只是大致上):

A* pa = (A*)malloc(sizeof(A));          //获得一块内存空间
pa->A::A(3);                                                    //调用构造函数
return pa;                                                        //返回正确的指针

虽然从效果上看,这三句话也得到了一个有效的指向堆上的A对象的指针pa,但区别在于,当malloc失败时,它不会调用分配内存失败处理程序 new_handler,而使用 new的话会的。因此我们还是要尽可能的使用 new,除非有一些特殊的需求。

 

new的三种形态


到目前为止,本文所提到的new都是指的“new operator”或称为“new expression”,但事实上在C++中一提到new,至少可能代表以下三种含义:new operatoroperator newplacement  new

new operator就是我们平时所使用的new,其行为就是前面所说的三个步骤,我们不能更改它。但具体到某一步骤中的行为,如果它不满足我们的具体要求时,我们是有可能更改它的。三个步骤中最后一步只是简单的做一个指针的类型转换,没什么可说的,并且在编译出的代码中也并不需要这种转换,只是人为的认识罢了。但前两步就有些内容了。

new operator的第一步分配内存实际上是通过调用operator new来完成的,这里的new实际上是像加减乘除一样的操作符,因此也是可以重载的。operator new默认情况下首先调用分配内存的代码,尝试得到一段堆上的空间,如果成功就返回,如果失败,则转而去调用一个new_hander,然后继续重复前面过程。如果我们对这个过程不满意,就可以重载operator new,来设置我们希望的行为。例如:

class A
{
public:
   void* operatornew(size_t size)
   {
       printf("operatornew called/n");
       return ::operatornew(size);
   }
};
A* a = new A();

这里通过:: operator new调用了原有的全局的 new,实现了在分配内存之前输出一句话。全局的 operator  new也是可以重载的,但这样一来就不能再递归的使用 new来分配内存,而只能使用malloc了:

void* operator new(size_tsize)
{
   printf("global new/n");
   return malloc(size);
}

相应的, delete也有 delete operatoroperator  delete之分,后者也是可以重载的。并且,如果重载了 operator  new,就应该也相应的重载 operator  delete,这是良好的编程习惯。

new的第三种形态——placement new是用来实现定位构造的,因此可以实现new operator三步操作中的第二步,也就是在取得了一块可以容纳指定类型对象的内存后,在这块内存上构造一个对象,这有点类似于前面代码中的“p->A::A(3);”这句话,但这并不是一个标准的写法,正确的写法是使用placement new

#include <new.h>
void main()
{
   char s[sizeof(A)];
   A* p = (A*)s;
   new(p) A(3);      //p->A::A(3);
   p->Say();
}

对头文件<new>或<new.h>的引用是必须的,这样才可以使用 placement new。这里“ new(p) A(3)” 这种奇怪的写法便是placement new了,它实现了在指定内存地址上用指定类型的构造函数来构造一个对象的功能,后面A(3)就是对构造函数的显式调用。这里不难发现,这块指定的地址既可以是,又可以是,placement对此不加区分。但是,除非特别必要,不要直接使用 placement new ,这毕竟不是用来构造对象的正式写法,只不过是 new operator的一个步骤而已。使用 new operator的编译器会自动生成对 placement new的调用的代码,因此也会相应的生成使用 delete时调用析构函数的代码。如果是像上面那样在栈上使用了 placement new,则必须手工调用析构函数,这也是显式调用析构函数的唯一情况:

p->~A();

当我们觉得默认的new operator对内存的管理不能满足我们的需要,而希望自己手工的管理内存时,placement new就有用了。STL中的allocator就使用了这种方式,借助placement new来实现更灵活有效的内存管理。


处理内存分配异常


正如前面所说,operator new的默认行为是请求分配内存,如果成功则返回此内存地址,如果失败则调用一个new_handler,然后再重复此过程。于是,想要从operator new的执行过程中返回,则必然需要满足下列条件之一:

l         分配内存成功

l         new_handler中抛出bad_alloc异常

l         new_handler中调用exit()或类似的函数,使程序结束

于是,我们可以假设默认情况下operator new的行为是这样的:

void* operator new(size_tsize)
{
   void* p = null
   while(!(p = malloc(size)))
   {
       if(null == new_handler)
          throw bad_alloc();
       try
       {
          new_handler();
       }
       catch(bad_alloce)
       {
          throw e;
       }
       catch(…)
       {}
   }
   return p;
}

在默认情况下, new_handler的行为是抛出一个 bad_alloc异常,因此上述循环只会执行一次。但如果我们不希望使用默认行为,可以自定义一个 new_handler,并使用 std::set_new_handler函数使其生效。在自定义的 new_handler中,我们可以抛出异常,可以结束程序,也可以运行一些代码使得有可能有内存被空闲出来,从而下一次分配时也许会成功,也可以通过 set_new_handler来安装另一个可能更有效的 new_handler。例如:

void MyNewHandler()
{
   printf(“New handler called!/n”);
   throw std::bad_alloc();
}
std::set_new_handler(MyNewHandler);

这里 new_handler程序在抛出异常之前会输出一句话。应该注意,在 new_handler的代码里应该注意避免再嵌套有对 new的调用,因为如果这里调用 new再失败的话,可能会再导致对 new_handler的调用,从而导致无限递归调用。 ——这是我猜的,并没有尝试过。

在编程时我们应该注意到对new的调用是有可能有异常被抛出的,因此在new的代码周围应该注意保持其事务性,即不能因为调用new失败抛出异常导致不正确的程序逻辑或数据结构的出现。例如:

class SomeClass
{
   static int count;
   SomeClass() {}
public:
   static SomeClass* GetNewInstance()
   {
       count++;
       return newSomeClass();
   }
};

静态变量count用于记录此类型生成的实例的个数,在上述代码中,如果因 new分配内存失败而抛出异常,那么其实例个数并没有增加,但count变量的值却已经多了一个,从而数据结构被破坏。正确的写法是:

static SomeClass*GetNewInstance()
{
   SomeClass* p = newSomeClass();
   count++;
   return p;
}

这样一来,如果 new失败则直接抛出异常,count的值不会增加。类似的,在处理线程同步时,也要注意类似的问题:

void SomeFunc()
{
   lock(someMutex); //加一个锁
   delete p;
   p = newSomeClass();
   unlock(someMutex);
}

此时,如果new失败,unlock将不会被执行,于是不仅造成了一个指向不正确地址的指针p的存在,还将导致someMutex永远不会被解锁。这种情况是要注意避免的。


STL的内存分配与traits技巧


在《STL源码剖析》一书中详细分析了SGISTL的内存分配器的行为。与直接使用new operator不同的是,SGI STL并不依赖C++默认的内存分配方式,而是使用一套自行实现的方案。首先SGI STL将可用内存整块的分配,使之成为当前进程可用的内存,当程序中确实需要分配内存时,先从这些已请求好的大内存块中尝试取得内存,如果失败的话再尝试整块的分配大内存。这种做法有效的避免了大量内存碎片的出现,提高了内存管理效率。

为了实现这种方式,STL使用了placement new,通过在自己管理的内存空间上使用placement new来构造对象,以达到原有newoperator所具有的功能。

template<class T1, class T2>
inline voidconstruct(T1* p, const T2& value)
{
   new(p)T1(value);
}

此函数接收一个已构造的对象,通过拷贝构造的方式在给定的内存地址p上构造一个新对象,代码中后半截T1(value)便是 placement new语法中调用构造函数的写法,如果传入的对象value正是所要求的类型T1,那么这里就相当于调用拷贝构造函数。 类似的,因使用了placement new,编译器不会自动产生调用析构函数的代码,需要手工的实现:

template<class T>
inline voiddestory(T* pointer)
{
   pointer->~T();
}

与此同时,STL中还有一个接收两个迭代器的destory版本,可将某容器上指定范围内的对象全部销毁。典型的实现方式就是通过一个循环来对此范围内的对象逐一调用析构函数。如果所传入的对象是非简单类型,这样做是必要的,但如果传入的是简单类型,或者根本没有必要调用析构函数的自定义类型(例如只包含数个int成员的结构体),那么再逐一调用析构函数是没有必要的,也浪费了时间。为此,STL使用了一种称为“type traits”的技巧,在编译器阶段就判断出所传入的类型是否需要调用析构函数:

template<class ForwardIterator>
inline voiddestory(ForwardIterator first, ForwardIterator last)
{
   __destory(first, last, value_type(first));
}

其中value_type()用于取出迭代器所指向的对象的类型信息,于是:

template<classForwardIterator, class T>
inline void__destory(ForwardIterator first, ForwardIterator last, T*)
{
   typedef typename__type_traits<T>::has_trivial_destructortrivial_destructor;
   __destory_aux(first, last,trivial_destructor());
}

//如果需要调用析构函数:
template<classForwardIterator>
inline void__destory_aux(ForwardIterator first, ForwardIterator last, __false_type)
{
   for(; first < last; ++first)
       destory(&*first); //因first是迭代器,*first取出其真正内容,然后再用&取地址
}
//如果不需要,就什么也不做:
tempalte<classForwardIterator>
inline void__destory_aux(ForwardIterator first, ForwardIterator last, __true_type)
{}

因上述函数全都是inline的,所以多层的函数调用并不会对性能造成影响,最终编译的结果根据具体的类型就只是一个for循环或者什么都没有。 这里的关键在于__type_traits<T>这个模板类上,它根据不同的T类型定义出不同的has_trivial_destructor的结果,如果T是简单类型,就定义为__true_type类型,否则就定义为__false_type类型。其中__true_type、__false_type只不过是两个没有任何内容的类,对程序的执行结果没有什么意义,但在编译器看来它对模板如何特化就具有非常重要的指导意义了,正如上面代码所示的那样。__type_traits<T>也是特化了的一系列模板类:

struct__true_type {};
struct__false_type {};
template<class T>
struct__type_traits
{
public:
   typedef __false _typehas_trivial_destructor;
   ……
};
template<>//模板特化
struct__type_traits<int>    //int的特化版本
{
public:
   typedef __true_type has_trivial_destructor;
   ……
};
…… //其他简单类型的特化版本

如果要把一个自定义的类型MyClass也定义为不调用析构函数,只需要相应的定义__type_traits<T>的一个特化版本即可:

template<>
struct__type_traits<MyClass>
{
public:
   typedef __true_type has_trivial_destructor;         //简单类型,就定义为__true_type类型
   ……
};

模板是比较高级的C++编程技巧,模板特化、模板偏特化就更是技巧性很强的东西,STL中的type_traits充分借助模板特化的功能,实现了在程序编译期通过编译器来决定为每一处调用使用哪个特化版本,于是在不增加编程复杂性的前提下大大提高了程序的运行效率。更详细的内容可参考《STL源码剖析》第二、三章中的相关内容。


new[]和delete[]


我们经常会通过new来动态创建一个数组,例如:

char* s = new char[100];
……
delete s;

严格的说,上述代码是不正确的,因为我们在分配内存时使用的是 new[],而并不是简单的 new,但释放内存时却用的是 delete。正确的写法是使用 delete[]

delete[] s;

但是,上述错误的代码似乎也能编译执行,并不会带来什么错误。事实上,newnew[]deletedelete[]是有区别的,特别是当用来操作复杂类型时。假如针对一个我们自定义的类MyClass使用new[]

MyClass* p = new MyClass[10];

上述代码的结果是在堆上分配了10个连续的MyClass实例,并且已经对它们依次调用了构造函数,于是我们得到了10个可用的对象,这一点与Java、C#有区别的,Java、C#中这样的结果只是得到了10个null。换句话说,使用这种写法时MyClass必须拥有不带参数的构造函数,否则会发生编译期错误,因为编译器无法调用有参数的构造函数。

当这样构造成功后,我们可以再将其释放,释放时使用delete[]

delete[] p;

当我们对动态分配的数组调用delete[]时,其行为根据所申请的变量类型会有所不同。如果p指向简单类型,如int、char等,其结果只不过是这块内存被回收,此时使用delete[]与delete没有区别,但如果p指向的是复杂类型,delete[]会针对动态分配得到的每个对象调用析构函数,然后再释放内存。因此,如果我们对上述分配得到的p指针直接使用delete来回收,虽然编译期不报什么错误(因为编译器根本看不出来这个指针p是如何分配的),但在运行时(DEBUG情况下)会给出一个Debug assertion failed提示。

到这里,我们很容易提出一个问题——delete[]是如何知道要为多少个对象调用析构函数的?要回答这个问题,我们可以首先看一看new[]的重载。

class MyClass
{
   int a;
public:
   MyClass() { printf("ctor\n"); }
   ~MyClass() { printf("dtor\n"); }
};
void* operator new[](size_tsize)
{
   void* p = operatornew(size);
   printf("calling new[] with size=%daddress=%p\n", size, p);
   return p;
}
 
// 主函数
MyClass* mc = new MyClass[3];
printf("addressof mc=%p\n", mc);
delete[] mc;

运行此段代码,得到的结果为:(VS2012)

calling new[]with size=16
address=0069E370
ctor
ctor
ctor
address ofmc=0069E374
dtor
dtor
dtor

虽然对构造函数和析构函数的调用结果都在预料之中,但所申请的内存空间大小以及地址的数值却出现了问题。我们的类MyClass的大小显然是4个字节,并且申请的数组中有3个元素,那么应该一共申请12个字节才对,但事实上系统却为我们申请了16字节,并且在 operator new[]返回后我们得到的内存地址是实际申请得到的内存地址值加4的结果。也就是说,当为复杂类型动态分配数组时,系统自动在最终得到的内存地址前空出了4个字节,我们有理由相信这4个字节的内容与动态分配数组的长度有关。通过单步跟踪,很容易发现这4个字节对应的int值为0x00000003,也就是说记录的是我们分配的对象的个数。改变一下分配的个数然后再次观察的结果证实了我的想法。于是,我们也有理由认为 new[] operator的行为相当于下面的伪代码:

template<class T>
T* New[](intcount)
{
   int size = sizeof(T) * count + 4;
   void* p = T::operatornew[](size);
   *(int*)p = count;
   T* pt = (T*)((int)p + 4);
   for(int i = 0; i < count; i++)
       new(&pt[i])T();
   return pt;
}

上述示意性的代码省略了异常处理的部分,只是展示当我们对一个复杂类型使用 new[]来动态分配数组时其真正的行为是什么,从中可以看到它分配了比预期多4个字节的内存并用它来保存对象的个数,然后对于后面每一块空间使用 placement new来调用无参构造函数,这也就解释了为什么这种情况下类必须有无参构造函数,最后再将首地址返回。类似的,我们很容易写出相应的 delete[]的实现代码:

template<class T>
void Delete[](T*pt)
{
   int count = ((int*)pt)[-1];
   for(int i = 0; i < count; i++)
       pt[i].~T();
   void* p = (void*)((int)pt – 4);
   T::operatordelete[](p);
}

由此可见,在默认情况下 operator new[]operator new的行为是相同的, operator delete[]operator delete也是,不同的是 new operatornew[] operatordeleteoperatordelete[] operator。当然,我们可以根据不同的需要来选择重载带有和不带有“[]”的 operator newdelete,以满足不同的具体需求。

把前面类MyClass的代码稍做修改——注释掉析构函数,然后再来看看程序的输出:

calling new[]with size=16 address=0069E370
ctor
ctor
ctor
address of mc=0069E370

这一次, new[]老老实实的申请了12个字节的内存,并且申请的结果与 new[] operator返回的结果也是相同的,看来,是否在前面添加4个字节,只取决于这个类有没有析构函数,当然,这么说并不确切,正确的说法是这个类是否需要调用构造函数,因为如下两种情况下虽然这个类没有声明析构函数,但还是多申请了4个字节:一是这个类中拥有需要调用析构函数的成员,二是这个类继承自需要调用析构函数的类。于是,我们可以递归的定义“需要调用析构函数的类”为以下三种情况之一:

1 显式的声明了析构函数的

2 拥有需要调用析构函数的类的成员的

3 继承自需要调用析构函数的类的

类似的,动态申请简单类型的数组时,也不会多申请4个字节。于是在这两种情况下,释放内存时使用deletedelete[]都可以,但为养成良好的习惯,我们还是应该注意只要是动态分配的数组,释放时就使用delete[]

释放内存时如何知道长度

但这同时又带来了新问题,既然申请无需调用析构函数的类或简单类型的数组时并没有记录个数信息,那么operatordelete,或更直接的说free()是如何来回收这块内存的呢?这就要研究malloc()返回的内存的结构了。与new[]类似的是,实际上在malloc()申请内存时也多申请了数个字节的内容,只不过这与所申请的变量的类型没有任何关系,我们从调用malloc时所传入的参数也可以理解这一点——它只接收了要申请的内存的长度,并不关系这块内存用来保存什么类型。


malloc()以及free()的机制


new delete在实现上其实调用了malloc(), free()函数!

malloc()并不是从一个编译时就确定的固定大小的数组中分配空间,而是在需要的时向操作系统申请空间。因为程序中的某些地方可能不通过malloc()调用申请空间(通过其它方式申请空间),因此,malloc()管理的空间不一定是连续的。这样空闲存储空间以空闲块链表的方式组织,每个块包含一个长度、一个指向下一块的指针以及一个指向自身存储空间的指针。

事实上,仔细看一下free()的函数原型,也许也会发现似乎很神奇,free()函数非常简单,只有一个参数,只要把指向申请空间的指针传递给free()中的参数就可以完成释放工作!这里要追踪到malloc()的申请问题了。申请的时候实际上占用的内存要比申请的大。因为超出的空间是用来记录对这块内存的管理信息。先看一下在《UNIX环境高级编程》中第七章的一段话:

大多数实现所分配的存储空间比所要求的要稍大一些,额外的空间用来记录管理信息——分配块的长度,指向下一个分配块的指针等等。这就意味着如果写过一个已分配区的尾端,则会改写后一块的管理信息。这种类型的错误是灾难性的,但是因为这种错误不会很快就暴露出来,所以也就很难发现。将指向分配块的指针向后移动也可能会改写本块的管理信息。

以上这段话已经给了我们一些信息了。malloc()申请的空间实际就是分了两个不同性质的空间。一个就是用来记录管理信息的空间,另外一个就是给用户的可用空间了。用来记录管理信息的实际上是一个结构体。在C语言中,用结构体来记录同一个对象的不同信息是天经地义的事!下面看看这个结构体的原型:

程序代码:

   struct mem_control_block {
    int is_available;    //这里英文单词却显示出空间是否可用的一个标记?
    int size;            //这是实际空间的大小
    }; 

对于size,这个是实际空间大小。所以, free()就是根据这个结构体的信息来释放 malloc()申请的空间!而结构体的两个成员的大小我想应该是操作系统的事了。 malloc()申请空间后返回一个指针应该是指向第二种空间,也就是给用户的可用空间!

好了!下面看看free()的源代码:

程序代码:

  // code...
    void free(void *ptr) 
    {
            struct mem_control_block *free;
            free = ptr - sizeof(structmem_control_block);
            free->is_available = 1; // // 使用情况,1为空闲,0为已分配 
            return;
    }

   看一下函数第二句,这句非常重要和关键。其实这句就是把指向可用空间的指针倒回去,让它指向管理信息的那块空间,因为这里是在值上减去了一个结构体的大小!后面那一句free->is_available = 1;我有点纳闷,这里is_available应该只是一个标记而已!因为从这个变量的名称上来看,is_available 翻译过来就是“是可以用”。这个变量的值是1,表明是可以用的空间!  

当然,这里可能还是有人会有疑问,为什么这样就可以释放呢??我刚才也有这个疑问。后来我想到,释放是操作系统的事,那么就free()这个源代码来看,什么也没有释放,对吧?但是它确实是确定了管理信息的那块内存的内容。所以,free()只是记录下这块内存空闲可用的信息,然后告诉操作系统那块内存可以再次被malloc()了。

   那么,我之前有个错误的认识,就是认为指向那块内存的指针不管移到那块内存中的哪个位置都可以释放那块内存!但是,这是大错特错!释放是不可以释放一部分的!首先这点应该要明白。而且,free()的源代码看,ptr只能指向可用空间的首地址,不然,减去结构体大小之后一定不是指向管理信息空间的首地址。所以,要确保指针指向可用空间的首地址!不信吗?自己可以写一个程序然后移动指向可用空间的指针,看程序会不会崩溃!


详细的可以参见malloc()函数的具体实现。。。。。。

可能不同的编译器不同的平台的具体实现不太一样,在VS2012里愣是没找到记录管理信息的那块内存在哪。。。。



More…


更深入的探讨可参见这里:

如何在Linux下检测内存泄露

摘取一段:

当我们在程序中写下 new delete 时,我们实际上调用的是 C++语言内置的 new operator delete operator。所谓语言内置就是说我们不能更改其含义,它的功能总是一致的。以 new operator为例,它总是先分配足够的内存,而后再调用相应的类型的构造函数初始化该内存。而 delete operator总是先调用该类型的析构函数,而后释放内存。我们能够施加影响力的事实上就是 new operator delete operator执行过程中分配和释放内存的方法。

new operator 为分配内存所调用的函数名字是 operator new,其通常的形式是 void * operator new(size_t size);其返回值类型是 void*,因为这个函数返回一个未经处理(raw)的指针,未初始化的内存。参数 size确定分配多少内存,你能增加额外的参数重载函数 operator new,但是第一个参数类型必须是 size_t

delete operator 为释放内存所调用的函数名字是 operator delete,其通常的形式是 void operator delete(void*memoryToBeDeallocated);它释放传入的参数所指向的一片内存区。

这里有一个问题,就是当我们调用 new operator分配内存时,有一个 size参数表明需要分配多大的内存。但是当调用 delete operator时,却没有类似的参数,那么 delete operator如何能够知道需要释放该指针指向的内存块的大小呢?答案是:对于系统自有的数据类型,语言本身就能区分内存块的大小,而对于自定义数据类型(如我们自定义的类),则 operator new operator delete之间需要互相传递信息。

当我们使用 operator new为一个自定义类型对象分配内存时,实际上我们得到的内存要比实际对象的内存大一些,这些内存除了要存储对象数据外,还需要记录这片内存的大小,此方法称为 cookie。这一点上的实现依据不同的编译器不同。(例如 MFC选择在所分配内存的头部存储对象实际数据,而后面的部分存储边界标志和内存大小信息。g++则采用在所分配内存的头 4个自己存储相关信息,而后面的内存存储对象实际数据。)当我们使用 delete operator进行内存释放操作时,delete operator就可以根据这些信息正确的释放指针所指向的内存块。

以上论述的是对于单个对象的内存分配/释放,当我们为数组分配/释放内存时,虽然我们仍然使用 new operator delete operator,但是其内部行为却有不同:new operator调用了operator new的数组版的兄弟- operator new[],而后针对每一个数组成员调用构造函数。而 delete operator先对每一个数组成员调用析构函数,而后调用 operator delete[]来释放内存。需要注意的是,当我们创建或释放由自定义数据类型所构成的数组时,编译器为了能够标识出在 operator delete[]中所需释放的内存块的大小,也使用了编译器相关的 cookie技术。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值