需要预先了解:


1. 类的实例的内存占用

类的实例(对象)的内存占用,指的是 sizeof(类名) 的结果——它只统计数据成员和编译器为支持语言特性而插入的隐藏成员(如虚表指针),不包含静态成员变量和成员函数。先看一组真实测量(64 位平台):

#include <cstdio>

class Empty {};                  // 空类

class WithInt
{
public:
    int x;
};

class WithVirt
{
public:
    virtual void f() {}
    int x;
};

class WithStatic
{
public:
    static int s;                // 静态成员变量
    int x;
};
int WithStatic::s = 0;

int main()
{
    printf("sizeof(Empty)      = %zu\n", sizeof(Empty));
    printf("sizeof(WithInt)    = %zu\n", sizeof(WithInt));
    printf("sizeof(WithVirt)   = %zu\n", sizeof(WithVirt));
    printf("sizeof(WithStatic) = %zu\n", sizeof(WithStatic));
    return 0;
}

编译并运行代码后,看到这样的输出:

sizeof(Empty)      = 1
sizeof(WithInt)    = 4
sizeof(WithVirt)   = 16
sizeof(WithStatic) = 4

下面逐个解释这些数字的来源。


1.1 空类:为什么占 1 字节

Empty 没有任何成员,但 sizeof 是 1 而不是 0。原因是 C++ 标准的一条要求:同一类型的两个不同对象必须有不同的地址。

Empty arr[5];    // 如果 sizeof(Empty) == 0,这 5 个元素的地址会全部相同

如果空类大小为 0,数组里每个元素的地址都一样,&arr[0] == &arr[1],程序就失去了区分对象的手段。所以编译器给每个空类对象分配 1 个字节的占位存储。实测相邻对象的地址确实相差 1:

&e1 = 000000d61bfff8fb  &e2 = 000000d61bfff8fa
&arr[0] = 000000d61bfff8f5
&arr[1] = 000000d61bfff8f6
&arr[2] = 000000d61bfff8f7

空基类优化(Empty Base Optimization,EBO):作为基类的空类可以占 0 字节,因为派生类对象内"基类子对象"的地址由派生类对象本身代表,不需要额外存储:

class Empty2 {};
class Derived : public Empty2 { int x; };   // 空基类不占空间

// sizeof(Derived) == 4,而不是 4 + 1

注:标准库大量使用 EBO——std::vector 等容器内部的分配器(allocator)通常是空类,继承它而不是作为成员,可以省掉一个成员占用的字节。


1.2 成员变量与内存对齐

对象大小不是成员大小的简单相加,还要服从内存对齐(memory alignment)规则:

  1. 每个成员的偏移必须是它自身对齐值的整数倍(对齐值通常是成员的大小)
  2. 整个对象的大小必须是最大成员对齐值的整数倍
struct A { char c; int i; char d; };   // char, int, char
struct B { char c; char d; int i; };   // char, char, int

两个结构体成员完全相同,只是顺序不同:

sizeof(A) = 12
sizeof(B) = 8

A 的布局:c 在偏移 0,i 的对齐值是 4,必须从偏移 4 开始(偏移 1~3 是填充),d 在偏移 8,最后整体大小补到 4 的倍数 12。B 把 i 放到最后,c、d 紧挨着占偏移 0、1,i 从偏移 4 开始,总共 8 字节。

为什么要对齐? CPU 按"字"(8 字节)读取内存,对齐的数据可能只需一次读取;跨边界的数据要读两次再拼接。有些平台(如 ARM)干脆禁止未对齐访问,直接崩溃。

注意:成员声明顺序会影响对象大小。把大类型成员放在前面、小类型放在后面,通常能减少填充浪费。也可以用 #pragma pack(1) 强制紧凑排列(省内存、牺牲访问效率,一般用于网络协议结构体)。


1.3 虚函数:虚表指针

类里有虚函数时,编译器会给对象插入一个隐藏成员——虚表指针(vptr,virtual table pointer),指向该类的虚函数表。64 位平台上 vptr 占 8 字节:

sizeof(WithVirt) = 16    // vptr(8) + int x(4) + 填充(4)

vptr 通常放在对象开头。实测 WithVirt 对象的前 8 个字节就是一个指向虚表的地址:

对象地址 = 00000040e5bff8b0
前 8 字节(vptr) = b0 99 74 e6 f7 7f 00 00

多态的本质就是:调用虚函数时,通过对象的 vptr 找到虚表(vtable),再查表拿到真正的函数地址。虚表本身存在只读数据段,对象里只有那个指针。

虚继承(virtual 继承)同样会给对象插入额外的指针(指向虚基类子对象),布局比普通继承更复杂,这里不展开。


1.4 静态成员变量:不占实例空间

静态成员变量属于类而不属于任何对象,它存放在静态存储区(.data 段有初始值、.bss 段零初始化),所有对象共享同一份:

sizeof(WithStatic) = 4    // 只有 int x,静态成员 s 不在对象里

实测 StaticHolder::count 的地址落在进程的静态数据区,与栈上的对象地址相距甚远:

StaticHolder::count 地址 = 00007ff7e6748000   // 静态存储区
对象地址                 = 00000040e5bff8b0   // 栈上

1.5 成员函数:不占实例空间

成员函数的代码也只有一份,存在代码段,不属于任何对象。调用成员函数时,编译器把对象地址作为 this 指针传入,函数通过 this 访问不同对象的成员:

obj1.set(10);    // 等价于 set(&obj1, 10)
obj2.set(20);    // 等价于 set(&obj2, 20)

代码只有一份,this 不同,操作的对象就不同。所以无论类里有多少成员函数,sizeof 都不受影响。


2. 类本身的内存占用

"类本身"没有运行期对象,但类相关的数据确实在内存中占有一席之地:成员函数代码、静态成员变量、虚函数表。这部分用汇编和符号表来看最直观。对下面的类编译并查看目标文件符号:

class Base
{
public:
    virtual void f() {}
    virtual void g() {}
    int x;
};

class StaticHolder
{
public:
    static int count;
    int value;
};
int StaticHolder::count = 42;

用 g++ -c 编译后执行 nm -C a.o,看到:

0000000000000000 t .text$_ZN4Base1fEv         // Base::f() 的代码
0000000000000000 r .rdata$_ZTV4Base           // vtable for Base
0000000000000000 D _ZN12StaticHolder5countE   // StaticHolder::count

符号名里 _Z 开头的是 C++ 的名称修饰(name mangling)结果,nm -C 已经把它还原成了可读形式。注意每个符号的段标记:

段标记 段 内容
t / T .text 成员函数代码
r / R .rodata(PE 里叫 .rdata) 虚函数表、常量
D / d .data 有初始值的静态成员变量
B / b .bss 零初始化的静态成员变量

2.1 成员函数:代码段

所有成员函数(包括虚函数)的机器码都存放在 .text 段,每个函数只有一份。不管实例化 1 个还是 1 万个对象,代码都只有这一份——对象之间共享它。

两个细节:

  1. 内联函数:被 inline 标记(或编译器自行决定)的函数,代码会嵌入每个调用点,而不是单独一份。调用点越多,代码膨胀越大。
  2. 虚函数:虚函数本体在 .text 段也只有一份,虚表里存的是它的地址。多态的开销来自"查表",而不是"复制代码"。

2.2 静态成员变量:数据段

StaticHolder::count 有初始值 42,所以落在 .data 段(段标记 D)。如果初始值是 0 或没有初始化器,会落在 .bss 段(段标记 B),加载时统一清零。

静态成员变量在程序启动时分配(静态存储期),程序结束时释放。它的生命周期与对象个数无关——即使一个对象都没创建,count 也已经存在。


2.3 虚函数表:只读数据段

虚表(vtable for Base)存放在只读数据段:内容是虚函数的地址(还可能附带 RTTI 信息),程序运行期间不会被修改,所以可以被多个进程共享、也不会被意外篡改。

每个有虚函数的类在程序里有一张虚表;每个对象通过自己的 vptr 找到它。虚表的具体内容由构造对象时写入 vptr——这也是为什么构造函数不能是虚函数:对象构造完成前,vptr 还没有指向正确的虚表。


2.4 小结

内容 存放位置 何时分配 与对象个数的关系
数据成员 对象内部(栈/堆) 对象创建时 每个对象一份
虚表指针 vptr 对象内部 对象创建时 每个有虚函数的对象一份
静态成员变量 .data / .bss 程序启动时 全程序一份
成员函数代码 .text 程序加载时 全程序一份
虚函数表 .rodata 程序加载时 每个多态类一份