C++内存管理与分配相关知识点总结

八股的一部分

Posted by 谢玄xx on April 8, 2022

c++的指针:先在栈中存指针a,再向堆中申请内存空间存该指针所指向的元素,然后a指向该地址。

参考链接: C/C++内存分配管理——作者HUST_Miao

一、写在前面——单片机/DSP等硬件开发与纯软开发的区别

  • 单片机是没有操作系统的,所以每次写完代码,都需要借助烧写器把程序烧录进去,这样程序才能跑起来。
  • 单片机的CPU是直接操作内存的物理地址。在这种情况下,要想在内存中同时运行两个程序,是不可能的(会立即崩溃)。如果第一个程序在地址为2000的位置写入一个新的值,将会“擦掉”第二个程序存放在相同地址的所有内容。这里的关键问题在于,两个程序都引用了绝对物理地址,这是我们需要避免的。

如何避免物理地址冲突?

  • 我们可以把进程所使用的地址分离开,即让操作系统为每个进程分配独立的一套虚拟地址。这个虚拟地址“人人都有”,大家互不干涉。但前提是每个进程都不能访问物理地址。虚拟地址怎么落到物理内存里,对进程来说都是透明的。
  • 操作系统已经考虑到这一点了——操作系统会提供这样一种机制,将不同进程的虚拟地址和不同内存的物理地址产生特定的映射关系。如果程序要访问虚拟地址,就会由操作系统转换成不同的物理地址。这样的话,当不同的进程运行的适航,写入的是不同的物理地址,这样就不会冲突了。

二、内存概述

在C++中内存分为5个区,分别是自由存储区全局/静态存储区常量存储区

  • :堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。

  • :在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

  • 自由存储区:自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。

  • 全局/静态存储区:这块内存是在程序编译的时候就已经分配好的,在程序整个运行期间都存在。例如全局变量,静态变量。

  • 常量存储区:这是一块比较特殊的存储区,他们里面存放的是常量(const),不允许修改。


三、内存分配的方式

  1. 静态存储区分配

内存在程序编译的时候就已分配好,这块内存在程序的整个运行空间内都存在,如静态变量全局变量等。

  1. 栈空间分配

程序在运行期间,函数内的局部变量通过栈空间(函数调用栈的方式)来分配存储。当函数执行完毕return时,栈空间就会被立即回收,如局部变量。

  1. 堆空间分配

运行程序时,主要通过堆空间为其分配内存空间。通过malloc和new创建的对象都是从堆空间分配内存。这类空间则需要我们程序员自己管理,即用多少开多少,不用的时候及时释放掉。否则就可能会造成内存溢出的后果。

四、常见的内存错误及解决方法

  1. 内存分配未成功,却使用了它。

    解决方法:在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。如果是用malloc来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。如果是用new来申请内存,申请失败是会抛出异常,所以应该捕捉异常来进行放错处理。

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

    解决方法:尽管有时候缺省时会自动初始化,但是无论创建什么对象均要对其进行初始化,即便是赋零值也不可省略,不要嫌麻烦。

  3. 内存分配成功,但越界访问。

  解决方法:对数组for循环时要把握边界,否则可能会导致数组越界。

  1. 忘记了释放内存,导致内存泄漏。

  解决方法:动态内存的申请和释放必须配对,new-delete和malloc-free且使用次数必须相同。


五、malloc关键字

  • malloc()函数的头文件是stdlib.h,其函数声明如下: void* malloc(size_t size); 其中参数size_t size表示动态内存分配空间的大小,以字节为单位。

  • size_t 是typedef重定义的类型,重定义这样数据类型的作用就是让使用者一目了然,指示使用者这个参数表示一个长度,在size后加上t,表示是整型相关数据类型的,以后看到xxx_t的类型,通常都是整型相关数据类型重定义。 在这里malloc()函数的返回值是一个指针,或者说是分配后内存空间的首地址

  • malloc() 分配的是虚拟内存。

5.1 malloc 是如何分配内存的?

malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。

方式一:通过 brk() 系统调用从堆分配内存;
方式二:通过 mmap() 系统调用在文件映射区域分配内存;

方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。

方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。

  • 如果malloc()函数申请空间成功则返回一段内存空间的首地址,失败则返回NULL。

5.2 什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存?

malloc() 源码里默认定义了一个阈值:

  • 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
  • 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;

5.3 free 释放内存,会归还给操作系统吗?

  • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;
  • malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。

六、new与malloc的区别

提到内存分配,就不可避免地涉及new和malloc。

  1. 申请的内存所在位置 new操作符从自由存储区(3)上为对象动态分配内存空间,而malloc函数从堆上(1)动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。

那么自由存储区是否能够是堆(问题等价于new是否能在堆上动态分配内存),这取决于operator new 的实现细节。自由存储区不仅可以是堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。

特别地,new甚至可以不为对象分配内存!

  1. 返回类型安全性 new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void* ,需要通过强制类型转换将void* 指针转换成我们需要的类型。 类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图方法自己没被授权的内存区域。关于C++的类型安全性可说的又有很多了。

  2. 内存分配失败时的返回值 new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。 在使用C语言时,我们习惯在malloc分配内存后判断分配是否成功.

  3. 是否需要指定内存大小 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算,而malloc则需要显式地指出所需内存的尺寸。

  4. 是否调用构造函数/析构函数 使用new操作符来分配对象内存时会经历三个步骤:

    • 第一步:调用operator new 函数(对于数组是operator new[])分配一块足够大的,原始的,未命名的内存空间以便存储特定类型的对象。
    • 第二步:编译器运行相应的构造函数以构造对象,并为其传入初值。
    • 第三步:对象构造完成后,返回一个指向该对象的指针。

使用delete操作符来释放对象内存时会经历两个步骤:

  • 第一步:调用对象的析构函数。
  • 第二步:编译器调用operator delete(或operator delete[])函数释放内存空间。

总之,new/delete会调用对象的构造函数/析构函数以完成对象的构造/析构。而malloc则不会。


七、内存泄漏

7.1 什么是内存泄漏?

指程序未能释放已经不用的内存的情况,即应用程序分配某段内存后,失去了对该段内存的控制,从而造成内存浪费的现象。

7.2 内存泄漏的危害

简而言之,内存泄漏使得程序响应越来越慢,最终卡死。

7.3 内存泄露的方式

内存泄漏分为两类:堆内存泄露系统资源泄露

  • 堆内存泄露

“堆内存”指的是程序执行中使用malloc/new等从堆中分配的一块内存(值得注意的是,int a这样的内存是从栈内分配的),用完后必须free/delete掉。假设没有,就会出现堆内存泄露现象。

  • 系统资源泄露

“系统资源”指的是使用系统分配的资源,如套接字、文件描述符等。它们没有使用对应的函数释放掉,导致系统资源浪费。严重的会导致系统效能减少,从而导致系统不稳定。

7.4 如何避免内存泄漏

  1. 当然是养成良好的工程设计规范,申请了内存空间记得释放。但必须注意的是,有时候释放了也会出问题..具体情况具体分析吧!
  2. 使用内部实现的私有内存管理库(有些企业自己会做这种库,形成自己的平台代码体系)。这种库带有内存泄漏检测功能。
  3. “事后检查”——使用现成的内存泄漏检测工具(不过一般不太靠谱..)
  4. “事前预防”——采用RAII思想或智能指针来管理资源。智能指针详见我的博文:智能指针专题

八、相关问题

32位和64位CPU的区别是什么? 32 位和 64 位软件的区别是什么?

  • 32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据

32 位 CPU 一次可以计算 4 个字节的数据;
64 位 CPU 一次可以计算 8 个字节的数据;
这里的 32 位和 64 位,通常称为 CPU 的位宽

之所以 CPU 要这样设计,是为了能计算更大的数值。举个栗子,如果是 8 位的 CPU,那么一次只能计算 1 个字节 0~255 范围内的数值,这样就无法一次完成计算 10000 * 500。(但请注意,8位CPU可以支持最大 255 * 255 的运算,因为 8 位 CPU 可以一次读入 8 位的数字,并且 8 位 CPU 内部的逻辑运算单元也支持 8 位数字的计算。)于是为了能一次计算大数的运算,CPU 需要支持多个 byte 一起计算,所以 CPU 位宽越大,可以计算的数值就越大。比如说 32 位 CPU 能计算的最大整数是 4294967295。

CPU 内部还有一些组件,常见的有寄存器、控制单元和逻辑运算单元等。其中,控制单元负责控制 CPU 工作,逻辑运算单元负责计算,而寄存器可以分为多种类,每种寄存器的功能又不尽相同。

  • 软件位不同,实际上代表指令不同(是 64 位还是 32 位的)。

如果 32 位指令在 64 位机器上执行,需要一套兼容机制,就可以做到兼容运行了。但是如果 64 位指令在 32 位机器上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令;
操作系统其实也是一种程序,我们也会看到操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是操作系统中程序的指令是多少位,比如 64 位操作系统,指令也就是 64 位,因此不能装在 32 位机器上。

  • 总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽。

CPU是如何执行程序的?

  • 第一步,CPU 读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。
  • 第二步,CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行;
  • 第三步,CPU 执行完指令后,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4;