【C++基础】第8章:动态内存管理

【C++基础】第8章:动态内存管理,第1张

【C++基础】第8章:动态内存管理

动态内存管理
  • 1 动态内存基础
    • 1.1 栈内存 堆内存
      • 1.1.1 栈内存的特点:更好的局部性,对象自动销毁
      • 1.1.2 堆内存的特点:运行期动态扩展,需要显式释放
    • 1.2 在 C++ 中通常使用 new 与 delete 来构造、销毁对象
    • 1.3 对象的构造分成两步:分配内存与在所分配的内存上构造对象;对象的销毁与之类似
    • 1.4 new 的几种常见形式
      • 1.4.1 构造单一对象 / 对象数组
      • 1.4.2 nothrow new
      • 1.4.3 placement new
      • 1.4.4 new auto
    • 1.5 new 与对象对齐
    • 1.6 delete 的常见用法
      • 1.6.1 销毁单一对象
      • 1.6.2 销毁单一对象数组
      • 1.6.3 placement delete
    • 1.7 使用 new 与 delete 的注意事项
      • 1.7.1 根据分配的是单一对象还是数组,采用相应的方式销毁
      • 1.7.2 delete nullptr
      • 1.7.3 不能 delete 一个非 new 返回的内存
      • 1.7.4 同一块内存不能 delete 多次
    • 1.8 调整系统自身的 new / delete 行为
  • 2 智能指针
    • 2.1 使用 new 与 delete 的问题:内存所有权不清晰,容易产生不销毁,多销毁的情况
    • 2.2 C++ 的解决方案:智能指针
      • 2.2.1 auto_ptr ( C++17 删除)
      • 2.2.2 shared_ptr / uniuqe_ptr / weak_ptr
    • 2.3 shared_ptr——基于引用计数的共享内存解决方案
      • 2.3.1 基本用法
      • 2.3.2 reset / get 方法
      • 2.3.3 指定内存回收逻辑
      • 2.3.4 std::make_shared
      • 2.3.5 支持数组( C++17 支持 shared_ptr

1 动态内存基础 1.1 栈内存 堆内存


在之前讨论的函数,声明函数的对象等,使用的是栈内存;在动态内存里面,所使用的是堆内存。

1.1.1 栈内存的特点:更好的局部性,对象自动销毁

上图红色Stack,我们给栈放了一帧进去,这个栈帧里面包含了我需要声明的对象(在函数内部需要调用的对象),以及函数形参,实参等信息。一帧比较紧凑,不会占太多空间,因此有很好的局部相关性。

局部相关性:假设要访问一个局部对象x,在访问x之后,要访问另外一个对象y,通常来说,y和x所处的内存空间较近,这样读取的话,系统性能比较好,同时构造的局部对象会被销毁。

为什么对象会被自动销毁?

一个栈帧中的函数在调用完之后,这个函数所对应的栈帧会被出栈,出栈的过程本质上即对应这个栈帧里面所构造的所有对象都会被销毁。换句话说,在函数里面声明的这样一些局部对象(局部变量)在函数结束之后,会被自动销毁。

1.1.2 堆内存的特点:运行期动态扩展,需要显式释放

如何构造一个对象放在堆内存里面,和构造一个对象放在栈内存(简单的声明int x)不同,我们需要使用new,delete这样的方式维护一个对象的生存周期。正是因为这样的特点,实际上,调用new来构造一块内存给对象使用,new是什么时候会被调用?在运行期被调用。换句话说,我们在调用new时,需要提供给系统说,我需要分配多少内存。换句话说,需要多少内存这样的事情是在运行期决定的。那么内幕才能也就能支持在运行期动态扩展,比如说,刚开始分配了包含10个int的内存,后面发现不够用了,我们就可以再进一步分配包含20个int的内存。

之前看到的一些典型的数据结构:vector、string。vector、string都可以在运行期动态的往里面放一些元素,这本质上就是使用动态内存来实现的。


  1. 堆内存有另外一个特点:它需要显式释放:

对比栈内存,如何栈帧d出去之后,栈帧中所包含的对象就会被销毁;但是堆内存不同,我们在一个函数里分配了一块堆内存,系统会在下图的heap里面加了一块内存,标记对应接下来要去使用(可能用来表示int,可能用来表示10个字符),等函数运行结束后,堆上的内存还放在那,。如果不需要再使用这块内存,我们需要显式释放这块内存。通常会使用delete函数来进行释放。

  1. 堆内存不具有更好的局部性
    相对栈帧而言,下图的Heap可能很大,刚开始时分配一块内存,这块内存一直在使用,然后我们在free里面又分配了一块内存,那么这两块内存之间的跳跃比较大,如果在一个函数中可以同时使用这两块内存,那么这两块内存就没有很好的局部性,因此我对一块内存的读写,并不能对另外一块内存进行加速。
1.2 在 C++ 中通常使用 new 与 delete 来构造、销毁对象
  1. 构造对象(使用new)
    分配一块内存,同时把这块内存和对象关联起来;

如下图代码,5行的x占用的是栈内存,当程序执行到main函数里面时,会构造一个栈帧,这个栈帧包含一块内存,这块内存将被用作x的读写,和x关联起来。接下来,第5行声明完x之后,执行6行,系统会在这个栈内存所对应的位置,把2写进去。执行7行,系统会把x所对应的那块内存的数值取出来,把取出来的值传递给cout,传递给标准输出,让它打印出来:

以上即栈内存。

对于堆内存:
5行:使用new来分配了一块堆内存,int有多大,这块内存就有多大。在我的系统中,int占4个字节,因此new int(2)表达式会在堆内存中找一块连续的4个字节,然后用int来解释这4个字节中的内容,然后把2保存在这连续的四个字节中,然后把这4个字节当中的第一个字节的地址返回出来,保存在y这样的int型指针上。

以上即在堆中分配内存,同时关联对象的过程。
与栈内存对比:
5行:可以使用x来直接访问内存的内容;
但是在堆里面分配完一块内存之后,通常来讲,我们获取到的是指向这个内存的指针,即new返回的是这块内存所对应的地址。

  1. 销毁对象(使用delete)
    接触这个对象和这块内存的关联,同时把这块内存交给系统,告诉系统,接下来再有人需要这块内存时,可以使用这块内存。

delete(10行)后,即告诉系统,y所对应的那块内存中的那4个字节,我们不再使用。

堆内存的显式释放的好处:堆内存可以有更长的生存周期

上图,5行使用new来分配了一块堆内存,然后把堆内存所对应的地址res返回到11行y里面。因为我们是在堆上分配的内存,因此fun函数结束之后,这块内存还是存在的(还是对应我们5行分配的int),故在12行打印y,是没问题的。

但是如果分配的是栈内存(下图5行),下图代码非常危险:

11行是指向临时对象的指针,5行分配了一个对象,这个对象对应res,6行把res这个对象的地址返回回来。但是res是栈内存,在fun函数结束之后,这个栈帧会被抛出去,那么这块内存已经不再具有res的含义,此时执行12行,可能错误。

1.3 对象的构造分成两步:分配内存与在所分配的内存上构造对象;对象的销毁与之类似

对象的销毁:把对应内存上所构造的对象销毁,再把之前分配的内存归还给系统(告诉系统,接下来可以将这块内存给其他人使用)。

上图,有两个 *** 作:分配了一块内存;把2写入这块内存,即在这块内存中构造了一个int对象,这个对象的初值是2。

1.4 new 的几种常见形式 1.4.1 构造单一对象 / 对象数组
  1. 构造单一对象
    下图相当于使用new来构造了单一的int,构造了int之后返回int*的这样的地址,这样就是构造了单一对象。同时下图5行还使用2来初始化这块内存中的内容。

    或:(都可以)

下图这样也可以:
5行相当于开辟了一块内存,这块内存占4个字节,能够放下一个int类型的数字,同时把这块内存的地址返回给y。

实际上,5行对应的是对象的缺省初始化,

  1. 构造单一对象数组:
    5行开辟了一段内存,这段内存能够存储5个int数,即开辟了连续的20个字节的内存空间,同时把这20个字节解释成5个int,接下来把这5个int使用缺省的方式初始化(数组中添加的元素随机),接下来把这20个字节的内存空间所对应的首地址返回给y,保存在y中。换句话说,y指的是这连续的20个字节(或5个int)中第一个int所对应的地址。即我们可以使用*y访问数组的第一个元素;使用y[1]访问数组的第2个元素。。

    c++11以后,我们也可以为上图的数组初始化赋值:(下图使用列表初始化数组)


    注意!!构造了单一对象,可以使用delete y;来删除对象y;构造了单一数组,需要使用delete[ ] y;删除对象数组y。
1.4.2 nothrow new

如果分配内存失败,系统会抛出异常。

但是有些情况下,我们不希望抛出异常。那么我们如何判断分配内存是否成功?

我们可以使用nothrow new。

上图程序和之前的程序相比,行为发生改变。上图程序分配内存也可能失败,如果失败了,系统不抛出异常。但是y所对应的是一个指向nullptr的这样的指针(所对应的地址是0)。

即:
如果y == bullptr,分配内存不成功;否则才进行下一步处理:

如果6行的nothrow去掉,当分配内存失败时会抛出异常,直接跳转到异常相关的处理逻辑里面,而不会执行以下代码(下图7~13行)。

1.4.3 placement new

现在已经有一块内存了,我不需要你再去分配内存了。我只需要你在这块内存上构造对象。

什么时候会用到上述特性?vector会用到,如vector原先包含2个元素,接下来又来了个元素,我发现当前的内存只能包含两个元素。那么我们可以重新分配一块内存,然后把原先内存中的两个元素拷贝到新内存中,再把第3个元素放到新内存后面。

但是我们不断地往vector里面插入元素,插入第一个元素,给我分配一块内存,插入第二个元素又再分配一块大一点的内存,把第一块内存的元素拷贝过来,把旧内存删掉,然后我们再将第二个元素放入新内存中;插入第三个元素时,也是类似 *** 作。这样的过程很耗时。

故vector分配的内存会多分配一些,典型做法是分配的内存为插入元素的两倍字节。即比如原先系统包含2个元素,接下来来了一个新元素,希望将其插入进去,由两个元素变成3个元素,此时vector需要分配内存了,但是它不去分配3个元素,而是分配4个元素,即这块新内存能包含4个元素,接下来,把旧内存的内存复制到新内存前面一半,接下来会在分配的第3块元素上构造新的对象,第四块元素留着不动(没有构造任何对象)。后面再插入一个新元素时,就不需要重新开辟内存了,这样相当于内存分配和对象构造的过程显式分离了,故我们必须在c++中通过语法形式支持这种我已经分配好一段内存,然后在这已经分配好的内存中构造对象的这样的方式。

当然了,c++也要能支持先分配内存,但先不在内存中构造对象的这样的行为。

综上,c++要支持:假设有一块内存了,我们需要把它重新解释成对象的形式(在这块内存构造对象),我们实际上使用的就是placement new。

6行:在栈中开辟了一块内存,这块内存是一个char型数组,其中包含sizeof(int)个元素,如我的电脑中int占4个字节,那么sizeof(int)为4,换句话说,6行开辟了连续4个字节的内存,这连续4个字节叫ch。
7行:不加(ch):在堆上开辟了一块内存,把4写到内存中,接下来那内存首地址赋给y。
加入(ch):(ch)中的ch会被隐式转换为指针,当new之后,ch这个char型指针会再次隐式转换为void*指针(一般意义上的指针,对应任何内存地址)。

那么第7行相当于placement new,即我不需要在堆上给我开辟一段新内存,ch对应了一块内存,这块内存是使用(ch)作为内存的首指针来提供的,我们可以使用这块内存把int构造出来。(前提:ch是合法地址。第一,它是一个指针;第二,指针所对应的地址有效且足够大)

即我们可以使用placement new忽略分配内存这一步,直接跳到在内存上构造对象这一步。

注意,ch对应的内存可以是堆内存,也可以是栈内存。

如下图,传入的是栈内存:
main函数在结束时,ch所对应的这块内存会被销毁掉,如果在mian函数里面调用fun函数,代码危险:13行y可能指向一块被销毁的内存

1.4.4 new auto


错:

1.5 new 与对象对齐

4行:构造一个结构体
8行:为这个结构体分配内存

上图打印出来的是ptr所对应的内存的地址。

我们之前在讨论对象时,对象实际上包含对齐属性。上图4行没有为Str引入相应的对齐信息,即Str会产生缺省方式来进行对齐。我们也可以引入额外对齐信息,如:
上图4行表示Str开辟的这块内存的首地址一定要是256的整数倍,即字节对齐是256个字节,256个字节这样对齐(输出的地址的最后两位一定为0)。

再如下:输出地址一定要是1024的整数倍:

1.6 delete 的常见用法 1.6.1 销毁单一对象

1.6.2 销毁单一对象数组

1.6.3 placement delete

有placement new就有placement delete。
placement new:在已经存在的内存中构造对象。
placement delete:把内存中构造的对象销毁,但不会把这块内存归还给系统。

placement delete不是使用delete关键字来实现的。我们是通过以下两种情况实现placement delete:

  1. 如果placement new构造的是内建数据类型(int,double,char),这些内建数据类型在delete时,从概念上讲,是要在内存上销毁,把所分配的内存还给系统,但是因为它们是内建数据类型,实际上我们不需要去考虑placement delete。

那么什么时候真正应用到placement delete?

我们构造了一个类或一个结构体,同时为这个类或结构体定义了相应的析构函数,此时就需要考虑placement delete。如构造了一个文件流的对象,在销毁这个文件流对象时就需要调用析构函数(刷新缓存,关闭文件)。

即如果使用placement new来构造了一个文件对象,那么就要调用placement delete显式触发析构函数的调用。

如何触发析构函数的调用?(后续讨论类和结构体时再说)

1.7 使用 new 与 delete 的注意事项 1.7.1 根据分配的是单一对象还是数组,采用相应的方式销毁



错:

1.7.2 delete nullptr

下图:x是是一个指针,里面包含了一个地址信息,如果这个地址是0或nullptr,那么delete什么都不做,

1.7.3 不能 delete 一个非 new 返回的内存

下图:x是一个栈内存中的对象,&x:对x取地址;delete是用来删除堆内存,故下图代码错误

下图也错:

又如:
8行:ptr2指向7行5个元素的第2个元素
9行这样不对:因为ptr2不是new返回的内存,ptr2只是指向了new返回的内存中的一个地方,但不是new直接返回的指针,故9行这样写,系统行为也是未定义

1.7.4 同一块内存不能 delete 多次

下图7行,开辟了一段内存,同时把这段内存对应的地址扔给ptr:

上图9行,在调用delete时,系统会把输出的那一串数所对应的内存释放掉,同时把这块内存归还给系统。

但是上图ptr是一个对象,这个对象放在栈中(和我们定义int x;是一样的),只不过ptr这个对象包含的数据指向在堆中分配的内存(因为new int生成一块内存,接下来把这块内存的地址返回),故在调用完delete之后,ptr还是能够使用的。同时大概率来讲,ptr中的内容不会发生改变。


delete只是把ptr对象中所对应的地址取出来,然后把这个地址进行销毁。但是不会改变ptr中的内容。

那么可能出现下图代码:


错因,9行、11行:同一块内存 delete 多次。

如何防止这种情况?将delete过一次的ptr赋成0(10行):

1.8 调整系统自身的 new / delete 行为

不要轻易使用

如,我们在动态库中调整系统自身的 new / delete 行为。我们在动态库中分配一块内存,把这块内存传出来,然后会在调用的地方释放。但此时会有个问题,我们在动态库中分配内存时,可能是使用一个调整后的new来分配的,然后释放时使用的是系统自带的delete。此时new和delete行为不匹配。这样会很危险。

2 智能指针 2.1 使用 new 与 delete 的问题:内存所有权不清晰,容易产生不销毁,多销毁的情况

之前已经有了基于new和delete这样的动态内存管理方式,但这种方式有问题:

  1. 内存所有权不清晰,容易产生不销毁,多销毁的情况。
    下图:
    4行:fun函数返回一个指针
    7行:返回一个指针res
    12行:调用fun函数

    这个代码逻辑没问题,但是从概念上看,是有问题的。fun函数返回一个指针(4行),通常而言,如果返回一个指针,可能这个指针是构造出来的,既然有构造,那么就要有销毁。那么销毁这件事情到底谁来干?

我们在12行构造了指针y,main函数调用完fun函数之后,这个y拿到了,那么是main函数具有y的所有权还是fun函数具有y的所有权?

这就是内存所有权不清晰,可能会导致不销毁指针,造成内存泄露。或者在不同函数中都销毁了同一个指针。

2.2 C++ 的解决方案:智能指针

相比于new和delete,智能指针本质上是抽象数据类型。抽象数据类型能够提供析构函数,在对象要销毁时调用析构函数,那么就能把调用delete释放内存这件事情放到析构函数里面,只要我这个对象被析构了,那么就被销毁了。用户不需要显式调用delete,就不需要考虑上述的内存所有权问题。

2.2.1 auto_ptr ( C++17 删除) 2.2.2 shared_ptr / uniuqe_ptr / weak_ptr

shared_ptr / uniuqe_ptr / weak_ptr替换auto_ptr 。

2.3 shared_ptr——基于引用计数的共享内存解决方案

shared_ptr:共享指针

2.3.1 基本用法 2.3.2 reset / get 方法 2.3.3 指定内存回收逻辑 2.3.4 std::make_shared 2.3.5 支持数组( C++17 支持 shared_ptr ; C++20 支持 make_shared 分配数组) 2.3.6 注意: shared_ptr 管理的对象不要调用 delete 销毁 2.4 unique_ptr——独占内存的解决方案 2.4.1 基本用法 2.4.2 unique_ptr 不支持复制,但可以移动 2.4.3 为 unique_ptr 指定内存回收逻辑 2.5 weak_ptr——防止循环引用而引入的智能指针 2.5.1 基于 shared_ptr 构造 2.5.2 lock 方法 3 动态内存的相关问题 3.1 sizeof 不会返回动态分配的内存大小 3.2 使用分配器( allocator )来分配内存 3.3 使用 malloc / free 来管理内存 3.4 使用 aligned_alloc 来分配对齐内存 3.5 动态内存与异常安全 3.6 C++ 对于垃圾回收的支持

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/4948619.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-11-13
下一篇 2022-11-13

发表评论

登录后才能评论

评论列表(0条)

保存