C++内存管理

导语

  内存管理是C++最令人切齿痛恨的问题,也是C++最有争议的问题,C++高手从中获得了更好的性能,更大的自由,C++菜鸟的收获则是一遍一遍的检查代码和对C++的痛恨,但内存管理在C++中无处不在,内存泄漏几乎在每个C++程序中都会发生,因此要想成为C++高手,内存管理一关是必须要过的,除非放弃C++,转到Java或者.NET,它们的内存管理基本是自动的,当然你也放弃了自由和对内存的支配权,还放弃了C++超绝的性能。本期专题将从内存管理、内存泄漏、内存回收这三个方面来探讨C++内存管理问题。

一、内存管理

1、C++内存管理详解

1.1 内存分配方式

1.1.1 分配方式简介

  在C++中,内存分成5个区,分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

  • 栈:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
  • 堆:就是那些由new分配的内存块,它们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
  • 自由存储区:就是那些由malloc等分配的内存块,它和堆是十分相似的,不过它是用free来结束自己的生命的。
  • 全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,它们共同占用同一块内存区。
  • 常量存储区:这是一块比较特殊的存储区,它们里面存放的是常量,不允许修改。

1.1.2 明确区分堆与栈

  堆与栈的区分问题,似乎是一个永恒的话题,由此可见,初学者对此往往是混淆不清的。

首先举一个例子:

void f() {
    int *p = new int[5];
}

  这条语句就包含了 堆 与 栈 ,看到 new,首先就应该想到,分配了一块堆内存,那么指针p呢?它分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中,它在VC6下的汇编代码如下:

00401028 push 14h
0040102A call operator new (00401060)
0040102F add esp,4
00401032 mov dword ptr [ebp-8],eax
00401035 mov eax,dword ptr [ebp-8]
00401038 mov dword ptr [ebp-4],eax

  这里为了简单并没有释放内存,那么该怎么去释放呢?delete []p,这是为了告诉编译器:删除的是一个数组。

1.1.3 堆和栈究竟有什么区别?

主要的区别由以下几点:

  • 管理方式:对于栈来讲,是由编译器自动管理,无需手动控制;对于堆来说,释放工作由程序员控制,容易产生memory leak(内存泄漏)。

  • 空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,当然这是可以修改的。

  • 碎片问题:对于堆,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈,则不会存在这个问题,因为栈是先进后出的队列,以至于永远都不可能有一个内存块从栈中间弹出,在它弹出之前,在它上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里就不讨论了。

  • 生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。

  • 分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,它的动态分配是由编译器进行释放,无需手动控制。

  • 分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

  由此可以看出堆和栈相比,由于在申请堆内存时大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以推荐大家尽量用栈,而不是用堆。

  虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。

  无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:)

1.2 控制C++的内存分配

  在嵌入式系统中使用C++的一个常见问题是内存分配,即对 new 和 delete 操作符的失控。

  具有讽刺意味的是,问题的根源却是C++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。

  这当然是个好事情,但是这种使用的简单性使得程序员们过度使用 new 和 delete,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。

  作为忠告,保守的使用内存分配是嵌入式环境中的第一原则。

  但当你必须要使用new 和delete时,你不得不控制C++中的内存分配。你需要用一个全局的 new 和 delete来代替系统的内存分配符,并且一个类一个类的重载new 和delete。

  一个防止堆破碎的通用方法是从不同固定大小的内存池中分配不同类型的对象。对每个类重载 new 和 delete就提供了这样的控制。

1.2.1 重载全局的new和delete操作符

  标准库头文件new中定义了全局默认的 new、new[]、delete、delete[]函数签名

重载全局的new和delete操作符如下所示:

#include <iostream>
// 重载全局的 new 操作符
void *operator new(size_t size) {
    void *p = malloc(size);
    std::cout << "override global new" << std::endl;
    return (p);
}

// 重载全局的 delete 操作符
void operator delete(void *p, size_t size) noexcept {
    std::cout << "override global delete" << std::endl;
    free(p);
}

class Test {
public:
    // 所有 Test 对象的内存分配都采用这段代码。
    // 更进一步,任何从 Test 继承的子类类也都会采用这一方式,除非子类自己也重载了 new 和 delete 操作符。
    // 通过重载 new 和 delete 操作符的方法,可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。
    void *operator new(size_t size) {
        void *p = malloc(size);
        std::cout << "Test override new" << std::endl;
        return (p);
    }

    // Test 类特有的 delete 操作符动作
    void operator delete(void *p, size_t size) {
        free(p);
        std::cout << "Test override delete" << std::endl;
    }
};

int main(int argc, char *argv[]) {
    int *a = new int(10);
    delete a;

    Test *b = new Test();
    delete b;
    return 0;
}

  这段代码可以代替默认的操作符来满足内存分配的请求。出于解释C++的目的,也可以直接调用malloc() 和free()。

1.2.2 为单个的类重载 new[ ]和delete[ ]

  必须小心对象数组的分配。你可能希望调用到被你重载过的 new 和 delete 操作符,但并不如此。内存的请求被定向到全局的 new[ ] 和 delete[ ] 操作符,而这些内存来自于系统堆。

  C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配。为了改变这种方式,你同样需要重载 new[ ] 和 delete[ ] 操作符。

#include <iostream>
class Test {
public:
    void *operator new[](size_t size) {
        void *p = malloc(size);
        std::cout << "Test override new[]" << std::endl;
        return (p);
    }

    void operator delete[](void *p) {
        free(p);
        std::cout << "Test override delete[]" << std::endl;
    }
};

int main(int argc, char *argv[]) {
    Test *a = new Test[10];
    delete[] a;
    return 0;
}

  但是注意:对于多数C++的实现,new[] 操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。

1.3 常见的内存错误及其对策

  发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。常见的内存错误及其对策如下:

  • 内存分配不成功,却在后面使用了。

  编程新手常犯这种错误,没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL 。如果指针p是某个函数的调用参数,那么在函数的入口处用 assert(p!=NULL) 进行检查。如果是用 malloc 或new来为指针p申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防止内存申请失败的逻辑处理。

  • 内存分配虽然成功,但是尚未初始化就引用它。

  犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。 内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。

  • 内存分配成功并且已经初始化,但操作越过了内存的边界。

  例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。

  • 忘记了释放内存,造成内存泄露。

  含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。

  • 释放了内存却继续使用它。

有三种情况:

(1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。

(2)函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。

(3)使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。

【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

【规则3】避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。

【规则4】动态内存的申请与释放必须配对,防止内存泄漏。

【规则5】用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。

1.4 指针与数组的对比

  C++/C程序中,指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为两者是等价的。

  数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。

  指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。下面以字符串为例比较指针与数组的特性。

1.4.1 修改内容

  下面示例中,字符数组a的容量是5个字符,其内容为hello。a 的内容可以改变,如a[0]= ‘X’。指针p指向常量字符串“world”(位于静态存储区,内容为world),常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句p[0]= ‘X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。

#include <iostream>
int main(int argc, char *argv[]) {
    char a[] = "hello";
    a[0] = 'X';
    std::cout << a << std::endl;
    char *p = "world";      // 注意p指向常量字符串
    p[0] = 'X';             // 编译器不能发现该错误,运行时报错:Process finished with exit code -1073741819 (0xC0000005)
    std::cout << p << std::endl;
    return 0;
}

1.4.2 内容复制与比较

  不能对数组名进行直接复制与比较。若想把数组a的内容复制给数组b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数 strcpy 进行复制。同理,比较b和a的内容是否相同,不能用 if(b==a) 来判断,应该用标准库函数 strcmp 进行比较。

  语句p = a 并不能把a的内容复制指针p,而是把a的地址赋给了p。要想复制a的内容,可以先用库函数 malloc 为p申请一块容量为 strlen(a)+1 个字符的内存,再用 strcpy 进行字符串复制。同理,语句 if(p==a) 比较的不是内容而是地址,应该用库函数 strcmp 来比较。

#include <iostream>
#include <cstring>
int main(int argc, char *argv[]) {
    // 数组
    char a[] = "hello";
    char b[10];
    // 不能用 b = a;
    strcpy(b, a);
    // 不能用 if (b == a)
    if (strcmp(b, a) == 0) {
        std::cout << "b == a" << std::endl;
    }

    // 指针
    int len = strlen(a);
    char *p = (char *) malloc(sizeof(char) * (len + 1));
    // 不要用 p = a;
    strcpy(p, a);
    // 不要用 if (p == a)
    if (strcmp(p, a) == 0) {
        std::cout << "p == a" << std::endl;
    }
    return 0;
}

1.4.3 计算内存容量

  用运算符 sizeof 可以计算出数组的容量(字节数)。如下示例中,sizeof(a) 的值是12。指针p指向a,但是sizeof(p)的值却是4。这是因为 sizeof(p) 得到的是一个指针变量的字节数,相当于sizeof(char*),而不是p所指的内存容量。C++/C语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。

#include <iostream>
int main(int argc, char *argv[]) {
    char a[] = "hello world";
    char *p = a;
    std::cout << sizeof(a) << std::endl;        // 12字节
    std::cout << sizeof(p) << std::endl;        // 8字节
    std::cout << sizeof(char *) << std::endl;   // 8字节
    return 0;
}

  注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。如下示例中,不论数组 a 的容量是多少,sizeof(a) 始终等于 sizeof(char *) 。

#include <iostream>
void Func(char a[100]) {
    // 8字节而不是100字节
    std::cout << sizeof(a) << std::endl;
    std::cout << a << std::endl;
}

int main(int argc, char *argv[]) {
    char a[] = "hello world";
    Func(a);
    return 0;
}

1.5 指针参数是如何传递内存的?

  如果函数的参数是一个指针,不要指望用该指针去申请动态内存。如下示例中 getMemory(str, 200) 并没有使str获得期望的内存,str 依旧是 NULL,为什么?见下方示例:

#include <iostream>
#include <cstring>
void getMemory(char *p, int num) { p = (char *) malloc(sizeof(char) * num); }
int main(int argc, char *argv[]) {
    char *str = nullptr;
    getMemory(str, 100);        // str 仍然为 NULL
    strcpy(str, "hello");       // 运行错误
    return 0;
}

  问题出在 getMemory ,编译器总是要为函数的每个参数制作临时副本,指针参数p的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数p的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p申请了新的内存,只是把_p所指的内存地址改变了,但是p丝毫未变。所以函数 getMemory 并不能输出任何东西。事实上,每执行一次 getMemory 就会泄露一块内存,因为没有用 free 释放内存。

  如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”,见下方示例:

#include <iostream>
#include <cstring>
void getMemory2(char **p, int num) { *p = (char *) malloc(sizeof(char) * num); }
int main(int argc, char *argv[]) {
    char *str = nullptr;
    getMemory2(&str, 100);  // 注意参数是 &str,而不是str
    strcpy(str, "hello");
    std::cout << str << std::endl;
    free(str);
    return 0;
}

  由于“指向指针的指针”这个概念不容易理解,我们可以用函数返回值来传递动态内存。这种方法更加简单,见下方示例:

#include <iostream>
#include <cstring>
char *getMemory3(int num) {
    // 此处的指针变量p的内存虽然在栈里,但p申请的内存在堆中,堆内存并不会因为函数返回出栈后p的回收而回收
    char *p = (char *) malloc(sizeof(char) * num);
    return p;
}

int main(int argc, char *argv[]) {
    char *str = nullptr;
    str = getMemory3(100);
    strcpy(str, "hello");
    std::cout << str << std::endl;
    free(str);
    return 0;
}

  用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把return语句用错了。这里强调不要用return语句返回指向“栈内存”的指针,因为该内存在函数结束时自动消亡,见下方示例:

#include <iostream>
char *getString() {
    char p[] = "hello world";       // 没有使用new/malloc所以此处申请的是栈内存
    return p;                       // 函数返回栈内存时,编译器将提出警告
}

int main(int argc, char *argv[]) {
    char *str = nullptr;
    str = getString();                  // str 的内容是垃圾
    std::cout << str << std::endl;
    return 0;
}

  如果把上述示例改写成如下示例,会怎么样?

#include <iostream>
char *getString2() {
    char *p = "hello world";
    return p;
}

int main(int argc, char *argv[]) {
    char *str = nullptr;
    str = getString2();
    std::cout << str << std::endl;
    return 0;
}
// 运行虽然不会出错,但是函数 getString2 的设计概念却是错误的。因为 getString2 内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用 getString2 ,它返回的始终是同一个“只读”的内存块。

1.6 杜绝“野指针”

  “野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。 “野指针”的成因主要有两种:

(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。例如:

#include <cstdlib>
int main(int argc, char *argv[]) {
    char *p = nullptr;
    char *str = (char *) malloc(100);
    return 0;
}

(2)指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。

(3)指针操作超越了变量的作用域范围。这种情况让人防不胜防,示例程序如下:

#include <iostream>
class A {
public:
    void Func() { std::cout << "Func of class A" << std::endl; }
};

int main(int argc, char *argv[]) {
    A *p;
    {
        A a;
        p = &a;             // 注意 a 的生命期
    }
    p->Func();              // p是“野指针”
    return 0;
}

  在执行语句 p->Func() 时,对象 a 已经消失,而 p 是指向 a 的,所以 p 就成了“野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。

1.7 有了malloc/free为什么还要new/delete?

  malloc 与 free 是C++/C语言的标准库函数,new/delete 是C++的运算符。它们都可用于申请动态内存和释放内存。

  对于非内部数据类型的对象(指的是程序员自定义的复杂类型)而言,光用maloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。

  因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。先看一看malloc/free和new/delete如何实现对象的动态内存管理,见示例:

#include <iostream>
class Obj {
public :
    Obj() { std::cout << "Initialization" << std::endl; }
    ~Obj() { std::cout << "Destroy" << std::endl; }
    void initialize() { std::cout << "Initialization" << std::endl; }
    void destroy() { std::cout << "Destroy" << std::endl; }
};

void useMallocFree() {
    Obj *a = (Obj *) malloc(sizeof(Obj));   // 申请动态内存
    a->initialize();                        // 初始化
    a->destroy();                           // 清除工作
    free(a);                                // 释放内存
}

void useNewDelete() {
    Obj *a = new Obj;       // 申请动态内存并且初始化
    delete a;               // 清除并且释放内存
}

  类Obj的函数 initialize模拟了构造函数的功能,函数 destroy模拟了析构函数的功能。函数 useMallocFree 中,由于 malloc/free 不能执行构造函数与析构函数,必须调用成员函数 initialize 和 destroy 来完成初始化与清除工作。函数 useNewDelete 则简单得多。

  所以不要企图用 malloc/free 来完成动态对象的内存管理,应该用 new/delete 。由于内部数据类型的“对象”没有构造与析构的过程,对它们而言 malloc/free 和 new/delete 是等价的。

  既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。

  如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,结果也会导致程序出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

1.8 内存耗尽怎么办?

  如果在申请动态内存时找不到足够大的内存块,malloc和new将返回NULL指针,宣告内存申请失败。通常有三种方式处理“内存耗尽”问题。

(1)判断指针是否为 nullptr ,如果是则马上用return语句终止本函数。

(2)判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行。

(3)为new和malloc设置异常处理函数。

上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。

  很多人不忍心用exit(1),问:“不编写出错处理程序,让操作系统自己解决行不行?”

  不行。如果发生“内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用exit(1) 把坏程序杀死,它可能会害死操作系统。

  有一个很重要的现象要告诉大家。对于32位以上的应用程序而言,无论怎样使用malloc与new,几乎不可能导致“内存耗尽”。因为32位操作系统支持“虚存”,内存用完了,自动用硬盘空间顶替。

#include <iostream>
int main(int argc, char *argv[]) {
    float *p = nullptr;
    while (true) {
        p = new float[1000000];
        std::cout << "eat memory" << std::endl;
        if (p == nullptr) {
            exit(1);
        }
    }
    return 0;
}

1.9 malloc/free的使用要点

// malloc函数原型
void *malloc(size_t size);
// 申请一块长度为length的整数类型的内存
int *p = (int *) malloc(sizeof(int) * length);
// 应当把注意力集中在两个要素上:“类型转换”和“sizeof”。
// 函数返回值类型是void *,所以在调用时要显式地进行类型转换,将void * 转换成所需要的指针类型。
// 函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。
// 使用sizeof运算符是良好的风格,但要当心不要写出 p = malloc(sizeof(p))这样的程序来。

// free函数原型
void free( void * p );
// 为什么free函数不象malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存。如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。

1.10 new/delete的使用要点

int *p1 = (int *)malloc(sizeof(int) * length);
int *p2 = new int[length];

  运算符new使用起来要比函数malloc简单得多,这是因为new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时还完成了初始化工作。如果对象有多个构造函数,那么new的语句也可以有多种形式。例如

#include <iostream>
class Obj {
public :
    Obj() {std::cout << "无参构造" << std::endl;}
    Obj(int x) {std::cout << "有参构造" << std::endl;}
};

int main(int argc, char *argv[]) {
    Obj *a = new Obj;
    Obj *b = new Obj(1); // 初值为1
    delete a;
    delete b;
    return 0;
}

  如果用new创建对象数组,那么只能使用对象的无参构造函数。例如:

Obj *objects = new Obj[100];		// 创建100个动态对象,此处使用的是默认构造并且忽略了()
Obj *objects = new Obj[100]();		// 创建100个动态对象

// 下方写法是错误的,构造数组时只能使用无参构造函数
Obj *objects = new Obj[100](1);		// 创建100个动态对象的同时赋初值1

// 在用delete释放对象数组时,留意不要丢了符号‘[]’例如:
delete []objects;					// 正确的用法
delete objects;						// 错误的用法,有可能引起程序崩溃和内存泄漏。

2、C++中的健壮指针和资源管理

  对资源的定义是:任何在你的程序中获得并在此后释放的东西,内存是一个相当明显的资源的例子。它需要用new来获得,用delete来释放。同时也有许多其它类型的资源文件句柄、重要的片断、Windows中的GDI资源,等等。将资源的概念推广到程序中所有对象的创建、释放也是十分贴切的,无论对象是在堆中分配的还是在栈中或者是在全局作用域内的。

  对于给定的资源的拥有者,是负责释放资源的一个对象或者是一段代码。资源的所有权分为两种级别 自动的 和显式的(automatic and explicit),如果一个对象的释放是由语言本身的机制来保证的,这个对象的就是被自动地所有。例如,一个大而复杂的对象中的某个成员对象,这个成员对象的清除需要其它对象在清除的时候保证。外面大而复杂的对象被看作成员对象的所有者。类似地,每个在栈上创建的对象(作为自动变量)的释放是在控制流离开了对象被定义的作用域的时候保证的。这种情况下作用域被看作是对象的所有者。注意所有的自动所有权都是和语言的其他机制相容的,包括异常。无论是如何退出作用域的——正常流程控制退出、一个break语句、一个return、一个goto、或者是一个throw——自动资源都可以被清除。

  到目前为止,一切都很好!问题是在引入指针、句柄和抽象的时候产生的。如果通过一个指针访问一个对象的话,比如对象在堆中分配,C++不自动地关注它的释放。程序员必须明确的用适当的程序方法来释放这些资源。比如一个对象是通过调用new来创建的,它需要用delete来回收。一个文件是用CreateFile(Win32 API)打开的,它需要用CloseHandle来关闭。用EnterCritialSection进入的临界区(Critical Section)需要LeaveCriticalSection退出,等等。一个"裸"指针,文件句柄,或者临界区状态没有所有者来确保它们的最终释放。基本的资源管理的前提就是确保每个资源都有他们的所有者。

2.1 第一条规则(RAII)

  一个指针,一个句柄,一个临界区状态只有在将它们封装入对象的时候才会拥有所有者。这就是我们的第一规则:在构造函数中分配资源,在析构函数中释放资源。

  当你按照规则将所有资源封装的时候,你可以保证你的程序中没有任何的资源泄露。这点在当封装对象(Encapsulating Object)在栈中建立或者嵌入在其他的对象中的时候非常明显。但是对那些动态申请的对象呢?不要急!任何动态申请的东西都被看作一种资源,并且要按照上面提到的方法进行封装。这一对象封装对象的链不得不在某个地方终止。它最终终止在最高级的所有者,自动的或者是静态的。这些分别是对离开作用域或者程序时释放资源的保证。

2.2 Smart Pointers

  至今还没有讨论最常见类型的资源,用操作符new分配,此后用指针访问的一个对象。需要为每个对象分别定义一个封装类吗?(事实上 C++的 memory 中已经定义了 auto_ptr (现已废弃不在推荐使用了,具体原因见:https://www.cnblogs.com/hhddd-1024/p/17773665.html),作用就是提供这种封装。一会儿在回到auto_ptr)让我们从一个极其简单、呆板但安全的东西开始。看下面的Smart Pointer模板类。

template<class T>
class SmartPointer {
public:
    ~SmartPointer() { delete _p; }
    T *operator->() { return _p; }
    T const *operator->() const { return _p; }
protected:
    SmartPointer() : _p(0) {}
    explicit SmartPointer(T *p) : _p(p) {}
    T *_p;
};

  为什么要把 SmartPointer 的构造函数设计为 protected 呢?如果我需要遵守第一条规则,那么就必须这样做。资源在这里是类型 T 的一个实例对象,必须在封装器的构造函数中分配。但是不能简单的直接调用new T,因为不确定类型 T 的构造函数具体有哪些参数;需要为它定义个另外一个封装器。通过继承SmartPointer定义一个新的封装器,并且提供一个特定的构造函数。

template<class Item>
class SmartItem : public SmartPointer<Item> {
public:
    explicit SmartItem(int i) : SmartPointer<Item>(new Item(i)) {}
};

#include <iostream>
int main(int argc, char *argv[]) {
    SmartItem<int> a(1);
    return 0;
}

  为每一个类提供一个Smart Pointer真的值得吗?不!但很有教学的价值,一旦学会如何遵循第一规则的话,就可以放松规则并使用一些高级的技术。这一技术是让SmartPointer的构造函数成为public,但只是用它来做资源转换(Resource Transfer)意思是用 new 操作符的结果直接作为 SmartPointer 的构造函数的参数,像这样:

SmartPointer<Item> item (new Item (i));

原博客中这些内容都不知道在讲什么,所以就不贴了

2.3 Resource Transfer

2.4 Strong Pointers

2.5 Parser

2.6 Transfer Semantics

2.7 Strong Vectors

2.8 Code Inspection

2.9 共享的所有权

2.10 所有权网络

二、内存泄漏

1、C++中动态内存分配引发问题的解决方案

  假设要开发一个String类,它可以方便地处理字符串数据。在类中声明一个数组,考虑到有时候字符串极长,可以把数组大小设为200,但一般的情况下又不需要这么多的空间,这样是浪费了内存。可以使用new操作符,这样是十分灵活的,但在类中就会出现许多意想不到的问题,本文就是针对这一现象而写的。它是一个不完善的类。刻意地使它出现各种各样的问题,这样才好对症下药。开始吧!

【String类】

#include <iostream>
#include <cstring>
class String {
public:
    // 默认构造函数
    String() {
        len = 0;
        str = new char[len + 1];
        str[0] = '"0';
    }

    // 构造函数
    String(const char *s) {
        len = strlen(s);
        str = new char[len + 1];
        strcpy(str, s);
    }

    // 析构函数
    ~String() {
        std::cout << "这个字符串将被删除:" << str << std::endl;
        delete[] str;
    }

    friend std::ostream &operator<<(std::ostream &os, const String &st) {
        os << st.str;
        return os;
    }

private:
    char *str;                  // 存储数据
    int len;                    // 字符串长度
};

【测试一】

#include <iostream>
int main() {
    String temp("hello");
    std::cout << temp << std::endl;
    std::cin.get();
    return 0;
}

  以上程序十分正确,而且也是十分有用的。不能被表面现象所迷惑!

【测试二】

#include <iostream>
void aaa(const String &);
void bbb(const String);

int main() {
    String test1("11111111111111");
    String test2("22222222222222");
    String test3("33333333333333");
    String test4("44444444444444");
    
    // 这里会报错的原因:这里使用了默认的拷贝构造,str成员发生浅拷贝,所以 test1.str 与 test5->str 会指向同一片内存区域
    String *test5 = new String(test1);
    std::cout << "1:" << *test5 << std::endl;
    //  delete test5;
    //  当执行 delete test5 时进入析构器逻辑,导致了 test5 与 test1 共有的 str 内存区域被释放,所以下方 test1 访问了已经被释放的 str 内存就会报错
    //  std::cout << "2:" << test1 << std::endl;

    // 这里不会报错的原因:是因为函数调用传递的是引用,没有申请栈内存,函数内部没有释放 test2,所以没有报错
    aaa(test2);
    std::cout << "4:" << test2 << std::endl;

    // 这里会报错的原因:函数调用时传入 test2 到 bbb 函数参数发生值拷贝(浅拷贝),因为是是浅拷贝所以函数内的 a 与 test2 的成员 str 指向同一片内存区域
    // 又因为语言本身的机制,当 bbb 函数结束后,内部的 a 会执行析构函数,导致 a 与 test2 共有的 str 内存区域被释放,最终导致 test2 持有了非法的 str 内存区域而报错
    //  bbb(test2);
    //  std::cout << "6:" << test2 << std::endl;

    // 这里会报错的原因:test6 是通过 test3 与其默认的拷贝构造得来的,所以成员 str 的内存被二者共同持有,程序结束后 str 的内存被 delete 了两次从而导致报错
    // String test6(test3);
    // std::cout << "7:" << test6 << std::endl;

    // 这里会报错的原因:这里使用的是默认的赋值构造,也是浅拷贝,所以成员 str 的内存被二者共同持有,程序结束后 str 的内存被 delete 了两次从而导致报错
    // String test7;
    // test7 = test4;
    // std::cout << "8:" << test7 << std::endl;

    std::cout << "9:" << "析构函数将被调用" << std::endl;
    return 0;
}

void aaa(const String &a) { std::cout << "3:" << "aaa 函数内部输出:" << a << std::endl; }

// 注意,参数非引用,而是按值传递。
void bbb(const String a) { std::cout << "5:" << "bbb 函数内部输出:" << a << std::endl; }

  首先要知道的是,C++类有以下这些极为重要的函数:

  • 复制(拷贝)构造函数
  • 赋值函数
  • 移动构造函数

  先来讲复制构造函数。那什么是复制(拷贝)构造函数呢?比如这样的代码:String test1(test2);这是进行初始化。我们知道初始化对象要用构造函数。可这儿呢?按理说应该有声明为这样的构造函数:String(const String &);可是我们并没有定义这个构造函数,实际上C++提供了默认的复制(拷贝)构造函数,问题也就出在这儿。

(1)以String类为例,思考什么时候会调用复制构造函数呢?

  当提供这样的代码:String test1(test2)时,就会被调用;当函数参数列表为按值传递,也就是没有用引用和指针作为类型时如:void bbb(const String),它也会被调用。还有一些其它情况就不一一列举了。

(2)它是什么样的函数。

  它的作用就是把两个类进行复制。拿String类为例,C++提供的默认复制构造函数是这样的:

String(const String &a) {
    str = a.str;
    len = a.len;
}

// 可以禁止默认的复制构造,如下
String(const String &a) = delete;

  在平时,这样并不会有任何的问题出现,但我们用了new操作符,涉及到了动态内存分配,我们就不得不谈谈浅复制深复制了。以上的函数就是实行的浅复制,它只是复制了指针,而并没有复制指针指向的数据,可谓一点儿用也没有。打个比方,就像一个朋友让你把一个程序通过网络发给他,而你大大咧咧地把快捷方式发给了他,有什么用处呢?

  我们来具体谈谈:假如,A对象中存储字符串:“C++”。它的地址为2000。现在,我们把A对象赋给B对象:String B=A。现在,A和B对象的str指针均指向2000地址。看似可以使用,但如果B对象的析构函数被调用时,则地址2000处的字符串“C++”已经被从内存中抹去,而A对象仍然指向地址2000。这时如果写下这样的代码:cout<< A<< endl;或是等待程序结束,A对象的析构函数被调用时,A对象的数据能否显示出来呢?只会是乱码。而且,程序还会这样做:连续对地址2000处使用两次delete操作符,这样的后果是十分严重的!

  本例中,有这样的代码:

String* test5 = new String(test1);
std::cout << *test5 << std::endl;
delete test5;

  假设test1中str指向的地址为2000,而test5中str指针同样指向地址2000,删除了2000处的数据,而test1对象呢?已经被破坏了。大家从运行结果上可以看到,使用cout << test1时,一点反应也没有。而在test1的析构函数被调用时,显示是这样:“这个字符串将被删除:”。

再看看这段代码:

std::cout << "使用错误的函数:" << std::endl;
bbb(test2);
std::cout << test2 << std::endl;	// 这一段代码出现严重的错误!

  bbb 函数的参数列表 void bbb(const String a)是按值传递的,所以相当于执行了这样的代码:String a=test2;函数执行完毕由于生存周期的缘故,对象 a 被析构函数删除,我们马上就可以看到错误的显示结果了:这个字符串将被删除:?= ,导致 test2 也被破坏了。解决的办法很简单,当然是自己定义一个复制构造函数

String(const String &a) {
    len = a.len;
    str = new char(len + 1);
    strcpy(str, a.str);
}

  我们执行的是深复制。这个函数的功能是这样的:假设对象A中的str指针指向地址2000,内容为“I am a C++ Boy!”。我们执行代码String B=A时,我们先开辟出一块内存,假设为3000。我们用strcpy函数将地址2000的内容拷贝到地址3000中,再将对象B的str指针指向地址3000。这样,就互不干扰了。

  大家把这个函数加入程序中,问题就解决了大半,但还没有完全解决,问题在赋值函数上。我们的程序中有这样的段代码:

String test7;
test7 = test4;

  经过前面的讲解,大家应该也会对这段代码进行寻根摸底:凭什么可以这样做:test7=test4 ?原因是,C++为了用户的方便,提供的这样的一个操作符重载函数:operator=。所以可以这样做。大家应该猜得到,它同样是执行了浅复制,出了同样的毛病。比如执行了这段代码后,析构函数开始大展神威。由于这些变量是后进先出的,所以最后的 test7 变量先被删除,最后删除到 test4 的时候,问题来了:这个字符串将被删除:?=。原因不用赘述了

  平时,我们可以写这样的代码:x=y=z。(均为整型变量。)而在类对象中,我们同样要这样,因为这很方便。而对象A=B=C就是A.operator=(B.operator=(c))。而这个operator=函数的参数列表应该是:const String& a,所以,大家不难推出,要实现这样的功能,返回值也要是String&,这样才能实现A=B=C。我们先来写写看:

String &operator = (const String &a) {
    delete[] str;                   // 先删除自身的数据
    len = a.len;
    str = new char[len + 1];
    strcpy(str, a.str);             // 拷贝成员str内容
    return *this;                   // 返回自身的引用
}

  是不是这样就行了呢?我们假如写出了这种代码:A=A,那么大家看看,岂不是把A对象的数据给删除了吗?这样可谓引发一系列的错误。所以,我们还要检查是否为自身赋值。只比较两对象的数据是不行了,因为两个对象的数据很有可能相同。我们应该比较地址。以下是完好的赋值函数:

String &operator=(const String &a) {
    if (this == &a){
        return *this;
    }
    delete[] str;
    len = a.len;
    str = new char[len + 1];
    strcpy(str, a.str);
    return *this;
}

把这些代码加入程序【测试二】,问题就完全解决

2、如何对付内存泄漏?

  当代码中到处充满了new 操作、delete操作和指针运算的话,你将会在某个地方搞晕了头,导致内存泄漏,指针引用错误,以及诸如此类的问题。这和你如何小心地对待内存分配工作其实完全没有关系:代码的复杂性最终总是会超过你能够付出的时间和努力。于是随后产生了一些成功的技巧,它们依赖于将内存分配(allocations)与重新分配(deallocation)工作隐藏在易于管理的类型之后。标准容器(standard containers)是一个优秀的例子。它们不是通过你而是自己内部在管理元素的内存,从而避免了产生糟糕的结果。想象一下,没有 string 和 vector 的帮助,写出这个:

#include<vector>
#include<string>
#include<iostream>
#include<algorithm>

int main() {
    std::vector<std::string> v;
    std::string s;
    while (std::cin >> s) {
        if (s == "q") { break; }	// 输入 q 时结束循环
        v.push_back(s);
    }
    sort(v.begin(), v.end());		// 排序
    std::string cat;
    typedef std::vector<std::string>::const_iterator Iter;
    for (Iter p = v.begin(); p != v.end(); ++p) {
        if ((p + 1) == v.end()) { cat += *p; } else { cat += *p + "+"; }
    }
    std::cout << cat << std::endl;
}

  你有多少机会在第一次就得到正确的结果?你又怎么知道你没有导致内存泄漏呢?

  如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle),以将内存泄漏的可能性降至最低。这里有个例子:我需要通过一个函数,在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象。毕竟,我们不能说,仅仅关注当这个指针要被释放的时候,谁将负责去做。使用资源句柄,这里用了标准库中的 unique_ptr ,使需要为之负责的地方变得明确了。

#include<memory>
#include<iostream>
struct S {
    S() { std::cout << "make an S" << std::endl; }
    S(const S &) { std::cout << "copy initialize an S" << std::endl; }
    S &operator=(const S &) {
        std::cout << "copy assign an S" << std::endl;
        return *this;
    }
    ~S() { std::cout << "destroy an S" << std::endl; }
};

S *f() {
    return new S; // 谁该负责释放这个S?
};

std::unique_ptr<S> g() {
    return std::make_unique<S>(); // 显式传递负责释放这个S
}

int main(int argc, char *argv[]) {
    S *p = f();
    std::cout << "after f() before g()" << std::endl;
    std::unique_ptr<S> q = g();
    std::cout << "exit main" << std::endl;
    // p 产生了内存泄漏
    // q 被自动释放
    return 0;
}

3、浅谈C/C++内存泄漏及其检测工具

  对于一个c/c++程序员来说,内存泄漏是一个常见的也是令人头疼的问题。已经有许多技术被研究出来以应对这个问题,比如Smart Pointer,Garbage Collection等。Smart Pointer技术比较成熟,STL中已经包含支持Smart Pointer的class,但是它的使用似乎并不广泛,而且它也不能解决所有的问题;Garbage Collection 技术在Java中已经比较成熟,但是在 c/c++ 领域的发展并不顺畅,虽然很早就有人思考在 C++ 中也加入GC的支持。现实世界就是这样的,作为一个c/c++程序员,内存泄漏是你心中永远的痛。不过好在现在有许多工具能够帮助我们验证内存泄漏的存在,找出发生问题的代码。

3.1 内存泄漏的定义

  一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定),使用完后必须显示释放的内存。应用程序一般使用 malloc,realloc,new 等函数从堆中分配到一块内存,使用完后,程序必须负责相应的调用 free 或 delete 释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。以下这段小程序演示了堆内存发生泄漏的情形:

void MyFunction(int nSize) {
    char *p = new char[nSize];
    if (!GetStringFrom(p, nSize)) {
        MessageBox("Error");
        return;
    }
    delete p;
}

  当函数 GetStringFrom() 返回零的时候,指针p指向的内存就不会被释放。这是一种常见的发生内存泄漏的情形。程序在入口处分配内存,在出口处释放内存,但是程序可以在任何地方退出,所以一旦有某个出口处没有释放应该释放的内存,就会发生内存泄漏。

  广义的说,内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态 HANDLE ,GDI Object,SOCKET, Interface 等,从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。而且,某些对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。所以相比之下,系统资源的泄漏比堆内存的泄漏更为严重。

  GDI Object的泄漏是一种常见的资源泄漏:

void CMyView::OnPaint(CDC *pDC) {
    CBitmap bmp;
    CBitmap *pOldBmp;
    bmp.LoadBitmap(IDB_MYBMP);
    pOldBmp = pDC->SelectObject(&bmp);
    if (Something()) {
        return;
    }
    pDC->SelectObject(pOldBmp);
    return;
}

3.2 内存泄漏的发生方式

  以发生的方式来分类,内存泄漏可以分为4类:

1、常发性内存泄漏:发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。比如例二,如果 Something() 函数一直返回True,那么 pOldBmp 指向的 HBITMAP 对象总是发生泄漏。

2、 偶发性内存泄漏:发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。比如例二,如果Something()函数只有在特定环境下才返回True,那么pOldBmp指向的HBITMAP对象并不总是发生泄漏。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。

3、 一次性内存泄漏:发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,但是因为这个类是一个Singleton,所以内存泄漏只会发生一次。另一个例子:

char *g_lpszFileName = NULL;

void SetFileName(const char *lpcszFileName) {
    if (g_lpszFileName) {
        free(g_lpszFileName);
    }
    g_lpszFileName = strdup(lpcszFileName);
}

  如果程序在结束的时候没有释放g_lpszFileName指向的字符串,那么,即使多次调用SetFileName(),总会有一块内存,而且仅有一块内存发生泄漏。

4、隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

3.3 检测内存泄漏

  检测内存泄漏的关键是要能截获住对 分配内存 和 释放内存 的函数的调用。截获住这两个函数,我们就能跟踪每一块内存的生命周期,比如,每当成功的分配一块内存后,就把它的指针加入一个全局的 list 中;每当释放一块内存,再把它的指针从list中删除。这样,当程序结束的时候,list中剩余的指针就是指向那些没有被释放的内存。这里只是简单的描述了检测内存泄漏的基本原理。

  如果要检测堆内存的泄漏,那么需要截获住malloc/realloc/free和new/delete就可以了(其实new/delete最终也是用malloc/free的,所以只要截获前面一组即可)。对于其他的泄漏,可以采用类似的方法,截获住相应的分配和释放函数。比如,要检测BSTR的泄漏,就需要截获SysAllocString/SysFreeString;要检测HMENU的泄漏,就需要截获CreateMenu/ DestroyMenu。(有的资源的分配函数有多个,释放函数只有一个,比如,SysAllocStringLen也可以用来分配BSTR,这时就需要截获多个分配函数)

  在Windows平台下,检测内存泄漏的工具常用的一般有三种,MS C-Runtime Library内建的检测功能;外挂式的检测工具,诸如,Purify,BoundsChecker等;利用Windows NT自带的Performance Monitor。这三种工具各有优缺点,MS C-Runtime Library虽然功能上较之外挂式的工具要弱,但是它是免费的;Performance Monitor虽然无法标示出发生问题的代码,但是它能检测出隐式的内存泄漏的存在,这是其他两类工具无能为力的地方。

3、探讨C++内存回收

1、C++内存对象大会战

  我们知道,C++将内存划分为三个逻辑区域:堆、栈和静态存储区。既然如此那么就可以把存在它们之中的对象分别称为堆对象,栈对象以及静态对象。那么这些不同的内存对象有什么区别了?堆对象和栈对象各有什么优劣了?如何禁止创建堆对象或栈对象了?这些便是今天的主题。

1.1 基本概念

  先来看看栈。栈,一般用于存放局部变量或对象,如我们在函数定义中用类似下面语句声明的对象:

void test() {
    int a;  // Type stack_object; 
}

  stack_object 便是一个栈对象,它的生命期是从定义点开始,当所在函数返回时,生命结束。

  另外,几乎所有的临时对象都是栈对象。比如,下面的函数定义:

Type fun(Type object);
int test(int object);

  这个函数至少产生两个临时对象,首先参数是按值传递的,所以会调用拷贝构造函数生成一个临时对象object_copy1 ,在函数内部使用的不是使用的不是object,而是object_copy1,自然object_copy1 是一个栈对象,它在函数返回时被释放;还有这个函数是值返回的,在函数返回时,如果我们不考虑返回值优化(NRV),那么也会产生一个临时对象object_copy2,这个临时对象会在函数返回后一段时间内被释放。比如某个函数中有如下代码:

Type tt, result;		// 生成两个栈对象
tt = fun(tt);			// 函数返回时,生成的是一个临时对象object_copy2

  上面的第二个语句的执行情况是这样的,首先函数fun返回时生成一个临时对象object_copy2 ,然后再调用赋值运算符执行

tt = object_copy2;		// 调用赋值运算符

  看到了吗?编译器在我们毫无知觉的情况下,为我们生成了这么多临时对象,而生成这些临时对象的时间和空间的开销可能是很大的,所以你也许明白了,为什么对于“大”对象最好用const引用传递代替按值进行函数参数传递了。

  接下来看看堆。堆,又叫自由存储区,它是在程序执行的过程中动态分配的,所以它最大的特性就是动态性。在C++中,所有堆对象的创建和销毁都要由程序员负责,所以如果处理不好就会发生内存问题。如果分配了堆对象,却忘记了释放就会产生内存泄漏;而如果已释放了对象,却没有将相应的指针置为NULL,该指针就是所谓的“悬挂指针”,再度使用此指针时,就会出现非法访问,严重时就导致程序崩溃。

  那么,C++中是怎样分配堆对象的?唯一的方法就是用new(当然用类 malloc 指令也可获得C式堆内存),只要使用 new,就会在堆中分配一块内存,并且返回指向该堆对象的指针。

  再来看看静态存储区。所有的静态对象、全局对象都于静态存储区分配。关于全局对象,是在 main() 函数执行前就分配好了的。其实,在 main() 函数中的显示代码执行之前,会调用一个由编译器生成的 _main() 函数,而 _main() 函数会进行所有全局对象的的构造及初始化工作。而在main()函数结束之前,会调用由编译器生成的exit函数,来释放所有的全局对象。比如下面的代码:

#include <iostream>
int main(int argc, char *argv[], char *env[]) {
    int a = 0;
    while (env[a] != nullptr) {
        std::cout << env[a] << std::endl;
        a++;
    }
    return 0;
}

 实际上,被转化成这样:

#include <iostream>
int main(int argc, char *argv[], char *env[]) {
    _main();		 // 隐式代码,由编译器产生,用以构造所有全局对象
    int a = 0;
    while (env[a] != nullptr) {
        std::cout << env[a] << std::endl;
        a++;
    }
    exit() ; 		// 隐式代码,由编译器产生,用以释放所有全局对象
    return 0;
}

  所以,知道了这个之后,便可以由此引出一些技巧,假如要在main()函数执行之前做某些准备工作,那么可以将这些准备工作写到一个自定义的全局对象的构造函数中,这样在main()函数的显式代码执行之前,这个全局对象的构造函数会被调用,执行预期的动作,这样就达到了我们的目的。 刚才讲的是静态存储区中的全局对象,那么,局部静态对象了?局部静态对象通常也是在函数中定义的,就像栈对象一样,只不过,其前面多了个static关键字。局部静态对象的生命期是从第一次执行到该静态对象的声明代码时,产生该静态局部对象,直到整个程序结束时,才销毁该对象。

  还有一种静态对象,那就是它作为class的静态成员。考虑这种情况时,就牵涉了一些较复杂的问题。

  第一个问题是class的静态成员对象的生命期,class的静态成员对象随着第一个class object的产生而产生,在整个程序结束时消亡。也就是有这样的情况存在,在程序中我们定义了一个class,该类中有一个静态对象作为成员,但是在程序执行过程中,如果我们没有创建任何一个该class object,那么也就不会产生该class所包含的那个静态对象。还有,如果创建了多个class object,那么所有这些object都共享那个静态对象成员。

  第二个问题是,当出现下列情况时:它们所访问的s_object是同一个对象吗?答案是肯定的,它们的确是指向同一个对象

#include <iostream>
class Base {
public:
    inline static int s_object = 1;
};

// 公共继承
class A : public Base { };
// 公共继承
class B : public Base { };

int main(int argc, char *argv[], char *env[]) {
    Base example;
    A example1;
    B example2;
    example.s_object = 1;
    example1.s_object = 2;
    example2.s_object = 3;
    std::cout << Base::s_object << std::endl;
    return 0;
}

1.2 三种内存对象的比较

  栈对象的优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;而且栈对象的创建速度一般较堆对象快,因为分配堆对象时,需要调用operator new操作,operator new会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量比较小,一般是1MB~2MB,所以体积比较大的对象不适合在栈中分配。特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。

  堆对象,其产生时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的生命具有完全的控制权。我们常常需要这样的对象,比如,我们需要创建一个对象,能够被多个函数所访问,但是又不想使其成为全局的,那么这个时候创建一个堆对象无疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相比于栈空间,堆的容量要大得多。实际上,当物理内存不够时,如果这时还需要生成新的堆对象,通常不会产生运行时错误,而是系统会使用虚拟内存来扩展实际的物理内存。

  接下来看看static对象。

  首先是全局对象。全局对象为类间通信和函数间通信提供了一种最简单的方式,虽然这种方式并不优雅。一般而言,在完全的面向对象语言中,是不存在全局对象的,比如C#,因为全局对象意味着不安全和高耦合,在程序中过多地使用全局对象将大大降低程序的健壮性、稳定性、可维护性和可复用性。C++也完全可以剔除全局对象,但是最终没有,我想原因之一是为了兼容C。

  其次是类的静态成员,上面已经提到,基类及其派生类的所有对象都共享这个静态成员对象,所以当需要在这些class之间或这些class objects之间进行数据共享或通信时,这样的静态成员无疑是很好的选择。

  接着是静态局部对象,主要可用于保存该对象所在函数被屡次调用期间的中间状态,其中一个最显著的例子就是递归函数,我们都知道递归函数是自己调用自己的函数,如果在递归函数中定义一个nonstatic局部对象,那么当递归次数相当大时,所产生的开销也是巨大的。这是因为nonstatic局部对象是栈对象,每递归调用一次,就会产生一个这样的对象,每返回一次,就会释放这个对象,而且,这样的对象只局限于当前调用层,对于更深入的嵌套层和更浅露的外层,都是不可见的。每个层都有自己的局部对象和参数。

  在递归函数设计中,可以使用static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放nonstatic对象的开销,而且static对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。

1.3 使用栈对象的意外收获

  前面已经介绍到,栈对象是在适当的时候创建,然后在适当的时候自动释放的,也就是栈对象有自动管理功能。那么栈对象会在什么会自动释放了?第一,在其生命期结束的时候;第二,在其所在的函数发生异常的时候。你也许说,这些都很正常啊,没什么大不了的。是的,没什么大不了的。但是只要我们再深入一点点,也许就有意外的收获了。

  栈对象,自动释放时,会调用它自己的析构函数。如果我们在栈对象中封装资源,而且在栈对象的析构函数中执行释放资源的动作,那么就会使资源泄漏的概率大大降低,因为栈对象可以自动的释放资源,即使在所在函数发生异常的时候。实际的过程是这样的:函数抛出异常时,会发生所谓的stack_unwinding(堆栈回滚),即堆栈会展开,由于是栈对象,自然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执行,从而释放其所封装的资源。除非,除非在析构函数执行的过程中再次抛出异常――而这种可能性是很小的,所以用栈对象封装资源是比较安全的。基于此认识,我们就可以创建一个自己的句柄或代理来封装资源了。智能指针(auto_ptr)中就使用了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。

1.4 禁止产生堆对象

  上面已经提到,你决定禁止产生某种类型的堆对象,这时你可以自己创建一个资源封装类,该类对象只能在栈中产生,这样就能在异常的情况下自动释放封装的资源。

  那么怎样禁止产生堆对象了?我们已经知道,产生堆对象的唯一方法是使用new操作,如果我们禁止使用new不就行了么。再进一步,new操作执行时会调用operator new,而operator new是可以重载的。方法有了,就是使new operator 为private,为了对称,最好将operator delete也重载为private。现在,你也许又有疑问了,难道创建栈对象不需要调用new吗?是的,不需要,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而operator new的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上面已经提到过了。好,让我们看看下面的示例代码:

#include <cstdlib>
#include <iostream>
// 代表需要被封装的资源类
class Resource {
public:
    void test(){ std::cout << "Resource test func" << std::endl; }
};

class NoHashObject {
public:
    // 此处可以获得需要封装的资源,并让ptr指针指向该资源
    NoHashObject() { ptr = new Resource(); }

    // 释放封装的资源
    ~NoHashObject() { delete ptr; }

    // 方式一
    void *operator new(size_t size) = delete;

    void operator delete(void *pp) = delete;

private:
    // 指向被封装的资源
    Resource *ptr;

    // 方式二
    //  void *operator new(size_t size) { return malloc(size); }
    // void operator delete(void *pp) { free(pp); }
};

int main(int argc, char *argv[], char *env[]) {
    // NoHashObject 现在就是一个禁止堆对象的类了,如果你写下如下代码:
    // NoHashObject *fp = new NoHashObject();      // 编译期错误!
    // delete fp;
    return 0; }

  上面代码会产生编译期错误。好了,现在你已经知道了如何设计一个禁止堆对象的类了,你也许和我一样有这样的疑问,难道在类NoHashObject的定义不能改变的情况下,就一定不能产生该类型的堆对象了吗?不,还是有办法的,我称之为“暴力破解法”。C++是如此地强大,强大到你可以用它做你想做的任何事情。这里主要用到的是技巧是指针类型的强制转换。

int main(int argc, char *argv[], char *env[]) {
    char *temp = new char[sizeof(NoHashObject)];
    // 强制类型转换,现在 obj_ptr 是一个指向 NoHashObject 对象的指针
    NoHashObject *obj_ptr = (NoHashObject *) temp;
    // 防止通过 temp指针 修改 NoHashObject 对象
    temp = nullptr;
    // 再一次强制类型转换,让rp指针指向堆中NoHashObject对象的ptr成员
    Resource *rp = (Resource *) obj_ptr;
    // 初始化 obj_ptr 指向的 NoHashObject 对象的 ptr 成员
    rp = new Resource();
    rp->test();
    // 到这里就实现了在没有使用 new 操作符的情况下就在堆中创建了一个 NoHashObject 的实例对象,且可以通过 obj_ptr 指针使用堆中的 NoHashObject 对象成员
    
    // 释放资源
    delete rp;
    temp = (char *) obj_ptr;
    // 防止悬挂指针产生
    obj_ptr = nullptr;
    // 释放NoHashObject对象所占的堆空间。
    delete[] temp;
    return 0;
}

  上面的实现是麻烦的,而且这种实现方式几乎不会在实践中使用,但是我还是写出来路,因为理解它,对于我们理解C++内存对象是有好处的。对于上面的这么多强制类型转换,其最根本的是什么了?我们可以这样理解:

  某块内存中的数据是不变的,而类型就是我们戴上的眼镜,当我们戴上一种眼镜后,我们就会用对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。

  所谓强制类型转换实际上就是换上另一副眼镜后再来看同样的那块内存数据。

  另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不一样的,比如,大多数编译器将NoHashObject 的 ptr 指针成员安排在对象空间的头4个字节,这样才会保证下面这条语句的转换动作像我们预期的那样执行:

Resource* rp = (Resource*)obj_ptr ; 

  但是,并不一定所有的编译器都是如此。

  既然我们可以禁止产生某种类型的堆对象,那么可以设计一个类,使之不能产生栈对象吗?当然可以。

1.5 禁止产生栈对象

  前面已经提到了,创建栈对象时会移动栈顶指针以“挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要 operator new/delete 操作的,所以将 operator new/delete 设置为 private 不能达到目的。当然从上面的叙述中,你也许已经想到了:将构造函数或析构函数设为私有的,这样系统就不能调用构造/析构函数了,当然就不能在栈中生成对象了。

  这样的确可以,而且我也打算采用这种方案。但是在此之前,有一点需要考虑清楚,那就是如果我们将构造函数设置为私有,那么我们也就不能用new来直接产生堆对象了,因为new在为对象分配空间后也会调用它的构造函数啊。所以,我打算只将析构函数设置为private。再进一步,将析构函数设为private除了会限制栈对象生成外,还有其它影响吗?是的,这还会限制继承。

  如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为private。

  为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所示:

class NoStackObject {
public:
    // 这里把析构函数执行的默认动作包装了一层
    void destroy() { delete this; }

protected:
    ~NoStackObject() {}

// 对继承有影响
//private:
//    ~NoStackObject() {}
};

int main(int argc, char *argv[], char *env[]) {
    // 因为系统调用不到被 protected 保护的析构函数,所以申请栈对象 a 时报错
    // NoStackObject a;

    NoStackObject *b = new NoStackObject();
    // 对 b 指向的对象进行释放操作
    b->destroy();
    return 0;
}

  是不是觉得有点怪怪的,我们用new创建一个对象,却不是用delete去删除它,而是要用destroy方法。很显然,用户是不习惯这种怪异的使用方式的。所以我决定将构造函数也设为 private 或 protected。这又回到了上面曾试图避免的问题,即不用 new,那么该用什么方式来生成一个对象了?我们可以用间接的办法完成,即让这个类提供一个static成员函数专门用于产生该类型的堆对象。(设计模式中的singleton模式就可以用这种方式实现。)让我们来看看:

class NoStackObject {
public:
    // 调用保护的构造函
    static NoStackObject *getInstance() { return new NoStackObject(); }
    // 调用保护的析构函数
    void destroy() { delete this; }
//protected:
//    NoStackObject() {}
//    ~NoStackObject() {}
private:
    NoStackObject() {}
    ~NoStackObject() {}
};

int main(int argc, char *argv[], char *env[]) {
    // 构造函数与析构函数必须同时是公开的访问权限(public)才能创建其栈对象
    // NoStackObject test;

    NoStackObject *a = NoStackObject::getInstance();
    // 对 a 指向的对象进行操作
    a->destroy();
    // 防止使用悬挂指针
    a = nullptr;
    return 0;
}

现在感觉是不是好多了,生成对象和释放对象的操作一致了。

2、浅议C++ 中的垃圾回收方法

  许多 C 或者 C++ 程序员对垃圾回收嗤之以鼻,认为垃圾回收肯定比自己来管理动态内存要低效,而且在回收的时候一定会让程序停顿在那里,而如果自己控制内存管理的话,分配和释放时间都是稳定的,不会导致程序停顿。最后很多 C/C++ 程序员坚信在C/C++ 中无法实现垃圾回收机制。这些错误的观点都是由于不了解垃圾回收的算法而臆想出来的。

  其实垃圾回收机制并不慢,甚至比动态内存分配更高效。因为可以只分配不释放,那么分配内存的时候只需要从堆上一直的获得新的内存,移动堆顶的指针就够了;而释放的过程被省略了,自然也加快了速度。现代的垃圾回收算法已经发展了很多,增量收集算法已经可以让垃圾回收过程分段进行,避免打断程序的运行了。而传统的动态内存管理的算法同样有在适当的时间收集内存碎片的工作要做,并不比垃圾回收更有优势。

  而垃圾回收的算法的基础通常基于扫描并标记当前可能被使用的所有内存块,从已经被分配的所有内存中把未标记的内存回收来做的。C/C++ 中无法实现垃圾回收的观点通常基于无法正确扫描出所有可能还会被使用的内存块,但是看似不可能的事情实际上实现起来却并不复杂。首先通过扫描内存的数据,指向堆上动态分配出来内存的指针是很容易被识别出来的,如果有识别错误,也只能是把一些不是指针的数据当成指针,而不会把指针当成非指针数据。这样回收垃圾的过程只会漏回收掉而不会错误的把不应该回收的内存清理。其次如果回溯所有内存块被引用的根,只可能存在于全局变量和当前的栈内,而全局变量(包括函数内的静态变量)都是集中存在于 bss 段或 data段中。

  垃圾回收的时候,只需要扫描 bss 段, data 段以及当前被使用着的栈空间,找到可能是动态内存指针的量,把引用到的内存递归扫描就可以得到当前正在使用的所有动态内存了。

  如果肯为你的工程实现一个不错的垃圾回收器,提高内存管理的速度,甚至减少总的内存消耗都是可能的。

转载地址

https://www.cnblogs.com/lancidie/archive/2011/08/05/2128318.html

posted @ 2023-10-29 22:38  黄河大道东  阅读(25)  评论(0编辑  收藏  举报