虚函数调用比普通函数多出三步:读取对象的vptr、从vtable中按偏移取函数指针、间接跳转执行;这导致额外访存、无法内联及分支预测失败等性能开销。

虚函数调用比普通函数调用多出的三步关键操作
虚函数调用不是“慢一点”,而是多了确定函数地址的运行时路径。普通函数地址在链接后就固化为 call 0x12345678 这样的指令;虚函数则必须在每次调用时动态查表,多出以下三步:
- 从
this指针所指对象内存起始处读取 vptr(虚表指针),这是一次内存加载(通常命中 L1 cache,但仍是额外访存) - 根据虚函数在类虚表中的固定偏移(编译期已知),从 vptr 指向的
vtable中取出对应函数指针,又是一次内存加载 - 跳转到该函数指针指向的实际地址执行,即
call [rax + 0x10]类指令,无法被编译器内联
NULL 指针调用虚函数为什么会崩溃,而普通函数不会
这个现象直接暴露了虚函数调用路径对对象内存布局的强依赖:
-
Base* b = nullptr;后调用b->fun2()(非虚):只要fun2不访问this成员(如a),它就只是个普通函数调用,this参数传入 0 也无妨 - 同理调用
b->fun()(虚):第一步就要从地址 0 处读 vptr,触发段错误(SIGSEGV)——因为地址 0 不可读 - 即使对象非空,若内存被破坏(如 use-after-free),vptr 被覆写为非法值,后续查表也会 crash 或跳转到随机地址
编译器能否优化掉虚函数调用?什么情况下可以
编译器不是完全束手无策,但它需要足够强的类型信息才能绕过虚表:
- 当对象是栈上局部变量且类型明确(如
Dog d; d.speak();),即使speak是虚函数,也可能被 devirtualize 并内联(GCC/Clang 在 -O2 及以上常做) - 当通过具体类型指针调用(如
Dog* p = new Dog; p->speak();),而非基类指针,现代编译器大概率能推断出实际类型 - 但一旦走
Animal* p = new Dog;,再调p->speak(),绝大多数情况下无法消除虚表查表开销——因为编译器无法 100% 排除运行时替换为其他派生类对象的可能 -
final修饰的类或虚函数能帮编译器锁死继承链,显著提升 devirtualization 概率
虚函数调用的性能影响到底有多大
单次调用的开销本身很小(一次额外访存 + 间接跳转),但真正拖慢系统的往往是它带来的连锁效应:
- 无法内联:失去所有基于内联的优化机会,比如常量传播、死代码消除、寄存器复用
- 阻碍 CPU 分支预测:间接跳转目标不固定,容易引发 misprediction,尤其在虚函数表项较多或热路径频繁切换类型时
- 缓存局部性差:vtable 通常分散在只读数据段,与对象数据不在同一 cache line;高频调用下会增加 cache miss
- 注意:如果虚函数体本身很重(如含 IO 或算法),那虚调用开销占比就微不足道;但如果是在 tight loop 里每轮都调一个轻量虚函数(如
visit()),那查表成本就不可忽视
虚函数表不是黑箱,它是编译器生成的静态数据结构;但每次调用时的查表动作,让“函数地址在哪”这件事彻底脱离了编译期控制——这点在写高性能或嵌入式代码时,必须亲手看汇编确认。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











