- 浏览: 208963 次
- 性别:
- 来自: 杭州
文章分类
最新评论
-
Prepared:
Hadoop的几个明显缺点 -
CSunDNan:
...
openjdk jvm 方法字节码执行过程 -
幻影之蚀:
...
mysql 源码分析2 源码调试环境建立 -
shukongchengje:
紧急呼唤楼主,mysql代码从哪里弄?官网wiki上看的一头雾 ...
mysql源码分析 整体架构 -
yeshaoting:
好文章.不介意的话转载了.
jvm 字节码中文含义
“new”是C++的一个关键字,同时也是操作符。关于new的话题非常多,因为它确实比较复杂,也非常神秘,下面我将把我了解到的与new有关的内容做一个总结。
new的过程
当我们使用关键字new在堆上动态创建一个对象时,它实际上做了三件事:获得一块内存空间、调用构造函数、返回正确的指针。当然,如果我们创建的是简单类型的变量,那么第二步会被省略。假如我们定义了如下一个类A:
class A
{
int i;
public:
A(int _i) :i(_i*_i) {}
void Say() { printf("i=%dn", 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 operator、operator new、placement new。
new operator就是我们平时所使用的new,其行为就是前面所说的三个步骤,我们不能更改它。但具体到某一步骤中的行为,如果它不满足我们的具体要求时,我们是有可能更改它的。三个步骤中最后一步只是简单的做一个指针的类型转换,没什么可说的,并且在编译出的代码中也并不需要这种转换,只是人为的认识罢了。但前两步就有些内容了。
new operator的第一步分配内存实际上是通过调用operator new来完成的,这里的new实际上是像加减乘除一样的操作符,因此也是可以重载的。operator new默认情况下首先调用分配内存的代码,尝试得到一段堆上的空间,如果成功就返回,如果失败,则转而去调用一个new_hander,然后继续重复前面过程。如果我们对这个过程不满意,就可以重载operator new,来设置我们希望的行为。例如:
class A
{
public:
void* operator new(size_t size)
{
printf("operator new calledn");
return ::operator new(size);
}
};
A* a = new A();
这里通过::operator new调用了原有的全局的new,实现了在分配内存之前输出一句话。全局的operator new也是可以重载的,但这样一来就不能再递归的使用new来分配内存,而只能使用malloc了:
void* operator new(size_t size)
{
printf("global newn");
return malloc(size);
}
相应的,delete也有delete operator和operator 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_t size)
{
void* p = null
while(!(p = malloc(size)))
{
if(null == new_handler)
throw bad_alloc();
try
{
new_handler();
}
catch(bad_alloc e)
{
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 new SomeClass();
}
};
静态变量count用于记录此类型生成的实例的个数,在上述代码中,如果因new分配内存失败而抛出异常,那么其实例个数并没有增加,但count变量的值却已经多了一个,从而数据结构被破坏。正确的写法是:
static SomeClass* GetNewInstance()
{
SomeClass* p = new SomeClass();
count++;
return p;
}
这样一来,如果new失败则直接抛出异常,count的值不会增加。类似的,在处理线程同步时,也要注意类似的问题:
void SomeFunc()
{
lock(someMutex); //加一个锁
delete p;
p = new SomeClass();
unlock(someMutex);
}
此时,如果new失败,unlock将不会被执行,于是不仅造成了一个指向不正确地址的指针p的存在,还将导致someMutex永远不会被解锁。这种情况是要注意避免的。(参考:C++箴言:争取异常安全的代码)
STL的内存分配与traits技巧
在《STL原码剖析》一书中详细分析了SGI STL的内存分配器的行为。与直接使用new operator不同的是,SGI STL并不依赖C++默认的内存分配方式,而是使用一套自行实现的方案。首先SGI STL将可用内存整块的分配,使之成为当前进程可用的内存,当程序中确实需要分配内存时,先从这些已请求好的大内存块中尝试取得内存,如果失败的话再尝试整块的分配大内存。这种做法有效的避免了大量内存碎片的出现,提高了内存管理效率。
为了实现这种方式,STL使用了placement new,通过在自己管理的内存空间上使用placement new来构造对象,以达到原有new operator所具有的功能。
template <class T1, class T2>
inline void construct(T1* p, const T2& value)
{
new(p) T1(value);
}
此函数接收一个已构造的对象,通过拷贝构造的方式在给定的内存地址p上构造一个新对象,代码中后半截T1(value)便是placement new语法中调用构造函数的写法,如果传入的对象value正是所要求的类型T1,那么这里就相当于调用拷贝构造函数。类似的,因使用了 placement new,编译器不会自动产生调用析构函数的代码,需要手工的实现:
template <class T>
inline void destory(T* pointer)
{
pointer->~T();
}
与此同时,STL中还有一个接收两个迭代器的destory版本,可将某容器上指定范围内的对象全部销毁。典型的实现方式就是通过一个循环来对此范围内的对象逐一调用析构函数。如果所传入的对象是非简单类型,这样做是必要的,但如果传入的是简单类型,或者根本没有必要调用析构函数的自定义类型(例如只包含数个int成员的结构体),那么再逐一调用析构函数是没有必要的,也浪费了时间。为此,STL使用了一种称为“type traits”的技巧,在编译器就判断出所传入的类型是否需要调用析构函数:
template <class ForwardIterator>
inline void destory(ForwardIterator first, ForwardIterator last)
{
__destory(first, last, value_type(first));
}
其中value_type()用于取出迭代器所指向的对象的类型信息,于是:
template<class ForwardIterator, class T>
inline void __destory(ForwardIterator first, ForwardIterator last, T*)
{
typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor;
__destory_aux(first, last, trivial_destructor());
}
//如果需要调用析构函数:
template<class ForwardIterator>
inline void __destory_aux(ForwardIterator first, ForwardIterator last, __false_type)
{
for(; first < last; ++first)
destory(&*first); //因first是迭代器,*first取出其真正内容,然后再用&取地址
}
//如果不需要,就什么也不做:
tempalte<class ForwardIterator>
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 _type has_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;
……
};
模板是比较高级的C++编程技巧,模板特化、模板偏特化就更是技巧性很强的东西,STL中的type_traits充分借助模板特化的功能,实现了在程序编译期通过编译器来决定为每一处调用使用哪个特化版本,于是在不增加编程复杂性的前提下大大提高了程序的运行效率。更详细的内容可参考《STL源码剖析》第二、三章中的相关内容。
带有“[]”的new和delete
我们经常会通过new来动态创建一个数组,例如:
char* s = new char[100];
……
delete s;
严格的说,上述代码是不正确的,因为我们在分配内存时使用的是new[],而并不是简单的new,但释放内存时却用的是delete。正确的写法是使用delete[]:
delete[] s;
但是,上述错误的代码似乎也能编译执行,并不会带来什么错误。事实上,new与new[]、delete与delete[]是有区别的,特别是当用来操作复杂类型时。假如针对一个我们自定义的类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("ctorn"); }
~MyClass() { printf("dtorn"); }
};
void* operator new[](size_t size)
{
void* p = operator new(size);
printf("calling new[] with size=%d address=%pn", size, p);
return p;
}
// 主函数
MyClass* mc = new MyClass[3];
printf("address of mc=%pn", mc);
delete[] mc;
运行此段代码,得到的结果为:(VC2005)
calling new[] with size=16 address=003A5A58
ctor
ctor
ctor
address of mc=003A5A5C
dtor
dtor
dtor
虽然对构造函数和析构函数的调用结果都在预料之中,但所申请的内存空间大小以及地址的数值却出现了问题。我们的类MyClass的大小显然是4个字节,并且申请的数组中有3个元素,那么应该一共申请12个字节才对,但事实上系统却为我们申请了16字节,并且在operator new[]返后我们得到的内存地址是实际申请得到的内存地址值加4的结果。也就是说,当为复杂类型动态分配数组时,系统自动在最终得到的内存地址前空出了 4个字节,我们有理由相信这4个字节的内容与动态分配数组的长度有关。通过单步跟踪,很容易发现这4个字节对应的int值为0x00000003,也就是说记录的是我们分配的对象的个数。改变一下分配的个数然后再次观察的结果证实了我的想法。于是,我们也有理由认为new[] operator的行为相当于下面的伪代码:
template <class T>
T* New[](int count)
{
int size = sizeof(T) * count + 4;
void* p = T::operator new[](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::operator delete[](p);
}
由此可见,在默认情况下operator new[]与operator new的行为是相同的,operator delete[]与operator delete也是,不同的是new operator与new[] operator、delete operator与delete[] operator。当然,我们可以根据不同的需要来选择重载带有和不带有“[]”的operator new和delete,以满足不同的具体需求。
把前面类MyClass的代码稍做修改——注释掉析构函数,然后再来看看程序的输出:
calling new[] with size=12 address=003A5A58
ctor
ctor
ctor
address of mc=003A5A58
这一次,new[]老老实实的申请了12个字节的内存,并且申请的结果与new[] operator返回的结果也是相同的,看来,是否在前面添加4个字节,只取决于这个类有没有析构函数,当然,这么说并不确切,正确的说法是这个类是否需要调用构造函数,因为如下两种情况下虽然这个类没声明析构函数,但还是多申请了4个字节:一是这个类中拥有需要调用析构函数的成员,二是这个类继承自需要调用析构函数的类。于是,我们可以递归的定义“需要调用析构函数的类”为以下三种情况之一:
1 显式的声明了析构函数的
2 拥有需要调用析构函数的类的成员的
3 继承自需要调用析构函数的类的
类似的,动态申请简单类型的数组时,也不会多申请4个字节。于是在这两种情况下,释放内存时使用delete或delete[]都可以,但为养成良好的习惯,我们还是应该注意只要是动态分配的数组,释放时就使用delete[]。
释放内存时如何知道长度
但这同时又带来了新问题,既然申请无需调用析构函数的类或简单类型的数组时并没有记录个数信息,那么operator delete,或更直接的说free()是如何来回收这块内存的呢?这就要研究malloc()返回的内存的结构了。与new[]类似的是,实际上在 malloc()申请内存时也多申请了数个字节的内容,只不过这与所申请的变量的类型没有任何关系,我们从调用malloc时所传入的参数也可以理解这一点——它只接收了要申请的内存的长度,并不关系这块内存用来保存什么类型。下面运行这样一段代码做个实验:
char *p = 0;
for(int i = 0; i < 40; i += 4)
{
char* s = new char[i];
printf("alloc %2d bytes, address=%p distance=%dn", i, s, s - p);
p = s;
}
我们直接来看VC2005下Release版本的运行结果,DEBUG版因包含了较多的调试信息,这里就不分析了:
alloc 0 bytes, address=003A36F0 distance=3815152
alloc 4 bytes, address=003A3700 distance=16
alloc 8 bytes, address=003A3710 distance=16
alloc 12 bytes, address=003A3720 distance=16
alloc 16 bytes, address=003A3738 distance=24
alloc 20 bytes, address=003A84C0 distance=19848
alloc 24 bytes, address=003A84E0 distance=32
alloc 28 bytes, address=003A8500 distance=32
alloc 32 bytes, address=003A8528 distance=40
alloc 36 bytes, address=003A8550 distance=40
每一次分配的字节数都比上一次多4,distance值记录着与上一次分配的差值,第一个差值没有实际意义,中间有一个较大的差值,可能是这块内存已经被分配了,于是也忽略它。结果中最小的差值为16字节,直到我们申请16字节时,这个差值变成了24,后面也有类似的规律,那么我们可以认为申请所得的内存结构是如下这样的:
从图中不难看出,当我们要分配一段内存时,所得的内存地址和上一次的尾地址至少要相距8个字节(在DEBUG版中还要更多),那么我们可以猜想,这8个字节中应该记录着与这段所分配的内存有关的信息。观察这8个节内的内容,得到结果如下:
图中右边为每次分配所得的地址之前8个字节的内容的16进制表示,从图中红线所表示可以看到,这8个字节中的第一个字节乘以8即得到相临两次分配时的距离,经过试验一次性分配更大的长度可知,第二个字节也是这个意义,并且代表高8位,也就说前面空的这8个字节中的前两个字节记录了一次分配内存的长度信息,后面的六个字节可能与空闲内存链表的信息有关,在翻译内存时用来提供必要的信息。这就解答了前面提出的问题,原来C/C++在分配内存时已经记录了足够充分的信息用于回收内存,只不过我们平常不关心它罢了。
new的过程
当我们使用关键字new在堆上动态创建一个对象时,它实际上做了三件事:获得一块内存空间、调用构造函数、返回正确的指针。当然,如果我们创建的是简单类型的变量,那么第二步会被省略。假如我们定义了如下一个类A:
class A
{
int i;
public:
A(int _i) :i(_i*_i) {}
void Say() { printf("i=%dn", 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 operator、operator new、placement new。
new operator就是我们平时所使用的new,其行为就是前面所说的三个步骤,我们不能更改它。但具体到某一步骤中的行为,如果它不满足我们的具体要求时,我们是有可能更改它的。三个步骤中最后一步只是简单的做一个指针的类型转换,没什么可说的,并且在编译出的代码中也并不需要这种转换,只是人为的认识罢了。但前两步就有些内容了。
new operator的第一步分配内存实际上是通过调用operator new来完成的,这里的new实际上是像加减乘除一样的操作符,因此也是可以重载的。operator new默认情况下首先调用分配内存的代码,尝试得到一段堆上的空间,如果成功就返回,如果失败,则转而去调用一个new_hander,然后继续重复前面过程。如果我们对这个过程不满意,就可以重载operator new,来设置我们希望的行为。例如:
class A
{
public:
void* operator new(size_t size)
{
printf("operator new calledn");
return ::operator new(size);
}
};
A* a = new A();
这里通过::operator new调用了原有的全局的new,实现了在分配内存之前输出一句话。全局的operator new也是可以重载的,但这样一来就不能再递归的使用new来分配内存,而只能使用malloc了:
void* operator new(size_t size)
{
printf("global newn");
return malloc(size);
}
相应的,delete也有delete operator和operator 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_t size)
{
void* p = null
while(!(p = malloc(size)))
{
if(null == new_handler)
throw bad_alloc();
try
{
new_handler();
}
catch(bad_alloc e)
{
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 new SomeClass();
}
};
静态变量count用于记录此类型生成的实例的个数,在上述代码中,如果因new分配内存失败而抛出异常,那么其实例个数并没有增加,但count变量的值却已经多了一个,从而数据结构被破坏。正确的写法是:
static SomeClass* GetNewInstance()
{
SomeClass* p = new SomeClass();
count++;
return p;
}
这样一来,如果new失败则直接抛出异常,count的值不会增加。类似的,在处理线程同步时,也要注意类似的问题:
void SomeFunc()
{
lock(someMutex); //加一个锁
delete p;
p = new SomeClass();
unlock(someMutex);
}
此时,如果new失败,unlock将不会被执行,于是不仅造成了一个指向不正确地址的指针p的存在,还将导致someMutex永远不会被解锁。这种情况是要注意避免的。(参考:C++箴言:争取异常安全的代码)
STL的内存分配与traits技巧
在《STL原码剖析》一书中详细分析了SGI STL的内存分配器的行为。与直接使用new operator不同的是,SGI STL并不依赖C++默认的内存分配方式,而是使用一套自行实现的方案。首先SGI STL将可用内存整块的分配,使之成为当前进程可用的内存,当程序中确实需要分配内存时,先从这些已请求好的大内存块中尝试取得内存,如果失败的话再尝试整块的分配大内存。这种做法有效的避免了大量内存碎片的出现,提高了内存管理效率。
为了实现这种方式,STL使用了placement new,通过在自己管理的内存空间上使用placement new来构造对象,以达到原有new operator所具有的功能。
template <class T1, class T2>
inline void construct(T1* p, const T2& value)
{
new(p) T1(value);
}
此函数接收一个已构造的对象,通过拷贝构造的方式在给定的内存地址p上构造一个新对象,代码中后半截T1(value)便是placement new语法中调用构造函数的写法,如果传入的对象value正是所要求的类型T1,那么这里就相当于调用拷贝构造函数。类似的,因使用了 placement new,编译器不会自动产生调用析构函数的代码,需要手工的实现:
template <class T>
inline void destory(T* pointer)
{
pointer->~T();
}
与此同时,STL中还有一个接收两个迭代器的destory版本,可将某容器上指定范围内的对象全部销毁。典型的实现方式就是通过一个循环来对此范围内的对象逐一调用析构函数。如果所传入的对象是非简单类型,这样做是必要的,但如果传入的是简单类型,或者根本没有必要调用析构函数的自定义类型(例如只包含数个int成员的结构体),那么再逐一调用析构函数是没有必要的,也浪费了时间。为此,STL使用了一种称为“type traits”的技巧,在编译器就判断出所传入的类型是否需要调用析构函数:
template <class ForwardIterator>
inline void destory(ForwardIterator first, ForwardIterator last)
{
__destory(first, last, value_type(first));
}
其中value_type()用于取出迭代器所指向的对象的类型信息,于是:
template<class ForwardIterator, class T>
inline void __destory(ForwardIterator first, ForwardIterator last, T*)
{
typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor;
__destory_aux(first, last, trivial_destructor());
}
//如果需要调用析构函数:
template<class ForwardIterator>
inline void __destory_aux(ForwardIterator first, ForwardIterator last, __false_type)
{
for(; first < last; ++first)
destory(&*first); //因first是迭代器,*first取出其真正内容,然后再用&取地址
}
//如果不需要,就什么也不做:
tempalte<class ForwardIterator>
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 _type has_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;
……
};
模板是比较高级的C++编程技巧,模板特化、模板偏特化就更是技巧性很强的东西,STL中的type_traits充分借助模板特化的功能,实现了在程序编译期通过编译器来决定为每一处调用使用哪个特化版本,于是在不增加编程复杂性的前提下大大提高了程序的运行效率。更详细的内容可参考《STL源码剖析》第二、三章中的相关内容。
带有“[]”的new和delete
我们经常会通过new来动态创建一个数组,例如:
char* s = new char[100];
……
delete s;
严格的说,上述代码是不正确的,因为我们在分配内存时使用的是new[],而并不是简单的new,但释放内存时却用的是delete。正确的写法是使用delete[]:
delete[] s;
但是,上述错误的代码似乎也能编译执行,并不会带来什么错误。事实上,new与new[]、delete与delete[]是有区别的,特别是当用来操作复杂类型时。假如针对一个我们自定义的类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("ctorn"); }
~MyClass() { printf("dtorn"); }
};
void* operator new[](size_t size)
{
void* p = operator new(size);
printf("calling new[] with size=%d address=%pn", size, p);
return p;
}
// 主函数
MyClass* mc = new MyClass[3];
printf("address of mc=%pn", mc);
delete[] mc;
运行此段代码,得到的结果为:(VC2005)
calling new[] with size=16 address=003A5A58
ctor
ctor
ctor
address of mc=003A5A5C
dtor
dtor
dtor
虽然对构造函数和析构函数的调用结果都在预料之中,但所申请的内存空间大小以及地址的数值却出现了问题。我们的类MyClass的大小显然是4个字节,并且申请的数组中有3个元素,那么应该一共申请12个字节才对,但事实上系统却为我们申请了16字节,并且在operator new[]返后我们得到的内存地址是实际申请得到的内存地址值加4的结果。也就是说,当为复杂类型动态分配数组时,系统自动在最终得到的内存地址前空出了 4个字节,我们有理由相信这4个字节的内容与动态分配数组的长度有关。通过单步跟踪,很容易发现这4个字节对应的int值为0x00000003,也就是说记录的是我们分配的对象的个数。改变一下分配的个数然后再次观察的结果证实了我的想法。于是,我们也有理由认为new[] operator的行为相当于下面的伪代码:
template <class T>
T* New[](int count)
{
int size = sizeof(T) * count + 4;
void* p = T::operator new[](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::operator delete[](p);
}
由此可见,在默认情况下operator new[]与operator new的行为是相同的,operator delete[]与operator delete也是,不同的是new operator与new[] operator、delete operator与delete[] operator。当然,我们可以根据不同的需要来选择重载带有和不带有“[]”的operator new和delete,以满足不同的具体需求。
把前面类MyClass的代码稍做修改——注释掉析构函数,然后再来看看程序的输出:
calling new[] with size=12 address=003A5A58
ctor
ctor
ctor
address of mc=003A5A58
这一次,new[]老老实实的申请了12个字节的内存,并且申请的结果与new[] operator返回的结果也是相同的,看来,是否在前面添加4个字节,只取决于这个类有没有析构函数,当然,这么说并不确切,正确的说法是这个类是否需要调用构造函数,因为如下两种情况下虽然这个类没声明析构函数,但还是多申请了4个字节:一是这个类中拥有需要调用析构函数的成员,二是这个类继承自需要调用析构函数的类。于是,我们可以递归的定义“需要调用析构函数的类”为以下三种情况之一:
1 显式的声明了析构函数的
2 拥有需要调用析构函数的类的成员的
3 继承自需要调用析构函数的类的
类似的,动态申请简单类型的数组时,也不会多申请4个字节。于是在这两种情况下,释放内存时使用delete或delete[]都可以,但为养成良好的习惯,我们还是应该注意只要是动态分配的数组,释放时就使用delete[]。
释放内存时如何知道长度
但这同时又带来了新问题,既然申请无需调用析构函数的类或简单类型的数组时并没有记录个数信息,那么operator delete,或更直接的说free()是如何来回收这块内存的呢?这就要研究malloc()返回的内存的结构了。与new[]类似的是,实际上在 malloc()申请内存时也多申请了数个字节的内容,只不过这与所申请的变量的类型没有任何关系,我们从调用malloc时所传入的参数也可以理解这一点——它只接收了要申请的内存的长度,并不关系这块内存用来保存什么类型。下面运行这样一段代码做个实验:
char *p = 0;
for(int i = 0; i < 40; i += 4)
{
char* s = new char[i];
printf("alloc %2d bytes, address=%p distance=%dn", i, s, s - p);
p = s;
}
我们直接来看VC2005下Release版本的运行结果,DEBUG版因包含了较多的调试信息,这里就不分析了:
alloc 0 bytes, address=003A36F0 distance=3815152
alloc 4 bytes, address=003A3700 distance=16
alloc 8 bytes, address=003A3710 distance=16
alloc 12 bytes, address=003A3720 distance=16
alloc 16 bytes, address=003A3738 distance=24
alloc 20 bytes, address=003A84C0 distance=19848
alloc 24 bytes, address=003A84E0 distance=32
alloc 28 bytes, address=003A8500 distance=32
alloc 32 bytes, address=003A8528 distance=40
alloc 36 bytes, address=003A8550 distance=40
每一次分配的字节数都比上一次多4,distance值记录着与上一次分配的差值,第一个差值没有实际意义,中间有一个较大的差值,可能是这块内存已经被分配了,于是也忽略它。结果中最小的差值为16字节,直到我们申请16字节时,这个差值变成了24,后面也有类似的规律,那么我们可以认为申请所得的内存结构是如下这样的:
从图中不难看出,当我们要分配一段内存时,所得的内存地址和上一次的尾地址至少要相距8个字节(在DEBUG版中还要更多),那么我们可以猜想,这8个字节中应该记录着与这段所分配的内存有关的信息。观察这8个节内的内容,得到结果如下:
图中右边为每次分配所得的地址之前8个字节的内容的16进制表示,从图中红线所表示可以看到,这8个字节中的第一个字节乘以8即得到相临两次分配时的距离,经过试验一次性分配更大的长度可知,第二个字节也是这个意义,并且代表高8位,也就说前面空的这8个字节中的前两个字节记录了一次分配内存的长度信息,后面的六个字节可能与空闲内存链表的信息有关,在翻译内存时用来提供必要的信息。这就解答了前面提出的问题,原来C/C++在分配内存时已经记录了足够充分的信息用于回收内存,只不过我们平常不关心它罢了。
发表评论
-
Linux内存:内存管理的实质
2010-04-13 08:49 11001. 内核初始化: * 内核建立好内核页目录页表数据 ... -
Linux 的多线程编程
2010-04-12 09:32 1934本文们针 Linux 线程编程主特性总结出 5 条经验,以改善 ... -
浅析Linux下core文件
2010-04-08 10:32 2160当我们的程序崩溃时,内核有可能把该程序当前内存映射到core文 ... -
Linux下的CPU利用率计算原理详解
2010-04-07 17:13 1640我们在搞性能测试的时 ... -
linux cpu负载原理
2010-04-07 16:41 1254linux cpu负载原理 内核分析 待补 -
从VFS inode到LFS inode的寻址过程
2010-03-30 17:16 1835我们知道Linux是借用虚拟文件系统作为上层抽象的管理者来统一 ... -
CPU学习
2010-03-30 14:16 1466这是作者学习硬件基本知识过程中的笔记,由于以前很少接触这方面的 ... -
Cache Cohernce with Multi-Processor
2010-03-30 13:57 858刚写完一篇关于Cache Coherence的文章,就发现BN ... -
Cache 的write back和write through
2010-03-30 13:54 2306Cache 的write back和write through ... -
linux 2.6 Makefile详解
2010-03-30 13:48 1487熟悉内核的Makefile对开发设备驱动、理解内核代码结构都是 ... -
linux1
2010-03-30 13:38 684linux1 linux1 linux1 -
nfs
2010-03-30 13:29 679nfs文件系统 -
修改Linux内核增加系统调用
2010-03-30 09:44 1229本文修改内核2.4.29,分两部分,第一部分修改内核并测试,第 ... -
Linux内核裁剪的具体步骤
2010-03-29 17:27 1857在menuconfig中配置: 详 ... -
Linux内核修改实验
2010-03-29 17:10 1189Linux内核修改实验 实验 ... -
Linux内核剪裁实验
2010-03-29 17:08 1343实验目的: 1. 配置、编译Linux内核; 2 ... -
c语言深度解析
2010-03-29 15:38 753c语言深度解析 嘿嘿 -
Linux线程实现机制分析
2010-03-29 15:12 935内容: ·基础 ... -
内存屏障原语
2010-03-29 15:11 2038来自于在CU的一个讨论: http://linux.chin ... -
linux 经典进程切换实现代码
2010-03-29 15:10 1237extern _inline void switch_to(i ...
相关推荐
"深入C++的new关键字" 1. new关键字的定义和作用 new是一个C++的关键字,同时也是操作符new的主要组成部分。new的作用是用于动态创建对象,在堆上分配内存空间,并调用构造函数,最后返回正确的指针。 2. new的...
下面我们将深入探讨这四个运算符的使用、区别以及它们在内存管理中的作用。 首先,`new`和`new[]`都是用来动态分配内存的。`new`用于单个对象的分配,而`new[]`用于数组的分配。例如: ```cpp int* ptr1 = new int...
### C++中的new运算符详解 #### 一、new运算符概述 `new`是C++语言中的一个重要关键字,同时也是动态内存分配的核心操作符。它主要用于在运行时动态分配内存并初始化对象。对于C++程序员而言,理解`new`的工作原理...
malloc/free是库函数,new/delete作为运算符,有着更深入的编译器支持。 内存分配的具体操作: 1. malloc函数原型为void* malloc(size_t size),需要显式类型转换,并且只能分配指定字节大小的内存块。使用时要注意...
在C++编程语言中,`new`操作符...总之,“课程实例 c++ new”旨在帮助学习者深入理解C++中的动态内存分配和管理,这对于编写高效且无错误的C++代码至关重要。通过实践这些实例,你将能够更好地掌握这一关键的编程技巧。
21天学通C++(第五版) vs2008程序打包 深入浅出MFC 浙大教材C++达内 C++ 华为C++培训资料 visual c++_MFC 资源内容: visual c++_MFC 达内 C++ C++课件.ppt c++源码 .rar Core C++ Programming_new.ppt Core C++ ...
10. **内存管理**:C++提供了堆和栈两种内存管理方式,理解它们的差异以及如何正确使用new和delete操作符,可以避免内存泄漏和悬挂指针等问题。 通过深入学习《深入理解C++对象模型》,开发者能够掌握C++的精髓,更...
根据提供的文件信息,我们可以从《C++...综上所述,《C++ Primer Plus 第六版》是一本全面介绍C++语言的教程书籍,尤其注重于C++11新特性的讲解,对于希望深入了解和掌握现代C++编程的读者来说,是一本不可多得的好书。
《深入理解C++11代码》是一份专为C++开发者设计的学习资料,旨在帮助读者掌握C++11新标准引入的各种特性和改进。C++11是C++语言的一个重要里程碑,它在2011年发布,带来了许多现代化的编程特性,使得C++更加高效、易...
- C++提供了动态内存分配(new,delete)和智能指针(如unique_ptr,shared_ptr,weak_ptr),用于手动控制内存生命周期,防止内存泄漏。 8. **输入/输出流**: - C++标准库中的iostream组件提供了一套面向对象的...
《More Effective C++: 35 New Ways to Improve Your Programs and Designs》是一本深入探讨C++语言特性的书籍。本书不仅覆盖了C++中较为少见的部分,也重新审视了那些开发者们自认为已经熟悉的领域。通过阅读本书,...
本实例将深入探讨如何使用`new`运算符来动态创建多维数组,以及如何处理多维数组指针。 首先,让我们了解`new`运算符的基本用法。`new`是C++中的一个关键字,用于在堆上分配内存。当我们声明一个数组时,可以使用`...
总的来说,《深度探索C++对象模型》将带领读者深入C++的核心,理解对象模型的每一个细节,这对于提升C++编程技能,解决复杂问题和优化代码性能具有极大的价值。通过阅读本书,读者将能够更好地掌握C++这一强大工具,...
### Scott Meyers: C++11 新特性概览 #### 背景介绍 Scott Meyers是一位著名的软件开发顾问及作者,在C++社区享有盛誉。...对于希望深入了解C++11并将其应用于实际项目中的开发者来说,这份材料是非常宝贵的学习资源。
7. **内存管理**:C++提供了低级别的内存管理功能,如new/delete操作符,但同时也引入了智能指针等现代工具来帮助避免内存泄漏等问题。 ### C++的应用领域 - **系统软件**:由于其高效性和控制能力,C++常用于操作...
通过深入学习和实践,开发者可以掌握C++的高级特性,如模板元编程、协程、线程和并发处理,以及如何利用Boost库或C++标准库中的高级组件来构建高效、可靠的软件系统。C++的学习是一个持续的过程,不断更新的C++标准...
理解C++对象模型对于深入掌握C++语言特性、优化代码性能以及进行高效内存管理至关重要。 #### 对象模型的关键概念 **1. 类与对象** 在C++中,类是对象的模板或蓝图,用于定义对象的属性(数据成员)和行为(成员...
6. **内存管理**:C++提供了对内存的直接控制,包括动态内存分配(new, delete)和栈与堆的区别。理解内存管理对于避免内存泄漏和提高程序性能至关重要。 7. **C++11及更高版本的新特性**:自C++11以来,语言经历了...
《深入探索C++对象模型》是一本专门为C++程序员量身打造的专业书籍,旨在帮助读者深入理解C++的内部机制,特别是对象模型的细节。这本书由著名编程专家侯捷翻译,提供了中文和英文两个版本,方便不同语言背景的读者...
8. **内存管理**:C++提供了直接访问内存的能力,包括动态内存分配(new和delete)和智能指针(如shared_ptr、unique_ptr)。理解内存管理有助于避免内存泄漏和悬挂指针。 9. **多线程编程**:C++11引入了内置的多...