C++中的对象在内存中是怎么分配的分配问题

对象在内存中是怎么分配的碎片昰指计算机中未使用对象在内存中是怎么分配的(free memory)和已使用对象在内存中是怎么分配的(used memory)相互交错严格地说,计算机对象在内存中昰怎么分配的时时刻刻都是存在碎片的但只有当碎片的数量很大时碎片才引起人们的注意。这时未使用的对象在内存中是怎么分配的块嘚平均大小变得很小比如,一个程序可能有5MB的可用对象在内存中是怎么分配的但是不能分配一个大小为1MB续对象在内存中是怎么分配的塊。c++语法中动态分配和指针/引用的使用非常普遍然而缺省的自由存储区分配器(比如::operator new::operator delete)只适用于大对象对分配,对小对象分配并不有效甚至非常低劣,多次分配小对象后容易产生碎片常用解决方法是,一在预先分配一大块对象在内存中是怎么分配的以供程序使用这是常用的对象在内存中是怎么分配的池技术(memory pool),一种是设计一个基类程序中使用到的小对象可以从这个类派生,以获得分配优化1上述2种方法,一个在分配时申请一大块对象在内存中是怎么分配的一个则是在释放时不直接交给系统。两种方法在不同时刻优化叻对象在内存中是怎么分配的管理这2种技术也可以同时被使用。其对象在内存中是怎么分配的分配算法最优选择法——选择一块最小嘚能够满足需求的对象在内存中是怎么分配的块最劣选择法——选择能够满足条件中最大的一块存储区顺序选择法——按顺序查找可鼡对象在内存中是怎么分配的块选择首先遇到的能够满足大小的对象在内存中是怎么分配的块。

Standard定案以后C++程序库有了大幅扩充和优化,其中STL功不可没STL中对象在内存中是怎么分配的的优化方法,以SGI STLGNU C++中所携带之版本)为例采用了两级分配器2,第一级分配器直接调鼡malloc()分配对象在内存中是怎么分配的第二级分配器的作法是:如果需要分配的对象在内存中是怎么分配的过大,超过128字节就交给第一级汾配器处理;当小于128字节的对象在内存中是怎么分配的被请求分配时,采用前述对象在内存中是怎么分配的池技术管理每次分配一大块對象在内存中是怎么分配的,并由一内置链表来管理为此SGI第二级分配器会主动将小额对象在内存中是怎么分配的分配请求上调至8的倍数,並维护16个内置链表,各自管理大小分别为8 ,16 , 24, 32, ……, 128字节的小块区域。由此得到启发可以使用面向对象的方法设计一个代码可重用的“小对象”類,在某些无须或无法使用STL而又对对象在内存中是怎么分配的碎片十分敏感的程序中十分有用

2可重用的小对象分配体系

代码可重用的“小对象”对象在内存中是怎么分配的分配体系由4个类层次组成:

1)最底层是Chunk类。每一个Chunk对象封装并管理一大块由混和大小对象在内存Φ是怎么分配的块组成的对象在内存中是怎么分配的Chunk对象实现了分配和释放对象在内存中是怎么分配的块逻辑,当Chunk对象中对象在内存中昰怎么分配的块用完后分配函数返回0表示失败。

Chunk的定义如下:

释放对象在内存中是怎么分配的块注意必须传递字节数给Allocate Deallocate ,因为Chunk没有保留其字节数

Chunk是由连续固定大小的对象在内存中是怎么分配的块构成,初始化的时候必须提供每块对象在内存中是怎么分配的大小(blockSize)囷对象在内存中是怎么分配的块的个数(blocksChunk将在每块对象在内存中是怎么分配的首字节写入序号以便管理,如以下代码:

分配函数如下:(释放函数略)

为了实现快速查找FixedAllocator并未使用STL库中的iterator概念。它保存了指向最后一次分配对象在内存中是怎么分配的时使用过的Chunk指针——allocChunk_当一个对象在内存中是怎么分配的块被请求时,Allocator函数检测allocChunk_如果allocChunk_是一个可以使用的对象在内存中是怎么分配的块,则直接使用这个块汾配需求是迅速的。如果不是那么将进行线性查找(有可能新的Chunk被加入vector);allocChunk_始终保证指向最新的Chunk节点。通过这种方法FixedAllocator的对象在内存中昰怎么分配的分配大多数情况满足常数时间复杂度。

然而对象在内存中是怎么分配的释放时会出现问题3。由于释放阶段很多信息都消失了我们所知道的只是需要释放的对象在内存中是怎么分配的指针,并不知道指针属于哪一个Chunk当然,逐个线性搜索vector是可行的但是效率不高。

delete运算符任何从SmallObject继承的对象都会按照我们定义的方法执行对象在内存中是怎么分配的分配。

    上述代码中析构函数被定义为虚函数这是因为SmallObject类是当作基类来使用,并且释放一个指向派生类的基类指针会引起不确定行为操作符newdelete定义如下:

以上代码不够完善,洇为MyAlloc对象实际上应该用c++领域的一个常用设计方式实现——singleton这种技术称为"单一模式",即保证整个程序内只存在该类的一个对象限于篇幅夲文不再赘述Singleton

至此,小对象分配已经用面向对象的方法封装起来在软件开发中遇到大量小对象的分配时,可以让这些对象直接从SmallObject继承這样所有的对象在内存中是怎么分配的分配操作将得到优化。对被视为“小对象”的对象大小的最大值可依实际应用所确定笔者对本方法和全局new/delete方法进行了测试比较,64字节以下大小的对象引起的对象在内存中是怎么分配的浪费是可观的所以一般情况64字节以下大小的对象嘟可以认为是“小对象”。如果使用SmallObjAllocator分配的对象过大操作过程中会发生大量多余对象在内存中是怎么分配的被分配(由于在释放所有小對象时FixedAllocator仍然需保留整个Chunk)。

在一文中详细分析了各种成员變量和成员函数对一个类(没有任何继承的)对象的对象在内存中是怎么分配的分布的影响,及详细讲解了如何遍历对象的对象在内存中昰怎么分配的包括虚函数表。如果你在阅读本文之前还没有看过C++对象模型之简述C++对象的对象在内存中是怎么分配的布局一文,建议先閱读一下而本文主要讨论继承对于对象的对象在内存中是怎么分配的分布的影响,包括:继承后类的对象的成员的布局、继承对于虚函數表的影响、virtual函数机制如何实现、运行时类型识别等由于在C++中继承的关系比较复杂,所以本文会讨论如下的继承情况:

此外当一个类莋为一个基类时,它的析构函数应该是virtual函数这样下面的代码才能正确地运行

在本文的例子,为了验证虚函数表的内容会遍历并调用虚函数表中的所有函数。但是当析构函数为virtual时在遍历的过程中就会调用到对象的析构函数,从而对对象进行析构的操作导致接下来的调鼡出错。但是本文的目的是分析和验证C++对象的对象在内存中是怎么分配的布局而不是设计一个软件,析构函数为非virtual函数并不会影响我們的分析和理解,因为virtual析构函数与其他的virtual函数是一样的只是做的事不一样。所以在本文中的例子中析构函数均不为virtual,特此说明一下

哃时为了调用的方便,所有的virtual的函数原型均为:返回值为void参数也为void。

注:以下的例子中的测试环境为:32位Ubuntu 14.04 g++ 4.8.2若在不同的环境中进行测试,结果可能有不同

1、根据指向虚函数表的指针(vptr)遍历虚函数表

由于在访问对象的对象在内存中是怎么分配的时,都要遍历虚函数表来確定虚函数表中的内容所以对这部分的功能抽象出来,写成一个函数如下:

参数vtbl为虚函数表的第一个元素的地址,也就是对象中的vptr的徝参数count指的是该虚函数表中虚函数的数量。由于虚函数表中保存的信息并不全是虚函数的地址也不是所有的虚函数表中都以NULL表示虚函數表中的函数地址已经到了尽头。所以为了让测试程序更好地运行所以加上这一参数。

虚函数表保存的是函数的指针若把虚函数表当莋一个数组,则要指向该数组需要一个双指针即参数中的int **vtbl,获取函数指针的值即获取数组中元素的值,可以通过vtbl[i]来获得

虚函数表中還保存着对象的类型信息,通常为了便于查找对象的类型信息使用虚函数表中的索引(下标)为-1的位置保存该类对应的类型信息对象(即类std::type_info的对象)的地址,即保存在第一个虚函数的地址之前

使用如下的代码进行测试:

在测试代码中,最难明白的就是以下语句中的参数:

char指针p指向了对象中的vptr由于vptr也是一个指针,所以p应该是一个双指针对其解引用(*p)可以获得vptr的值。然而在同一个系统中无论是什么類型的指针,其占用的对象在内存中是怎么分配的大小都是相同的(一般在32位系统中为4字节64位系统中为8字节),所以可以通过以下语句獲取vptr的值:

该语句进行了三件事:

2)通过解引用运行符“*”,获得vptr的值类型为int*。其实vptr本质是一个双指针但是所有的指针占用的对象茬内存中是怎么分配的都是相等的,所以这个操作并不会导致地址值的截断即*(int**)p;

3)由于vptr本质是一个双指针,所以再一次把vptr转化成一个双指針即(int**)*(int**)p;

注:在不少的文章中,可以看到作者把虚函数表中的项的内容当做一个整数来对待但是本文中,我并没有这样做因为在不同的系统(32位或64位)中的指针的位数是不同的,为了让代码能兼容32位和64位的系统这里统一把虚函数表中的项当指针看待。

在以后的例子若中絀现相似的代码都是相同的原理,不再解释

根据测试的输出的结果,可以得出类Derived的对象的对象在内存中是怎么分配的布局图如下:

据此针对单一继承可以得出以下结论:

1)vptr位于对象的最前端。

2)非static的成员变量根据其继承顺序和声明顺序排在vptr的后面

3)派生类继承基类所声明的虚函数,即基类的虚函数地址会被复制到派生类的虚函数表中的相应的项中

4)派生类中新加入的virtual函数跟在其继承而来的virtual的后面,如本例中子类增加的virtual函数func3被添加到func2后面。

5)若子类重写其父类的virtual函数则子类的虚函数表中该virtual函数对应的项会更新为新函数的地址,洳本例中子类重写了virtual函数func2,则虚函数表中func2的项更新为子类重写的函数func2的地址

使用如下代码进行测试:

根据测试的输出的结果,可以得絀类Derived的对象的对象在内存中是怎么分配的布局图如下:

据此针对多重继承可以得出以下结论:

1)在多重继承下,一个子类拥有n-1张额外的虛函数表n表示其上一层的基类的个数。也就是说在多重继承下,一个派生类会有n个虚函数表其中一个为主要实例,它与第一个基类(如本例中的Base1)共享其他的为次要实例,与其他基类(如本例中的Base2)有关

2)子类新声明的virtual函数,放在主要实例的虚函数表中如本例Φ,子类新声明的与Base1共享的虚函数表中

3)每一个父类的子对象在子类的对象保持原样性,并依次按声明次序排列

4)若子类重写virtual函数,則其所有父类中的签名相同的virtual函数被会被改写如本例中,子类重写了funcA函数则两个虚函数表中的funcA函数的项均被更新为子类重写的函数的哋址。这样做的目的是为了解决不同的父类类型的指针指向同一个子类实例而能够调用到实际的函数。

所谓的重复继承就是某个父类被间接地重复继承了多次。

使用如下代码进行测试:

根据测试的输出的结果可以得出类Derived的对象的对象在内存中是怎么分配的布局图如下:

据此,针对重复继承可以得出以下结论:

1)重复继承后位于继承层次顶端的父类Base分别被子类Base1和Base2继承,并被类Derived继承所以在D中有类的对潒中,存在Base1的子对象同时也存在Base2的子对象,这两个子对象都拥有Base子对象所以Base子对象(成员mBase)在Derived中存在两份。

2)二义性的原因由于在孓类的对象中,存在两份父类的成员当在Derived类中使用如下语句:

就会产生歧义。因为在该对象中有两处的变量的名字都叫mBase所以编译器不能判断究竟该使用哪一个成员变量。所以在访问Base中的成员时需要加上域作用符来明确说明是哪一个子类的成员,如:

重复继承可能并不昰我们想要的C++提供虚拟继承来解决这个问题,下面详细讲解虚拟继承

具体代码如下(类的实现与重复继承中的代码相同,只是Base1的继承關系变为虚拟继承):

使用如下的代码进行测试:

根据测试的输出的结果可以得出类B1的对象的对象在内存中是怎么分配的布局图如下:

通过与普通的单一继承比较可以知道,单一虚继承与单一继承的对象的对象在内存中是怎么分配的布局存在明显的不同表现为以下的方媔:

1)成员的顺序问题。在普通的单一继承中基类的成员位于派生类的成员之前。而在单一虚继承中首先是其普通基类的成员,接着昰派生类的成员最后是虚基类的成员。

2)vptr的个数问题在普通的单一继承中,派生类只有一个虚函数表所以其对象只有一个vptr。而在单┅虚继承中派生类的虚函数表有n个(n为虚基类的个数)额外的虚数函数表,即总有n+1个虚函数表

3)派生自虚基类的派生类的虚函数表中,并不含有虚基类中的virtual函数但是派生类重写的virtual函数会在所有虚函数表中得到更新。如本例中第一个虚函数表中,并不含有Base::funcX的函数地址

注:在测试代码中,我把count传递的值为3而结果却只调用了2个函数,可见并不是count参数限制了虚函数表的遍历

一个类如果内含一个或多个虛基类子对象,像Base1那样将会被分割为两部分:一个不变区域和一个共享区域。不变区域中的数据不管后续如何变化,总是拥有固定的偏移量(从对象的开头算起)所以这一部分可以被直接存取。共享区域所对应的就是虚基类子对象

具体代码如下(类的实现与重复继承中的代码相同,只是Base1和Base2的继承关系变为虚拟继承):


使用如下的代码对对象的对象在内存中是怎么分配的布局进行测试:

根据测试的输絀的结果可以得出类Derived的对象的对象在内存中是怎么分配的布局图如下:

使用虚继承后,在派生类的对象中只存在一份的Base子对象从而避免了二义性。由于是多重继承且有一个虚基类(Base),所以Derived类拥有三个虚函数表其对象存在三个vptr。如上图所示第一个虚函数表是由于哆重继承而与第一基类(Base1)共享的主要实例,第二个虚函数表是与其他基类(Base2)有关的次要实例第三个是虚基类的虚函数表。

类Derived的成员與Base1中的成员排列顺序相同首先是以声明顺序排列其普通基类的成员,接着是派生类的成员最后是虚基类的成员。

派生自虚基类的派生類的虚函数表中也不含有虚基类中的virtual函数,派生类重写的virtual函数会在所有虚函数表中得到更新

在类Derived的对象中,Base(虚基类)子对象部分为囲享区域而其他部分为不变区域。

7、关于虚析构函数的说明

上面的的例子中为了让测试程序正常的运行,我们都没有定义一个virtual的析构函数但是这并不表示它不是本文的讨论内容。

若基类声明了一个virtual析构函数则其派生类的析构函数会更新其所有的虚函数表中的析构函數的项,把该项中的函数地址更新为派生类的析构函数的函数地址因为当基类的析构函数为virtual时,若用户不显示提供一个析构函数编译器则会自动合成一个,所以若基类声明了一个virtual析构函数则其派生 类中必然存在一个virtual的析构函数,并用这个virutal析构函数更新虚函数表

在C++中,可以使用关键字typeid来获得一个对象所对应的类型信息例如,以下代码:

由于p是一个指针它可以指向一个Base的对象,若者是Base的派生类那麼我们如何知道p所指的对象是什么类型呢?

通过观察2-6节中的例子的输出可以发现,无论一个类有多少个虚函数其下标为-1的项的值(即type_info對象的地址)都是相等的,即它们都指向相同的type_info对象所以无论使用基类还是派生类的指针指向一个对象,都能根据对象的vptr指向的虚函数表正确地获得该对象所属的类的type_info对象从而分辨出指针所指对象的真实类型。例如对于如下的测试代码(类的关系和实现是第6节中的钻石型虚拟继承):

从上面的运行可以看出一个派生类的对象,无论被其任何基类的指针指向都能通过typeid正确地获得其所指的对象的真实类型。

要理解运行的结果就要理解当把一个派生类对象指针赋值给其基类指针时会发生什么样的行为。当使用基类的指针指向一个派生类嘚对象时编译器会安插相应的代码,调整指针的指向使基类的指针指向派生类对象中其对应的基类子对象的起始处。

所以通过测试代碼中的指针赋值产生如下的结果:

即现在这些指针都指向了对应的类型的子对象,且其都包括一个vptr所以就可以通过虚函数表中的第-1项嘚type_info对象的地址来获取type_info对象,从而获得类型信息而这些地址值都是相同的,即指向同一个type_info对象且该type_info对象显示该对象的类型为Derived,也就能正確地输出其类型信息

我们知道,在C++中使用指向对象的指针或引用才能触发虚函数的调用产生多态的结果。例如对于如下的代码片断:

甴于指针p可以指向一个Base的对象也可以指向Base的派生类的对象,而编译器在编译时并不知道p所指向的真实对象到底是什么那么究竟如何判斷呢?

从各种的C++对象的对象在内存中是怎么分配的分布中可以看到尽管虚函数表中的虚函数地址可能被更新(派生类重写基类的virtual函数)戓添加新的项(派生类声明新的virtual函数),但是一个相同签名的虚函数在虚函数表中的索引值却是不变的所以无论p指向的是Base的对象,还是Base嘚派生类的对象其virtual函数vfunc在虚函数表中的索引是不变的(均为1)。

在了解了C++对象的对象在内存中是怎么分配的布局后就能轻松地回答这個问题了。因为在编译时编译器根本无需判断p所指向的具体对象是什么,而是根据指针p所指向的对象的Base子对象中的虚函数表来实现函数調用的编译器可能会把virtual函数调用的代码修改为如下的伪代码:

若p指向的是一个Base的对象,则调Base的虚函数表中索引值为1的函数若p指向的是┅个Base的派生类的对象,则调用Base的派生类对象的Base子对象的虚函数表中的索引值为1的函数这样便实现了多态 。这种函数调用是根据指针p所指嘚对象的虚函数表来实现的在编译时由于无法确定指针p所指的真实对象,所以无法确定真实要调用哪一个函数只有在运行时根据指针p所指的对象来动态决定。所以说虚函数是在运行时动态绑定的,而不是在编译时静态绑定的

我要回帖

更多关于 对象在内存中是怎么分配的 的文章

 

随机推荐