(四)C++对象内存模型详解:数据内存布局

C++对象内存模型详解:数据内存布局

1. 程序内存布局概述

1.1 内存区域划分

程序运行时的内存空间被划分为以下几个主要区域:

  1. 代码段(Text Segment)

    • 存储编译后的机器指令,设置为只读
    • 包含常量存储区:存放字符串字面量和编译时常量
  2. 数据段(Data Segment)

    • 初始化数据段:已初始化的全局变量和静态变量
    • BSS 段:未初始化的全局变量和静态变量(自动初始化为 0)
  3. 堆区(Heap)

    • 动态内存分配区域(mallocnew等)
    • 需要程序员手动管理,容易发生内存泄漏
  4. 栈区(Stack)

    • 存储局部变量、函数参数、返回地址
    • 编译器自动管理内存分配和释放
  5. 其他区域

    • 动态库加载区域:共享库代码和数据
    • 内存映射区域:文件映射、共享内存
    • 寄存器:CPU 内部高速存储单元
高地址 ┌─────────────────┐
      │    内核空间      │
      ├─────────────────┤
      │    栈区(Stack)   │ ↓ 向下增长
      ├─────────────────┤
      │   内存映射区域    │
      ├─────────────────┤
      │    堆区(Heap)    │ ↑ 向上增长
      ├─────────────────┤
      │   BSS段(未初始化) │
      ├─────────────────┤
      │  数据段(已初始化)  │
      ├─────────────────┤
低地址 │   代码段(只读)    │
      └─────────────────┘

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 被正确清理,释放相关资源。

全局对象的生命周期管理:

class GlobalTest {
private:
    int value;
public:
    GlobalTest() : value(42) {
        std::cout << "Global object constructed" << std::endl;
    }
    ~GlobalTest() {
        std::cout << "Global object destructed" << std::endl;
    }
};

GlobalTest g_obj;  // 全局对象

int main() {
    std::cout << "main() starts" << std::endl;
    // g_obj已经构造完成,可以使用
    std::cout << "main() ends" << std::endl;
    return 0;
}
// 程序输出:
// Global object constructed
// main() starts
// main() ends
// Global object destructed

关键特性:

  • 存储位置:数据段
  • 地址确定:编译期确定,运行期固定不变
  • 初始化:未显式初始化时默认清零
  • 构造时机main()函数执行前
  • 析构时机main()函数执行后

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

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

int Counter::count = 0;  // 静态成员定义

void createObject() {
    static Counter localStatic;  // 局部静态对象
    std::cout << "Function called, count: " << Counter::getCount() << std::endl;
}

int main() {
    createObject();  // 第一次调用,构造localStatic
    createObject();  // 第二次调用,不再构造
    return 0;
}

局部静态对象特点:

  • 首次调用函数时构造,只构造一次
  • 地址编译期确定,每次调用保持不变
  • 程序结束时析构

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++引入虚基类来解决此问题,确保基类的子对象只被继承一次,避免数据重复。
class MemoryLayout {
private:
    char a;       // 1字节
    int b;        // 4字节,可能有3字节填充
    double c;     // 8字节
    char d;       // 1字节,可能有7字节填充
public:
    void print() const {
        std::cout << "Size of MemoryLayout: " << sizeof(MemoryLayout) << std::endl;
        std::cout << "Address of a: " << (void*)&a << std::endl;
        std::cout << "Address of b: " << (void*)&b << std::endl;
        std::cout << "Address of c: " << (void*)&c << std::endl;
        std::cout << "Address of d: " << (void*)&d << std::endl;
    }
};

3.2 虚函数表布局

虚函数表(vtable)是编译器为支持动态多态生成的只读函数指针表;每个多态子对象在实例中保留一个 vptr(指向 vtable)的指针字段。不同继承模型会影响 vptr 的数量、位置与查找过程(单继承通常 1 个 vptr,多重/虚继承可能有多个 vptr 或额外的 vbptr),并且构造/析构阶段 vptr 的值会变化以保证语义正确。

示意布局(简化、按字节块表示)

  • 单继承(Base 有虚函数) [ vptr (-> vtable_Base) ][ Base::data ][ Derived::data ]
  • 多重继承(A,B 都有虚) [ A.vptr ][ A::data ][ B.vptr ][ B::data ][ C::data ]
  • 虚继承(B、C virtual A) [ B 子对象 (vbptr_B, B.vptr, B::data) ] [ C 子对象 (vbptr_C, C.vptr, C::data) ] [ 共享虚基 A 子对象 (A::data) ] [ C::own data… ]

伪结构表示(概念)

// vtable: 只读
using Fn = void(*)(void*);
struct VTable {
    void* typeinfo;    // RTTI(可选/ABI相关)
    Fn   func1;
    Fn   func2;
    // ...
};

// 对象内代表(单继承)
struct Base_Object {
    VTable* vptr;   // 指向 Base 的 vtable
    int baseData;
};

构造与虚调用(伪代码)

// 构造时(编译器插入)
void Derived_ctor(Derived* this) {
    // 先设置为 Base vtable(基类构造阶段)
    this->vptr = &vtable_Base;
    Base_ctor((Base*)this);
    // 然后设置为 Derived vtable(最派生类构造完成)
    this->vptr = &vtable_Derived;
}

// 虚调用 site(obj->f(); 编译器生成)
void call_f(Base* obj) {
    void** vptr = *(void**)obj;          // 取 vptr,编译期无法确定实际是哪个类型,因此需要间接寻址
    void (*fn)(Base*) = (void(*)(Base*))vptr[f_index]; // f_index是编译期确定的偏移
    fn(obj);                             // 间接跳转(可能要先调整 this)
}

3.3 虚继承内存布局

虚继承会把共享的虚基子对象只放一份,派生对象内除了各个基类子对象的 vptr 外还会有用于定位虚基的 vbptr/vbtable 信息;最派生类负责初始化虚基,调用时需通过子对象地址 + vptr(或 thunks)或通过 vbptr 表查找虚基偏移。

- 类定义(菱形):
  - class A { virtual ...; int a; };
  - class B : virtual A { ...; int b; };
  - class C : virtual A { ...; int c; };
  - class D : B, C { ...; int d; };

- D 对象内存布局(示意)
  [ B 子对象: (vbptr_B?, B.vptr, B::data) ]
  [ C 子对象: (vbptr_C?, C.vptr, C::data) ]
  [ 共享虚基 A 子对象: (A.vptr?, A::data) ]    <- 只存一次
  [ D::own data... ]

- vbtable/vbptr(示意)
  - vbtable(只读)存储从该子对象到每个虚基的偏移(offset-to-top / offset-to-vbase)。
  - vbptr(对象内)指向对应的 vbtable,用于 runtime 计算虚基地址: vbase_addr = (char*)subobj + vbtable[i]
// 示例:虚继承的内存与 vptr/vbptr 观察
#include <iostream>
struct A {
    virtual void fa() {};
    int a = 1;
};
struct B : virtual A {
    virtual void fb() {};
    int b = 2;
};
struct C : virtual A {
    virtual void fc() {};
    int c = 3;
};
struct D : B, C {
    virtual void fd() {};
    int d = 4;
};

int main() {
    D obj;
    std::cout << "&obj=" << &obj << "\n";

    // 观察 B 子对象处的 vptr/vbptr(粗略读取)
    char* bp = reinterpret_cast<char*>(static_cast<B*>(&obj));
    void** maybe_vptr = reinterpret_cast<void**>(bp);
    std::cout << "B subobj addr=" << (void*)bp
              << ", *maybe_vptr=" << *maybe_vptr << "\n";

    // 观察 C 子对象处
    char* cp = reinterpret_cast<char*>(static_cast<C*>(&obj));
    void** maybe_vptr_c = reinterpret_cast<void**>(cp);
    std::cout << "C subobj addr=" << (void*)cp
              << ", *maybe_vptr_c=" << *maybe_vptr_c << "\n";

    // 观察 A (虚基)子对象地址
    A* ap = static_cast<A*>(&obj);
    std::cout << "A (virtual base) addr=" << (void*)ap << "\n";

    // 调用以看动态分派行为
    B* bp2 = &obj;
    C* cp2 = &obj;
    A* ap2 = &obj;
    bp2->fb();
    cp2->fc();
    ap2->fa();
}
/*
&obj=0x7ffd2cc6a160
B subobj addr=0x7ffd2cc6a160, *maybe_vptr=0x55d7ec05abd8
C subobj addr=0x7ffd2cc6a170, *maybe_vptr_c=0x55d7ec05ac00
A (virtual base) addr=0x7ffd2cc6a180
*/

4. 动态内存管理

4.1 new 和 delete 的区别详解

基本数据类型:

void demonstrateNewBehavior() {
    // 基本类型 - 不加括号
    int* p1 = new int;        // 未初始化,值随机
    std::cout << "*p1 = " << *p1 << std::endl;  // 随机值

    // 基本类型 - 加括号
    int* p2 = new int();      // 初始化为0
    std::cout << "*p2 = " << *p2 << std::endl;  // 0

    // 基本类型 - 指定初值
    int* p3 = new int(100);   // 初始化为100
    std::cout << "*p3 = " << *p3 << std::endl;  // 100

    // 数组
    char* arr1 = new char[10];     // 未初始化
    char* arr2 = new char[10]();   // 初始化为0

    delete p1;
    delete p2;
    delete p3;
    delete[] arr1;
    delete[] arr2;
}

类对象:

class TestClass {
public:
    TestClass() : value(99) {
        std::cout << "Constructor called, value = " << value << std::endl;
    }
    ~TestClass() {
        std::cout << "Destructor called" << std::endl;
    }
private:
    int value;
};

void demonstrateClassNew() {
    TestClass* obj1 = new TestClass;    // 调用构造函数
    TestClass* obj2 = new TestClass();  // 调用构造函数
    // 对于有构造函数的类,两种写法效果相同

    delete obj1;
    delete obj2;
}

4.2 new/delete 的底层实现

// new的操作步骤等价于:
template<typename T>
T* myNew() {
    // 1. 调用operator new分配内存
    void* memory = operator new(sizeof(T));

    // 2. 调用构造函数
    T* obj = new(memory) T();  // placement new

    return obj;
}

// delete的操作步骤等价于:
template<typename T>
void myDelete(T* obj) {
    // 1. 调用析构函数
    obj->~T();

    // 2. 调用operator delete释放内存
    operator delete(obj);
}

4.3 placement new 的应用

#include <new>

class MemoryPool {
private:
    char* pool;
    size_t size;
    size_t used;

public:
    MemoryPool(size_t poolSize) : size(poolSize), used(0) {
        pool = new char[size];
    }

    ~MemoryPool() {
        delete[] pool;
    }

    template<typename T>
    T* allocate() {
        if (used + sizeof(T) > size) {
            throw std::bad_alloc();
        }

        T* result = new(pool + used) T();  // placement new
        used += sizeof(T);
        return result;
    }

    template<typename T>
    void deallocate(T* obj) {
        obj->~T();  // 只调用析构函数,不释放内存
        // 内存池统一管理内存释放
    }
};

void useMemoryPool() {
    MemoryPool pool(1024);

    TestClass* obj1 = pool.allocate<TestClass>();
    TestClass* obj2 = pool.allocate<TestClass>();

    pool.deallocate(obj1);
    pool.deallocate(obj2);
}

5. 内存管理深入探讨

5.1 delete 时的长度信息

void memoryLengthDemo() {
    // 单个对象 - 编译器知道类型大小
    int* singleInt = new int(42);
    delete singleInt;  // 编译器知道删除4字节

    // 数组 - 需要存储长度信息
    int* arrayInt = new int[10];
    delete[] arrayInt;  // 运行时系统知道数组长度
    /*
        - 若元素是非平凡析构,库通常在 user 指针之前写入一个 cookie(元素个数或字节数),delete[] 读取 cookie 用于逐个析构并计算应传给底层释放器的原始指针。
        - 若元素是平凡析构(例如 int),运行时常可省略存 cookie:因为不需要逐元素析构,delete[] 只需把指针交给底层释放器(malloc/free 的内部 metadata 已记录块大小,或 operator delete 会处理),无需额外长度信息。
        - 现代实现/ABI 还可能使用 sized deallocation(编译器把大小传给 operator delete(void*, size_t)),编译器可据此省去 cookie。
        - 标准并不规定 cookie 放哪儿或必须存在(implementation‑defined),所以不要依赖或读取实现细节(那通常是未定义行为或非可移植的)。
    */

    // 多态删除 - 需要虚析构函数
    class Base {
    public:
        virtual ~Base() = default;  // 虚析构函数很重要
    };

    class Derived : public Base {
        int* data;
    public:
        Derived() : data(new int[100]) {}
        ~Derived() { delete[] data; }
    };

    Base* obj = new Derived();
    delete obj;  // 正确调用Derived的析构函数
}

5.2 operator new vs new operator

class CustomAlloc {
public:
    // 重载operator new
    static void* operator new(size_t size) {
        std::cout << "Custom operator new called, size: " << size << std::endl;
        return std::malloc(size);
    }

    // 重载operator delete
    static void operator delete(void* ptr) {
        std::cout << "Custom operator delete called" << std::endl;
        std::free(ptr);
    }

    CustomAlloc() {
        std::cout << "Constructor called" << std::endl;
    }

    ~CustomAlloc() {
        std::cout << "Destructor called" << std::endl;
    }
};

void demonstrateOperatorOverload() {
    // 使用new operator(会调用重载的operator new和构造函数)
    CustomAlloc* obj = new CustomAlloc();
    delete obj;

    std::cout << "---" << std::endl;

    // 直接使用operator new(只分配内存,不调用构造函数)
    void* rawMemory = CustomAlloc::operator new(sizeof(CustomAlloc));
    CustomAlloc::operator delete(rawMemory);
}

99. quiz

1: 常量段为什么单独存放?

常量段独立于数据段的原因:

  • 安全性:只读保护防止意外修改
  • 优化:编译器可进行常量折叠等优化
  • 共享:多个进程可共享相同的常量段

2: malloc 的系统调用机制

  • 小内存分配(<128KB):使用brk系统调用扩展堆
  • 大内存分配(≥128KB):使用mmap映射虚拟内存
  • 缺页中断:访问虚拟地址时分配物理内存

3. 内存对齐优化

// 不优化的布局
class BadLayout {
    char a;      // 1字节
    double b;    // 8字节,前面有7字节填充
    char c;      // 1字节
    int d;       // 4字节,前面有3字节填充
};
// sizeof(BadLayout) = 24

// 优化后的布局
class GoodLayout {
    double b;    // 8字节
    int d;       // 4字节
    char a;      // 1字节
    char c;      // 1字节,后面有2字节填充
};
// sizeof(GoodLayout) = 16

8. 总结

理解 C++对象内存模型对于编写高效、安全的程序至关重要:

  1. 内存布局:掌握各内存区域的作用和特点
  2. 对象生命周期:理解构造和析构的时机
  3. 内存对齐:优化数据结构布局
  4. 动态内存:正确使用 new/delete 和智能指针
  5. 多线程安全:保护共享数据的访问

通过深入理解这些概念,可以写出更加健壮和高效的 C++程序。

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



    Enjoy Reading This Article?

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

  • (三)内核那些事儿:CPU中断和信号
  • (二)内核那些事儿:程序启动到运行的完整过程
  • (一)内核那些事儿:从硬件抽象到系统服务的完整框架
  • (七)内核那些事儿:操作系统对网络包的处理
  • (五)内核那些事儿:系统和程序的交互