C++中的资源管理:堆、栈和RAII
本文为现代C++实战30讲的学习笔记。
基础概念
C++的内存管理中存在一些基础概念,下面将分别进行介绍。
堆
英文名为heap,指的是内存中被动态分配的区域,内存管理中的堆和数据结构中的堆不同,该部分内存在被分配后,需要手动释放,不然会造成内存泄露。
在C++中,堆中存在一个子集,其名称为自由存储区(free store),特指使用new
和delete
进行分配和释放的区域。而malloc
和free
操作的是heap。new
和delete
的底层通常使用malloc
和free
实现,因而一般情况下不进行自由存储区和heap的区分。
在现代编程中,使用堆或者动态内存分配是随处可见的,如下述代码都会在堆上分配内存:
1 |
|
1 |
|
1 |
|
动态内存会带来不确定性,如内存分配耗时、分配失败等,因而在一些实时性要求较高的场合会禁用动态内存。
在进行动态内存分配时,程序通常会涉及三个可能的内存管理器的操作:
- 让内存管理器分配一个某个大小的内存块。
- 让内存管理器释放一个之前分配的内存块。
- 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放。
C++一般会运行上述的第一条和第二条;Java会运行上述的第一条和第三条;Python会运行上述三条。上述三个操作都较为复杂,且相互关联。
分配内存时要考虑程序当前还有多少未分配的内存。当剩余内存不足时需要向操作系统申请新的内存;内存充足时,从可用的内存区域中划分出一块合适大小的内存,将其标记为可用后,返回给请求内存的代码。
一般情况下,可用内存都比要求分配的内存大,因而代码只被允许使用其被分配的内存区域,剩余的区域仍属于未分配状态,可以在后续分配中被使用。同时,如果内存管理器支持垃圾收集,分配内存的操作还有可能出发垃圾回收机制。
释放内存不只是简单地将内存标记为未使用。对于连续的未使用的内存块,内存管理器会将其合并为一个大块,以满足后续的较大内存分配要求。
垃圾收集操作有很多不同的策略和实现方式,以实现性能、实时性和额外开销等各方面的平衡。C++中通常不使用垃圾收集机制。
下图为可视化内存分配过程:
内存分配
内存释放与合并
在没有垃圾收集的情况下,需要手动进行内存的释放,有可能会导致内存的碎片化。但是,通常我们不需要考虑这些问题,内存分配和释放由内存管理器完成,我们只需要正确使用new
和delete
即可。
但是,当我们忘记在使用new
后调用对应的delete
时,就会导致内存泄露的发生。如下述代码:
1 |
|
上述代码虽然最终使用了delete
释放动态申请的内存,但是实际上,在运行过程中中间部分的代码可能会产生异常,进而无法运行到delete
语句;同时,这种情况下,一般不需要申请堆内存。
更常见的情况是:分配和释放的代码不在同一个函数中:
1 |
|
此时就更可能遗漏delete
。
栈
英文名为stack,在内存管理中,指的是函数调用过程中产生的本地变量和所调用的数据的存放区域。类似于数据结构中的栈,满足“后进先出”。给出示例代码如下:
1 |
|
这段代码的栈的变化如下:
上图中的栈是向上增长的,在包含X86在内的大部分计算机体系架构中,栈的增长方向是低地址。任何一个函数,只能使用进入函数时栈指针向上部分的栈空间。当函数调用另一个函数时,会将参数压栈(忽略使用寄存器传递参数的情况),接着将下一行汇编指令的地址压栈,然后跳转到新函数处。进入新函数后,会首先执行必要的保存工作,接着调整栈指针,分配本地变量所需的空间,接着执行函数中的代码,执行完毕后,依据调用函数压入栈的地址,返回到被调用函数之后的未执行代码处继续执行。
栈上的内存操作有以下特点:
- 栈上进行内存分配非常简单,移动栈顶指针即可。
- 栈上内存的释放同样很简单,函数调用结束后移动栈顶指针即可。
- 后进先出的执行特点不可能导致内存碎片的产生。
上图中不同颜色的内存区域属于不同的函数,将各个内存空间成为栈帧。
上述的本地变量在C++中被称为POD(Plain Old Data)类型。有构造函数和析构函数的非POD类型,栈的内存分配同样有效。
比较重要的一点是:编译器会自动调用析构函数,当函数执行发生异常时同样会调用,发生异常时对析构函数的调用被成为栈展开(stack unwinding)。栈展开示意代码如下:
1 |
|
输出如下:
1 | Obj() |
可见,在程序发生异常时编译器自动调用了Obj
的析构函数。
在C++中,所有的变量缺省都是值语义,也就是说不使用指针或者引用,变量不会自动引用一个堆上的对象。对于智能指针类型,使用ptr->call()
和ptr.get()
都是正确的,而其它大部分语言都使用.
访问成员,实际上等于C++的->
。
RAII(Resource Acquisition Is Initialization)
C++支持将对象存储在栈上,但在有些情况下我们不希望将对象存储在栈上,例如:
- 对象很大;
- 对象的大小在编译时无法确定;
- 对象是函数的返回值,但是有时不能使用对象的值返回。
如在工厂方法或其他面向对象编程的情况下,返回值类型是基类:
1 |
|
上述的create_shape
方法返回一个shape
对象,对象的实际类型为shape
的某个子类,如circle
、triangle
等。此时,函数的返回值只能是指针或其变体形式,而不能采用值返回的形式。
采用值返回的形式可能会导致如下的问题:如果函数的返回类型是shape
,但实际返回的是一个circle
对象,编译器不会报错,但是返回对象多半是错误的,C++会对返回的circle
对象执行对象切片操作(object slicing),这是C++特有的编码错误,应该小心。
那么如何保证在使用create_shape
的返回值时不会发生内存泄露?
我们只需要把这个返回值放到一个本地变量里,并确保其析构函数会删除该对象即可,如下所示:
1 |
|
观察上述代码,在shape_wrapper
的析构函数中进行了必要的清理工作,这就是RAII的基本用法,这种清理不仅限于释放内存,也可以在析构函数中进行:
- 关闭文件;
- 释放同步锁;
- 释放其他重要的系统资源。