动态内存基础
在讨论动态内存之前,首先需要明确两个概念:
- 栈内存:有更好的局部性,在栈中构造的局部对象和局部变量在内存地址上一般都是相邻的,方便读取。同时在栈中构造的对象会在程序返回或者局部变量超出其定义域时被自动销毁,不需要程序员关注内存的管理。
- 堆内存:堆内存可以在运行期动态拓展,比如STL容器中的Vector的自动拓展大小本质上就利用了堆内存的特性。同时堆内存需要进行显式的释放,需要程序员花费更多的精力来关注内存的管理,但也使得我们可以灵活地控制一个对象的生命周期。
注意这里我们提到的是特点而不是优点,在不同的应用场景下这些特点可能是缺点也可能是优点。
在本章中我们讨论的动态内存管理主要针对的是堆内存。在C++中我们通常使用new
来构造对象,使用delete
来销毁对象。这里提供一个简单的例子来展示堆内存的基本使用方法
int* x = new int(2);
std::cout << *x << std::endl;
delete x;
在C++中,一个对象的构造分成两步:
- 分配内存
- 在所分配的内存上构造对象
与之类似,对象的销毁也分为两步:
- 在被分配的内存上销毁对象
- 将分配的内存归还给系统
对于new
,我们有几种常见的使用形式:
- 构造单一对象/对象数组:
int* x = new int(2); int *y = new int[5]{1,2,3,4,5};
注意,对于y如果想要释放内存,需要使用
delete[]
- nothrow new:有时候我们会遇到内存分配失败的情况(内存不够或者可用内存片段的最大尺寸低于需要分配的尺寸),这时候系统默认会抛出异常,同时跳转到异常处理逻辑,不会继续执行后续的代码。但是这个机制有时候可能不是我们想要的,这时候我们就需要一种方法来避免执行异常处理的同时判断是否分配成功,具体的代码如下
int* x = new (std::nothrow) int(2); if(x == nullptr){ // ... }
加了
nothrow
后,如果分配失败,则返回nullptr
这里一定要注意,如果不加nothrow
的话,上述的判断代码是无效的的,分配失败后不会执行下面的判断语句,而是直接跳转到异常处理函数 - placement new:这个用法是用来在已有的内存上构造对象,而不需要重新分配内存。最典型的例子就是
Vector
,对于Vector
来说,当达到已分配内存所能容纳的元素数量上限时,它重新分配的内存会比其中元素所需要的内存多,一般是两倍,这样在新的元素被插入时,就不需要执行十分耗时的分配内存,拷贝对象,销毁旧内存的步骤。要实现这个操作,C++就需要能够支持分配内存而不构造对象的行为,也就是这里的placement new。具体的代码如下:char ch[sizeof(int)]; int * y = new(ch) int(4);
这里需要注意,在
new(ch)
里面的ch
会被隐式的转换为void *
我们要确保提供的内存的大小要足够用来构造新的对象,负责程序会出现不可预期的行为
placement new
既可以在堆内存上构造,也可以在栈内存上构造。但是要注意,在栈内存上构造要小心,可能会发生内存被自动销毁而导致程序出现不可预期的行为 - new auto:可以根据
auto()
里面表达式的类型自动推导出需要new
的对象的类型。具体的代码如下:int *x = new auto(3);
此外,new
还可以根据我们的需要控制所分配内存的对齐信息,这里看下面一个代码:
struct alignas(256) Str{};
int main() {
Str *x = new Str();
std::cout << x << std::endl;
}
这里分配的内存首地址一定是
256
的倍数
在探讨完new
之后,我们需要探讨delete
的用法,delete
有以下两种常见的用法:
- 销毁单一对象/对象数组:与构造相反,具体的例子如下
int * x = new int; delete x; int * y = new int[5]; delete[] y;
- placement delete:与
placement new
相对,只销毁对象,但是不释放内存。这里典型的例子还是Vector
。Vector
在pop
对象的时候采用的就是placement delete
,而不是把剩下的内容重新开辟内存进行复制后再把原内存释放。需要注意的是对于内建的数据类型我们一般不需要使用placement delete
,只有对拥有析构函数且析构函数里面有具体的操作的时候我们才需要显示的进行placement delete
。具体的例子在学习类的时候给出。
在使用new
和delete
的时候我们需要强调一些注意事项:
- 根据分配的是单一对象还是数组,来选择对应的销毁方式
分配 | 销毁 |
---|---|
new int | delete |
new int[] | delete [] |
delete nullptr
是合法的代码,但本身并不执行任何操作,这么设计主要是为了方便类似以下的用法:int * x = nullptr; if(...){ x = new int(3); } delete x;
保证了不管x有没有被分配内存代码都是合法的
- 不能
delete
一个非new
返回的内存,比如以下的两种代码就是不合法的:int x; delete &x; int ptr = new int[5]; ptr2 = (ptr + 1); delete ptr2;
- 同一块内存不能够
delete
多次,典型的错误为double free
。有一种处理这种问题的方法就是在delete
之后将指针置为nullptr
,利用上面所述的机制来避免这样的错误。
最后,虽然new
和delete
本身对应的只是两个简单的关键词,但是背后对应着复杂的逻辑,而这个逻辑我们是可以进行调整的,具体的可以参考cppreference。
这里需要强调,虽然逻辑是可以调整的,但是不要轻易去调整系统自身的
new
/delete
行为,可能会产生一系列的问题。典型的比如调用者和第三方库中对于new
/delete
行为的逻辑定义不同,那么在内存传递的过程中就会产生不可预期的行为。