C++对象模型及性能优化杂谈一

一直想记录点这方面的知识,后续内容一部分来自深度探索C++对象模型,一部分来自C++性能优化指南;像那些(More)Effective C++、Effective STL、(More)Exceptional C++和源码剖析这几本书都非常值得阅读,这里不会分析怎么高效的使用STL组件,毕竟不同的业务场景有适合的组件。也不会去说明一个函数该不该定义成虚函数,类的抽象继承等。另外还有较复杂的Boost库。

这里的gcc版本是7.3.0,且主要是C++ 03,C++ 11以后用的不是很熟练,经验还不是很足,后面会一点点学习。

这里不会结合多线程技术,不然分析的够复杂,只在单线程下,然后内存布局和编译后的结果,以及怎么写出效率更高一些的代码,我以前也写过几篇相关的,但都比较简单。

还是那句,先把功能写正确,再去慢慢优化。

一) 当定义类实例时,发生了什么?

  7 class CBase {
  8 public:
  9     CBase(const uint32_t u = 1) : m_data(u) {
 10         std::cout << "CBase ctor" << std::endl;
 11     }
 12 
 13     virtual ~CBase() {
 14         std::cout << "~CBase dtor" << std::endl;
 15     }
 16 
 17     virtual void ShowData() {
 18         std::cout << this->m_data << std::endl;
 19     }
 20 
 21     void ShowData(const uint32_t unused) {
 22         std::cout << sizeof(*this) << std::endl;
 23     }
 24 private:
 25     uint32_t m_data;
 26 };
 27 
 28 int main(void) {
 29     CBase *pBase = new CBase;
 30     pBase->ShowData();
 31     pBase->ShowData(4);
 32     delete pBase;
 33     pBase = NULL;
 34 
 35     return 0;
 36 }

这里,有虚析构函数,默认构造函数,虚函数,那定义该类的实例时,占用内存是多少呢?内存局部如何呢?以及调用某个虚函数或非虚函数的性能如何呢等。

下面从反汇编开始分析,会大概分析下。
首先有两个符号:

0000000100000bdc T __ZN5CBase8ShowDataEj
0000000100000ba0 T __ZN5CBase8ShowDataEv
CBase::ShowData(unsigned int)
CBase::ShowData()

可以跟着gdb调度看下结果,这里会简单的回答下上面的问题。

CBase *pBase = new CBase;
100000c22:  bf 10 00 00 00  movl    $16, %edi
100000c27:  e8 28 01 00 00  callq   296
100000c2c:  48 89 c3    movq    %rax, %rbx

开辟一个CBase实例内存空间但未调用构造函数,大小为:指向虚表的指针+一个整型+对齐,一共16字节。

pBase->ShowData();
100000c40:  48 8b 45 e8     movq    -24(%rbp), %rax //取得pBase
100000c44:  48 8b 00    movq    (%rax), %rax //取得vptr
100000c47:  48 83 c0 10     addq    $16, %rax //加上偏移量
100000c4b:  48 8b 00    movq    (%rax), %rax //取得ShowData地址
100000c4e:  48 8b 55 e8     movq    -24(%rbp), %rdx 
100000c52:  48 89 d7    movq    %rdx, %rdi  //设置参数
100000c55:  ff d0   callq   *%rax  //调用虚函数

这一堆代码调用了虚函数,pBase->ShowData()会被编译器转换成如下形式:(*pBase->vptr[x])(pBase),如上面的注释,x表示ShowData在虚表中的索引位置。

为了印证上面说的,调试过程如下:

(gdb) p *pBase
$4 = {_vptr.CBase = 0x1000010b0 , m_data = 1}

(gdb) i r rax
rax            0x100600000  4301258752

(gdb) ni
0x0000000100000c47  30      pBase->ShowData();
(gdb) i r rax
rax            0x1000010b0  4294971568

(gdb) ni
0x0000000100000c4b  30      pBase->ShowData();
(gdb) i r rax
rax            0x1000010c0  4294971584

(gdb) i r rax
rax            0x100000ba0  4294970272  //这个是符号__ZN5CBase8ShowDataEv

然后再看一下:

pBase->ShowData(4);
100000c57:  48 8b 45 e8     movq    -24(%rbp), %rax
100000c5b:  be 04 00 00 00  movl    $4, %esi  //第二个参数
100000c60:  48 89 c7    movq    %rax, %rdi //第一个参数
100000c63:  e8 b6 00 00 00  callq   182

上面成员函数会被编译翻译成__ZN5CBase8ShowDataEv(pBase, 4),如上汇编代码。

题外话,为什么要定义虚析构函数,以及带默认形参的构造函数,如果这里不声明一个构造函数,那编译器是否会为我们合成一个构造函数,或拷贝构造函数?这些问题可以参考上面的书籍,或者手动写个继承类,看下结果如何。

二) 当考虑多重继承后结果又是如何(单继承比较简单)

  7 class CBase {
  8 public:
  9     virtual void ShowData() {
 10     }   
 11     
 12     virtual ~CBase() {
 13     }   
 14 };  
 15 
 16 class CBaseOne {
 17 public:
 18     virtual void ShowData() {
 19     }   
 20     
 21     virtual ~CBaseOne() {
 22     }   
 23 };  
 24 
 25 class CDerivedOne : public CBase, public CBaseOne {
 26 public:
 27     void ShowData() {
 28     }   
 29     
 30     ~CDerivedOne() {
 31     }   
 32 };  
 33 
 34 int main(void) {
 35     CBase *pBase = new CDerivedOne; //a
 36     pBase->ShowData();
 37     
 38     CBaseOne *pOne = new CDerivedOne; //b
 39     pOne->ShowData();
 40 
 41     delete pBase;
 42     delete pOne;
 43 
 44     pBase = NULL;
 45     pOne = NULL;
 46 
 47     return 0;
 48 }

首先这里没有在派生类中声明构造函数,这里编译器会合成一个,但是仅仅做他认为该做的事情,如设置vptr。如果派生类或者基类有数据要初始化则需要手动调用,然后编译器再扩展。

这里a和b的执行效果是一样的吗?
看下两个语句的构造过程:

CBase *pBase = new CDerivedOne;
100000a09:  bf 10 00 00 00  movl    $16, %edi
100000a0e:  e8 5d 01 00 00  callq   349
100000a13:  48 89 c3    movq    %rax, %rbx
100000a16:  48 89 df    movq    %rbx, %rdi
100000a19:  e8 10 01 00 00  callq   272
100000a1e:  48 89 5d e8     movq    %rbx, -24(%rbp)

CBaseOne *pOne = new CDerivedOne;
100000a35:  bf 10 00 00 00  movl    $16, %edi
100000a3a:  e8 31 01 00 00  callq   305
100000a3f:  48 89 c3    movq    %rax, %rbx
100000a42:  48 89 df    movq    %rbx, %rdi
100000a45:  e8 e4 00 00 00  callq   228

100000a4a:  48 85 db    testq   %rbx, %rbx
100000a4d:  74 06   je  6 <_main+0x55>
100000a4f:  48 8d 43 08     leaq    8(%rbx), %rax
100000a53:  eb 05   jmp 5 <_main+0x5A>
100000a55:  b8 00 00 00 00  movl    $0, %eax
100000a5a:  48 89 45 e0     movq    %rax, -32(%rbp)

从100000a4a〜100000a5a就和第一个区别开来,这里创建好实例后,需要调整指针到CBaseOne的偏移处。因为指针CBaseOne类型决定了这个指针能寻址的范围以及如何解释这块内存空间的位。

这里汇编的意义是判断pOne是否为空,是的话则赋值为空;否则取实例首地址+8的偏移量处,即CBaseOne的开始处,大致如下:
pOne = pOne ? (CBaseOne *)(((char *)pOne) + sizeof(class CBase)) : 0

delete pBase;
100000a71:  48 83 7d e8 00  cmpq    $0, -24(%rbp)
100000a76:  74 17   je  23 <_main+0x8F>
100000a78:  48 8b 45 e8     movq    -24(%rbp), %rax
100000a7c:  48 8b 00    movq    (%rax), %rax
100000a7f:  48 83 c0 10     addq    $16, %rax
100000a83:  48 8b 00    movq    (%rax), %rax
100000a86:  48 8b 55 e8     movq    -24(%rbp), %rdx
100000a8a:  48 89 d7    movq    %rdx, %rdi
100000a8d:  ff d0   callq   *%rax
delete pOne;
100000a8f:  48 83 7d e0 00  cmpq    $0, -32(%rbp)
100000a94:  74 17   je  23 <_main+0xAD>
100000a96:  48 8b 45 e0     movq    -32(%rbp), %rax
100000a9a:  48 8b 00    movq    (%rax), %rax
100000a9d:  48 83 c0 10     addq    $16, %rax
100000aa1:  48 8b 00    movq    (%rax), %rax
100000aa4:  48 8b 55 e0     movq    -32(%rbp), %rdx
100000aa8:  48 89 d7    movq    %rdx, %rdi
100000aab:  ff d0   callq   *%rax

如果delete pOne,反编译出的汇编代码和delete pBase一样,可能跟编译器或代码复杂度有关。书上说会在delete 时可能调整指针到适合的位置。

还有其他可能,在运行时需要调整指针的位置。通过以上两个实现,这时里没有列出虚拟继承和单继承,前者很复杂,在项目中也没有遇到过。

如果引入虚函数,带来的一定的弹性设计,但因为引入间接性,导致执行的时候会比较慢一些,而不像其他直接在编译时就能决定的事。

当然,通过对象调用某个虚函数则会被编译器静态决定为该类型的函数,不会引发虚机制。

三) 当这么定义构造函数时真实效率如何?

  7 class CBase {
  8 public:
  9     CBase(const std::string &addr) {
 10         m_addr = addr;
 13     }
 18 private:
 19     std::string m_addr;
 22 };
 23 
 24 int main(int argc, char **argv) {
 25     CBase t("hello");
 26     return 0;
 27 }

这里故意没定义析构函数,反汇编结果看,编译器帮我们合成了一个,如下:

__ZN5CBaseD1Ev:
100000ccc:  55  pushq   %rbp
100000ccd:  48 89 e5    movq    %rsp, %rbp
100000cd0:  48 83 ec 10     subq    $16, %rsp
100000cd4:  48 89 7d f8     movq    %rdi, -8(%rbp)
100000cd8:  48 8b 45 f8     movq    -8(%rbp), %rax
100000cdc:  48 89 c7    movq    %rax, %rdi
100000cdf:  e8 30 01 00 00  callq   304
100000ce4:  90  nop
100000ce5:  c9  leave
100000ce6:  c3  retq

构造函数反汇编指令如下:

100000c78:  55  pushq   %rbp
100000c79:  48 89 e5    movq    %rsp, %rbp
100000c7c:  53  pushq   %rbx
100000c7d:  48 83 ec 18     subq    $24, %rsp
100000c81:  48 89 7d e8     movq    %rdi, -24(%rbp)
100000c85:  48 89 75 e0     movq    %rsi, -32(%rbp)
100000c89:  48 8b 45 e8     movq    -24(%rbp), %rax
100000c8d:  48 89 c7    movq    %rax, %rdi
100000c90:  e8 79 01 00 00  callq   377
100000c95:  48 8b 45 e8     movq    -24(%rbp), %rax
100000c99:  48 8b 55 e0     movq    -32(%rbp), %rdx
100000c9d:  48 89 d6    movq    %rdx, %rsi
100000ca0:  48 89 c7    movq    %rax, %rdi
100000ca3:  e8 72 01 00 00  callq   370

这里先调用了string的默认构造函数,再调用赋值操作符,两次,为了印证结果,gdb的过程如下:

=> 0x0000000100000c89 <+17>:    mov    -0x18(%rbp),%rax
   0x0000000100000c8d <+21>:    mov    %rax,%rdi
   0x0000000100000c90 <+24>:    callq  0x100000e0e

(gdb) ni
0x0000000100000c90  9       CBase(const std::string &addr) {
(gdb) p *this
$3 = {m_addr = {static npos = 18446744073709551615, 
    _M_dataplus = {> = {> = {}, }, _M_p = 0x7ffeefbffab0 "\370\373\277\357\376\177"}, _M_string_length = 140732920756888, {
      _M_local_buf = " 5\000\000\001\000\000\000\000\000\000\000\001\000\000", 
      _M_allocated_capacity = 4294980896}}}

以上还没调用string的默认构造函数,该处内存上的位模式是脏的;

(gdb) p *this
$4 = {m_addr = {static npos = 18446744073709551615, 
    _M_dataplus = {> = {> = {}, }, _M_p = 0x7ffeefbffa40 ""}, _M_string_length = 0, {
      _M_local_buf = "\000\065\000\000\001\000\000\000\000\000\000\000\001\000\000", 
      _M_allocated_capacity = 4294980864}}}

执行完后0x0000000100000c90后的内容如上,此时为空串,长度为0;

(gdb) p this
$5 = (CBase * const) 0x7ffeefbffa30
(gdb) p *this
$6 = {m_addr = {static npos = 18446744073709551615, 
    _M_dataplus = {> = {> = {}, }, _M_p = 0x7ffeefbffa40 "hello"}, _M_string_length = 5, {
      _M_local_buf = "hello\000\000\000\000\000\000\000\001\000\000", 
      _M_allocated_capacity = 478560413032}}}

最后CBase构造函数返回时的内容正是我们调用时的结果值。

当我们开-O3选项时的结果,结果比较乱,没gdb调度执行过程,这里大概凑合着看下:

100000e26:  48 89 44 24 20  movq    %rax, 32(%rsp)
; : allocator_type(std::move(__a)), _M_p(__dat) { }
100000e2b:  48 8d 43 10     leaq    16(%rbx), %rax
; { _M_string_length = __length; }
100000e2f:  48 c7 44 24 28 05 00 00 00  movq    $5, 40(%rsp)

当我们这么定义构造函数时结果如何呢?

  9     CBase(const std::string &addr) : m_addr(addr) {
 10         //m_addr = addr;
 11     }

100000cb8:  55  pushq   %rbp
100000cb9:  48 89 e5    movq    %rsp, %rbp
100000cbc:  48 83 ec 10     subq    $16, %rsp
100000cc0:  48 89 7d f8     movq    %rdi, -8(%rbp)
100000cc4:  48 89 75 f0     movq    %rsi, -16(%rbp)
100000cc8:  48 8b 45 f8     movq    -8(%rbp), %rax
100000ccc:  48 8b 55 f0     movq    -16(%rbp), %rdx
100000cd0:  48 89 d6    movq    %rdx, %rsi
100000cd3:  48 89 c7    movq    %rax, %rdi
100000cd6:  e8 45 01 00 00  callq   325

(gdb) 
0x0000000100000cd6  9       CBase(const std::string &addr) : m_addr(addr) {
(gdb) 
11      }
(gdb) p *this
$2 = {m_addr = {static npos = 18446744073709551615, 
    _M_dataplus = {> = {> = {}, }, _M_p = 0x7ffeefbffa40 "hello"}, _M_string_length = 5, {
      _M_local_buf = "hello\000\000\000\000\000\000\000\001\000\000", 
      _M_allocated_capacity = 478560413032}}}

你看,就调用一次构造函数吧。所以平时写代码的时候还是建议使用初始化列表。

未完待续。。。

你可能感兴趣的:(C++对象模型及性能优化杂谈一)