1、C++类对象的内存布局
在C++的类对象中,有两种类的成员变量:static和非static,有三种成员函数:static、非static和virtual。那么,它们在C++的内存中是如何分布的呢?
C++程序的内存格局通常分为四个区:全局数据区(data area),代码区(code area),栈区(stack area),堆区(heap area)(即自由存储区)。全局数据区存放全局变量,静态数据和常量。所有类成员函数和非成员函数代码存放在代码区;为运行函数而分配的局部变量、函数参数、返回数据、返回地址等存放在栈区;余下的空间都被称为堆区。
在类的定义时,
- 类的成员函数被放在代码区。
- 类的静态成员变量在全局数据区。
- 非静态成员变量在类的实例内,实例在栈区或者堆区。
- 虚函数指针、虚基类指针在类的实例内,实例在栈区或者堆区。
类的实例如果是定义的类变量,则在栈内存区,如果是new出来的类指针,则在堆内存区,同时引用会保存在栈里。
为何这样设计?其实这是从c语言发展而来的。类的成员变量相当于c的结构体,类的成员函数类似于c的函数,类的静态变量类似于c的静态或全局变量,至于虚函数,函数体还是放在代码区,但虚函数的指针和成员变量一起放在数据区,这是因为虚函数的函数体有多个,不同的子类调用同一虚函数实则调用的不同函数体,因此需要在类的数据区保持真正的虚函数的指针。
类的成员函数为什么不需要在类的数据区保持指针?因为类的成员函数是唯一的,在编译时,编译器会为每个类的成员函数改头换面,如函数名加上类名,参数加上this类指针。这样类的成员函数和c的普通函数就一样了。虚函数由于其多态的特殊性,无法这样处理,所以需要保持在类的数据区。
下面就一个非常简单的类,通过逐渐向其中加入各种成员,来逐一分析上述两种成员变量及三种成员函数对类的对象的内存分布的影响。
注:以下的代码的测试结果均是基于Ubuntu 14.04 64位系统下的G++ 4.8.2,若在其他的系统上或使用其他的编译器,可能会运行出不同的结果。
2、含有非static成员变量及成员函数的类的对象的内存分布
类Persion的定义如下:
class Person
{
public:
Person():mId(0), mAge(20){}
void print()
{
cout << "id: " << mId
<< ", age: " << mAge << endl;
}
private:
int mId;
int mAge;
};
Person类包含两个非static的int型的成员变量,一个构造函数和一个非static成员函数。为弄清楚该类的对象的内存分布,对该类的对象进行一些操作如下:
int main()
{
Person p1;
cout << "sizeof(p1) == " << sizeof(p1) << endl;
int *p = (int*)&p1;
cout << "p.id == " << *p << ", address: " << p << endl;
++p;
cout << "p.age == " << *p << ", address: " << p << endl;
cout << endl;
Person p2;
cout << "sizeof(p2) == " << sizeof(p1) << endl;
p = (int*)&p2;
cout << "p.id == " << *p << ", address: " << p << endl;
++p;
cout << "p.age == " << *p << ", address: " << p << endl;
return 0;
}
其运行结果如下:
从上图可以看到类的对象的占用的内存均为8字节,使用普通的int*指针可以遍历输出对象内的非static成员变量的值,且两个对象中的相同的非static成员变量的地址各不相同。
据此,可以得出结论,在C++中,非static成员变量被放置于每一个类对象中,非static成员函数放在类的对象之外,且非static成员变量在内存中的存放顺序与其在类内的声明顺序一致。即person对象的内存分布如下图所示:
3、含有static和非static成员变量和成员函数的类的对象的内存分布
向Person类中加入一个static成员变量和一个static成员函数,如下:
class Person
{
public:
Person():mId(0), mAge(20){ ++sCount; }
~Person(){ --sCount; }
void print()
{
cout << "id: " << mId
<< ", age: " << mAge << endl;
}
static int personCount()
{
return sCount;
}
private:
static int sCount;
int mId;
int mAge;
};
测试代码不变,与第1节中的代码相同。其运行结果不变,与第1节中的运行结果相同。 据此,可以得出:static成员变量存放在类的对象之外,static成员函数也放在类的对象之外。
其内存分布如下图所示:
4、加入virtual成员函数的类的对象的内存分布
在Person类中加入一个virtual函数,并把前面的print函数修改为虚函数,如下:
class Person
{
public:
Person():mId(0), mAge(20){ ++sCount; }
static int personCount()
{
return sCount;
}
virtual void print()
{
cout << "id: " << mId
<< ", age: " << mAge << endl;
}
virtual void job()
{
cout << "Person" << endl;
}
virtual ~Person()
{
--sCount;
cout << "~Person" << endl;
}
protected:
static int sCount;
int mId;
int mAge;
};
为了查看类的对象的内存分布,对类的对象执行如下的操作代码,如下:
int main()
{
Person person;
cout << sizeof(person) << endl;
int *p = (int*)&person;
for (int i = 0; i < sizeof(person) / sizeof(int); ++i, ++p)
{
cout << *p << endl;
}
return 0;
}
其运行结果如下:
从上图可以看出,加virtual成员函数后,类的对象的大小为16字节,增加了8。通过int*指针遍历该对象的内存,可以看到,最后两行显示的是成员数据的值。
C++中的虚函数是通过虚函数表(vtbl)来实现,每一个类为每一个virtual函数产生一个指针,放在表格中,这个表格就是虚函数表。每一个类对象会被安插一个指针(vptr),指向该类的虚函数表。vptr的设定和重置都由每一个类的构造函数、析构函数和复制赋值运算符自动完成。
由于本人的系统是64位的系统,一个指针的大小为8字节,所以可以推出,在本人的环境中,类的对象的安插的vptr放在该对象所占内存的最前面。其内存分布图如下:
注:虚函数的顺序是按虚函数定义顺序定义的,但是它还包含其他的一些字段,本人还未明白它是什么,在下一节会详细说明虚函数表的内容。
5、虚函数表(vtbl)的内容及函数指针存放顺序
在第3节中,我们可以知道了指向虚函数表的指针(vptr)在类中的位置了,而函数表中的数据都是函数指针,于是便可利用这点来遍历虚函数表,并测试出虚函数表中的内容。
测试代码如下:
typedef void (*FuncPtr)();
int main()
{
Person person;
int **vtbl = (int**)*(int**)&person;
for (int i = 0; i < 3 && *vtbl != NULL; ++i)
{
FuncPtr func = (FuncPtr)*vtbl;
func();
++vtbl;
}
while (*vtbl)
{
cout << "*vtbl == " << *vtbl << endl;
++vtbl;
}
return 0;
}
代码解释:
由于虚函数表位于对象的首位置上,且虚函数表保存的是函数的指针,若把虚函数表当作一个数组,则要指向该数组需要一个双指针。我们可以通过如下方式获取Person类的对象的地址,并转化成int**指针:
Person person;
int **p = (int**)&person;
再通过如下的表达式,获取虚函数表的地址:
int **vtbl = (int**)*p;
然后,通过如下语句获得虚函数表中函数的地址,并调用函数。
FuncPtr func = (FuncPtr)*vtbl;
func();
最后,通过++vtbl可以得到函数表中下一项地址,从而遍历整个虚函数表。
其运行结果如下图所示:
从上图可以看出,遍历虚函数表,并根据虚函数表中的函数地址调用函数,它先调用print函数,再调用job函数,最后调用析构函数。函数的调用顺序与Person类中的虚函数的定义顺序一致,其内存分布与第3节中的对象内存分布图相一致。从代码和运行结果,可以看出,虚函数表以NULL标志表的结束。但是虚函数表中还含有其他的数据,本人还没有清楚其作用。
6、继承对于类的对象的内存分布的影响
本文并不打算详细地介绍继承对对象的内存分布的影响,也不介绍虚函数的实现机制。这里主要给出一个经过本人测试的大概的对象内存模型,由于代码较多,不一一贴出。假设所有的类都有非static的成员变量和成员函数、static的成员变量及成员函数和virtual函数。
1)单继承(只有一个父类)
类的继承关系为:class Derived : public Base
Derived类的对象的内存布局为:虚函数表指针、Base类的非static成员变量、Derived类的非static成员变量。
2)多重继承(多个父类)
类的继承关系如下:class Derived : public Base1, public Base2
Derived类的对象的内存布局为:基类Base1子对象和基类Base2子对象及Derived类的非static成员变量组成。基类子对象包括其虚函数表指针和其非static的成员变量。
3)重复继承(继承的多个父类中其父类有相同的超类)
类的继承关系如下:
class Base1 : public Base
class Base2: public Base
class Derived : public Base1, public Base2
Derived类的对象的内存布局与多继承相似,但是可以看到基类Base的子对象在Derived类的对象的内存中存在一份拷贝。这样直接使用Derived中基类Base的相关成员时,就会引发歧义,可使用多重虚拟继承消除之。
4)多重虚拟继承(使用virtual方式继承,为了保证继承后父类的内存布局只会存在一份)
类的继承关系如下:
class Base1 : virtual public Base
class Base2: virtual public Base
class Derived : public Base1, public Base2
Derived类的对象的内存布局与重复继承的类的对象的内存分布类似,但是基类Base的子对象没有拷贝一份,在对象的内存中仅存在在一个Base类的子对象。但是它的非static成员变量放置在对象的末尾处。