《C++应用程序性能优化::第一章C++对象模型》学习和理解
说明:《C++应用程序性能优化》 作者:冯宏华等 2007年版。最近出了新版,看了目录,在前面增加了一章的内容,其它的没变。
本书所有的学习总结,如果涉及到对象构造,那么都假设存在构造函数。--2010.9.10修正。
一、C++程序占用的内存区一般分为如下5种:
1、 全局/静态数据区;
2、 常量数据区;
3、 代码区;
4、 栈;
5、 堆。
二、对齐
字符串常量存储在常量数据区,而且是4个字节对齐的。通过new\malloc获取的内存是堆中的内存,按照16字节对齐。
三、静态成员
如果要在同一个类的多个对象之间共享数据,可以使用全局变量,但这样会破坏类的封装性。因此C++语言提供了类的静态成员变量,可以用来在类的多个对象之间共享数据。类的静态成员变量存储在全局/静态存储区中,并且只有一份拷贝,由类的所有对象共享。
四、堆和栈
char *p = new char[5];
通过new\malloc在堆上申请了5个char大小的内存,并将获得的地址保存在栈上的变量p中。当p的作用域结束时,栈上的p变量空间会被回收,而如果没有通过delete释放p所指向的堆空间,则会造成内存泄漏,5个char的空间会一直存在,直到整个程序运行结束。栈上的空间在作用域结束时会被自动释放,而堆上的空间必须调用delete/free释放,不然会导致内存泄漏。
使用堆上的内存是必要的,例如:链表,当需要为链表新增节点时,就需要在堆上申请内存并创建节点。另外堆和栈还有如下的差别:
1、 大小。一般说来,一个程序使用的栈的大小是固定的,由编译器决定,一般是1M,可以通过编译选项调整栈的大小。而堆的大小一般只受限于系统有效的虚拟内存的大小。
2、 效率。栈上的内存是系统自动分配的,压栈和出栈都有相应的指令进行操作。因此效率较高,并且分配的内存空间是连续的,不会产生内存碎片;而堆上的内存是由开发人员来动态分配和回收的。当开发人员通过new或malloc申请堆上的内存时,系统需要按一定的算法在堆空间中寻找合适大小的空闲堆,并修改相应的维护堆空闲空间的链表,然后返回地址给程序。因此效率比栈要低,此外还容易产生内存碎片。
五、C++中的对象
从C++对象模型的角度来说,对象就是内存中的一片区域(C++标准,1.8:An object is a region of storage)。
根据C++标准,一个对象可以通过定义变量,或者通过new操作符,或者通过实现来创建(C++标准, 1.8:An object is created by a definition(3.1),by a new-expression(5.3.4)or by the implementation(12.2)when needed)。如果一个对象通过定义在某个函数内的变量或者实现需要的临时变量来创建时,它是栈上的一个对象;如果一个对象是定义在全局范围内的变量,则它是存储在全局/静态数据区;如果一个对象是通过new操作符来创建时,它是堆上的一个对象。
六、对象的生命周期
1、通过定义变量创建对象:在这种情况下,变量的作用域决定了对象的生命周期。当进入变量的作用域时,对象被创建。而退出变量的作用域时,对象被销毁。其中,全局变量:在程序调用main()函数之前被创建,当程序退出main()函数之后,全局对象才被销毁。静态变量:跟全局变量类似,不过作用域不是整个程序,静态变量存储在全局/静态数据区中,在程序开始时已经分配好。声明为静态变量的对象第一次进入作用域时被创建,直到程序退出时被销毁,如果程序从没有进入其作用域,则永远不会被创建。注意:C++中的作用域由‘{’和‘}’定义,并不一定是整个函数。
2、通过new操作符创建对象:这种情况相对比较简单,也最容易造成内存泄漏。通过new创建的对象会一直存在,直到被delete销毁。即使指向该对象的指针已被销毁,但还没有调用delete,该对象会一直存在,直到程序退出,指针销毁了,对象还占据内存,这就是内存泄漏。
3、通过实现创建对象:这种情况一般是指一些隐藏的中间临时变量的创建和销毁。它们的生命周期很短,也不易被开发人员察觉。但常常是造成程序性能下降的瓶颈,尤其是对于那些占用内存较多,创建速度较慢的对象。这些临时对象一般是通过拷贝构造函数创建的。
七、C++对象的内存布局
1、对象占用内存的大小。
(1)非静态数据成员是影响对象占据内存大小的主要因素,随着对象数据的增加,非静态数据成员占据的内存会相应增加。
(2)所有的对象共享一份静态数据成员,所以静态数据成员占据的内存的数量不会随着对象的数目的增加而增加。
(3)静态成员函数和非静态成员函数不会影响对象内存的大小,虽然其实现会占据相应的内存空间,但是不会随着对象数目的增加而增加。
(4)如果对象中包含虚函数,会增加4个字节的空间用于保存虚函数表指针。
八、虚函数
虚函数是C++中的一个重要特性,用来实现面向对象中的多态性。即只有在程序运行时,才能决定一个父类对象的指针调用的函数是父类的还是子类中的实现。为了实现这一特性,C++编译器在创建含有虚函数的类的对象时,会分配一个指针指向一个函数地址表,该地址表即是“虚函数表”,该指针就是虚函数表指针。每一个对象都会有一个虚函数表指针,用于指向虚函数表。同一个类的多个对象指向同一张虚函数表。
在虚函数表vtable中不必完全是指向虚函数实现的指针。当指定编译器打开RTTI开关时,vtable中的第1个指针指向的是一个typeinfo的结构,每个类只产生一个typeinfo结构的实例。当程序调用typeid()来获取类的信息时,实际上就是通过vtable中的第1个指针获得了typeinfo。
九、继承
1、 普通单继承
派生类的对象在生成的时候,首先调用基类的构造函数,然后再调用派生类的构造函数。在内存空间上,按地址顺序从低到高,首先是基类的成员变量,然后是派生类的成员变量。如果基类有虚函数的话,那么虚函数指针还是放在对象内存空间的第一个位置。
2、普通多继承
一个派生类有多个基类。可以这样使用,把功能A、B定义为单独的基类,如果派生类继承了A基类,并实现其中的方法,那么便有了A功能;如果都继承了,那就有了A、B功能。假如另外有C的功能要扩展,那么再定义一个C类,然后让派生类继承就可以了。
派生类的对象的内存布局,跟继承的顺序有关。假如先继承A,再继承B(class D : public A, public B),那么派生类对象的内存布局:首先是基类A的虚函数表指针和成员变量,然后是基类B的虚函数表指针和成员变量,最后是派生类自己的成员变量。调用构造函数的顺序是先调用A类的构造函数,然后调用B类的构造函数,最后调用派生类的构造函数。
如果使用VS编译器/GCC编译器,有多少个拥有虚函数的基类,派生类对象就有多少个指向不同虚函数表的指针。并且只要有虚函数表指针,对象内存空间的第一个位置就总是虚函数表指针。如果派生类有自己的虚函数,那么派生类的虚函数的地址就放在第一个位置的虚函数表指针所指向的虚函数表中,也就是说派生类的虚函数的地址跟第一基类的虚函数地址保存在同一张虚函数表中。
2、 虚拟继承
有这样的继承关系:基类B有两个派生类C1和C2,然后D类继承了C1和C2,这种情况下D类对象调用基类B的成员时,会出现二义,不知道该调用C1类的基类B,还是调用C2类的基类B。这种继承关系叫做“菱形继承”。D类对象会创建两个B类的实例,因此菱形继承存在二义性,为了避免这种情况,必须使用“虚拟继承”。当使用虚拟继承时,公共基类只有一个实例。
这本书分析得比较简单。另外找资料,另外分析学习,分析起来一大段。
十、构造和析构
五中介绍了C++创建的三种方式,无论采用哪种方式,创建一个对象时,都需要获得所需的内存空间,并且调用类的构造函数。
内存空间要根据对象是哪种类型的变量来决定,如是栈上的空间,或者是通过new获得的堆上的空间。
对于构造函数,C++标准规定每一个类都必须有构造函数。如果开发人员没有定义,则编译器会提供一个默认的构造函数(此处书本有误,编译器有时候不会提供默认构造函数。参考《深度……》第47页。我也反汇编过一个例子(VS、GCC),确实存在没有构造和析构函数的情况--2010.9.10修正)。这个默认的构造函数不带任何参数,也不会对成员数据进行初始化(通过第二章的知识可知,成员对象会调用默认构造函数进行初始化)。如果类中定义了任何一种形式的构造函数,则不会产生默认的构造函数。
除了默认的构造函数,每个类中还必须定义拷贝构造函数。同样,如果没有定义,编译器会自动产生一个默认的拷贝构造函数。这个拷贝构造函数执行的是位拷贝,即按照对象的内存空间逐个字节进行复制。有时这种默认的拷贝构造函数会带来隐含的内存问题。
如果不想通过赋值或者拷贝构造对象,可以将拷贝构造函数定义在private区域,这样ClassA b = a;就会在编译时出错。
2010.8.21
cs_wuyg@126.com