C++ 语言特性的性能分析

  1. 云栖社区>
  2. 博客列表>
  3. 正文

C++ 语言特性的性能分析

rollenholt 2016-05-06 12:23:11 浏览836 评论0

摘要:  大多数开发人员通常都有这个观点,即汇编语言和 C 语言适合用来编写对性能要求非常高的程序。而 C++ 语言的主要应用范围是编写复杂度非常高的程序,但是对性能要求不是那么严格的程序。但是事实往往并非如此,很多时候,一个程序的速度在框架设计完成时大致已经确定了,而并非是因为采用了C++语言才使其速度没有达到预期的目标。

 大多数开发人员通常都有这个观点,即汇编语言和 C 语言适合用来编写对性能要求非常高的程序。而 C++ 语言的主要应用范围是编写复杂度非常高的程序,但是对性能要求不是那么严格的程序。但是事实往往并非如此,很多时候,一个程序的速度在框架设计完成时大致已经确定了,而并非是因为采用了C++语言才使其速度没有达到预期的目标。因此当一个程序的性能需要提高时,首先需要做的是用性能检测工具对其运行的时间分布进行一个准确的测量,找出关键路径和真正的瓶颈所在,然后针对瓶颈进行分析和优化,而不是一味盲目地将性能低劣归咎于所采用的语言。事实上,如果框架设计不做修改,即使用C语言或者汇编语言重新改写,也并不能保证提高总体性能。

因此当遇到性能问题时,首先检查和反思程序的总体框架。然后用性能检测工具对其实际运行做准确地测量,再针对瓶颈进行分析和优化,这才是正确的思路。

但不可否认的是,确实有一些操作或者C++的一些语言特性比其他因素更容易成为程序的瓶颈,一般公认的有如下因素。

(1)缺页:缺页往往意味着需要访问外部存储。因为外部存储访问相对于访问内存或者代码执行,有数量级的差别。因此只要有可能,应该尽量想办法减少缺页。

(2)从堆中动态申请和释放内存:如C语言中的malloc/free和C++语言中的new/delete操作非常耗时,因此要尽可能优先考虑从线程栈中获得内存。优先考虑栈而减少从动态堆中申请内存,不仅仅是因为在堆中开辟内存比在栈中要慢很多,而且还与"尽量减少缺页"这一宗旨有关。当执行程序时,当前栈帧空间所在的内存页肯定在物理内存中,因此程序代码对其中变量的存取不会引起缺页;相反,从堆中生成的对象,只有指向它的指针在栈上,对象本身却是在堆中。堆一般来说不可能都在物理内存中,而且因为堆分配内存的特性,即使两个相邻生成的对象,也很有可能在堆内存位置上相隔很远。因此当访问这两个对象时,虽然分别指向它们指针都在栈上,但是通过这两个指针引用它们时,很有可能会引起两次"缺页"。

(3)复杂对象的创建和销毁:这往往是一个层次相当深的递归调用,因为一个对象的创建往往只需要一条语句,看似很简单。另外,编译器生成的临时对象因为在程序的源代码中看不到,更是不容易察觉,因此尤其值得警惕和关注。本章中专门有两节分别讲解对象的构造和析构,以及临时对象。

(4)函数调用:因为函数调用有固定的额外开销,因此当函数体的代码量相对较少,且该函数被非常频繁地调用时,函数调用时的固定额外开销容易成为不必要的开销。C语言的宏和C++语言的内联函数都是为了在保持函数调用的模块化特征基础上消除函数调用的固定额外开销而引入的,因为宏在提供性能优势的同时也给开发和调试带来了不便。在C++中更多提倡的是使用内联函数。

构造函数与析构函数

  

  构造函数和析构函数的特点是当创建对象时,自动执行构造函数;当销毁对象时,析构函数自动被执行。这两个函数分别是一个对象最先和最后被执行的函数,构造函数在创建对象时调用,用来初始化该对象的初始状态和取得该对象被使用前需要的一些资源,比如文件/网络连接等;析构函数执行与构造函数相反的操作,主要是释放对象拥有的资源,而且在此对象的生命周期这两个函数都只被执行一次。

创建一个对象一般有两种方式,一种是从线程运行栈中创建,也称为"局部对象",一般语句为:

 
{
        ……
        Object obj;             ①
        ……
}                               ②

  

销毁这种对象并不需要程序显式地调用析构函数,而是当程序运行出该对象所属的作用域时自动调用。比如上述程序中在①处创建的对象obj在②处会自动调用该对象的析构函数。在这种方式中,对象obj的内存在程序进入该作用域时,编译器生成的代码已经为其分配(一般都是通过移动栈指针),①句只需要调用对象的构造函数即可。②处编译器生成的代码会调用该作用域内所有局部的用户自定义类型对象的析构函数,对象obj属于其中之一,然后通过一个退栈语句一次性将空间返回给线程栈。

另一种创建对象的方式为从全局堆中动态创建,一般语句为:

{
        ……
        Object* obj = new Object;   ①
        ……
        delete obj;                 ②
        ……
}  

  当执行①句时,指针obj所指向对象的内存从全局堆中取得,并将地址值赋给obj。但指针obj本身却是一个局部对象,需要从线程栈中分配,它所指向的对象从全局堆中分配内存存放。从全局堆中创建的对象需要显式调用delete销毁,delete会调用该指针指向的对象的析构函数,并将该对象所占的全局堆内存空间返回给全局堆,如②句。执行②句后,指针obj所指向的对象确实已被销毁。但是指针obj却还存在于栈中,直到程序退出其所在的作用域。即执行到③处时,指针obj才会消失。需要注意的是,指针obj的值在②处至③处之间,仍然指向刚才被销毁的对象的位置,这时使用这个指针是危险的。

  在Win32平台中,访问刚才被销毁对象,可能出现3种情况。

      第1种情况是该处位置所在的"内存页"没有任何对象,堆管理器已经将其进一步返回给系统,此时通过指针obj访问该处内存会引起"访问违例",即访问了不合法的内存,这种错误会导致进程崩溃;

      第2种情况是该处位置所在的"内存页"还有其他对象,且该处位置被回收后,尚未被分配出去,这时通过指针obj访问该处内存,取得的值是无意义的,虽然不会立刻引起进程崩溃,但是针对该指针的后续操作的行为是不可预测的;

      第3种情况是该处位置所在的"内存页"还有其他对象,且该处位置被回收后,已被其他对象申请,这时通过指针obj访问该处内存,取得的值其实是程序其他处生成的对象。

  

虽然对指针obj的操作不会立刻引起进程崩溃,但是极有可能会引起该对象状态的改变。从而使得在创建该对象处看来,该对象的状态会莫名其妙地变化。第2种和第3种情况都是很难发现和排查的bug,需要小心地避免。

创建一个对象分成两个步骤,即首先取得对象所需的内存(无论是从线程栈还是从全局堆中),然后在该块内存上执行构造函数。在构造函数构建该对象时,构造函数也分成两个步骤。即第1步执行初始化(通过初始化列表),第2步执行构造函数的函数体,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class  Derived  :  public Base
{
public :
        Derived() : i(10), string("unnamed") ①
        {
            ...                               ②
        }
        ...
     
private :
        int  i;
        string  name;
        ...
};

  

①步中的 ": i(10), string("unnamed")" 即所谓的"初始化列表",以":"开始,后面为初始化单元。每个单元都是"变量名(初始值)"这样的模式,各单元之间以逗号隔开。构造函数首先根据初始化列表执行初始化,然后执行构造函数的函数体,即②处语句。对初始化操作,有下面几点需要注意。

(1)构造函数其实是一个递归操作,在每层递归内部的操作遵循严格的次序。递归模式为首先执行父类的构造函数(父类的构造函数操作也相应的包括执行初始化和执行构造函数体两个部分),父类构造函数返回后构造该类自己的成员变量。构造该类自己的成员变量时,一是严格按照成员变量在类中的声明顺序进行,而与其在初始化列表中出现的顺序完全无关;二是当有些成员变量或父类对象没有在初始化列表中出现时,它们仍然在初始化操作这一步骤中被初始化。内建类型成员变量被赋给一个初值。父类对象和类成员变量对象被调用其默认构造函数初始化,然后父类的构造函数和子成员变量对象在构造函数执行过程中也遵循上述递归操作。一直到此类的继承体系中所有父类和父类所含的成员变量都被构造完成后,此类的初始化操作才告结束。

(2)父类对象和一些成员变量没有出现在初始化列表中时,这些对象仍然被执行构造函数,这时执行的是"默认构造函数"。因此这些对象所属的类必须提供可以调用的默认构造函数,为此要求这些类要么自己"显式"地提供默认构造函数,要么不能阻止编译器"隐式"地为其生成一个默认构造函数,定义除默认构造函数之外的其他类型的构造函数就会阻止编译器生成默认构造函数。如果编译器在编译时,发现没有可供调用的默认构造函数,并且编译器也无法生成,则编译无法通过。

(3)对两类成员变量,需要强调指出即"常量"(const)型和"引用"(reference)型。因为已经指出,所有成员变量在执行函数体之前已经被构造,即已经拥有初始值。根据这个特点,很容易推断出"常量"型和"引用"型变量必须在初始化列表中正确初始化,而不能将其初始化放在构造函数体内。因为这两类变量一旦被赋值,其整个生命周期都不能修改其初始值。所以必须在第一次即"初始化"操作中被正确赋值。

(4)可以看到,即使初始化列表可能没有完全列出其子成员或父类对象成员,或者顺序与其在类中声明的顺序不符,这些成员仍然保证会被"全部"且"严格地按照顺序"被构建。这意味着在程序进入构造函数体之前,类的父类对象和所有子成员变量对象都已经被生成和构造。如果在构造函数体内为其执行赋初值操作,显然属于浪费。如果在构造函数时已经知道如何为类的子成员变量初始化,那么应该将这些初始化信息通过构造函数的初始化列表赋予子成员变量,而不是在构造函数体中进行这些初始化。因为进入构造函数体时,这些子成员变量已经初始化一次。

下面这个例子演示了构造函数的这些重要特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>
using namespace std;
 
class A
{
    public:
        A() {   cout << "A::A()" << endl;   }
};
 
class B : public A
{
    public:
        B() : j(0)  {   cout << "B::B()" << endl;   }
 
private:
        int j;
};
 
class C1
{
public:
        C1(int i)  :  a(i)  {   cout << "C1::C1()" << endl; }
 
private:
        int a;
};
 
class C2
{
public:
        C2(double val)  :  d(val)  { cout << "C2::C2()" << endl;     }
 
private:
        double d;
};
 
class C3
{
public:
        C3(int v = 0)  :  j(v)  {  cout << "C3::C3()" << endl;  }
     
private:
        int j;
};
 
class D : public B
{
public:
        D(double v2,int v1):c2(v2),c1(v1){cout<< "D::D()"<<endl;}   ②
 
private:
        C1 c1;
        C2 c2;
        C3 c3;
};
 
int main()
{
        D  d(1.0,  3);   ①
        return 0;
}

  输出为:

A::A()                
B::B()                
C1::C1()              
C2::C2()              
C3::C3()              
D::D()       

在C++程序中,创建/销毁对象是影响性能的一个非常突出的操作。首先,如果是从全局堆中生成对象,则需要首先进行动态内存分配操作。众所周知,动态内存分配/回收在C/C++程序中一直都是非常费时的。因为牵涉到寻找匹配大小的内存块,找到后可能还需要截断处理,然后还需要修改维护全局堆内存使用情况信息的链表等。因为意识到频繁的内存操作会严重影响性能的下降,所以已经发展出很多技术用来缓解和降低这种影响,比如内存池技术。其中一个主要目标就是为了减少从动态堆中申请内存的次数,从而提高程序的总体性能。当取得内存后,如果需要生成的目标对象属于一个复杂继承体系中末端的类,那么该构造函数的调用就会引起一长串的递归构造操作。在大型复杂系统中,大量此类对象的创建很快就会成为消耗CPU操作的主要部分。因为注意和意识到对象的创建/销毁会降低程序的性能,所以开发人员往往对那些会创建对象的代码非常敏感。在尽量减少自己所写代码生成的对象同时,开发人员也开始留意编译器在编译时"悄悄"生成的一些临时对象。开发人员有责任尽量避免编译器为其程序生成临时对象。

已经看到,如果在实现构造函数时,没有注意到执行构造函数体前的初始化操作已经将所有父类对象和成员变量对象构造完毕。而在构造函数体中进行第2次的赋值操作,那么也会浪费很多的宝贵CPU时间用来重复计算。这虽然是小疏忽,但在大型复杂系统中积少成多,也会造成程序性能的显著下降。

减少对象创建/销毁的一个很简单且常见的方法就是在函数声明中将所有的值传递改为常量引用传递

继承和虚函数

  

虚拟函数是C++语言引入的一个很重要的特性,它提供了"动态绑定"机制,正是这一机制使得继承的语义变得相对明晰。

  (1)基类抽象了通用的数据及操作,就数据而言,如果该数据成员在各派生类中都需要用到,那么就需要将其声明在基类中;就操作而言,如果该操作对各派生类都有意义,无论其语义是否会被修改或扩展,那么就需要将其声明在基类中。

  (2)有些操作,如果对于各个派生类而言,语义保持完全一致,而无需修改或扩展,那么这些操作声明为基类的非虚拟成员函数。各派生类在声明为基类的派生类时,默认继承了这些非虚拟成员函数的声明/实现,如同默认继承基类的数据成员一样,而不必另外做任何声明,这就是继承带来的代码重用的优点。

  (3)另外还有一些操作,虽然对于各派生类而言都有意义,但是其语义并不相同。这时,这些操作应该声明为基类的虚拟成员函数。各派生类虽然也默认继承了这些虚拟成员函数的声明/实现,但是语义上它们应该对这些虚拟成员函数的实现进行修改或者扩展。另外在实现这些修改或扩展过程中,需要用到额外的该派生类独有的数据时,将这些数据声明为此派生类自己的数据成员。

   再考虑更大背景下的继承体系,当更高层次的程序框架(继承体系的使用者)使用此继承体系时,它处理的是一个抽象层次的对象集合(即基类)。虽然这个对象集合的成员实质上可能是各种派生类对象,但在处理这个对象集合中的对象时,它用的是抽象层次的操作。并不区分在这些操作中,哪些操作对各派生类来说是保持不变的,而哪些操作对各派生类来说有所不同。这是因为,当运行时实际执行到各操作时,运行时系统能够识别哪些操作需要用到"动态绑定",从而找到对应此派生类的修改或扩展的该操作版本。

  也就是说,对继承体系的使用者而言,此继承体系内部的多样性是"透明的"。它不必关心其继承细节,处理的就是一组对它而言整体行为一致的"对象"。即只需关心它自己问题域的业务逻辑,只要保证正确,其任务就算完成了。即使继承体系内部增加了某种派生类,或者删除了某种派生类,或者某某派生类的某个虚拟函数的实现发生了改变,它的代码不必任何修改。这也意味着,程序的模块化程度得到了极大的提高。而模块化的提高也就意味着可扩展性、可维护性,以及代码的可读性的提高,这也是"面向对象"编程的一个很大的优点。

 

  虚拟函数的"动态绑定"特性虽然很好,但也有其内在的空间以及时间开销每个支持虚拟函数的类(基类或派生类)都会有一个包含其所有支持的虚拟函数指针的"虚拟函数表"(virtual table)。另外每个该类生成的对象都会隐含一个"虚拟函数指针"(virtual pointer),此指针指向其所属类的"虚拟函数表"。当通过基类的指针或者引用调用某个虚拟函数时,系统需要首先定位这个指针或引用真正对应的"对象"所隐含的虚拟函数指针。"虚拟函数指针",然后根据这个虚拟函数的名称,对这个虚拟函数指针所指向的虚拟函数表进行一个偏移定位,再调用这个偏移定位处的函数指针对应的虚拟函数,这就是"动态绑定"的解析过程(当然C++规范只需要编译器能够保证动态绑定的语义即可,但是目前绝大多数的C++编译器都是用这种方式实现虚拟函数的),通过分析,不难发现虚拟函数的开销:

  • 空间:每个支持虚拟函数的类,都有一个虚拟函数表,这个虚拟函数表的大小跟该类拥有的虚拟函数的多少成正比,此虚拟函数表对一个类来说,整个程序只有一个,而无论该类生成的对象在程序运行时会生成多少个。
  • 空间:通过支持虚拟函数的类生成的每个对象都有一个指向该类对应的虚拟函数表的虚拟函数指针,无论该类的虚拟函数有多少个,都只有一个函数指针,但是因为与对象绑定,因此程序运行时因为虚拟函数指针引起空间开销跟生成的对象个数成正比。
  • 时间:通过支持虚拟函数的类生成的每个对象,当其生成时,在构造函数中会调用编译器在构造函数内部插入的初始化代码,来初始化其虚拟函数指针,使其指向正确的虚拟函数表。
  • 时间:当通过指针或者引用调用虚拟函数时,跟普通函数调用相比,会多一个根据虚拟函数指针找到虚拟函数表的操作。

内联函数:因为内联函数常常可以提高代码执行的速度,因此很多普通函数会根据情况进行内联化,但是虚拟函数无法利用内联化的优势,这是因为内联函数是在"编译期"编译器将调用内联函数的地方用内联函数体的代码代替(内联展开),但是虚拟函数本质上是"运行期"行为,本质上在"编译期"编译器无法知道某处的虚拟函数调用在真正执行的时候会调用到那个具体的实现(即在"编译期"无法确定其绑定),因此在"编译期"编译器不会对通过指针或者引用调用的虚拟函数进行内联化。也就是说,如果想利用虚拟函数的"动态绑定"带来的设计优势,那么必须放弃"内联函数"带来的速度优势。

临时对象

  对象的创建与销毁对程序的性能影响很大。尤其当该对象的类处于一个复杂继承体系的末端,或者该对象包含很多成员变量对象(包括其所有父类对象,即直接或者间接父类的所有成员变量对象)时,对程序性能影响尤其显著。因此作为一个对性能敏感的开发人员,应该尽量避免创建不必要的对象,以及随后的销毁。这里"避免创建不必要的对象",不仅仅意味着在编程时,主要减少显式出现在源码中的对象创建。还有在编译过程中,编译器在某些特殊情况下生成的开发人员看不见的隐式的对象。这些对象的创建并不出现在源码级别,而是由编译器在编译过程中"悄悄"创建(往往为了某些特殊操作),并在适当时销毁,这些就是所谓的"临时对象"。需要注意的是,临时对象与通常意义上的临时变量是完全不同的两个概念

  临时对象在C++语言中的特征是未出现在源代码中,从堆栈中产生的未命名对象。这里需要特别注意的是,临时对象并不出现在源代码中。即开发人员并没有声明要使用它们,没有为其声明变量。它们由编译器根据情况产生,而且开发人员往往都不会意识到它们的产生。

  

产生临时对象一般来说有如下两种场合。

  (1)当实际调用函数时传入的参数与函数定义中声明的变量类型不匹配。

  (2)当函数返回一个对象时(这种情形下也有例外)。

内联函数

  

  在C++语言的设计中,内联函数的引入可以说完全是为了性能的考虑。因此在编写对性能要求比较高的C++程序时,非常有必要仔细考量内联函数的使用。 所谓"内联",即将被调用函数的函数体代码直接地整个插入到该函数被调用处,而不是通过call语句进行。当然,编译器在真正进行"内联"时,因为考虑到被内联函数的传入参数、自己的局部变量,以及返回值的因素,不仅仅只是进行简单的代码拷贝,还需要做很多细致的工作,但大致思路如此

开发人员可以有两种方式告诉编译器需要内联哪些类成员函数,一种是在类的定义体外;一种是在类的定义体内。

  (1)当在类的定义体外时,需要在该成员函数的定义前面加"inline"关键字,显式地告诉编译器该函数在调用时需要"内联"处理

  (2)当在类的定义体内且声明该成员函数时,同时提供该成员函数的实现体。此时,"inline"关键字并不是必需的


  因为C++是以"编译单元"为单位编译的,而一个编译单元往往大致等于一个".cpp"文件。在实际编译前,预处理器会将"#include"的各头文件的内容(可能会有递归头文件展开)完整地拷贝到cpp文件对应位置处(另外还会进行宏展开等操作)。预处理器处理后,编译真正开始。一旦C++编译器开始编译,它不会意识到其他cpp文件的存在。因此并不会参考其他cpp文件的内容信息。

  联想到内联的工作是由编译器完成的,且内联的意思是将被调用内联函数的函数体代码直接代替对该内联函数的调用。这也就意味着,在编译某个编译单元时,如果该编译单元会调用到某个内联函数,那么该内联函数的函数定义(即函数体)必须也包含在该编译单元内。因为编译器使用内联函数体代码替代内联函数调用时,必须知道该内联函数的函数体代码,而且不能通过参考其他编译单元信息来获得这一信息。

  如果有多个编译单元会调用到某同一个内联函数,C++规范要求在这多个编译单元中该内联函数的定义必须是完全一致的,这就是"ODR"(one-definition rule)原则。考虑到代码的可维护性,最好将内联函数的定义放在一个头文件中,用到该内联函数的各个编译单元只需#include该头文件即可。进一步考虑,如果该内联函数是一个类的成员函数,这个头文件正好可以是该成员函数所属类的声明所在的头文件。这样看来,类成员内联函数的两种声明可以看成是几乎一样的,虽然一个是在类外,一个在类内。但是两个都在同一个头文件中,编译器都能在#include该头文件后直接取得内联函数的函数体代码。讨论完如何声明一个内联函数,来查看编译器如何内联的。继续上面的例子,假设有个foo函数:

  

1
2
3
4
5
6
7
8
9
10
11
#include "student.h"
...
 
void foo()
{
        ...
        Student abc;
        abc.SetAge(12);
        cout << abc.GetAge();
        ...
}

  

foo函数进入foo函数时,从其栈帧中开辟了放置abc对象的空间。进入函数体后,首先对该处空间执行Student的默认构造函数构造abc对象。然后将常数12压栈,调用abc的SetAge函数(开辟SetAge函数自己的栈帧,返回时回退销毁此栈帧)。紧跟着执行abc的GetAge函数,并将返回值压栈。最后调用cout的<<操作符操作压栈的结果,即输出。

内联后大致如下:

  

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "student.h"
...
 
void foo()
{
        ...
        Student abc;
        {
            abc.age = 12;
        }
        int tmp = abc.age;
        cout << tmp;
        ...
}

这时,函数调用时的参数压栈、栈帧开辟与销毁等操作不再需要,而且在结合这些代码后,编译器可能进一步优化为如下结果:

1
2
3
4
5
6
7
8
9
#include "student.h"
...
 
void foo()
{
        ...
        cout << 12;
        ...
}

  

从上述分析中,可以看到使用内联函数至少有如下两个优点。

(1)减少因为函数调用引起开销,主要是参数压栈、栈帧开辟与回收,以及寄存器保存与恢复等。

(2)内联后编译器在处理调用内联函数的函数(如上例中的foo()函数)时,因为可供分析的代码更多,因此它能做的优化更深入彻底。前一条优点对于开发人员来说往往更显而易见一些,但往往这条优点对最终代码的优化可能贡献更大。

函数调用过程

  假设下面代码:

  

1
2
3
4
5
6
void foo()
{
        ...
        i = func(a, b, c);  ①
        ...                 ②
}

  

调用者(这里是foo)在调用前需要执行如下操作。

(1)参数压栈:这里是a、b和c。压栈时一般都是按照逆序,因此是c->b->c。如果a、b和c有对象,则需要先进行拷贝构造(前面章节已经讨论)。

(2)保存返回地址:即函数调用结束返回后接着执行的语句的地址,这里是②处语句的地址。

(3)保存维护foo函数栈帧信息的寄存器内容:如SP(堆栈指针)和FP(栈帧指针)等。到底保存哪些寄存器与平台相关,但是每个平台肯定都会有对应的寄存器。

(4)保存一些通用寄存器的内容:因为有些通用寄存器会被所有函数用到,所以在foo调用func之前,这些寄存器可能已经放置了对foo有用的信息。这些寄存器在进入func函数体内执行时可能会被func用到,从而被覆写。因此foo在调用func前保存一份这些通用寄存器的内容,这样在func返回后可以恢复它们。

接着调用func函数,它首先通过移动栈指针来分配所有在其内部声明的局部变量所需的空间,然后执行其函数体内的代码等。

最后当func执行完毕,函数返回时,foo函数还需要执行如下善后处理。

(1)恢复通用寄存器的值。

(2)恢复保存foo函数栈帧信息的那些寄存器的值。

(3)通过移动栈指针,销毁func函数的栈帧,

(4)将保存的返回地址出栈,并赋给IP寄存器。

(5)通过移动栈指针,回收传给func函数的参数所占用的空间。

在前面章节中已经讨论,如果传入参数和返回值为对象时,还会涉及对象的构造与析构,函数调用的开销就会更大。尤其是当传入对象和返回对象是复杂的大对象时,更是如此。

  因为函数调用的准备与善后工作最终都是由机器指令完成的,假设一个函数之前的准备工作与之后的善后工作的指令所需的空间为SS,执行这些代码所需的时间为TS,现在可以更细致地从空间与时间两个方面来分析内联的效果。

  

(1)在空间上,一般印象是不采用内联,被调用函数的代码只有一份,调用它的地方使用call语句引用即可。而采用内联后,该函数的代码在所有调用其处都有一份拷贝,因此最后总的代码大小比采用内联前要大。但事实不总是这样的,如果一个函数a的体代码大小为AS,假设a函数在整个程序中被调用了n次,不采用内联时,对a的调用只有准备工作与善后工作两处会增加最后的代码量开销,即a函数相关的代码大小为:n * SS + AS。采用内联后,在各处调用点都需要将其函数体代码展开,即a函数相关的代码大小为n * AS。这样比较二者的大小,即比较(n * SS + AS)与(n*AS)的大小。考虑到n一般次数很多时,可以简化成比较SS与AS的大小。这样可以得出大致结论,如果被内联函数自己的函数体代码量比因为函数调用的准备与善后工作引入的代码量大,内联后程序的代码量会变大;相反,当被内联函数的函数体代码量比因为函数调用的准备与善后工作引入的代码量小,内联后程序的代码量会变小。这里还没有考虑内联的后续情况,即编译器可能因为获得的信息更多,从而对调用函数的优化做得更深入和彻底,致使最终的代码量变得更小。

(2)在时间上,一般而言,每处调用都不再需要做函数调用的准备与善后工作。另外内联后,编译器在做优化时,看到的是调用函数与被调用函数连成的一大块代码。即获得的代码信息更多,此时它对调用函数的优化可以做得更好。最后还有一个很重要的因素,即内联后调用函数体内需要执行的代码是相邻的,其执行的代码都在同一个页面或连续的页面中。如果没有内联,执行到被调用函数时,需要跳到包含被调用函数的内存页面中执行,而被调用函数所属的页面极有可能当时不在物理内存中。这意味着,内联后可以降低"缺页"的几率,知道减少"缺页"次数的效果远比减少一些代码量执行的效果。另外即使被调用函数所在页面可能也在内存中,但是因为与调用函数在空间上相隔甚远,所以可能会引起"cache miss",从而降低执行速度。因此总的来说,内联后程序的执行时间会比没有内联要少。即程序的速度更快,这也是因为内联后代码的空间"locality"特性提高了。但正如上面分析空间影响时提到的,当AS远大于SS,且n非常大时,最终程序的大小会比没有内联时要大很多。代码量大意味着用来存放代码的内存页也会更多,这样因为执行代码而引起的"缺页"也会相应增多。如果这样,最终程序的执行时间可能会因为大量的"缺页"而变得更多,即程序的速度变慢。这也是为什么很多编译器对于函数体代码很多的函数,会拒绝对其进行内联的请求。即忽略"inline"关键字,而对如同普通函数那样编译。

综合上面的分析,在采用内联时需要内联函数的特征。比如该函数自己的函数体代码量,以及程序执行时可能被调用的次数等。当然,判断内联效果的最终和最有效的方法还是对程序的大小和执行时间进行实际测量,然后根据测量结果来决定是否应该采用内联,以及对哪些函数进行内联。

  《完》

【云栖快讯】阿里云栖开发者沙龙(Java技术专场)火热来袭!快来报名参与吧!  详情请点击

网友评论