虚函数的实现

虽然C++标准并没有规定编译器实现虚函数的方式,但是大部分编译器均是采用了虚函数表来实现虚函数,即对于每一个包含虚成员函数的类生成一个虚函数表,一个指向虚函数表的指针被放在对象的首地址(不考虑多继承等复杂情况),虚函数表中存储该类所有的虚函数地址。当使用引用或者指针调用虚函数时,首先通过虚函数表指针找到虚函数表,然后通过偏移量找到虚函数地址并调用。关于虚函数表的更多细节,建议阅读《深度探索C++对象模型》这本书。

虚函数表面上的开销

  1. 空间开销

    首先,由于需要为每一个包含虚函数的类生成一个虚函数表,所以程序的二进制文件大小会相应的增大;其次,对于包含虚函数的类的实例来说,每个实例都包含一个虚函数表指针用于指向对应的虚函数表,所以每个实例的空间占用都增加一个指针大小(32位系统4字节,64位系统8字节)。这些空间开销可能会造成缓存的不友好,在一定程度上影响程序性能。

  2. 时间开销

    虚函数的时间开销主要是增加了一次内存寻址,通过虚函数表指针找到虚函数表,虽对程序性能有一些影响,但是影响并不大。

虚函数隐藏在背后的开销

上述虚函数表面上的开销其实是微不足道的,真正影响虚函数性能的是隐藏在背后的,不被人轻易察觉的,只有对计算机体系结构有一定理解才能探寻出藏在背后的“性能杀手”。

首先我们先看调用虚函数时,在汇编层生成了什么代码:

1
2
3
4
5
6
7
...
movq (%rax), %rax
movq (%rax), %rax
movq -24(%rbp), %rdx
movq %rdx, %rdi
call *%rax
...

上述汇编代码最重要的就是第6行,在AT&T格式汇编中,这是一个间接调用,意义是从%rax指明的地址处读取跳转的目标位置。这也是虚函数调用与普通成员函数的区别所在,普通函数调用是一个直接调用。直接调用与间接调用的区别就是跳转地址是否确定,直接调用的跳转地址是编译器确定的,而间接调用是运行到该指令时从寄存器中取出地址然后跳转。

有了上面的基本认识,我们就可以分析虚函数的性能开销所在了,其实说到底,这个隐藏在背后的关键点就是分支预测器,如果看过我之前的博客,相信对分支预测器已经很熟悉了,如果感觉分支预测器还是很陌生,推荐阅读我以前的分支预测器的四篇文章:

C++性能榨汁机之分支预测器1

C++性能榨汁机之分支预测器2

C++性能榨汁机之分支预测器3

C++性能榨汁机之分支预测器4

有了分支预测器和CPU指令流水线的基本知识,我们可以发现对于直接调用而言,是不存在分支跳转的,因为跳转地址是编译器确定的,CPU直接去跳转地址取后面的指令即可,不存在分支预测,这样可以保证CPU流水线不被打断。而对于间接寻址,由于跳转地址不确定,所以此处会有多个分支可能,这个时候需要分支预测器进行预测,如果分支预测失败,则会导致流水线冲刷,重新进行取指、译码等操作,对程序性能有很大的影响。

网上有部分文章中说对于虚函数这种间接跳转会直接导致流水线冲刷,这种说法明显是自相矛盾的,如果间接跳转必定会导致流水线冲刷,那把这些指令放进流水线的意义何在呢?其实查阅资料就可以知道,Intel和AMD的CPU中存在两级自适应预测器用于预测间接跳转,此预测器可以预测多分支跳转。

总结

本文探究出影响到虚函数调用性能的背后原因是流水线和分支预测,由于虚函数调用需要间接跳转,所以会导致虚函数调用比普通函数调用多了分支预测的过程,产生性能差距的原因主要是分支预测失败导致的流水线冲刷性能开销。

本文的目的并不是为了说明虚函数调用有额外开销而让大家避免使用虚函数,使用不使用虚函数应该由自己程序的需要而定,如果程序逻辑需要使用动态绑定,如果不使用虚函数而是自己实现相应逻辑的话产生的性能损耗一般会比使用虚函数的性能损耗大得多。但对于一些性能敏感的程序,在虚函数可用可不用的时候,可以考虑不使用虚函数以提高性能。