C++中的资源管理:堆、栈和RAII

C++中的资源管理:堆、栈和RAII

本文为现代C++实战30讲的学习笔记。

基础概念

C++的内存管理中存在一些基础概念,下面将分别进行介绍。

英文名为heap,指的是内存中被动态分配的区域,内存管理中的堆和数据结构中的堆不同,该部分内存在被分配后,需要手动释放,不然会造成内存泄露。

在C++中,堆中存在一个子集,其名称为自由存储区(free store),特指使用newdelete进行分配和释放的区域。而mallocfree操作的是heap。newdelete的底层通常使用mallocfree实现,因而一般情况下不进行自由存储区和heap的区分。

在现代编程中,使用堆或者动态内存分配是随处可见的,如下述代码都会在堆上分配内存:

1
2
3

// C++
auto ptr = new std::vector<int>();
1
2
3

// Java
ArrayList<int> list = new ArrayList<int>();
1
2
3

# Python
lst = list()

动态内存会带来不确定性,如内存分配耗时、分配失败等,因而在一些实时性要求较高的场合会禁用动态内存。

在进行动态内存分配时,程序通常会涉及三个可能的内存管理器的操作:

  • 让内存管理器分配一个某个大小的内存块。
  • 让内存管理器释放一个之前分配的内存块。
  • 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放。

C++一般会运行上述的第一条和第二条;Java会运行上述的第一条和第三条;Python会运行上述三条。上述三个操作都较为复杂,且相互关联。

  1. 分配内存时要考虑程序当前还有多少未分配的内存。当剩余内存不足时需要向操作系统申请新的内存;内存充足时,从可用的内存区域中划分出一块合适大小的内存,将其标记为可用后,返回给请求内存的代码。

    一般情况下,可用内存都比要求分配的内存大,因而代码只被允许使用其被分配的内存区域,剩余的区域仍属于未分配状态,可以在后续分配中被使用。同时,如果内存管理器支持垃圾收集,分配内存的操作还有可能出发垃圾回收机制。

  2. 释放内存不只是简单地将内存标记为未使用。对于连续的未使用的内存块,内存管理器会将其合并为一个大块,以满足后续的较大内存分配要求。

  3. 垃圾收集操作有很多不同的策略和实现方式,以实现性能、实时性和额外开销等各方面的平衡。C++中通常不使用垃圾收集机制。

下图为可视化内存分配过程:

  • 内存分配

    img

  • 内存释放与合并

    img

在没有垃圾收集的情况下,需要手动进行内存的释放,有可能会导致内存的碎片化。但是,通常我们不需要考虑这些问题,内存分配和释放由内存管理器完成,我们只需要正确使用newdelete即可。

但是,当我们忘记在使用new后调用对应的delete时,就会导致内存泄露的发生。如下述代码:

1
2
3
4
5
6
7

void foo()
{
bar* ptr = new bar();

delete ptr;
}

上述代码虽然最终使用了delete释放动态申请的内存,但是实际上,在运行过程中中间部分的代码可能会产生异常,进而无法运行到delete语句;同时,这种情况下,一般不需要申请堆内存。

更常见的情况是:分配和释放的代码不在同一个函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

bar* make_bar(…)
{

try {
bar* ptr = new bar();

}
catch (...) {
delete ptr;
throw;
}
return ptr;
}

void foo()
{

bar* ptr = make_bar(…)

delete ptr;
}

此时就更可能遗漏delete

英文名为stack,在内存管理中,指的是函数调用过程中产生的本地变量和所调用的数据的存放区域。类似于数据结构中的栈,满足“后进先出”。给出示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

void foo(int n)
{

}

void bar(int n)
{
int a = n + 1;
foo(a);
}

int main()
{

bar(42);

}

这段代码的栈的变化如下:

img

上图中的栈是向上增长的,在包含X86在内的大部分计算机体系架构中,栈的增长方向是低地址。任何一个函数,只能使用进入函数时栈指针向上部分的栈空间。当函数调用另一个函数时,会将参数压栈(忽略使用寄存器传递参数的情况),接着将下一行汇编指令的地址压栈,然后跳转到新函数处。进入新函数后,会首先执行必要的保存工作,接着调整栈指针,分配本地变量所需的空间,接着执行函数中的代码,执行完毕后,依据调用函数压入栈的地址,返回到被调用函数之后的未执行代码处继续执行。

栈上的内存操作有以下特点:

  • 栈上进行内存分配非常简单,移动栈顶指针即可。
  • 栈上内存的释放同样很简单,函数调用结束后移动栈顶指针即可。
  • 后进先出的执行特点不可能导致内存碎片的产生。

上图中不同颜色的内存区域属于不同的函数,将各个内存空间成为栈帧。

上述的本地变量在C++中被称为POD(Plain Old Data)类型。有构造函数和析构函数的非POD类型,栈的内存分配同样有效。

比较重要的一点是:编译器会自动调用析构函数,当函数执行发生异常时同样会调用,发生异常时对析构函数的调用被成为栈展开(stack unwinding)。栈展开示意代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

#include <stdio.h>

class Obj {
public:
Obj() { puts("Obj()"); }
~Obj() { puts("~Obj()"); }
};

void foo(int n)
{
Obj obj;
if (n == 42)
throw "life, the universe and everything";
}

int main()
{
try {
foo(41);
foo(42);
}
catch (const char* s) {
puts(s);
}
}

输出如下:

1
2
3
4
5
Obj()
~Obj()
Obj()
~Obj()
life, the universe and everything

可见,在程序发生异常时编译器自动调用了Obj的析构函数。

在C++中,所有的变量缺省都是值语义,也就是说不使用指针或者引用,变量不会自动引用一个堆上的对象。对于智能指针类型,使用ptr->call()ptr.get()都是正确的,而其它大部分语言都使用.访问成员,实际上等于C++的->

RAII(Resource Acquisition Is Initialization)

C++支持将对象存储在栈上,但在有些情况下我们不希望将对象存储在栈上,例如:

  • 对象很大;
  • 对象的大小在编译时无法确定;
  • 对象是函数的返回值,但是有时不能使用对象的值返回。

如在工厂方法或其他面向对象编程的情况下,返回值类型是基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

enum class shape_type {
circle,
triangle,
rectangle,

};

class shape { … };
class circle : public shape { … };
class triangle : public shape { … };
class rectangle : public shape { … };

shape* create_shape(shape_type type)
{

switch (type) {
case shape_type::circle:
return new circle(…);
case shape_type::triangle:
return new triangle(…);
case shape_type::rectangle:
return new rectangle(…);

}
}

上述的create_shape方法返回一个shape对象,对象的实际类型为shape的某个子类,如circletriangle等。此时,函数的返回值只能是指针或其变体形式,而不能采用值返回的形式

采用值返回的形式可能会导致如下的问题:如果函数的返回类型是shape,但实际返回的是一个circle对象,编译器不会报错,但是返回对象多半是错误的,C++会对返回的circle对象执行对象切片操作(object slicing),这是C++特有的编码错误,应该小心。

那么如何保证在使用create_shape的返回值时不会发生内存泄露?

我们只需要把这个返回值放到一个本地变量里,并确保其析构函数会删除该对象即可,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

class shape_wrapper {
public:
explicit shape_wrapper(
shape* ptr = nullptr)
: ptr_(ptr) {}
~shape_wrapper()
{
delete ptr_;
}
shape* get() const { return ptr_; }
private:
shape* ptr_;
};

void foo()
{

shape_wrapper ptr_wrapper(
create_shape(…));

}

观察上述代码,在shape_wrapper的析构函数中进行了必要的清理工作,这就是RAII的基本用法,这种清理不仅限于释放内存,也可以在析构函数中进行:

  • 关闭文件;
  • 释放同步锁;
  • 释放其他重要的系统资源。

参考