🌽动态内存管理
2022-5-30
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property

 

C/C++内存分布

在计算机中,每个应用程序之间的内存是相互独立的,通常情况下应用程序 A 并不能访问应用程序 B,当然一些特殊技巧可以访问。例如在计算机中,一个视频播放程序与一个浏览器程序,它们的内存并不能访问,每个程序所拥有的内存是分区进行管理的。
在计算机系统中,运行程序 A 将会在内存中开辟程序 A 的内存区域 1,运行程序 B 将会在内存中开辟程序 B 的内存区域 2,内存区域 1 与内存区域 2 之间逻辑分隔。
notion image
在程序 A 开辟的内存区域 1 会被分为几个区域,这就是内存四区,内存四区分为栈区、堆区、数据区与代码区。
notion image
  • 栈区(stack)指的是存储一些临时变量的区域,临时变量包括了局部变量、返回值、参数、返回地址等,当这些变量超出了当前作用域时将会自动弹出。该栈的最大存储是有大小的,该值固定,超过该大小将会造成栈溢出。
    • 执行函数时,函数内部局部变量的存储单元都可以在栈上创建函数执行结束后这些存储单元会被自动释放。栈内存分配运算内置于处理器的指令集中,拥有很高的效率,但是分配的内存容量是有限的。
栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。
  • 堆区(heap)指的是一个比较大的内存空间,主要用于对动态内存的分配;堆是可以上增长的。在程序开发中一般是开发人员进行分配与释放,若在程序结束时都未释放,系统将会自动进行回收。其分配方式类似于链表。
  • 数据区指的是主要存放全局变量、常量和静态变量的区域,数据区又可以进行划分,分为全局区与静态区。全局变量与静态变量将会存放至该区域。程序结束后由系统释放。
  • 代码区主要是存储可执行代码,存储函数体(类的成员函数、全局函数)的二进制代码,该区域的属性是只读的。
    • 一个程序起来之后,会把它的空间进行划分,而划分是为了更好地管理。函数调用,函数里可能会有很多变量,函数调用建立栈帧,栈帧里存形参、局部变量等等。
  • 内存映射段(memory mapping),是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享共享内存,做进程间通信。
notion image
 
 

内存泄漏

内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。(内存泄漏是指针丢了)
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死
 
C/C++程序中一般我们关心两种方面的内存泄漏:
  • 堆内存泄漏(Heap leak)
    • 堆内存指的是程序执行中依据须要分配通过malloc/calloc/realloc/new等从堆中分配的一块内存,用完后必须通过调用相应的free或者delete删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak
  • 系统资源泄漏
    • 指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
 
 

C语言中动态内存管理的方式

在C语言中提供了一些动态内存开辟的函数:mallocfreecallocrealloc
notion image

malloc

其中size是要向内存申请的空间的大小,单位为字节。
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。如果开辟成功,则返回一个指向开辟好空间的指针;如果开辟失败,则返回一个NULL指针。因此,一定要对malloc函数的返回值进行检查。
malloc函数的返回值是void*的,因为malloc函数并不知道要开辟空间的类型,具体在使用该函数的时候由使用者自己决定。如果参数size为0,这时malloc函数的行为是标准未定义的,取决于编译器。
 
 

free

free函数是用来释放动态开辟的内存的。就是将ptr所指向的空间的使用权还给操作系统了,就不再属于我当前系统了但是free函数并没有将ptr的值改变   即ptr还是指向那块空间的。释放空间之后的ptr就是一个野指针了,所以要在释放时候将ptr置为空指针。
如果参数ptr所指的空间不是动态内存开辟的,这时free函数的行为是标准未定义的,取决于编译器。
如果参数ptr是一个空指针   则free函数什么也不做。
当使用完一块动态开辟的空间之后没有释放的时候,如果程序结束,动态申请的内存由操作系统自动回收,但是如果程序不结束,动态开辟的内存是不会自动回收的,这时候就会造成内存泄漏的问题。
 
 

calloc

calloa函数的功能是为num个大小为size的元素开辟一块空间,并且把每一个字节都初始化为0
 

realloc

realloc函数的功能是可以做到对动态开辟内存的大小进行调整,ptr是要调整的空间的地址,size是调整之后的大小,realloc函数的返回值是调整之后的内存空间的起始地址(如果扩容失败,会返回一个空指针)。
 
realloc函数在扩容的时候在内存中会出现两种情况:
  1. 原空间(即参数ptr所指的空间)小于扩容后的大小(即size)
    1. 这时realloc函数会在堆区的另外位置找到一块符合扩容大小的空间,并且将原来空间中的内容挪到新开辟的那块空间中去。
  1. 原空间(即参数ptr所指的空间)大于等于扩容后的大小(即size):
    1. 这时realloc函数就不需要再在堆区中再找空间,而是可以直接在原空间之后继续进行追加空间进行扩容的操作。
所以,在使用realloc函数进行扩容之后,一定要对其返回值做检查,而不是直接使用那块“扩容好”的空间或者直接将原空间的指针置为新开辟的空间(这样一旦扩容失败,原有的数据也找不到了)。
 

C++动态内存管理方式

C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力而且使用起来比较麻烦。为了解决这种问题,C++又进化出属于自己的内存管理方式。通过newdelete操作符进行动态内存管理。new 用于分配,delete 用于释放。
使用这两个操作符进行内存管理比之智能指针是更为易错的方式。并且,自己管理内存的类不能依赖于默认定义的拷贝、赋值和析构函数。因而,使用智能指针的程序将更加容易书写和调试。
 

使用new动态分配和初始化对象

在堆上分配的对象是无名的,new没有任何方式可以给其分配的对象取名。new返回一个指向其分配的对象的指针:
默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象是未定义的。类类型对象将执行默认构造函数进行初始化。
 
初始化动态分配对象可以使用直接初始化形式,可以使用传统构造方式——括号形式,亦可以使用新标准下的列初始化形式(括弧形式):
 
如果在动态分配的对象后跟随一对空括号或空括弧,对象将是值初始化的。对于内置类型则为0,对于类类型对象则调用其默认构造函数:
总是初始化动态分配的对象是一个好的习惯。
 
有时候可以在等号的右边使用 auto ,当使用new操作符时,而且括号中的只有一个参数时,编译器使用括号中的值类型去推断生成的动态对象的类型。此时将使用传入的参数去初始化动态对象,它们具有一样的值和类型:
 

动态分配const对象

new分配const对象是合法的:
所有的常量,包括动态分配的常量都必须进行初始化。对于定义了默认构造函数的类类型的动态对象可以调用其默认构造函数进行隐式初始化,所以可以不用提供初始值。而任何其它类型,特别是内置类型以及没有默认构造函数的类类型都必须进行显式初始化。由于分配的对象是const的,所以返回的指针亦是指向const的对象。
 

内存耗尽

当内存耗尽时,new操作符会抛出bad_alloc异常。可以通过使用另一种形式的new来禁止new抛出异常,这种新形式的new称为定位 new(placement new),这种新的表达式可以传递额外的参数给new
这里传递给new一个标准库中的对象nothrow,这个对象定义在new头文件中。nothrow 对象告诉new一定不要抛出任何异常,如果无法分配内存时就返回空指针。
 

delete释放动态内存

C++ 使用delete操作符来释放不再使用的动态内存,其操作数是一个指向需要释放的动态对象的指针:
delete表达式做了两件事:析构指针所指向的对象,释放其内存
 
 

指针和 delete

传递给 delete 的指针必须指向动态分配内存或者是空指针。删除一个不是由 new 分配的内存指针,或者删除一个指针两次,结果将是未定义的。
通常编译并不知道一个指针指向的是静态的还是动态分配的对象。编译器也无法知道指针指向的对象是否已经被释放过了。几乎所有编译器都认为删除指针是合法的,即便它们本身的确是错误的。
 
尽管动态分配的const对象本身不能改变,对象却是可以被销毁的。通过在const动态对象的指针上调用delete将回收其内存。
 

动态分配的对象只能通过delete进行释放

直接通过new分配的动态内存是不会自动回收的。由内置指针管理的动态对象只有显式删除才会回收内存。直接返回动态内存指针的函数将释放的内存的责任交给了调用者,调用者必须记得回收这一部分内存。
与类类型不同的事,当内置类型对象销毁时不会发生任何事情。特别是,当一个指针离开作用域,不会在其指向的对象上发生任何事情。如果指针指向了动态内存,这块内存不会自动释放。
 
newdelete 来管理内存有三个易错的地方:
  1. 忘记 delete 内存,从而造成内存泄漏;测试内存泄漏是十分困难的,几乎只有在运行足够长并且内存被耗尽时才能发现
  1. 在动态对象被删除之后依然还在使用,这种错误可以通过在释放内存后将指针设为空来解决;
  1. 删除同一块内存两次;这在同时有两个指针指向同一块动态内存时可能会发生。如果其中一个指针已经被删除了,然而其它的指针并不知道这块内存已经被删除了。这种错误通常很容易发生,但是很难定位;
通过在任何情况下都使用智能指针可以避免以上的错误,智能指针只有在没有其它智能指针仍然在指向这块动态内存时,才会销毁这块内存。
 
 

在删除指针所指向的对象后,重置指针

当删除一个指针时,指针将变得无效。尽管指针已经无效了,在很多机器上指针依然保有那个已经被释放的内存的地址。此时指针已经变成了空悬指针(dangling pointer),即一个指向不存在的对象的指针。空悬指针的问题与未初始化的指针是一样的。通过删除内存后nullptr赋值给指针,可以保证当删除后指针不指向任何对象。
尽管这样做了依然不能彻底解决问题,原因在于还有别的指针可能会指向相同的内存。仅仅只重置那个删除内存的指针,并不会对其它指针造成任何影响,这样其它指针依然可能会被错误使用,从而访问已经被删除的对象。在真实系统中想要找出所有的指向同一个内存的指针是非常困难的。
 
动态内存的一个基本问题是可能有多个指针指向相同的内存。在delete内存之后重置指针的方法只对这个指针有效,对其他任何仍指向(已释放的)内存的指针是没有作用的
本例中pq指向相同的动态分配的对象。delete此内存,然后将p置为nullptr,指出它不再指向任何对象。但是,重置pq没有任何作用,在释放p所指向的(同时也是q所指向的!)内存时,q也变为无效了。在实际系统中,查找指向相同内存的所有指针是异常困难的。
 
 
 

C++动态内存管理的优势

malloc/freenew/delete对于内置类型没有本质区别,那么它存在的意义是什么呢?
对于自定义类型,malloc 和 new 的对比:
同样是申请单个A对象和5个对象数组,C++写法明显是是更简单。new不仅会开内存,还会调用对应的构造函数初始化。
 
free 与 delete 的对比
相对的,free只是把 p1、p2 指向的空间释放掉。
delete不仅会释 p1、p2 指向的空间,delete还会调用对应的析构函数。
 

new 和 delete 的底层探索

 

operator new 与 operator delete 函数

operator new实际上也是通过 malloc 来申请空间的;operator delete最终也是通过free来释放空间的。
如果malloc申请空间成功就直接返回,否则执行用户提供的空间不足的应对措施,如果用户提供该措施就继续申请,否则就抛异常。
 
面向过程的语言处理错误的方式:返回值 + 错误码解决
 
而面向对象语言处理错误的方式:一般是抛异常,C++中也要求出错抛异常 —— try catch
 
C++提出newdelete,主要是解决两个问题:
  • 自定义类型对象自动申请的时候,初始化合清理的问题。new/delete会调用构造函数和析构函数。
  • new失败了以后要求抛异常,这样才符合面向语言的出错处理机制。
delete和free 一般不会失败,如果失败了,都是释放空间上存在越界或者释放指针位置不对
针对链表的节点 ListNode 通过重载类专属 operator new / operator delete,实现链表节点使用内存池申请和释放内存,提高效率。
按照C的方式写:
创建节点还需要用 malloc 申请空间,还需要强制类型转换,之后还要自己写上初始化,因为 malloc 失败返回 NULL,会存在野指针隐患,所以出于安全还要检查一下。
C++ 的方式:
在C++里,因为 new 会自动调用构造函数去完成初始化,就很舒服。而且还不需要去检查是否开辟失败,因为 new 失败不会返回空,而是抛异常。
 

new 和 delete 的实现原理

如果申请的是内置类型的空间,new 和 malloc,delete 和 free 基本相似。不同的地方是,new / delete 申请和释放的是单个元素的空间,new[] 和 delete[] 申请的是连续空间。而且 new 再申请空间失败时会抛异常。
operator new 和 operator delete 就是对 malloc 和 free 的封装。operator new 中调用 malloc 后申请内存,失败以后,改为抛异常处理错误,这样符合C++面向对象语言处理错误的方式。
notion image
 
对于自定义类型
new 的原理:
  1. 调用 operator new 函数申请空间。
  1. 在申请空间上执行构造函数,完成对象的构造。
 
delete 的原理:
  1. 在空间上执行析构函数,完成对象中资源的清理工作
  1. 调用 operator delete 函数释放对象的空间
 
new T[N] 的原理:
  1. 调用 operator new[] 函数,在 operator new[] 中实际调用 operator new 函数完成 N 个对象空间的申请。
  1. 在申请的空间上调用 N 次构造函数,对它们分别初始化。
 
delete[] 的原理:
  1. 在释放的对象空间上执行 N 次析构函数,完成 N 个对象中资源的清理
  1. 调用 operator delete[] 释放空间,实际在 operator delete[] 中调用 operator delete 来释放空间
 
 

定位new

如果不用 new,想手动调用构造函数初始化,假设这有一块空间,是从内存池取来的,或者是 malloc 出来的、operator new 出来的……
不想用new,但是想对他进行初始化,行不行?
定位new表达式帮你!
定位 new 表达式实在已分配的原始空间中调用构造函数初始化一个对象。简单来说就是,定位new表达式可以在已有的空间进行初始化。
定位 new 是很有用的!比如开的空间是从内存池来的,如果想初始化,我们就可以使用它。因为内存池分配出的内存初始化,所以如果是自定义类型的对象,需要使用 new 定义的表达式进行显示调用构造函数进行初始化。
 
不带参定位new:
 
带参定位new:
 
模拟一下 new 的行为:
但是有时候,内存不一定是从堆来的,比如从内存池来的,定位 new 就可以大显神功。
高并发内存池,实现定长内存池的时候就需要使用 定位 new。
 
析构函数释放
析构函数是可以显式调用的(构造函数不行)
  • C++
  • 特定容器算法智能指针
    目录