(四)C++对象内存模型那些事儿:数据内存布局

(四)C++对象内存模型那些事儿:数据内存布局

1. 程序的内存布局

  1. 代码段(Text Segment / Code Segment)
    • 该区域存放程序编译后的机器代码,即可执行指令。为保证程序运行的稳定性,这部分内存设置为只读,防止程序在运行过程中意外修改自身代码。
    • 常量存储区:此区域用于存放常量数据,例如字符串字面量以及编译时常量表达式的结果。这些数据在程序执行过程中始终保持不变,因此也存储在代码段中,与机器代码一同在程序加载到内存时就位。
  2. 数据段(Data Segment)
    • 初始化数据段:存储程序中已初始化的全局变量和静态变量的值。这些变量在程序启动时就已确定初始值,并存储在此区域。
    • BSS段(Block Started by Symbol):用于存放未初始化的全局变量和静态变量。系统通常会自动将这些变量初始化为0或空指针。
    • 全局区/静态区:这一术语常与数据段概念相关联,主要强调其存储全局变量和静态变量的功能。它实际上涵盖了初始化数据段和BSS段,是这两类变量存储区域的统称。
  3. 堆区(Heap):作为动态内存分配区域,程序运行时通过 malloccallocnew 等函数申请的内存空间均位于此。由于该区域内存的分配与释放由程序员手动管理,若操作不当,极易引发内存泄漏问题。
  4. 栈区(Stack):主要用于存储函数调用过程中的局部变量、函数参数以及返回地址等信息。每当函数被调用,一个新的栈帧会被压入栈中;函数返回时,相应栈帧则会被弹出。栈区内存的分配与释放由编译器自动处理。
  5. 动态库加载区域:若程序使用动态链接库(如Windows下的DLL或Linux下的.so文件),这些库的代码和数据会被加载到该区域。操作系统负责管理这些共享库的加载与卸载操作,确保程序能够正确调用库中的功能。
  6. 内存映射区域:包括文件映射、共享内存等功能。操作系统能够将磁盘上的文件或其他资源映射到内存中,使程序可以像访问内存一样直接访问这些资源,提高了数据访问的效率和灵活性。
  7. 寄存器:作为CPU内部的高速存储单元,寄存器用于快速存储和访问数据。编译器会尽可能优化代码,充分利用寄存器来提升程序的执行性能。

2. 全局变量

2.1 全局对象的存储位置与地址分配

  • 数据段存储:全局变量和全局对象存储于数据段。数据段作为程序的一部分,用于存放静态分配的变量,包括全局变量与静态变量。
  • 内存默认清零:若未给全局对象设置初始值,编译器会默认将全局对象所在内存清零,即其所有成员变量初始化为零值。
  • 编译期确定地址与分配内存:全局变量在编译阶段便完成空间分配,其地址在编译期间确定,且该内存空间在程序运行时始终存在,地址固定不变。
  • 构造时机:全局对象的构造在 main() 函数执行前进行。全局对象存储于数据段且默认值为零,编译器会在程序启动前调用其构造函数,以确保正确初始化。
  • 析构时机:全局对象的析构在 main() 函数执行完毕后进行。编译器会在程序结束时调用其析构函数,保证资源的正确清理。

2.2 全局对象的构造和析构步骤

  1. 地址与内存分配:全局对象 g_aobj 的地址及内存空间均在编译时确定并分配,在程序运行期间其地址保持固定。
  2. 静态初始化:程序启动前,编译器对全局对象 g_aobj 执行静态初始化,将其内存内容清零,使所有成员变量初始化为零值。
  3. 构造函数调用:在 main() 函数执行前,编译器调用全局对象 g_aobj 所属类 A 的构造函数,确保 g_aobjmain() 函数执行前正确初始化。
  4. ``main() 函数执行:程序开始执行 main()函数,此时已初始化的全局对象g_aobj 可供使用。
  5. 析构函数调用main() 函数执行完毕后,编译器调用全局对象 g_aobj 所属类 A 的析构函数,确保程序结束时 g_aobj 被正确清理,释放相关资源。

2.3 局部静态对象的构造和析构

  • 构造时机:局部静态对象的构造函数仅在首次调用包含该对象定义的函数时执行一次。此后,即便多次调用该函数,该对象也不会再次构造。
  • 地址特性:局部静态对象的内存地址在编译阶段即已确定,每次调用函数时,该对象的地址始终保持不变。
  • 初始化过程:若未对局部静态对象进行显式初始化,程序启动时它会被默认初始化为零值。直到首次调用包含它的函数时,才会触发其构造函数进行正式初始化。
  • 析构时机:局部静态对象的析构函数会在 main() 函数执行完毕后被调用,以此确保在程序结束时,该对象所占用的资源能够被正确释放。

3. 类数据成员布局

  1. 声明顺序与排列:非静态数据成员在对象中的排列顺序遵循其在类定义中的声明顺序。也就是说,编译器会按照声明先后为数据成员分配内存。在同一个访问控制段(如 privateprotectedpublic)内,后声明的数据成员会被分配到相对较高的内存地址。从内存地址增长方向来看,数据成员从低地址向高地址依次分配存储。这里需要提及大小端存储的概念,它描述了数据在内存中的存储方式。

    • 大端存储:数据的低位保存在内存中的高地址中,数据的高位保存在内存中的低地址中。
    • 小端存储:数据的低位保存在内存中的低地址中,数据的高位保存在内存中的高地址中。大小端存储方式虽然与数据成员的声明顺序和排列没有直接关联,但在理解数据在内存中的实际存储形式时是重要的基础知识。
  2. 内存对齐:为优化数据访问速度,数据成员会按照其自身的自然对齐边界进行对齐。例如,一个四字节的整型通常会被对齐到四字节边界。这种对齐机制可能导致在数据成员之间插入额外的填充字节,以满足对齐要求。通过特定的编译指令可以调整对齐方式,例如:

#pragma pack(1) // 对齐方式设置为1字节对齐(不对齐)

#pragma pack() // 取消指定对齐,恢复缺省对齐;
  1. 静态数据成员:静态数据成员并不在对象实例中存储,而是在程序的全局数据区域或静态区进行分配。这使得所有类实例共享相同的静态数据成员实例。

  2. 虚函数表:若类中包含虚函数,编译器会为该类生成一个虚函数表(vtable),它本质上是一个函数指针数组,存储了类中所有虚函数的地址。每个含有虚函数的类实例会包含一个指向这个vtable的指针(称为vptr),一般情况下,该指针位于对象的起始位置,但具体位置依赖于编译器实现。虚函数表则在代码区。

  3. 多重继承:在多重继承场景下,派生类可能会拥有多个虚函数表指针,每个指针分别指向其不同基类的虚函数表,以此来支持正确的动态调度。此外,为保证基类子对象布局的正确性,可能还需要进行额外的偏移量调整。

  4. 空对象:即便一个类没有数据成员,编译器通常也会为其分配一个字节的空间。这是为了确保每个对象实例都具有唯一的地址,从而能够有效识别空对象。

  5. 访问控制:不同的访问控制段(publicprotectedprivate)虽然不会对数据成员在内存中的物理布局产生影响,但它们严格限定了成员的访问权限。

  6. 位域成员:当类定义中包含位域时,这些成员会根据位域的定义紧密打包存储,这可能导致内存布局不符合直观预期。

3.1 多重继承数据布局

  1. 基类子对象:在多重继承中,每个基类的子对象都会嵌入到派生类对象内,保持各自基类的布局。基类子对象的排列顺序依编译器实现而异,部分编译器倾向于按继承列表中基类出现的顺序安排。每个基类子对象包含自身的非静态数据成员,若基类有虚函数,还包含一个指向该基类虚函数表的指针(vptr)。
  2. 内存对齐:每个基类子对象的起始位置需满足其内部数据成员的对齐要求。同时,派生类起始部分及各基类子对象间的布局也会考虑对齐,以实现最佳访问性能。
  3. 虚函数表指针(vptr):多重继承时,可能存在多个虚函数表指针。若基类中有虚函数,派生类对象通常需包含一个或多个指向虚函数表的指针。对于有相同虚函数的基类,编译器可能采用虚基类机制或优化策略(如共享虚函数表指针)以避免重复。
  4. 虚基类:若基类本身是多重继承的结果且被继承为虚基类,派生类对象中仅包含一个虚基类的实例,而非每个继承路径上各有一个。虚基类表指针(vbptr)可能用于定位这个共享的虚基类实例,确保对虚基类数据成员的正确访问。
  5. 派生类特有的数据成员:在所有基类子对象之后,是派生类自定义的非静态数据成员,它们按声明顺序排列并满足对齐要求。
  6. 内存填充:为确保对齐,编译器可能在基类子对象之间、基类子对象与派生类数据成员之间插入填充字节。
  7. 菱形继承问题:菱形继承是多重继承中典型的问题,即一个类直接继承自两个或更多个类,而这些类又共同继承自同一个基类。C++引入虚基类来解决此问题,确保基类的子对象只被继承一次,避免数据重复。

3. 内存分配

3.1 new 类对象时加括号与不加括号的差异

在C++中,使用new创建类对象或基本数据类型对象时,加括号与不加括号会导致不同的行为表现。以下通过示例代码进行说明:

A *pa = new A(); // 函数调用
delete pa;

A *pa2 = new A;

int *p3 = new int;  // 初始值随机
int *p4 = new int(); // 初始值为 0
int *p5 = new int(100); // 初始值为 100
  • 基本数据类型的情况

  • 当使用new创建基本数据类型对象时,若不加括号,对象的初始值是未定义的,通常表现为随机值。例如int *p3 = new int;,此时p3所指向的int类型对象的初始值是随机的。这是因为不加括号时,系统仅为对象分配内存空间,并未对其进行初始化操作。
  • 若加括号,对象会被初始化为默认值。如int *p4 = new int();p4所指向的int类型对象的初始值为0。这是C++语言规定的默认初始化行为,对于数值类型,默认初始化为0。
  • 还可以通过括号传入具体的值来进行初始化,比如int *p5 = new int(100);p5所指向的int类型对象的初始值就被设定为100。这种方式允许开发者根据需求精确设置对象的初始值。

另外,对于数组形式的创建:

void func(){
    auto foo = new char[10];    // 数组元素初始值未定义,不为0
    auto foo = new char[10]();  // 数组元素初始值为0
}

这里new char[10]创建的字符数组,元素初始值是未定义的;而new char[10]()会将数组中每个元素初始化为0,这是因为加括号触发了对数组元素的默认初始化。

  • 类类型的情况

  • 空类的情况:如果类为空(即没有任何成员变量和用户自定义的构造函数),new A()new A这两种写法在实际效果上没有区别。不过,在实际编程中,仅定义一个空类的情况较为少见,通常类会包含一些成员或方法。
  • 类有成员变量但无自定义构造函数的情况:当类A包含成员变量时,new A()会对成员变量进行默认初始化,即把与成员变量相关的部分内存清零。这是因为虽然没有用户自定义构造函数,但编译器会生成一个合成的默认构造函数,该构造函数会对类中的基本数据类型成员变量进行零初始化。然而,这个合成的默认构造函数并不会将整个对象的内存都清零,例如类中可能存在一些未使用的填充字节等,这些部分不会被清零。而new A不会触发这种默认初始化操作,对象的内存处于未初始化状态。
  • 类有构造函数的情况:要是类A有构造函数,无论是new A()还是new A,都会调用类的构造函数来完成对象的初始化,所以最终的初始化结果是一样的。这是因为只要类中定义了构造函数,编译器就不会再使用合成的默认构造函数,而是直接调用用户定义的构造函数,无论是否加括号,都会执行相同的初始化逻辑。

3.2 newdelete 的本质

  • new的操作 new在创建对象时,实际上执行了两个关键步骤。以创建类A的对象为例:
class A {
public:
    A() {
        // 构造函数逻辑
    }
};
A* aPtr = new A();

首先,new会调用operator new函数。从底层实现来看,operator new函数通常借助malloc来分配内存,为对象在堆上开辟所需的内存空间。这里需要注意的是,operator new函数可以被重载,以便开发者根据具体需求定制内存分配策略。例如,在某些对内存管理要求较高的场景下,可以通过重载operator new来实现内存池技术,提高内存分配效率。

接着,new会调用类A的构造函数,对刚刚分配的内存进行初始化,使对象处于可用状态。

  • delete的操作 delete在销毁对象时,同样执行两个重要步骤。还是以类A的对象为例:
delete aPtr;

它首先会调用类A的析构函数。析构函数的主要作用是释放对象内部动态分配的资源,例如对象中包含的指针成员所指向的内存空间等。通过析构函数,确保对象在销毁前能够正确清理自身占用的资源,避免内存泄漏。

然后,delete会调用operator delete函数。operator delete函数底层通常使用free函数,将对象占用的内存归还给系统,完成内存的释放操作。同样,operator delete函数也可以被重载,以实现自定义的内存释放逻辑。

综上所述,在使用newdelete时,除了要留意加括号和不加括号的区别(如前文所述,不同情况会导致对象初始化行为的差异),还需深入理解它们在对象创建和销毁过程中的具体操作。只有这样,才能正确地管理内存和对象的生命周期,编写出高效、稳定且内存安全的C++程序。

3.3 delete的时候需要知道长度吗?

在C++的内存管理中,delete操作在处理不同类型数据时对长度信息的获取方式有所不同。

对于非数组数据,由于其类型信息在编译时是已知的,编译器能够根据类型信息确定对象所占用的内存长度,进而完成delete操作。在多态场景下,对象的析构函数调用依据当前对象的实际类型。为确保在多态调用中不会发生内存泄露,多态的父类析构函数必须声明为虚函数。这样,在通过基类指针删除派生类对象时,程序会根据对象的实际类型(而非指针类型)调用正确的析构函数。

对于数组数据,编译器会在分配内存时额外存储数组的长度信息。一般情况下,该长度信息被存储在分配的内存块起始位置之前的特定区域,这个区域常被称为“头部”。当使用delete[]释放数组内存时,delete[]操作符会首先读取这个头部信息,以此得知需要释放多少个元素的内存空间,从而完成正确的内存释放操作。

3.4 operator newnew的区别

  • new:在C++中,new是一个运算符,它执行两个关键步骤。首先,调用operator new函数来分配所需的内存空间;然后,调用对象的构造函数对分配的内存进行初始化,使对象处于可用状态。

  • operator new:这是一个函数,其唯一职责是在堆上分配内存,它仅仅负责提供一块足够大小的内存区域,并不会调用对象的构造函数。因此,通过operator new分配的内存处于未初始化状态。

  • ``delete:同样是C++中的运算符,delete执行两个操作。首先,调用对象的析构函数,释放对象内部动态分配的资源,确保对象在销毁前清理自身占用的资源;然后,调用operator delete函数,将对象占用的内存归还给系统,完成内存的释放操作。

  • operator delete:作为一个函数,operator delete的作用仅是释放operator new分配的内存,它不会调用对象的析构函数。所以,在调用operator delete之前,必须确保对象的析构函数已被正确调用,以避免内存泄漏。

3.5 placement new

placement new是C++中的一种特殊的new表达式,它允许在已分配好的内存块上构造对象,而无需像普通new那样重新分配内存。这在一些特定场景下非常有用,例如:

  • 内存池的使用:在需要频繁创建和销毁对象的场景中,使用内存池预先分配一块较大的内存,然后通过placement new在这块内存上创建对象,可以减少内存碎片的产生,提高内存分配效率。
  • 嵌入对象:当需要在已有的数据结构内部创建对象时,placement new能够直接在该数据结构的内存空间上构造对象,避免额外的内存分配开销。

以下是一个使用placement new的代码示例,同时说明其用法:

#include <iostream>
#include <new>

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass constructor called" << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass destructor called" << std::endl;
    }
};

void foo() {
    char* rawMemory = new char[sizeof(MyClass)];
    MyClass* obj = new (rawMemory) MyClass(); // 使用placement new在已分配的内存上构造对象
    // 使用obj对象
    obj->~MyClass(); // 手动调用析构函数
    delete[] rawMemory; // 释放预先分配的内存
}

int main() {
    foo();
    return 0;
}

在上述示例中,首先通过new char[sizeof(MyClass)]分配了一块大小为MyClass对象的内存。然后,使用placement new在这块内存上构造了MyClass对象。需要注意的是,使用placement new创建的对象,在销毁时需要手动调用析构函数,之后再释放预先分配的内存,以确保内存管理的正确性。

99. quiz

1. 常量段和数据段为什么要分开?

我曾以为常量数据作为一种数据,实际上也是在数据段的。但实际上不然,常量段的只读特性大于其作为数据的意义。因此常量段,是被放在代码段而非数据段。

常量段和数据段分开主要基于以下几方面原因:

  • 保护机制:常量段的数据具有只读属性,不应被修改。将常量段与数据段分离,操作系统可针对它们设置不同的内存保护权限,有效防止程序意外修改常量数据,增强程序的稳定性和安全性。
  • 优化策略:编译器和链接器能够对常量数据进行优化。例如,若一个常量在程序中多次使用,编译器只需在常量段存储一份副本,避免重复存储,提高内存使用效率。
  • 内存管理优势:在某些系统中,常量段可映射到只读物理内存或ROM中,从而节省可读写物理内存。总之,常量段与数据段分开有助于提升程序安全性与效率,同时优化内存管理。

2. C++的对象内存空间

在C++中,类的成员函数并非存储于每个类实例中。实际上,成员函数仅有一份代码,存储在内存的代码段,而非对象的内存空间。

每个对象存储自身的数据成员,成员函数通过隐式参数this访问这些数据。此外,若类包含虚函数,对象会包含一个虚函数表指针(vptr),用于实现多态性。

3. 多线程的内存空间是如何管理的

在C++多线程编程中,每个线程都有独立的栈空间。该栈空间由操作系统在线程创建时自动分配,用于存储线程局部变量和函数调用上下文。

线程栈位于进程虚拟地址空间内,各线程栈相互独立。其大小通常有默认值,部分系统和编程环境下,开发者可在创建线程时指定。

尽管每个线程都有自己的栈空间,但所有线程共享进程的堆空间。这使得线程间可通过堆共享数据,但需注意同步和数据一致性。比如,多个线程同时读写堆上共享数据,可能引发数据竞争,导致程序结果不可预测。为解决此问题,常使用互斥锁(mutex)、信号量(semaphore)等同步机制,确保同一时刻只有一个线程能访问共享数据,保证数据一致性和程序正确性。




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (六)模板那些事儿:类型擦除
  • (五)多线程那些事儿:并行库 openmp
  • (五)模板那些事儿:模板元
  • (四)多线程那些事儿:并行库 tbb
  • (三)多线程那些事儿:怎么用好