使用new和delete进行动态内存分配
本节阅读量:为什么需要动态内存分配?
C++支持三种基本类型的内存分配,您已经见过其中两种。
- 静态内存分配用于静态变量和全局变量。这些变量的内存在程序运行时分配一次,并在程序的整个生命周期中保持不变。
- 自动内存分配用于函数参数和局部变量。这些变量的内存在进入相关代码块时分配,在退出该代码块时释放。
- 动态内存分配,这是本文的主题。
静态分配和自动分配有两个共同点:
- 在编译时必须知道变量/数组的大小。
- 内存分配和释放自动发生(当变量被实例化/销毁时)。
大多数时候,这些机制都很好用。然而,在处理外部输入(来自用户或文件)时,您可能会遇到其中一个或两个约束无法满足的情况。
例如,我们可能希望使用字符串来保存某人的姓名,但在对方输入姓名之前,我们不知道姓名有多长。或者,我们可能想从磁盘中读入许多记录,但事先不知道有多少条记录。又或者,我们可能正在创建一个游戏,让数量可变的怪物(随着时间推移,一些怪物死亡,同时又有新怪物生成)试图杀死玩家。
如果必须在编译时声明所有内容的大小,我们能做的最好选择就是猜测所需变量的最大大小,并希望它足够使用:
|
|
这是一个糟糕的解决方案,至少有四个原因:
首先,如果变量没有被实际使用,就会导致内存浪费。例如,如果我们为每个名称分配25个字符,但名称平均只有12个字符长,那么使用的内存就超过实际所需的两倍。或者考虑上面的渲染数组:如果渲染只使用10000个多边形,那么就有20000个多边形的内存未被使用!
第二,我们如何知道哪些内存实际被使用了?对于字符串,这很容易:以\0开头的字符串显然没有被使用。但怪物呢?第24个位置的怪物现在是活着还是死了?它一开始是否被初始化过?这就需要某种方法来记录每个怪物的状态,从而增加复杂性,并可能占用额外内存。
第三,大多数普通变量(包括固定数组)都分配在称为栈的内存区域中。程序的栈内存通常相当小——Visual Studio默认堆栈大小为1MB。如果超过这个数字,将导致栈溢出,操作系统可能会关闭该程序。
在Visual Studio上,您可以尝试编写这样的程序,看看会发生什么:
|
|
对于许多程序来说,仅限于1MB的内存是有问题的,特别是那些处理图形的程序。
第四,也是最重要的一点,它可能导致人为限制或数组溢出。当用户试图从磁盘读取600条记录,但我们只为最多500条记录分配了内存时,会发生什么?要么我们必须向用户报告错误,只读取500条记录;要么(在最坏的情况下,如果我们根本不处理这种情况)记录数组会溢出,并引发一些糟糕的问题。
幸运的是,这些问题很容易通过动态内存分配来解决。动态内存分配是一种在需要时向操作系统请求内存的方法。这些内存不是来自程序有限的栈内存,而是从操作系统管理的更大内存池(称为堆)中分配。在现代机器上,堆的大小可以达到GB级别。
动态分配单个变量
要动态分配单个变量,我们使用new运算符:
|
|
在上面的例子中,我们向操作系统请求一块用于存放整数的内存。new操作符会使用该内存创建对象,然后返回一个指针,该指针包含已分配内存的地址。
通常,我们将返回值分配给指针变量,以便稍后访问分配的内存。
|
|
然后,我们可以解引用指针以访问内存:
|
|
现在,您应该至少看到了一种指针有用的场景。如果没有指针来保存刚刚分配的内存地址,我们将无法访问这块内存!
请注意,访问堆分配的对象通常比访问栈分配的对象慢。因为编译器知道栈上分配对象的地址,所以可以直接访问该地址来获取值。堆分配的对象通常通过指针访问。这需要两个步骤:先从指针获取对象地址,再通过该地址获得值。
动态内存分配是如何工作的?
您的计算机拥有可供应用程序使用的内存(可能很多)。运行应用程序时,操作系统会将应用程序加载到其中的某些区域。应用程序使用的内存被划分为不同区域,每个区域都有不同用途。一个区域包含您的代码。另一个区域用于记录程序执行状态(跟踪调用了哪些函数,创建和销毁全局变量与局部变量等…)。我们稍后会详细讨论这些内容。当然,还有许多可用内存处于空闲状态,等待分配给请求它的程序。
当动态分配内存时,您是在要求操作系统分配一些内存供程序使用。如果操作系统可以满足这个请求,它会将该内存的地址返回给您的应用程序。从那时起,应用程序就可以根据需要使用这块内存。当应用程序使用完这块内存后,可以将其返回给操作系统,以便提供给其他程序。
与静态或自动内存不同,程序本身负责请求和处理动态分配的内存。
关键点
栈上对象的分配和释放会自动完成。我们没有必要处理内存地址——编译器生成的代码会替我们做到这一点。
堆上对象的分配和释放不会自动完成,需要我们参与。这意味着我们需要某种明确的方法来引用特定的堆分配对象,以便使用它,或在之后销毁它。我们引用这些对象的方式就是通过内存地址。
当我们使用操作符new时,它会返回一个指针,其中包含新分配对象的内存地址。我们通常会将其存储在指针中,以便之后使用该地址访问对象(并最终请求销毁它)。
初始化动态分配的变量
动态分配变量时,还可以通过直接初始化或统一初始化对其进行初始化:
|
|
销毁单个变量
当我们处理完动态分配的变量后,需要显式地告诉C++释放内存,以便重用。对于单个变量,这是通过delete运算符完成的:
|
|
delete内存意味着什么?
delete操作符实际上并不删除任何内容。它只是将所指向的内存返回给操作系统。之后,操作系统可以将该内存重新分配给另一个应用程序(或稍后再次分配给本应用程序)。
尽管看起来我们正在删除一个变量,但事实并非如此!指针变量仍然具有与以前相同的作用域,并且可以像其他变量一样被赋予新值。
请注意,删除不指向动态分配的内存的指针可能会导致糟糕的事情发生。
悬空指针
C++不保证使用指向已删除内存的指针时会发生什么。在大多数情况下,返回给操作系统的内存仍会包含归还之前的相同值。
指向已释放内存的指针称为悬空指针。解引用或删除悬空指针将导致未定义的行为。请看以下程序:
|
|
在上面的程序中,ptr指向的内存已经归还给操作系统,此时再尝试访问该内存将导致未定义的行为。
一次delete操作可能会导致多个悬空指针。请看以下示例:
|
|
以下是一些有帮助的最佳实践。
首先,尽量避免让多个指针指向同一块动态内存。如果一定要这么做,请明确哪个指针“拥有”这块内存(并负责删除它),以及哪些指针只是访问它。
其次,当您删除指针时,如果该指针随后不会立即超出作用域,请将其设置为nullptr。后续我们将详细讨论空指针,以及它们为什么有用。
最佳实践
将删除的指针设置为nullptr,除非它们随后立即超出作用域。
new操作可能会失败
从操作系统请求内存时,在极少数情况下,操作系统可能没有足够的内存来满足请求。
默认情况下,如果new失败,则会抛出bad_alloc异常。如果这个异常没有被正确处理(而它通常不会被正确处理,因为我们还没有讨论异常或异常处理),程序将直接终止(崩溃),并显示未处理的异常错误。
在许多情况下,让new引发异常(或让程序崩溃)是不可取的,因此还有另一种形式的new,可以让new在无法分配内存时返回空指针。这是通过在new关键字和分配类型之间添加std::nothrow来完成的:
|
|
在上面的示例中,如果new未能分配内存,它将返回空指针,而不是已分配内存的地址。
请注意,如果随后尝试解引用该指针,将导致未定义的行为(很可能是程序崩溃)。因此,如果使用nothrow,最佳实践是检查所有内存请求,确保它们在使用所分配内存之前确实成功。
|
|
因为请求新内存很少失败(在开发环境中几乎永远不会失败),所以人们通常会忘记执行此检查!
空指针和动态内存分配
空指针(设置为nullptr的指针)在处理动态内存分配时特别有用。在动态内存分配的上下文中,空指针基本上表示“没有内存分配给该指针”。这允许我们执行类似“指针是否有效”的逻辑:
|
|
删除空指针没有任何问题。因此,不需要这样写:
|
|
相反,您可以直接写:
|
|
如果ptr不为nullptr,则会删除动态分配的内存。如果ptr为nullptr,则不会发生任何事情。
最佳实践
删除空指针是允许的,并且不会执行任何操作。没有必要对delete语句进行条件判断。
内存泄漏
动态分配的内存会保持分配状态,直到它被显式释放或程序结束(此时操作系统会清理它)。然而,用于保存动态分配内存地址的指针遵循局部变量的正常作用域规则。这种不匹配可能会产生一些问题。
请看以下函数:
|
|
该函数动态分配了一个整数,但从未使用delete释放它。因为指针变量ptr只是普通变量,所以当函数结束时,ptr会超出作用域。由于ptr是唯一保存动态分配整数地址的变量,因此当ptr被销毁时,就不再有任何引用指向这块动态分配的内存。这意味着程序现在“丢失”了动态分配内存的地址。因此,无法再删除这个动态分配的整数。
这称为内存泄漏。当程序在将动态分配的内存返回给操作系统之前丢失其地址时,就会发生内存泄漏。发生这种情况时,程序无法删除动态分配的内存,因为它不再知道这块内存在哪里。操作系统也无法使用该内存,因为该内存仍被认为正在由程序使用。
内存泄漏会在程序运行时逐渐耗尽可用内存,从而减少当前程序以及其他正在运行程序可用的内存。存在严重内存泄漏的程序可能会占用所有可用内存,导致整台计算机运行缓慢,甚至崩溃。只有在程序终止后,操作系统才能清理并“回收”所有泄漏的内存。
尽管指针超出作用域可能会导致内存泄漏,但还有其他方式也可能导致内存泄漏。例如,如果把另一个值赋给保存动态分配内存地址的指针,就可能发生内存泄漏:
|
|
可以通过在重新给指针赋值之前删除指针来解决此问题:
|
|
类似地,还可以通过重复分配造成内存泄漏:
|
|
第二次分配返回的地址会覆盖第一次分配的地址。因此,第一次分配的内存就泄漏了!
同样,可以通过确保在重新分配之前先删除指针来避免这种情况。
结论
运算符new和delete允许我们为程序动态分配单个变量。
动态分配的内存具有动态持续时间,并会保持分配状态,直到您将它归还给操作系统或程序终止。
注意不要对悬空指针或空指针执行解引用操作。
在下一课中,我们将了解如何使用new和delete来分配和删除数组。