尽力全面的C++内存管理

  • 前言
  • 0. 管理内存的理由
  • 1. Windows内存管理策略
    • 附:物理内存 与 虚拟内存
  • 2. Linux内存管理策略
    • 附:brk() 和 mmap()
  • 3. 内存对齐
    • 3.1 内存对齐3原则
    • 3.2 内存对齐对数据读取效率的影响
    • 内存对齐小结
  • 4. C++内存管理精髓
    • 4.1 new 操作符 & delete 操作符
      • 4.1.1 使用new分配内存
      • 4.1.2 内存溢出?
    • 4.2 使用delete释放内存
      • 附:内存池
        • 内存池的意义
        • 内存池的工作原理及优缺点
    • 4.3 使用 new 创建动态数组
      • 4.3.1 new一个动态数组
      • 4.3.2 使用一个动态数组
      • 4.3.3 指针与数组
    • 4.4 new & delete 的原理
      • 4.4.1 “既生瑜,何生亮”
      • 4.4.2 new 的执行流程
      • 4.4.3 operator new
      • 4.4.4 placement new
    • 4.5 malloc/free 和 new/delete 的比较
  • 5. 图解C/C++内存布局
    • 5.1 C++程序内存布局
      • 栈 Stack
      • 自由存储区 Free Area
      • 堆 Heap
      • 代码段 Code Segment
      • 数据段 Data Segment
    • 5.2 堆与栈的比较
    • 5.3 设计一个只能在堆上/栈上创建对象的类
      • 只在堆上
      • 只在栈上
  • 6. 其它常见问题讨论
    • 6.1 单例模式
      • 6.1.1 单例模式定义及使用场景
      • 6.1.2 单例模式的优缺点
      • 6.1.3 单例模式的实现
        • 饿汉式
        • 懒汉式
        • 多线程下单例模式方法
    • 6.2 垃圾收集
      • 6.2.1 垃圾收集的定义
      • 6.2.2 常见垃圾收集器的类型
  • 7. 结语

前言

内存管理是C++的核心之一,也是其优势所在。无论深入学习,抑或备考企业面试,基于C++的内存管理都是绕不开的话题。

但是,对于初级学习者而言,掌握这个知识点是相对困难的,因为大部分主流初级C++教材没有对这一内容单独进行系统的编排,导致知识点如天女散花般散落于各个章节,较难提炼。

基于此,作这篇文字,意图以清晰的思路、简洁可懂的语言配合一定的实例代码来展现C++内存管理的核心内容。文章篇幅较长,尽可能覆盖了内存管理的初级内容,某些科普性质的内容读者可以选择性忽略。

0. 管理内存的理由

内存管理是计算机编程领域的重要领域之一。在众多脚本语言中,不必担心内存如何管理,但这并不影响内存管理的重要性。在实际编程的过程中,对内存管理器的理解力至关重要。大部分系统语言,尤其是本文讨论的C以及C++,必须进行内存管理

回顾计算机编程语言,从使用原始的机器语言编程,到汇编语言时代,内存管理都并不复杂,理由很明了,因为在使用这些语言编程,实际上是在运行整个系统。系统的内存有多少,可供使用的内存就有多少。

在使用简单计算机时,如果对于内存的需要比较固定,那么只需要选择一个内存范围并使用它即可。但是,当不知道程序的每个部分需要多少内存时,就需要解决以下问题:

  1. 确定是否有足够的内存来处理数据;
  2. 从可用的内存中获取一部分内存
  3. 向内存池返回部分内存

实现以上问题的程序就被称为分配器(Allocator),它们负责分配和回收内存。程序的动态性越强,内存管理就越重要。

那么,我们就将从常见的操作系统出发,熟悉和掌握C尤其是C++管理内存的方法,最后回归内存硬件,探索整个内存管理的知识。

1. Windows内存管理策略

要理解内存在程序中是如何分配的,首先需要解释如何将内存从操作系统分配给程序。

作为普通计算机使用者日常接触最多的操作系统,Windows历经多年的迭代,形成了下图所示的一套以此为基础的内存管理策略:

Windows系统通过 TLB 将物理内存(Physical Memory)映射为连续的虚拟内存(Virtual Memory),同时提供了管理虚拟内存所使用的相关 API,诸如VirtualAlloc,VirutualFree等等。

在虚拟内存API上构建了堆内存的API,我们熟悉的C语言的内存管理策略就是通过malloc以及free构建在堆上的,同时,本文重点讨论的C++也是同样如此,详情见后文。

在使用VirtualAlloc分配内存时,每次只能分配页面大小整数倍的连续虚拟内存,页面大小通常默认为4KB。

附:物理内存 与 虚拟内存

计算机中的每一个进程都认为自己可以访问所有的物理内存。但是很明显,各个进程不可能占有全部内存。实际上,这些进程使用的是虚拟内存

操作系统维持了一个虚拟地址到物理地址的转换表,以便计算机硬件响应地址请求。

一个程序正在访问某个地址的内存,实际上虚拟内存不需要将这个程序存储在该地址的物理内存上,如果物理内存已满,那么虚拟内存会将其转移到硬盘上。这种不必反映内存所在物理位置的这类地址,被称为虚拟地址

当然如果这个进程所需空间大于内存的话,那么地址(就是虚拟内存)将会被分配在硬盘上。操作系统会暂停这个进程,将其他内存转存到硬盘上,从硬盘加载被请求的内存,使得进程能够拥有可以使用的地址空间。虚拟内存就是这样使得进程可以访问比物理安装的内存更多的内存的。

2. Linux内存管理策略

Linux系统没有采用分页机制,因此逻辑地址与虚拟地址是一个概念。在Linux内核中,虚拟地址与物理地址大多只相差一个线性偏移量,在用户空间上使用多页表进行映射。

在x86结构中,Linux内核的虚拟地址空间划分0至3G为用户空间,3至4G为内核空间,因此内核可以使用的线性地址只有1G.。而内核虚拟空间又划分为三个类型的分区:
ZONE_DMA : 3G之后的起始的16MB
ZONE_NORMAL : 16MB 至 896MB
ZONE_HIGHMEM : 896MB 至 1G

以上简单介绍的是Linux系统中的内存映射机制,在此基础上利用伙伴算法以及 slab 分配器解决系统外部碎片问题,借助 brk 或者 mmap 函数从用户空间申请连续内存。

附:brk() 和 mmap()

当一个进程被加载时,它将获得一个系统中断决定的特定地址的初始内存分配。通俗地说就是在内存上作一个标记,证明这个进程被加载,如果进程运行过程中超出了这部分初始内存,它就必须请求申请更多内存。

brk()
将系统中断确定的内存边界向前或者向后移动,以此来向进程添加或者取走内存。

mmap()
顾名思义就是memory map,内存映射的意思。它可以映射任何位置的内存,不仅可以将虚拟内存映射到物理内存中,还可以将它们映射到文件和文件位置,对文件进行读写

3. 内存对齐

内存对齐是C++管理内存的一个代表性的实例,即使在日常编程与实际应用中会较少地留意,但是通过研究这一机制可以让我们领会到内存管理的重要性。

3.1 内存对齐3原则

  1. 结构体变量的对齐值为其最宽成员的大小,变量的起始地址要能被对齐值整除;
  2. 结构体的每个成员相对于起始地址的偏移量被其自身对齐值整除,若不能则在最后一个成员后补充字节;
  3. 结构体总体大小被对齐值整除,若不能则在后面补充字节。

选用以下简单结构体作为我们讲解的例子:

struct Exp0
{char a;        //align = 1, compensate = 3int b;     //align = 4, compensate = 0short c;   //align = 2, compensate = 2
}

C++使用 alignof 函数就可以获取类型的对齐值。
上述结构体 Exp0 中,char 类型的对齐值为1,int 对齐值为4,short 对齐值为2。其中最宽成员大小为4,所以结构体的对齐值为4。

假设上述结构体变量的起始地址已经对齐,则第一个变量 a 也已经对齐。此时需要将第二个变量 b 相对于起始地址对齐,而 a 的大小仅为1,b 此时相对于起始位置的偏移量为1,不能被其自身对齐值 4 整除。为了满足原则 2 ,我们在最后一个变量 a 后补充填充3个字节使得 b 相对于起始地址的偏移量为4,完成对齐。

此时再考虑第3个变量c,此时结构体的大小为(1+3)+ 4 + 2 = 10,不能被4整除,因此根据原则 3 在结构体最后填充2个字节,此时结构体的大小为12,至此整个结构体对齐完成。

接下来,我们基于上述结构体,将其中成员顺序进行交换,再尝试进行对齐:

struct Exp1      //align = 4
{int b;     //align = 4, compensate = 0char a;        //aligh = 1, compensate = 1short c;   //aligh = 2, compensate = 0
}

在上述结构体Exp1中,结构体对齐值为4。假设变量 b 起始地址已经对齐,b 的对齐值为4,则变量 a 相对于起始地址的偏移量为4,可以被自身对齐值1整除。

考虑变量 c,此时 c 相对于起始地址的偏移量为5,不能被其自身的对齐值2整除,因此根据原则 2,在最后一个成员 a 后填充一个字节,此时变量 c 相对于起始位置的偏移量为6,满足了原则 2。最后考虑整个结构体,此时结构体大小为4+(1+1)+2 = 8,能被对齐值4整除,满足原则 3,不需要进行调整。

综上,根据简单的排列,这三个变量 a,b,c(假定他们类型不变)一共有6种排列方式,有的如Exp0需要占据12个字节,而有的如Exp1则仅占据8个字节,通过有意识地利用内存对齐的方式对结构体变量进行合理的排列,结构体大小会产生变化,可以起到节省内存占用的效果。

3.2 内存对齐对数据读取效率的影响

在3.1中,关于内存对齐可以“节省内存占用”的介绍中,我们都默认了第一个成员变量的起始地址已经对齐,实际上在现实中并非容易达到这样理想的效果,因此我们考虑如下情况:


考虑一个double类型的数组 Array,已知其对齐值为8,它在内存中的位置如上图所示。

数组的起始地址为2,不符合原则 1要求的起始地址能被其对齐值整除,数组没有对齐。考虑到系统以及CPU每次从内存中以8字节的整数倍的地址读入8字节的数据,可以想见,若 Array 没有对齐,则每次读取 Array 中的一个成员,都需要CPU对内存进行两次访问才可完成,而 Array 若对齐的话,则仅需一次。因此内存对齐也可以有效提升内存数据读取的效率。

内存对齐小结

内存对齐的优点:

  1. 减少内存占用;
  2. 提升数据读取效率。

4. C++内存管理精髓

要说明如何进行C++的内存管理,一定绕不开new与delete,而new与delete的内容又建立在C++的指针之上,因此我们需要从指针来简单谈起。

计算机程序对于存储数据时会着重考虑以下几点:

  1. 数据存储位置;
  2. 存储的值;
  3. 存储数据的形式

以上三点我们往往通过定义一个简单的变量就可以实现。定义变量之时,变量的类型,变量的值,以及计算机会指定一个位置来存储这个变量。

当然,我们还非常熟悉另一种存储数据的方法,那就是指针。指针是一种存储地址值的特殊变量,通过读取其存储的地址值可以指向内存的另一个位置,从那个位置获取具体的数值。

指针的使用很好地反映了C++这门语言面向对象(OOP)进行编程的特质。相比于直接定义一个数组,通过定义指针的方式来定义数组,可以实现在进程中整数组大小从而实现节省资源的目的。

以上,我们回顾了指针的基本概念和用法,而指针的核心价值体现在程序实时运行中指针通过分配空闲内存来存储数据,此时指针成为了唯一可以触及内存的方式。在C语言中,这种操作使用库函数 malloc()即可实现。malloc() 库函数的实现相对复杂,将在另一篇文章中详细讲述。在C++中,我们同样可以使用 malloc(),但是C++拥有一个更好的途径:new。

4.1 new 操作符 & delete 操作符

4.1.1 使用new分配内存

int *pn = new int;

以上为使用 new 操作符的一个典型例子,使用者利用 new 告知内存需要的数据类型( int ),new 找到并返回合适大小的数据块的地址,将这个地址指向指针( pn )。

new 操作符的声明格式如下:

typeName *pointer_name = new typeName;  //New operator usage

其中数据类型 typeName 使用了两次,一次用于标明请求内存的种类,另一次是声明合适的指针。

new 操作符与常见定义的变量使用了内存的不同区域,使用 new 分配的内存被称为 (heap) 或者 自由存储区(free store)。

4.1.2 内存溢出?

在使用 new 操作符时,可以预见有时系统可能无法提供 new 请求的可用的内存空间。此时,new 一般会通过返回一个空指针( null ptr )来抛出故障,而较旧的版本则会返回一个值 0。

总之,C++提供了内存分配错误的检测和报错机制,而这已经包含在了 new 中。

4.2 使用delete释放内存

以上介绍的使用 new 操作符来申请内存只是C++内存管理大礼包的一半,正如中国传统文化中的阴阳理论,有分配就要对应释放,大礼包的另一半就是 delete 操作符。delete 操作符将申请的内存在使用完毕返回给内存池( memory pool )。这是高效使用内存的重要一步。

int *ps = new int;  //Allocate memory with new
......              //Use the memory allocated
delete ps;          //Free the memory with delete when done

一个 new 对应一个 delete,这两个的存在是完全同步的,缺一不可。一旦缺少,将会发生内存泄漏(memory leak),即分配出去的内存无法再次使用。当内存泄漏过于严重时,程序可能会因此崩溃。

int *ps = new int;
delete ps;          //Legal
delete ps;          //Illegal,ps freed
int p = 11;
int *pt = &p;
delete pt;          //Illegal,pt is not initialized by new

delete既不可以用于释放已经释放的内存,也不可以释放不是用 new 申请的普通变量。

int *ps = new int;
int *pt = ps;
delete pt;          //Legal, don't hesitate

值得注意的是,如上代码所示,只要指针指向的内存是 new 申请得到的,就可以使用 delete 来释放。ps 指针使用 new 申请,用指针 pt 指向 ps 所指向的地址,pt 可以用 delete 来释放。理由其实很好理解,delete 释放的是内存,而不是指针。

有的读者会问,用 delete 来释放空指针是否可以呢?答案是可以的。

当然,通常情况下我们不至于使用两个指针来指向同一块内存。但是使用第二个指针,在一个需要返回指针的函数中是有意义的。

附:内存池

内存池的意义

以上在讲述 new 操作符时我们明确讲到,new 操作符申请到的内存位于堆上,因此我们可以换种说法,即 new 是对堆内存进行管理。

在分配堆资源的时候,有一个重要的课题就是如何确定要分配堆的位置。在实际应用中,堆中会有很多位置已经被征用,因此我们需要对这些位置进行记录以达到管理的目的,从而更好地定位空闲位置。

C/C++采用了两张表,记录被使用了的内存块的表为alloc_List,记录未使用内存块的表为free_List。申请内存的步骤,实际就是调查free_List中的空闲内存块并调用,从表中删除并将这段内存加入alloc_List。释放内存的情况正好相反。

上面的办法看似完美解决了问题,即便现实中确实采用了这种思路,但是,多次的申请释放操作,加上每次申请、释放的内存大小、类型不尽相同,因此很容易使得堆这一连续的内存空间变得支离破碎,这种状态就称为“内存碎片”。内存碎片的存在导致时间和空间开销非常大。

基于以上问题,内存池应运而生。

内存池的工作原理及优缺点

内存池实际上就是一块预先分配好的大内存,以满足下列这种情形:

使用者需要频繁对某种固定大小内存块进行申请和释放

那么假定这种内存块的大小均为256B,那么我们申请一块256KB大小的大内存,分为1024块,每次申请从中取出一块,释放时并不进行 free 操作,而是归还内存块并进行标记,供下次调用。

因此,可以发现,使用内存池可以极大提升内存申请和释放的效率,操作的时间复杂度为O(1),同时也可以减少内存碎片的产生
(实际上这个时间复杂度是O(n),但是在实际计算过程中通常会除以一个非常大的因数,所以这里可以认为是O(1))。

当然,内存池也会有明显的缺点。首先,内存池作为一种特型内存管理器并不通用,只能分配特定长度的内存块。并且,因为内存池并不会从实际上 free 内存,导致实际情况下内存占用反而更高

4.3 使用 new 创建动态数组

4.3.1 new一个动态数组

读者可能发现,明明本文在讨论内存管理,为何指针和数组占据了很长的篇幅?其实正是在操作指针、创建数组的过程中,内存管理的细枝末节的知识点才能得到显现。

我们现在回顾 new 的用法,new 可以在内存中申请空间来存储大量的数据,诸如数组、字符串、结构体等等。假设一种情况,我们在编写程序的过程中并没有明确的是否需要一个数组的想法,这需要根据程序运行过程中的状况来决定。如果我们声明了一个数组,那它在编译的过程中就已经在内存中分配。无论是否使用,这个数组一直都占用着内存。

上述情况,在编译过程中为数组分配内存,被称为静态封装( static binding )。但是利用 new,我们可以在程序运行的过程中,在需要的时候创建数组,在不需要的时候直接跳过,这就称为动态封装( dynamic binding )。

int *array = new int[10];   //Get a memory block of 10 ints
...
delete [] array;            //Free a dynamic array

上述代码非常易懂,需要指出几个细节:

  1. 内存分配了10个 int 长度的内存块,用指针 array 保存第一个内存块的地址;
  2. delete 后的 [ ] 告知了系统需要释放整个数组。

使用 new 来创建一个动态数组:

typeName *pointer_name = new typeName [num_elements];

4.3.2 使用一个动态数组

#include<iostream>
using namespace std;
int main()
{double *p3 = new double[3];p3[0] = 2;p3[0] = 5;p3[0] = 8;cout << "p3[1] is " << p3[1] << endl;p3++;  //Point to the second element of the arraycout << "Now p3[0] is " << p3[0] << endl;cout << "Now p3[1] is " << p3[1] << endl;p3--;   //Move back the pointer to the headdelete [] p3;return 0;
}

以上程序展示了一个使用动态数组的例子,其运行结果为:
p3[1] is 5
Now p3[0] is 5
Now p3[1] is 8
其中,p3++这条语句尤其重要。在 p3 加一之后,p3[0] 此时指向之前数组中的第二个元素。因此,对 p3 加一使其指向了下一个元素,而减一使其重新指向数组的第一个元素。这时使用 delete[ ] 可以释放整个数组。

4.3.3 指针与数组

上面一节展现了C++指针与数组在操作方面的区别,我们在定义数组之后不能改变数组的名字,但是指针是变量,因此可以更改其值。

C++中,指针与数组有着很多相似之处,但它们并不等价。
数组在静态存储区或者栈上被创建,数组名对应一块内存,其地址在程序的生命周期中保持不变。
指针则可以随时指向任意类型的内存块,而数组不是指向而是对应。

C++指针和数组操作的相似性是这门语言艺术性的集中体现之一。

4.4 new & delete 的原理

4.4.1 “既生瑜,何生亮”

我们知道,在C语言中有 malloc() 和 free() 来对内存进行操作,那么C++为何还要引入 new 和 delete 呢?为了解释这个问题,我们首先来看 malloc() 函数的原型:

void *malloc(size_t size)

malloc() 函数的使用

int *p = (int*)malloc(sizeof(int)*length);

从上可见,malloc() 函数的作用与 new 如出一辙,但是使用 malloc() 需要使用进行类型转换。事实上,new 内置了 malloc(),甚至还内置了sizeof(),因此二者才会如此相像而 new 明显更加好用。

另外有一点是 malloc()/free() 不及 new/delete 之处,即 new/delete 可以对于非基本数据类型(比如常见的类对象)调用构造函数和析构函数

4.4.2 new 的执行流程

 T *A = new T;

C++中,new operator 特指 new 操作符,不能够被重载。如上代码,定义了一个 T 类的对象 A ,并在内存上分配了空间,其执行流程实际上为以下三步:

  1. 调用operator new分配内存,operator new(sizeof(T));
  2. 调用构造函数生成类的对象,T:T();
  3. 返回相应指针。

分析上述步骤可以发现,实际上进行内存分配操作的就是对operator new(size_t)的调用,也就是说,new operator 调用了 operator new。

C++默认提供了全局::operator new(size_t),而如果类(比方说T)重载了operator new,则调用T::operator new(size_t)。

那么operator new 又是何方神圣呢?

4.4.3 operator new

operator new 是函数,而new operator是操作符。
operator new 有一下三种形式:

void *operator new (std::size_t size) throw(std::bad_alloc);
void *operator new (std::size_t size, const std::nothrow_t & nothrow_constant) throw();
void *operator new (std::size_t size, void *ptr) throw();

第一种,分配一个size字节的内存,并将对象类型进行内存对齐。如果成功,返回非空指针指向分配内存的首地址,若失败,抛出bad_alloc异常。

第二种,在分配失败的情况下,返回一个空指针NULL。

第三种,本质上是对operator new的重载。它并不分配内存,而是调用构造函数在指针 ptr 所指的位置构造一个对象,最后返回指针 ptr。

T *A = new T;                  //Category 1
T *A = new(std::nothrow) T;        //Category 2
T *A = new(ptr) T;             //Category 3

综上,只有第三种 operator new 调用了构造函数,这种 operator new又称为 placement new。

4.4.4 placement new

我们之前已经反复谈到,new 所申请的内存空间一般是从系统的堆上进行分配。但是,如果想要在指定的内存位置创建对象的话,就需要用到placement new了。中文作者尝试翻译,暂称"定位 new"好了。

placement new的原型如上一节的第三种所示,我们通过一段简短的程序来说明这个操作:

#include<iostream>
using namespace std;class A
{public:A(){cout << "A is a constructor." << endl;}~A(){cout << "A is a destructor." << endl;}void show(){cout << "num: " << num << endl;}
private:int num;
}int main()
{char array[10];array[0] = 'A';array[1] = '\0';array[2] = '\0';array[3] = '\0';cout << (void*)array << endl;A *p = new (array) A;cout << p << endl;p->show();p->~A();return 0;
}

程序结果如下图所示:

分析程序,我们发现:

  1. 正常情况下,new 操作只在堆 heap 上分配存储空间,而使用 placement new,既可以在堆上,也可以在栈上生成对象。程序中,即将指针 p 指向了在栈上分配数组array的首地址;
  2. placement new 操作实际上并没有分配内存,而只是指向了已分配好的内存空间;
  3. A *p = new(array) A,自动调用了A的构造函数,在程序结束后需要手动释放。

4.5 malloc/free 和 new/delete 的比较

  1. 都在堆上(placement new除外)申请空间,都需要手动释放。
  2. malloc/free 是函数,new/delete 是操作符。
  3. malloc 申请空间需要sizeof()手动计算,而 new 自动根据类型判断空间大小。
  4. malloc/free只能申请内置类型空间,不能调用构造函数和析构函数;new 在申请空间后调用构造函数, delete在释放空间后调用析构函数。
  5. new/delete 由于封装了 malloc/free, 效率因此较低。

5. 图解C/C++内存布局

在前文中,我们多次提到了 new 操作符在堆上分配内存空间,普通变量则定义在栈上,那么堆是什么,栈是什么,它们在内存中是如何定位的?我们通过下图来进行介绍:

5.1 C++程序内存布局

C++程序的内存分布如上图所示,图片下方至上方表示了内存地址从低到高的方向。内存地址由低到高依次为:
BIOS,OS,代码段,数据段,堆,自由存储区,栈。

栈 Stack

栈就是我们熟悉的堆栈,采用了“LIFO”的逻辑模式。栈主要用于存储本地变量以及将参数传给函数,用于存储当函数调用结束后的下一条指令的返回地址。

栈向低地址生长。

栈内数据在不需要的时候,尤其是程序结束时会自动清除。

自由存储区 Free Area

自由存储区是一段没有被分配的内存空间,它在堆或者栈增长时提供空间。

堆 Heap

堆是我们这篇文章通篇的重点,主要用于动态内存的分配。在C语言中,malloc()/free() 操作在堆上进行,在C++中,由 new/delete 对堆进行操作。有读者认为堆和栈同属于数据结构中的“堆栈”,实则不是,这是两个概念,且堆并不采用LIFO的存取策略,这一点值得注意。

堆向高地址生长。

堆内数据在使用后,需要手动释放。

对于UNIX系统而言,brk()函数也是在堆上分配内存。

代码段 Code Segment

代码段存放程序的机器码以及只读数据。这段在内存中一般被标记为只读,任意对其的操作都会报错。

数据段 Data Segment

数据段用于存放已初始化的全局变量以及静态变量。

5.2 堆与栈的比较

  1. 管理方式不同:
    栈内数据由编译器自动管理,而堆内数据须手动释放;

  2. 空间大小不同:
    栈空间往往较小,而堆空间往往较大,在常见情况下,栈的大小为1MB,而堆可达到4GB;

  3. 是否产生碎片
    由于采用了LIFO的数据存储策略,栈的利用率相当高,几乎不会产生内存碎片;
    反观堆,多次的手动申请、释放非常容易产生内存碎片,造成堆空间的不连续;

  4. 生长方向不同:
    栈从高地址向低地址生长,堆从低地址向高地址生长;

  5. 分配方式不同:
    栈上空间可以静态分配,也可以动态分配(其实就是特指placement new,或者说得更深就是使用alloca()函数,由编译器进行释放),堆上空间只可以进行动态分配;

  6. 分配效率不同:
    栈的操作有系统底层支持,有专门的指令进行进栈和出栈的操作,相比之下,堆的操作由C/C++的库函数提供,机制复杂,需要多次嵌套,效率相比栈要低很多。

5.3 设计一个只能在堆上/栈上创建对象的类

只在堆上

只在堆上创建对象,做法就是将在栈上对象或者在栈上分配的方法屏蔽即可,因此只需要将构造函数私有化即可:

#include<iostream>
using namespace std;class Heap_Only
{public:             //Make member function public static void *Create(){return new Heap_Only;}
private:            //Make constructor privateHeap_Only(){}Heap_Only(const Heap_Only&)
}int main()
{Heap_Only *p = Heap_Only::Create();return 0;
}

只在栈上

只在栈上创建类时,只需屏蔽动态分配即可,因此将operator new 和 operator delete 私有化:

#include<iostream>
using namespace std;
class Stack_Only
{public:Stack_Only();~Stack_Only();
private:void *operator new (size_t);void *operator delete (void *);
}int main()
{Stack_Only p;return 0;
}

6. 其它常见问题讨论

6.1 单例模式

6.1.1 单例模式定义及使用场景

单例模式是一种常用的软件设计模式,之所以在这里提及这一点是因为不仅这是内存管理的一个实际案例,还是面试中经常涉及到的话题。

单例模式的定义是,类只能允许一个实例存在。

定义看起来很抽象,因此我们看一下利用单例模式设计软件的案例。我们日常使用的Windows系统中(高贵的Mac OS用户也有很多相同情形),打开“我的电脑”(win10的“此电脑”同样令人印象深刻),当我们尝试再打开一次我的电脑时,并不会给出一个新的我的电脑窗口。这种情况说明了,在整个操作系统的运行过程中,系统只维护了一个“我的电脑”的实例,这就是一个典型的单例模式的运用。

上述“我的电脑”的实例可以这样理解,在实际操作中我们并不需要打开两个“我的电脑”。每次调用“我的电脑”或者资源管理器的时候均需要调用大量内存,尤其是内存,同时“我的电脑”的资源是共享的,因此没有必要重复创建“我的电脑”的实例,增加系统的负担。

另一个使用单例模式的场景是网站计数器。每个用户的访问都会刷新计数器的值,如果此时有多个计数器的实例的话,光是实时同步各个计数器就需要浪费大量的资源。

综上,单例模式的存在适用于以下场景:

  1. 需要生成唯一序列的环境;
  2. 需要频繁实例化然后销毁的对象;
  3. 创建对象消耗时间或者资源过多,但又需要经常使用的对象;
  4. 方便资源相互通信的环境。

6.1.2 单例模式的优缺点

优点:

  1. 在内存中只有一个对象,节省内存空间;
  2. 避免频繁创建销毁对象,提高性能;
  3. 避免共享资源的多重占用;
  4. 为整个系统提供一个全局访问点。

缺点:

  1. 不适合频繁变化的对象;
  2. 滥用单例可能导致连接池溢出;
  3. 实例化的对象长时间不用会被认作垃圾而被回收。
    .

6.1.3 单例模式的实现

两个步骤:

  1. 将该类的构造函数私有化
    私有化构造函数使其它代码无法通过调用该类的构造函数来创建实例;

  2. 在该类中提供一个静态方法
    当我们调用这个静态方法时,通过该类创建的实例就是唯一的。

饿汉式

class Singleton_Hungry
{private: static Singleton_Hungry singleton = new Singleton_Hungry;Singleton_Hungry(){}static Singleton_Hungry getsingleton(){return singleton;}
}

优点:较为简单,在类装载的时候就完成实例化,避免了线程同步;
缺点:没有做到lazy loading,若实例不被使用,则会造成资源浪费。

懒汉式

class Singleton_Lazy
{private: static Singleton_Lazy singleton;Singleton_Lazy(){}
public: static Singleton_Lazy getsingleton(){if(singleton == NULL){singleton = new Singleton_Lazy();}return singleton;}
}

相比于饿汉模式,增加了对创建对象singleton的判空。这种被动创建的方式使得只有在需要使用时才被创建,单例实例被延迟加载,起到了lazy loading的效果。

懒汉模式的缺点也很明显,即它只能在单线程下使用。若在多线程下,线程进入if循环,还没来得及继续执行,下一个线程也同时(或者在同一个时钟)通过了if判断,那么就会创建不止一个实例。

多线程下单例模式方法

双重加锁机制
在多线程开发中,使用双重检测同步延迟加载去创建单例,这个思路不仅保证了单例,也切实提高了程序运行的效率。
静态初始化
利用公共语言运行库负责处理变量的初始化,也可以解决多线程环境下单例模式不安全的问题。

6.2 垃圾收集

6.2.1 垃圾收集的定义

垃圾收集是完全自动地检测并移除不再使用的数据对象的操作。与内存的分配器相对应,实现这种操作的程序称为垃圾收集器。

垃圾收集器会在内存减少到一个预定阈值时开始运行。垃圾收集器以程序可知可用的数据,如栈数据、全局变量、寄存器为出发点,尝试去追踪通过这些数据的每一块数据,最后没有经过的就是垃圾,收集器将它们销毁并重新使用这些数据。

为了有效管理内存,多数垃圾收集器都需要知道数据结构内指针的设计,因此,垃圾收集器必须是编程语言本身的一部分。

6.2.2 常见垃圾收集器的类型

  1. 复制
    这种垃圾收集器将内存分为两部分,数据仅驻留在其中一部分上。收集器开始工作时,从基本数据的内存上取出数据并复制到另一部分上。复制目标上的内存标记为活动中,将复制前的指针指向新位置,然后将原先部分上的剩余数据认为是垃圾并进行处理。

  2. 标记并清理
    将每一块数据设置一个标记flag。收集器遍历数据,若数据在内存中,则标记 1,若不再则标记为 0,将标记为 0 的认为是垃圾,以后分配内存时重新使用它们。

  3. 保守型
    保守的垃圾收集器不需要知道与数据结构相关的信息,它傻乎乎地认为所有数据类型都是指针,收集器将其标记并进行引用。没有引用的内存会被收集。其优势很明显,就是可以和任意编程语言集成。

7. 结语

为什么选择C++?对于作者这几乎是一个哲学问题。

选择C++的原因固然很多,但能够对内存进行有效管理,是这门语言的魅力与生命力的体现。

功利地思考,无论是日常考试面试,还是编程实践,内存的管理都是一个重要命题,希望各位阅读本文后能够对C++程序的内存管理有较为全面的认识和学习,并且产生自己的思考以及见解。在评论区大家可以自由发表意见,建议,批评,作者会根据这些金玉良言不断完善本文字,提前感激不尽。

最后附上各种内存内配策略的对比图,权当对本文的总结:

尽力全面的C++内存管理相关推荐

  1. 【linux】一篇全面的linux软件包管理的总结

    一篇全面的linux软件包管理的总结 文章目录 一篇全面的linux软件包管理的总结 零.开篇 一.查看软件包信息 (1-1)使用aptitude查看linux系统上安装了哪些软件包. (1-2)使用 ...

  2. 全网最全面的npm包管理学习

    包管理工具概述 本门博客的前置知识:JavaScript.ES6.模块化.git 本门博客的所有代码均书写在 nodejs 环境中,不涉及浏览器环境 概念 模块(module) 通常以单个文件形式存在 ...

  3. Spark Executor内存管理

    我们都知道 Spark 能够有效的利用内存并进行分布式计算,其内存管理模块在整个系统中扮演着非常重要的角色.为了更好地利用 Spark,深入地理解其内存管理模型具有非常重要的意义,这有助于我们对 Sp ...

  4. Spark 内存管理 spark.executor.memory /spark.memory.fraction/spark.memory.offHeap.size【堆外内存/内存管理】 钨丝计划

    spark1.6及之后: 堆内内存: spark.executor.memory 包含 spark.memory.fraction: spark.memory.fraction 包含 spark.me ...

  5. c++做题记录1 01:全面的MyString 查看提交统计提问 总时间限制: 1000ms 内存限制: 65536kB 描述 程序填空,输出指定结果

    001:全面的MyString 查看提交统计提问 总时间限制: 1000ms 内存限制: 65536kB 描述 程序填空,输出指定结果 #include #include using namespac ...

  6. [搬运工]移动游戏加载性能和内存管理全解析

    UWA 六月直播季 | 6.8 移动游戏加载性能和内存管理全解析 https://blog.uwa4d.com/archives/livebroadcast6-8.html 因为这篇文章没有提供PPT ...

  7. JVM——从源码编译到类执行与内存管理全流程梳理

    从Java源码编译开始说起 分为三个步骤: 1:分析和输入到符号表 分析:词法和语法分析,将代码字符串转变为token序列,由token序列生产抽象语法树 输入:将符号输入到符号表,确定类的超类型和接 ...

  8. php Excel工程进度管理,打造最全面的 PHPExcel 开发解决方案

    原标题:打造最全面的 PHPExcel 开发解决方案 过去工作中使用 PHPExcel较多,碰到并解决了各种大大小小的问题,总结出这样一篇文章,一方面记录自己踩过的坑,一方面与大家分享,让大家少走弯路 ...

  9. 万字最全Spark内存管理详解

    今天和大家介绍Spark的内存模型,干货多多,不要错过奥~ 与数据频繁落盘的Mapreduce引擎不同,Spark是基于内存的分布式计算引擎,其内置强大的内存管理机制,保证数据优先内存处理,并支持数据 ...

最新文章

  1. web编程速度大比拼(nodejs go python)(非专业对比)
  2. QIIME 2教程. 30补充资源SupplementaryResources(2020.11)
  3. PHP根据IP获取当前所在地地址
  4. 平板xmind怎么添加父主题_xmind 怎么插入子主题
  5. 为什么“消费降级”突然火了?数字基尼系数给你一点理论支撑
  6. springmvc框架原理分析
  7. 绕过图片防盗链的方法
  8. c语言中c4700在哪个位置,C语言单链表问题。。高手来啊warning C4700
  9. Java并发编程之CAS和AQS
  10. 实战Nginx与Perl、Java的安装与配置
  11. 使用SDM配置基于IPsec 加密的GRE隧道
  12. 计算机基础知识第三章答案,2011年河北省职称计算机模拟习题(基础知识第三章+标准答案)...
  13. Deepin系统标题栏及其按钮美化
  14. AliOS Things入门(1) 基于STM32L4与MDK搭建AliOS Things2.1.0开发环境
  15. 关于字符串中length与length()的区别
  16. 歇逼了兄弟,心态崩了
  17. 解决win10自带播放器 HEVC视频扩展 需付费方法
  18. 【高德地图API】从头德国高中生JS API(三)覆盖物——大喊|折线|多边形|信息表|聚合marker|点蚀图|照片覆盖...
  19. 远程控制软件如何实现两台电脑连接
  20. K8S在一个Pod中创建多个容器

热门文章

  1. 解释一下label中的写法:plt.plot(t, sig, b-, linewidth=2, label=r$\sigma(t) = \frac{1}{1 + e^{-t}}$)...
  2. 【源码】王者装逼工具/提升几倍的等级战力
  3. Microarchitecture: HyperThreading(超线程)
  4. go语言sql转struct在线工具
  5. 个人小程序开发有哪些限制?
  6. uniapp h5 海报
  7. 删了手机里的一个html文件,手机操作篇:手机上怎么删除pdf其中一页
  8. Git操作流程(非常详细)
  9. 无迹卡尔曼滤波估计SOC的simulink模型详解
  10. PCB设计时如何选择合适的叠层方案