被字节面试官喷了:天天背多态,连虚函数表的内存图都画不出?
博主介绍:程序喵大人
- 35 - 资深C/C++/Rust/Android/iOS客户端开发
- 10年大厂工作经验
- 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
- 《C++20高级编程》《C++23高级编程》等多本书籍著译者
- 更多原创精品文章,首发gzh,见文末
- 👇👇记得订阅专栏,以防走丢👇👇
😉C++基础系列专栏
😃C语言基础系列专栏
🤣C++大佬养成攻略专栏
🤓C++训练营
👉🏻个人网站
前几天,我带的一个学弟去面字节跳动的基础架构部门。面试官问他:“C++的多态是怎么实现的?” 学弟暗自窃喜,这可是八股文里的送分题啊,于是脱口而出:“通过虚函数实现的,基类声明 virtual,子类重写,运行时根据对象的实际类型调用对应的函数!”
面试官面无表情地递过来一张白纸和一支笔:别背这些虚的定义。来,给我画一下单继承和多继承下,对象在内存里的具体布局图。再顺便写一下底层汇编是怎么通过指针找到函数地址的。学弟当场懵B,拿着笔的手微微颤抖,最后只能灰溜溜地被请出大门。
兄弟,听老哥一句劝:到了大厂面试,尤其是字节、腾讯这种极其看重底层基本功的公司,光会喊一个接口,多种实现是绝对不够的。你必须得把C++的底裤扒掉,看看它在内存里到底是个什么长相!
1. 到底什么是 vtable 和 vptr?
其实编译器在背后为我们默默做了很多“脏活累活”。当你在一个类里写下 virtual 关键字的那一刻,编译器就会在底层对你的类进行“魔改”。
虚函数表(vtable):如果一个类里面有虚函数,编译器就会为这个类(注意,是类级别,不是对象级别)悄悄生成一张隐藏的表格。这张表本质上就是一个函数指针数组。里面按照声明的顺序,存放着这个类所有虚函数的实际内存地址。
虚指针(vptr):有了表,对象怎么找到这张表呢?编译器会在实例化这个类的每一个对象内部,强行塞进去一个隐藏的指针,通常叫做 __vptr。这个指针永远指向该对象所属类的 vtable。
这就像是去高档餐厅吃饭,类就是这家餐厅的总菜单(vtable),而每个进店的顾客(对象)手里都会发一张取餐码(vptr),通过取餐码就能查到自己该吃总菜单里的哪道菜。

2. 庖丁解牛:单继承下的内存布局图
咱们直接上代码,看看单继承的时候内存究竟长啥样:
#include <iostream>
class Base {
public:
int base_data;
virtual void func1() { std::cout << "Base::func1\n"; }
virtual void func2() { std::cout << "Base::func2\n"; }
};
class Derived : public Base {
public:
int derived_data;
// 重写了 func1
virtual void func1() override { std::cout << "Derived::func1\n"; }
// 增加了一个新的虚函数
virtual void func3() { std::cout << "Derived::func3\n"; }
};
int main() {
Base* p = new Derived();
p->func1(); // 触发多态,调用 Derived::func1
delete p;
return 0; // 老哥提醒:写代码养成好习惯,return 后面必须有空格!
}
内存透视分析:
当我们 new Derived() 的时候,内存里这个对象的布局是这样的(以64位系统为例):
- 最头部(偏移量 0):藏着虚指针 vptr(占8个字节)。它指向 Derived 专属的虚函数表。
- 紧接着(偏移量 8):是从父类继承来的 base_data(占4个字节,可能为了对齐会补齐到8字节)。
- 最后面:是子类自己的 derived_data。
而那张属于 Derived 类的虚函数表(vtable)里装了啥?
- 槽位 0:存放 Derived::func1 的地址(因为子类重写了,所以覆盖了父类的地址)。
- 槽位 1:存放 Base::func2 的地址(子类没重写,直接把父类的拿过来用)。
- 槽位 2:存放 Derived::func3 的地址(子类新增的)。
当执行 p->func1() 时,底层汇编实际上干了这么几件事:
第一步:拿到对象 p 的首地址,把里面藏着的 vptr 抠出来。
第二步:顺着 vptr 找到虚函数表。
第三步:编译器知道 func1 在表的第 0 个槽位,于是取出表里第 0 项的地址。
第四步:call 这个地址。这就完美实现了动态绑定!
3. 大厂深水区
很多同学单继承还能忽悠过去,一到多继承就原形毕露了。如果子类同时继承了两个带有虚函数的基类,会有几个虚指针?
老实交代:会有两个(甚至更多)虚指针!
class Base1 {
public:
virtual void f1() {}
};
class Base2 {
public:
virtual void f2() {}
};
class Derived : public Base1, public Base2 {
public:
virtual void f1() override {}
virtual void f2() override {}
virtual void f3() {}
};
在这个多继承模型里,Derived 对象的内存布局简直就是缝合怪:
- 第一个 vptr:指向一张主虚函数表(和 Base1 共享起始地址)。表里包含重写后的 f1、Derived 自己新增的 f3。
- Base1 的数据成员
- 第二个 vptr:指向第二张虚函数表(专为 Base2 准备的)。表里包含重写后的 f2。
- Base2 的数据成员
- Derived 自己的数据成员
字节面试官最爱追问的细节来了:当我们把 Derived 对象的指针强转为 Base2 类型的指针时,发生了什么?答案是:指针的地址会发生偏移!编译器会自动把指针的值加上一段偏移量,让它精确地指向第二个 vptr 所在的位置。这就是为什么在 C++ 里玩多继承,强转指针有时候地址会变的原因!如果你搞不清这个,乱用 C 语言风格的强转,分分钟搞出段错误。

4. 榨干性能:动态绑定的代价到底是什么?
懂了原理,咱们最后来拔高一下架构师的视角。为什么大厂的 C++ 规范里,都不建议乱用虚函数?
老哥告诉你,天下没有免费的午餐,多态的代价主要体现在两点:
👉 代价一:空间开销
每个包含虚函数的类都要多维护至少一张虚函数表。更要命的是,每一个实例化出来的对象,肚子里都要硬塞一个 8 字节的 vptr。如果你做高频交易系统,或者海量小对象存储(比如有几百万个粒子的物理引擎),这额外增加的 8 字节会极其恐怖,不仅费内存,还会严重破坏 CPU 的缓存行(Cache Line)命中率。
👉 代价二:时间开销(一次间接寻址 + 阻断内联优化)
普通的函数调用,地址在编译期就写死了,CPU 直接一条指令 call 过去。但虚函数呢?必须先读对象的内存拿到 vptr,再去读虚表拿到函数地址,最后再 call。这就是所谓的“一次间接寻址(Indirect Call)”。比起多读一两次内存,更致命的是,虚函数极大地阻碍了编译器的内联优化(Inline)。因为编译器在编译期根本不知道你到底要调哪个函数,所以没办法把函数体直接展开,这对于极其简短但被疯狂循环调用的热点代码来说,性能损失是毁灭性的!
下次再去面大厂,当面试官问出虚函数时,你就在白板上把这套内存布局图行云流水地画出来,顺便给他吐槽一下虚指针破坏 Cache Line 和阻断内联优化的坑。相信我,面试官看你的眼神绝对会从审视变成欣赏。
老规矩,纸上得来终觉浅,多在脑子里画画内存模型!今天这波硬核解析,你看懂了吗?
码字不易,欢迎大家点赞,关注,评论,谢谢!
更多推荐

所有评论(0)