先抛个问题:你在 C++ 里写了 class A { int x; virtual void f(); };,然后去 sizeof(A),返回多少?如果你的第一反应是“4”,那这篇文章正好是给你准备的。
我刚入行那会儿也干过这种蠢事。写业务代码写得飞起,直到有一次排查线上崩溃问题,发现是对象被当成 POD 数据直接 memcpy 拷贝,然后虚函数表指针(vptr)被一起复制,析构的时候程序直接错乱。那次之后我才意识到:不懂对象模型,写出来的 C++ 就跟蒙着眼睛开车似的,代码能跑纯粹是运气好。
后来去深挖底层,又撞上了内存模型。虚函数机制、内存对齐、原子操作、内存乱序、缓存一致性……这些东西分开看是几个独立的知识点,串起来才是一条完整的链路:你写的每一行 C++ 代码,最终都要落到 CPU 缓存和物理内存上,这中间经过编译器的转换、内存管理器的调度、硬件的乱序执行,任何一个环节不搞清楚,都会在生产环境给你上眼药。
这篇文章我不打算讲那些大学教材里抄来抄去的概念定义,而是结合我这些年实际调试、优化、踩坑的经验,用“从代码到硬件”的视角,把这两块内容拆开揉碎讲清楚。目标读者是已经会写 C++、但想弄明白“程序到底怎么跑起来的”的开发者,从编译结果到内存分布,从多线程问题到性能优化,尽量一次把链路打通。
1. 对象模型的核心问题:你的对象在内存里长什么样
1.1 从 struct 到 class,编译器帮你干了什么额外的事
先看最简单的场景。你定义一个结构体:
cpp复制struct Point { int x; int y; };
在 C++ 里,这个 Point 在内存里的布局其实和一个 C 语言的 struct 完全一样:两个 4 字节的 int 紧挨着放。对象的起始地址就是第一个成员 x 的地址,偏移 4 个字节处是 y。这种布局极其直观,也是你在内存里看到一个对象时最原始的形态。
但当你往结构体里加入成员函数,事情就开始变化了。看这段代码:
cpp复制class Counter {
public:
void increment() { ++count_; }
int getValue() const { return count_; }
private:
int count_;
};
Counter 的大小是多少?答案是 4 字节。有没有虚函数?没有。所以普通成员函数不占用任何对象的内存空间。为什么?因为成员函数实际上就是一个普通的函数,编译器会把 this 指针作为隐藏的第一个参数传进去。increment 在编译器眼里大概是这个样子的:
cpp复制void Counter_increment(Counter* this) {
++(this->count_);
}
那类对象在内存里唯一的实体就是成员变量,成员函数是编译期就确定好地址的代码,每个对象不需要保存一份。这就是对象模型的第一课:非静态成员数据决定了对象的大小和形态,成员函数是共享的“代码段常驻户”,不参与对象的内存布局。
真正让对象模型复杂起来的,是 virtual 关键词的出现。
1.2 虚函数表:多态背后的数据结构
当你给类加上虚函数,编译器就必须在对象里追加一个隐藏成员:指向虚函数表(vtable)的指针,也就是 vptr。每个对象的开头(或某个固定位置,具体看 ABI 规范)会有一个指针,指向这个类对应的虚函数表。
虚函数表本质上是一个数组,里面按声明顺序存放着该类的虚函数地址。假设你有:
cpp复制class Shape {
public:
virtual void draw() = 0;
virtual void area() = 0;
virtual ~Shape() {}
};
实际布局大概是:
vptr(8 字节,64 位系统)- 指向
vtable
而 vtable 里有:
typeinfo指针(用于 RTTI)- 虚析构函数的地址
draw()的地址area()的地址
调用 shape->draw() 时,编译器生成的代码流程是:先从对象的起始位置取出 vptr,再通过偏移量从 vtable 里取出 draw() 的函数指针,然后跳转执行。这个流程比普通函数调用多了一次间接跳转,所以虚函数调用确实有轻微的性能开销,但更重要的是它打破了“编译期定址”的规则,把行为延后到了运行时。
我在实际开发里遇到过很多次类似的情况:明明调用的虚函数没起作用,排查了半天发现是因为在
