C++内存回收
光猪跑3.1 C++内存对象⼤会战
如果⼀个⼈⾃称为程序⾼⼿,却对内存⼀⽆所知,那么我可以告诉你,他⼀定在吹⽜。⽤C或C++写程序,需要更多地关注内存,这不仅仅是因为内存的分配是否合理直接影响着程序的效率和性能,更为主要的是,当我们操作内存的时候⼀不⼩⼼就会出现问题,⽽且很多时候,这些问题都是不易发觉的,⽐如内存泄漏,⽐如悬挂指针。笔者今天在这⾥并不是要讨论如何避免这些问题,⽽是想从另外⼀个⾓度来认识C++内存对象。
我们知道,C++将内存划分为三个逻辑区域:堆、栈和静态存储区。既然如此,我称位于它们之中的对象分别为堆对象,栈对象以及静态对象。那么这些不同的内存对象有什么区别了?堆对象和栈对象各有什么优劣了?如何禁⽌创建堆对象或栈对象了?这些便是今天的主题。
3.1.1 基本概念
先来看看栈。栈,⼀般⽤于存放局部变量或对象,如我们在函数定义中⽤类似下⾯语句声明的对象:
Type stack_object ;
stack_object便是⼀个栈对象,它的⽣命期是从定义点开始,当所在函数返回时,⽣命结束。
另外,⼏乎所有的临时对象都是栈对象。⽐如,下⾯的函数定义:
Type fun(Type object);
这个函数⾄少产⽣两个临时对象,⾸先,参数是按值传递的,所以会调⽤拷贝构造函数⽣成⼀个临时对象object_copy1 ,在函数内部使⽤的不是使⽤的不是object,⽽是object_copy1,⾃然,object_copy1是⼀个栈对象,它在函数返回时被释放;还有这个函数是值返回的,在函数返回时,如果我们不考虑返回值优化(NRV),那么也会产⽣⼀个临时对象object_copy2,这个临时对象会在函数返回后⼀段时间内被释放。⽐如某个函数中有如下代码:
Type tt ,result ; //⽣成两个栈对象
tt = fun(tt); //函数返回时,⽣成的是⼀个临时对象object_copy2
上⾯的第⼆个语句的执⾏情况是这样的,⾸先函数fun返回时⽣成⼀个临时对象object_copy2,然后再调⽤赋值运算符执⾏
tt = object_copy2 ; //调⽤赋值运算符
看到了吗?编译器在我们毫⽆知觉的情况下,为我们⽣成了这么多临时对象,⽽⽣成这些临时对象
的时间和空间的开销可能是很⼤的,所以,你也许明⽩了,为什么对于“⼤”对象最好⽤const引⽤传递代替按值进⾏函数参数传递了。灌肠袋
接下来,看看堆。堆,⼜叫⾃由存储区,它是在程序执⾏的过程中动态分配的,所以它最⼤的特性就是动态性。在C++中,所有堆对象的创建和销毁都要由程序员负责,所以,如果处理不好,就会发⽣内存问题。如果分配了堆对象,却忘记了释放,就会产⽣内存泄漏;⽽如果已释放了对象,却没有将相应的指针置为NULL,该指针就是所谓的“悬挂指针”,再度使⽤此指针时,就会出现⾮法访问,严重时就导致程序崩溃。
那么,C++中是怎样分配堆对象的?唯⼀的⽅法就是⽤new(当然,⽤类malloc指令也可获得C式堆内存),只要使⽤new,就会在堆中分配⼀块内存,并且返回指向该堆对象的指针。
联想笔记本电池
再来看看静态存储区。所有的静态对象、全局对象都于静态存储区分配。关于全局对象,是在main()函数执⾏前就分配好了的。其实,在main()函数中的显⽰代码执⾏之前,会调⽤⼀个由编译器⽣成的_main()函数,⽽_main()函数会进⾏所有全局对象的的构造及初始化⼯作。⽽在main()函数结束之前,会调⽤由编译器⽣成的exit函数,来释放所有的全局对象。⽐如下⾯的代码:
void main(void)
{
… …// 显式代码
}
实际上,被转化成这样:
void main(void)
{
_main(); //隐式代码,由编译器产⽣,⽤以构造所有全局对象
… … // 显式代码
… …
exit() ; // 隐式代码,由编译器产⽣,⽤以释放所有全局对象
}
所以,知道了这个之后,便可以由此引出⼀些技巧,如,假设我们要在main()函数执⾏之前做某些准
备⼯作,那么我们可以将这些准备⼯作写到⼀个⾃定义的全局对象的构造函数中,这样,在main()函数的显式代码执⾏之前,这个全局对象的构造函数会被调⽤,执⾏预期的动作,这样就达到了我们的⽬的。刚才讲的是静态存储区中的全局对象,那么,局部静态对象了?局部静态对象通常也是在函数中定义的,就像栈对象⼀样,只不过,其前⾯多了个static关键字。局部静态对象的⽣命期是从其所在函数第⼀次被调⽤,更确切地说,是当第⼀次执⾏到该静态对象的声明代码时,产⽣该静态局部对象,直到整个程序结束时,才销毁该对象。
还有⼀种静态对象,那就是它作为class的静态成员。考虑这种情况时,就牵涉了⼀些较复杂的问题。
第⼀个问题是class的静态成员对象的⽣命期,class的静态成员对象随着第⼀个class object的产⽣⽽产⽣,在整个程序结束时消亡。也就是有这样的情况存在,在程序中我们定义了⼀个class,该类中有⼀个静态对象作为成员,但是在程序执⾏过程中,如果我们没有创建任何⼀个该class object,那么也就不会产⽣该class所包含的那个静态对象。还有,如果创建了多个class object,那么所有这些object都共享那个静态对象成员。
第⼆个问题是,当出现下列情况时:
class Ba
{
public:
static Type s_object ;
}
class Derived1 : public Ba / / 公共继承
{
… …// other data
}
class Derived2 : public Ba / / 公共继承
{
… …// other data
}
Ba example ;
Derivde1 example1 ;
Derivde2 example2 ;
example.s_object = …… ;
example1.s_object = …… ;
欲扬先抑作文
example2.s_object = …… ;
请注意上⾯标为⿊体的三条语句,它们所访问的s_object是同⼀个对象吗?答案是肯定的,它们的确是指向同⼀个对象,这听起来不像是真的,是吗?但这是事实,你可以⾃⼰写段简单的代码验证⼀下。我要做的是来解释为什么会这样?我们知道,当⼀个类⽐
如Derived1,从另⼀个类⽐如Ba继承时,那么,可以看作⼀个Derived1对象中含有⼀个Ba型的对象,这就是⼀个subobject。⼀
个Derived1对象的⼤致内存布局如下:
让我们想想,当我们将⼀个Derived1型的对象传给⼀个接受⾮引⽤Ba型参数的函数时会发⽣切割,那么是怎么切割的呢?相信现在你已经知道了,那就是仅仅取出了Derived1型的对象中的subobject,⽽忽略了所有Derived1⾃定义的其它数据成员,然后将这
个subobject传递给函数(实际上,函数中使⽤的是这个subobject的拷贝)。
所有继承Ba类的派⽣类的对象都含有⼀个Ba型的subobject(这是能⽤Ba型指针指向⼀个Derived1对象的关键所在,⾃然也是多态的关键了),⽽所有的subobject和所有Ba型的对象都共⽤同⼀个s_object对象,⾃然,从Ba类派⽣的整个继承体系中的类的实例都会共⽤同⼀个s_object对象了。上⾯提到的example、example1、example2的对象布局如下图所⽰:
3.1.2 三种内存对象的⽐较
栈对象的优势是在适当的时候⾃动⽣成,⼜在适当的时候⾃动销毁,不需要程序员操⼼;⽽且栈对象的创建速度⼀般较堆对象快,因为分配堆对象时,会调⽤operator new操作,operator new会采⽤某种内存空间搜索算法,⽽该搜索过程可能是很费时间的,产⽣栈对象则没有这么⿇烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量⽐较⼩,⼀般是1MB~2MB,所以体积⽐
较⼤的对象不适合在栈中分配。特别要注意递归函数中最好不要使⽤栈对象,因为随着递归调⽤深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产⽣运⾏时错误。
堆对象,其产⽣时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的⽣命具有完全的控制权。我们常常需要这样的对象,⽐如,我们需要创建⼀个对象,能够被多个函数所访问,但是⼜不想使其成为全局的,那么这个时候创建⼀个堆对象⽆疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相⽐于栈空间,堆的容量要⼤得多。实际上,当物理内存不够时,如果这时还需要⽣成新的堆对象,通常不会产⽣运⾏时错误,⽽是系统会使⽤虚拟内存来扩展实际的物理内存。
接下来看看static对象。
⾸先是全局对象。全局对象为类间通信和函数间通信提供了⼀种最简单的⽅式,虽然这种⽅式并不优雅。⼀般⽽⾔,在完全的⾯向对象语⾔中,是不存在全局对象的,⽐如C#,因为全局对象意味着不安全和⾼耦合,在程序中过多地使⽤全局对象将⼤⼤降低程序的健壮性、稳定性、可维护性和可复⽤性。C++也完全可以剔除全局对象,但是最终没有,我想原因之⼀是为了兼容C。
其次是类的静态成员,上⾯已经提到,基类及其派⽣类的所有对象都共享这个静态成员对象,所以
当需要在这些class之间或这些class objects之间进⾏数据共享或通信时,这样的静态成员⽆疑是很好的选择。
接着是静态局部对象,主要可⽤于保存该对象所在函数被屡次调⽤期间的中间状态,其中⼀个最显著的例⼦就是递归函数,我们都知道递归函数是⾃⼰调⽤⾃⼰的函数,如果在递归函数中定义⼀个nonstatic局部对象,那么当递归次数相当⼤时,所产⽣的开销也是巨⼤的。这是因为nonstatic局部对象是栈对象,每递归调⽤⼀次,就会产⽣⼀个这样的对象,每返回⼀次,就会释放这个对象,⽽且,这样的对象只局限于当前调⽤层,对于更深⼊的嵌套层和更浅露的外层,都是不可见的。每个层都有⾃⼰的局部对象和参数。
在递归函数设计中,可以使⽤static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调⽤和返回时产⽣和释
放nonstatic对象的开销,⽽且static对象还可以保存递归调⽤的中间状态,并且可为各个调⽤层所访问。
3.1.3 使⽤栈对象的意外收获
前⾯已经介绍到,栈对象是在适当的时候创建,然后在适当的时候⾃动释放的,也就是栈对象有⾃
动管理功能。那么栈对象会在什么会⾃动释放了?第⼀,在其⽣命期结束的时候;第⼆,在其所在的函数发⽣异常的时候。你也许说,这些都很正常啊,没什么⼤不了的。是的,没什么⼤不了的。但是只要我们再深⼊⼀点点,也许就有意外的收获了。
栈对象,⾃动释放时,会调⽤它⾃⼰的析构函数。如果我们在栈对象中封装资源,⽽且在栈对象的析构函数中执⾏释放资源的动作,那么就会使资源泄漏的概率⼤⼤降低,因为栈对象可以⾃动的释放资源,即使在所在函数发⽣异常的时候。实际的过程是这样的:函数抛出异常时,会发⽣所谓的stack_unwinding(堆栈回滚),即堆栈会展开,由于是栈对象,⾃然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执⾏,从⽽释放其所封装的资源。除⾮,除⾮在析构函数执⾏的过程中再次抛出异常――⽽这种可能性是很⼩的,所以⽤栈对象封装资源是⽐较安全的。基于此认识,我们就可以创建⼀个⾃⼰的句柄或代理来封装资源了。智能指针(auto_ptr)中就使⽤了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。
3.1.4 禁⽌产⽣堆对象
上⾯已经提到,你决定禁⽌产⽣某种类型的堆对象,这时你可以⾃⼰创建⼀个资源封装类,该类对象只能在栈中产⽣,这样就能在异常的情况下⾃动释放封装的资源。英语语法练习题
那么怎样禁⽌产⽣堆对象了?我们已经知道,产⽣堆对象的唯⼀⽅法是使⽤new操作,如果我们禁⽌使⽤new不就⾏了么。再进⼀
步,new操作执⾏时会调⽤operator new,⽽operator new是可以重载的。⽅法有了,就是使new operator 为private,为了对称,最好
将operator delete也重载为private。现在,你也许⼜有疑问了,难道创建栈对象不需要调⽤new吗?是的,不需要,因为创建栈对象不需要搜索内存,⽽是直接调整堆栈指针,将对象压栈,⽽operator new的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上⾯已经提到过了。好,让我们看看下⾯的⽰例代码:
#include <stdlib.h> //需要⽤到C式内存分配函数
class Resource ; //代表需要被封装的资源类
class NoHashObject
{
private:
Resource* ptr ;//指向被封装的资源
... ... //其它数据成员
void* operator new(size_t size) //⾮严格实现,仅作⽰意之⽤
{
淘宝怎么取消订单 return malloc(size) ;
}
void operator delete(void* pp) //⾮严格实现,仅作⽰意之⽤
{
free(pp) ;
}
public:
攀谈拼音 NoHashObject()
{
//此处可以获得需要封装的资源,并让ptr指针指向该资源
ptr = new Resource() ;
}
~NoHashObject()
{
delete ptr ; //释放封装的资源
}
};
NoHashObject现在就是⼀个禁⽌堆对象的类了,如果你写下如下代码:
NoHashObject* fp = new NoHashObject() ; //编译期错误!
delete fp ;
上⾯代码会产⽣编译期错误。好了,现在你已经知道了如何设计⼀个禁⽌堆对象的类了,你也许和我⼀样有这样的疑问,难道在
类NoHashObject的定义不能改变的情况下,就⼀定不能产⽣该类型的堆对象了吗?不,还是有办法的,我称之为“暴⼒破解法”。C++是如此地强⼤,强⼤到你可以⽤它做你想做的任何事情。这⾥主要⽤到的是技巧是指针类型的强制转换。
void main(void)
{
char* temp = new char[sizeof(NoHashObject)] ;
//强制类型转换,现在ptr是⼀个指向NoHashObject对象的指针
NoHashObject* obj_ptr = (NoHashObject*)temp ;
temp = NULL ; //防⽌通过temp指针修改NoHashObject对象
//再⼀次强制类型转换,让rp指针指向堆中NoHashObject对象的ptr成员牛牛碰免费视频
Resource* rp = (Resource*)obj_ptr ;
//初始化obj_ptr指向的NoHashObject对象的ptr成员
rp = new Resource() ;
//现在可以通过使⽤obj_ptr指针使⽤堆中的NoHashObject对象成员了
... ...
delete rp ;//释放资源
temp = (char*)obj_ptr ;
obj_ptr = NULL ;//防⽌悬挂指针产⽣
delete [] temp ;//释放NoHashObject对象所占的堆空间。
}
上⾯的实现是⿇烦的,⽽且这种实现⽅式⼏乎不会在实践中使⽤,但是我还是写出来路,因为理解它,对于我们理解C++内存对象是有好处的。对于上⾯的这么多强制类型转换,其最根本的是什么了?我们可以这样理解:
某块内存中的数据是不变的,⽽类型就是我们戴上的眼镜,当我们戴上⼀种眼镜后,我们就会⽤对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。
所谓强制类型转换实际上就是换上另⼀副眼镜后再来看同样的那块内存数据。
另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不⼀样的,⽐如,⼤多数编译器将NoHashObject的ptr指针成员安排在对象空间的头4个字节,这样才会保证下⾯这条语句的转换动作像我们预期的那样执⾏:
Resource* rp = (Resource*)obj_ptr ;
但是,并不⼀定所有的编译器都是如此。
既然我们可以禁⽌产⽣某种类型的堆对象,那么可以设计⼀个类,使之不能产⽣栈对象吗?当然可以。
3.1.5 禁⽌产⽣栈对象
前⾯已经提到了,创建栈对象时会移动栈顶指针以“挪出”适当⼤⼩的空间,然后在这个空间上直接调⽤对应的构造函数以形成⼀个栈对象,⽽当函数返回时,会调⽤其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要operator
new/delete操作的,所以将operator new/delete设置为private不能达到⽬的。当然从上⾯的叙述中,你也许已经想到了:将构造函数或析构函数设为私有的,这样系统就不能调⽤构造/析构函数了,当然就不能在栈中⽣成对象了。
这样的确可以,⽽且我也打算采⽤这种⽅案。但是在此之前,有⼀点需要考虑清楚,那就是,如果我们将构造函数设置为私有,那么我们也就不能⽤new来直接产⽣堆对象了,因为new在为对象分配空间后也会调⽤它的构造函数啊。所以,我打算只将析构函数设置为private。再进⼀步,将析构函数设为private除了会限制栈对象⽣成外,还有其它影响吗?是的,这还会限制继承。
如果⼀个类不打算作为基类,通常采⽤的⽅案就是将其析构函数声明为private。
为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所⽰:
class NoStackObject
{
protected:
~NoStackObject() { }
public:
void destroy()