C++内存管理

2021/5/6 7:28:02

本文主要是介绍C++内存管理,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

C++

目录
  • C++
    • 内存管理
      • 内存分区
        • 限制对象只在堆/栈上建立
        • 函数的传值方式
      • 内存分配
        • malloc/free
          • 内存分配原理
          • malloc内存分配过程
        • new operator和operator new
          • 重载
          • 内部
        • placement new
      • 内存计算
        • 结构体大小计算
          • 结构体定义
          • 结构体元素初始化
          • 内存对齐原则
        • 联合体大小计算
        • 类大小的计算
          • 空类大小
      • 指针
        • 获取地址
        • 访问地址
        • void*指针
      • 内存泄漏
      • 内存优化
        • Cache与缓存命中

内存管理

内存分区

img

data segment(数据段):存储程序中已初始化的全局变量和静态变量

bss segment(BSS段):存储未初始化的全局变量和静态变量(局部+全局),程序运行main之前时会统一初始化为0

memory mapping segment(文件映射区):存储动态链接库等文件映射、申请大内存(malloc时调用mmap函数)

一个由C/C++编译的程序占用的内存分为以下几个部分

分配效率方面:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。

  • 栈区stack

    • 编译器在需要的时候分配,在不需要的时候自动清除的变量的连续内存区域
    • 存储内容:里面的变量通常是函数的返回地址、参数、局部变量、返回值等
    • 分配方式:先进后出的队列,不会产生碎片,
    • 内存大小:栈顶的地址和栈的最大容量是系统预先规定好的,能从栈获得的空间较小。
    • 生长方向:从高地址向低地址增长
  • 堆区heap

    • 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。
    • 存储内容:一般头部1字节存放堆大小,具体内容由程序员决定
    • 分配方式:不同于数据结构的堆,系统是由链表在存储空闲内存地址,自然堆就是不连续的内存区域。
      • C/C++底层分配是malloc()和free()
      • 容易产生内存碎片
      • 分配顺序
        • 当系统收到程序的申请时,会遍历记录内存地址的链表,寻找第一个空间大于所申请的空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。
        • 对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样代码中的delete或free语句就能够正确的释放本内存空间
        • 由于找到的堆结点的大小不一定正好等于申请的大小,系统会将多余的那部分重新放入空闲链表中
    • 内存大小:堆的大小受限于计算机系统的有效虚拟内存空间,由此空间,堆获得的空间比较灵活
    • 在程序运行过程中可以动态增加堆大小(移动break指针)
    • 生长方向:从低地址向高地址增长(链表遍历方向)
  • 数据区:主要包括全局区和静态区,即存放全局变量和静态变量

    在以前的 C 语言中,全局变量和静态变量又分为

    静态数据成员按定义出现的先后顺序依次初始化,析构时的顺序是初始化的反顺序

    • 全局初始化区(DATA段) :存储程序中已初始化的全局变量和静态变量
    • 未初始化段(BSS段) :存储未初始化的全局变量和静态变量(局部+全局)。
  • 代码区

    • 常量区:即字符串常量,又叫只读存储区
    • 文本区:存储程序的机器代码。

在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句——保护断点,保存返回地址)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。

参数是由右往左入栈主要是为了支持可变长参数形式!

printf函数的原型是:printf(const char* format,…)

printf是一个不定参函数,在实际使用中编译器通过format中的%占位符的个数来确定参数的个数。

现在我们假设参数的压栈顺序是从左到右的,这时,函数调用的时候,format最先进栈,之后是各个参数进栈,最后pc进栈,此时,由于format先进栈了,上面压着未知个数的参数,想要知道参数的个数,必须找到format,而要找到format,必须要知道参数的个数,这样就陷入了一个无法求解的死循环了!!

而如果把参数从右到左压栈,函数调用时,先把若干个参数都压入栈中,再压format,最后压pc,这样一来,栈顶指针加2便找到了format,通过format中的%占位符,取得后面参数的个数,从而正确取得所有参数。

当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址——恢复断点,继续执行,也就是主函数中的下一条指令,程序由该点继续运行。

限制对象只在堆/栈上建立

限制对象只能建立在堆上:

构造函数设置为 protected,并提供一个 public 的静态函数来完成构造,而不是在类的外部使用 new 构造;将析构函数设置为 protected。原因:类似于单例模式,也保证了在派生类中能够访问析构函数。通过调用 create() 函数在堆上创建对象。

限制对象只能建立在栈上:

解决方法:将 operator new() 设置为私有。原因:当对象建立在堆上时,是采用 new 的方式进行建立,其底层会调用 operator new() 函数,因此只要对该函数加以限制,就能够防止对象建立在堆上。

函数的传值方式

指针和引用的不同之处:指针可以被重新赋值以指向另一个不同的对象。但是引用则总是指向在初始化时被指定的对象,以后不能改变。

  • 值传递
    • 被调函数的形参作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)
    • 特点:被调函数对形参的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值
  • 地址传递
    • 指针传递
      • 本质上是值传递,此时的形参是一个新的局部指针对象,指向传入的地址,无法改变传入的地址值(即主调函数的实参变量)
      • 但指针传递相比值传递来说,可以改变传入地址所存储的值
      • 浅拷贝,在函数里分配内存是分配在临时局部指针对象变上的,函数外面的指针不受影响
    • 引用传递
      • 被调函数的形参也作为局部变量在栈中开辟了内存空间,但此时存放的是由主调函数放进来的实参变量的地址。
      • 由于引用就是对象的一个别名,因此不需要拷贝对象,减小了开销。这同时也导致可以通过修改引用直接修改原始对象(毕竟引用和原始对象其实是同一个东西),因此,大多数时候,推荐函数参数设置为pass-by-reference-to-const。给引用加上底层const,既可以减小拷贝开销,又可以防止修改底层所引用的对象。
      • 特点:被调函数对形参的任何操作都会影响主调函数中的实参变量,都被处理成间接寻址

内存分配

分配 释放 可否重载
malloc free 不可
new delete 不可
operator new operator delete
new[] delete[] 不可
operator new[] operator delete[]

delete[]时,数组中的元素按逆序的顺序进行销毁

那么如何知道调用多少次析构函数呢?

C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了

malloc/free

在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk、mmap、munmap这些系统调用实现的。

内存分配原理

从操作系统角度来看,进程分配内存有2种方式,分别由2个系统调用完成:brk和mmap(不考虑共享内存)。

  1. brk是将数据段(.data)的最高地址指针_edata往高地址推
  2. mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。

这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。

malloc内存分配过程
  • 小于128K的内存分配
    • 使用brk分配内存,将_edata往高地址推(只分配虚拟空间,不对应物理内存(因此没有初始化)
    • 第一次读/写数据时,引起内核缺页中断,内核才分配对应的物理内存,然后虚拟地址空间建立映射关系)
  • 大于128K内存分配
    • 使用mmap分配内存,在堆和栈之间找一块空闲内存分配(对应独立内存,而且初始化为0)
    • 因为:brk分配的内存需要等到高地址内存释放以后才能释放(真正的内存释放-内存紧缩)
    • 而如果低地址内存被free掉,则仍然存在,且可重用(即如果新需求大小小于该大小,这也是内存碎片的由来)
  • 内存紧缩操作(trim)
    • 默认情况下:当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。
    • 即将_edata往低地址缩,真正释放空闲内存
  • 缺页中断
    • 当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:
      1. 检查要访问的虚拟地址是否合法
      2. 查找/分配一个物理页
      3. 填充物理页内容(读取磁盘,或者直接置0,或者啥也不干)
      4. 建立映射关系(虚拟地址到物理地址)
      5. 重新执行发生缺页中断的那条指令

new operator和operator new

当我们使用new进行内存分配时,new是所谓的new operator。该操作符意义无法改变,一直做两件事:

  1. 调用operator new,分配足够的内存
  2. 调用constructor,为分配的内存中的对象设定初值

new与malloc区别

  • new 申请空间时,无需指定分配空间的大小,编译器会根据类型自行计算;malloc 在申请空间时,需要确定所申请空间的大小
  • new 申请空间时,返回的类型是对象的指针类型,无需强制类型转换,是类型安全的操作符;malloc 申请空间时,返回的是 void* 类型,需要进行强制类型的转换,转换为对象类型的指针。
  • free和malloc不会调用析构函数和构造函数,new和delete会
  • malloc申请内存失败返回null,new申请内存失败返回std::bad_alloc

new operator和delete operator(即new和delete)是C++内建操作符,无法修改行为。我们能够改变的是分配内存那个行为operator new

Base *b1= new Base(1,2);
//等价于
Base *b1;
try
{
    //2,3可调换顺序
	//1.先分配内存,返回原始内存,底层调用malloc
	void *temp=operator new(sizeof(Base));
	//2.转型
	b1=static_cast<Base*>(temp);
	//3.只能由编译器进行调用构造函数
	b1->Base:Base(1,2)
    //若想直接调用ctor,可以用new(p)Base(1,2)
}
catch(std::bad_alloc)
{
	//失败情况
}
delete b1;
//等价于
b1->~Base();
operator delete(b1); //底层调用free

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_value) throw();
//placement new
void* operator new (std::size_t size, void* ptr) throw();
重载

可以重载operator new,加上额外参数,但第一参数类型必须总身size_t

这里的重载遵循作用域覆盖原则,即在里向外寻找operator new的重载时,只要找到operator new()函数就不再向外查找,如果参数符合则通过,如果参数不符合则报错,而不管全局是否还有相匹配的函数原型。

operator delete也可以重载,不过它的第一参数是void*,返回值为void。

  • operator delete的自定义参数重载并不能手动调用,只能老老实实delete p

  • 如果没有给出operator new对应的operator delete,编译器将会警告或出现异常行为

内部

operator new内部包含一个无限循环,跳出循环的办法有4种,分别为:

  • 得到了更多的可用内存
  • 安装了一个新的new_handler(出错处理函数)
  • 卸除了new_handler,抛出了一个std::bad_alloc或其派生类型的异常
  • 返回失败。

operator new在无法完成内存分配请求时,会在抛出异常之前调用客户指定的一个出错处理函数new_handler函数。new_handler指向一个没有输入参数也没有返回值的函数

typedef void (*new_handler());
new_handler set_new_handler(new_handler p)
{
  throw();
}

set_new_handler可以在malloc(需要调用set_new_mode(1))或operator new内存分配失败时指定一个入口函数new_handler

这个函数完成自定义处理(继续尝试分配,抛出异常,或终止程序),如果new_handler返回,那么系统将继续尝试分配内存,如果失败,将继续重复调用它,直到内存分配完毕或new_handler不再返回(抛出异常,终止)。

void handleBad{}			//定义入口函数
_set_new_mode(1);  			//使new_handler有效
set_new_handler(handleBad);	//指定入口函数 函数原型void f();

placement new

源代码

inline void *__cdecl operator new(size_t, void *_P)
{return (_P); }

它虽然只是返回指针,但它可以实现在ptr所指地址上构建一个对象(通过调用其构造函数),这在内存池技术上有广泛应用。

即上面所说的想直接调用ctor时,用的就是placement new,调用方式

new(p)className();//可以带参数
//等价于1.调用placement new,2.在p上调用className:className()

使用placement new在某内存块里产生对象,那么应该避免对其使用delete operator

内存计算

结构体大小计算

结构体定义
struct 结构体名
{
	成员列表;
}变量列表;

typedef  struct 结构体名
{
	成员列表;
}别名;

struct
{
    成员列表;
}变量列表;
结构体元素初始化
  • 定义结构体类型、定义结构体变量与初始化三者同时进行
    • 变量列表={......}
  • 定义结构体类型之后,定义结构体变量并初始化
    • struct(C语言才需要) 结构体名 变量={......}
  • 指定元素初始化,在{}内用'.'运算符指定元素
  • 定义变量后,再初始化

编译阶段,系统只会为结构体变量分配内存空间,而不会为结构体类型分分配内存单元

内存对齐原则

为何需要内存对齐

性能原因

​ CPU的字长,是CPU每次到内存中存取的数据的长度。
​ 在访问未对齐的内存时,处理器需要访问两次,而对齐的内存处理器只需要访问一次。

​ 内存字节对齐机制为了最大限度的减少内存读取次数,CPU读取速度比内存读取速度快至少一个数量级,所以是以空间换时间

平台原因

​ 不是所有的硬件平台都能访问任意地址的任意数据

未指定对齐系数时:

  • 第一个成员在与结构体变量偏移量(offset)为0的地址处。
  • 其他成员变量要对齐到对齐数的整数倍的地址处。
  • 结构体总大小为最大对齐数(每个成员变量除了第一个成员都有一个对齐数)的整数倍。
  • 总的来说对齐系数=长度最大的成员的长度
  • 若一次未装满,但后面的元素大小超过剩余空间,则剩余空间只能进行填充
  • 对齐系数×装的次数=总行李大小(结构体大小),

指定对齐系数时

  • 手动指定内存对齐

    #pragma pack(n);//n=1,2,4,8,16
    

    每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,比较小的那个进行

  • __attribute__((aligned(n)))
    

    放于结构体成员后面可单独改变该成员的m值

联合体大小计算

各成员共用一块内存空间,并且同时只有一个成员可以得到这块内存的使用权,各变量共用一个内存首地址

结构体是各成员各自拥有自己的内存,同时存在的

大小计算准则

  • 至少要容纳最大的成员变量
  • 必须是所有成员变量类型大小的整数倍
union un
{
	char a;
	int b;
	short c;
	double d;
	int e[5];
};
//大小为24

用处:使用union检查系统大小端模式

类大小的计算

类所占内存的大小主要是由成员变量(静态变量除外)决定的,成员函数(虚函数除外)是不计算在内的。

即类内存大小=成员变量+虚表指针(虚函数表)+虚偏移量表指针(虚继承)

类的内存计算与struct的对齐原则类似

子类的大小是本身成员变量的大小加上父类的大小

空类大小

空类占用内存空间是1

因为c++要求每个实例在内存中都有独一无二的地址。所以编译器隐含地为空类增加一个字节

指针

指针包含两部分信息:所指向的值和类型信息。

  • 指针的值:即是所指向元素的地址
  • 指针的类型信息:类型信息决定了这个指针指向的内存的字节数并如何解释这些字节信息

void* 指针是一种特殊的指针类型,可用于存放任意对象的地址,但是丢失了类型信息,解引用之前必须正确类型转换

指针类型

  • 二级指针,指向指针的指针
  • 指针数组,存储指向类型为type的指针的数组
  • 数组指针,指向含有一定数量的元素的数组的指针
  • 函数指针,指向函数的指针
  • 指针函数:指针函数本质是一个函数,只不过该函数的返回值是一个指针
int** p_pointer; 	//指向 一个整形变量指针的指针

int *ptr[3];		//存储三个指向int元素的指针数组

int(*p_arr)[3]; 	//指向含有3个int元素的数组指针 

int(*p_func)(int,int); 	//指向返回类型为int,有2个int形参的函数的指针  

获取地址

一般用取地址符号&

指针之间的赋值是一种浅拷贝,即指向同一个地址

指针值+1,编译后会让1乘上了单位sizeof(type)

特殊情况

  1. 数组名的值就是这个数组的第一个元素的地址
  2. 函数名的值就是这个函数的地址
  3. 字符串字面值常量作为右值时,就是这个字符串对应的字符数组的名称,也就是这个字符串在内存中的地址

访问地址

使用解引用符(*)来访问该对象

对于结构体和类,则使用->符号访问内部成员:

void*指针

  • void*指针在C与C++的区别:C语言中可以隐式转换,C++不行

  • void*指针数组在delete[]后只会释放指针内存,不会调用析构函数

内存泄漏

对于C++的内存泄漏,总结一句话:就是new出来的内存在不需要的时候没有通过delete合理及时的释放掉!

内存泄漏不是系统无法回收那片内存,而是你自己的应用程序无法使用那片内存。

当你程序结束时,你所有分配的内存自动都被系统回收,不存在泄漏问题。但是在你程序的生命期内,如果你分配的内存都不回收,你将很快没内存使用,这才是平时所指的内存泄漏问题。

常见情况

  • 不匹配使用new[] 和 delete[]
  • delete void * 的指针,导致没有调用到对象的析构函数,析构的所有清理工作都没有去执行从而导致内存的泄露;
  • 没有将基类的析构函数定义为虚函数,当基类的指针指向子类时,delete该对象时,不会调用子类的析构函数
    • 析构函数是一个特殊的函数,编译器在编译时,析构函数的名字统一为destucter,所以只要父类的析构函数定义为虚函数,不管子类的析构函数前是否加virtual(可以理解为编译器优化),都构成重写

操作系统本身就有内存管理的职责,一般而言,用malloc、new操作分配的内存,在进程结束后,操作系统是会自己的回收的。

  • 不要手动管理内存,可以尝试在适用的情况下使用智能指针。
  • 使用string而不是char*。string类在内部处理所有内存管理,而且它速度快且优化得很好。
  • 任何需要动态内存的东西都应该隐藏在一个RAII对象中,当它超出范围时释放内存。RAII在构造函数中分配内存并在析构函数中释放内存,这样当变量离开当前范围时,内存就可以被释放。
    (注:RAII资源获取即初始化,也就是说在构造函数中申请分配资源,在析构函数中释放资源)
  • 使用了内存分配的函数,要记得使用其想用的函数释放掉内存。可以始终在new和delete之间编写代码,通过new关键字分配内存,通过delete关键字取消分配内存。

内存优化

Cache与缓存命中

Cache就是缓存,一般用在慢速设备和快速设备之间,目的是方便快速存取。

处理器和内存之间,存在着巨大的速度差异,提前将数据从内存载入到Cache中,在需要的时候,直接从Cache中获取,因为Cache的速度更快,这样就提高了CPU的处理能力。

Cache之所以能提高系统性能,主要在于程序执行具有局部性现象,包括时间局部性和空间局部性。

  • 时间局部性是指,程序即将用到的数据和指令可能就是目前正在使用的数据和指令。利用时间局部性,可以将当前访问的数据和指令存放到Cache中,以便将来使用,比如C++语言中的for、while循环、递归调用等。

  • 空间局部性是指,程序即将用到的数据和指令可能与目前正在使用的数据和指令在地址空间上相邻或者相近。

利用空间局部性,可以在处理器处理当前数据和指令时,把内存中相邻区域的指令/数据读取到Cache中,以备将来使用,比如数组访问、顺序执行的指令等。局部性原理也符合80-20原则,即程序20%的代码占用了处理器百分之八十的执行时间,占用了80%的内存。Cache预取就是根据局部性原理,预测数据和指令使用情况,并提前载入到Cache中,这样,当数据/指令需要被使用时,就能快速从Cache中获取到,而不需要访问内存。



这篇关于C++内存管理的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程