内存管理之堆、栈、RAII

0.导语

半个月没有敲代码了,终于复活了!

最近看到吴老师的《现代C++实战30讲》,觉得很是不错,于是学习一下,本文中的一些文字概念引用自这里。同时,对于这个课的代码我放在了我的《C++那些事》仓库里面

https://github.com/Light-City/CPlusPlusThings

内存管理之堆、栈、RAII_垃圾收集

1.基本概念

C++里面的堆,英文是 heap,在内存管理的语境下,指的是动态分配内存的区域。这个堆跟数据结构 里的堆不是一回事。这里的内存,被分配之后需要手工释放,否则,就会造成内存泄漏。

C++ 标准里一个相关概念是自由存储区,英文是 free store,特指使用 new 和 delete 来分配和释放内存的区域。一般而言,这是堆的一个子集:

(1)new 和 delete 操作的区域是 free store

(2)malloc 和 free 操作的区域是 heap

但 new 和 delete 通常底层使用 malloc 和 free 来实现,所以 free store 也是 heap。

英文:stack,同数据结构中的stack,满足后进先出。

  • RAII

英文是 Resource Acquisition Is Initialization,是 C++ 所特有的资源管理方式。有少量其他语言,如 D、Ada 和 Rust 也采纳了 RAII,但主流的编程语言中, C++是唯一一个依赖 RAII 来做资源管理的。

原理:RAII 依托栈和析构函数,来对所有的资源——包括堆内存在内——进行管理。 对 RAII 的 使用,使得 C++ 不需要类似于 Java 那样的垃圾收集方法,也能有效地对内存进行管理。RAII 的存在,也是垃圾收集虽然理论上可以在 C++ 使用,但从来没有真正流行过的主要原因。

2.深入学习

2.1 堆

堆牵扯的通常是动态分配内存,在堆上分配内存,有些语言可能使用 new 这样的关键字,有些语言则是在对象的构造时隐式分配,不需要特殊关键字。不管哪种情况,程序通常需要牵涉到三个可能的内存管理器的操作:

  1. 让内存管理器分配一个某个大小的内存块

  2. 让内存管理器释放一个之前分配的内存块

  3. 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放

例如:C++ 通常会做上面的操作 1 和 2。Java 会做上面的操作 1 和 3。而 Python 会做上面的操 作 1、2、3。这是语言的特性和实现方式决定的。

下面详细阐述上述三个步骤:

第一,分配内存要考虑程序当前已经有多少未分配的内存。内存不足时要从操作系统申请新 的内存。内存充足时,要从可用的内存里取出一块合适大小的内存,做簿记工作将其标记为 已用,然后将其返回给要求内存的代码。

注意:代码只被允许使用其被分配的内存区域,剩余的内存区域属于未分配状态。如果内存管理器支持垃圾收集的话,分配内存的操作可能会出触发垃圾收集。

第二,释放内存不只是简单地把内存标记为未使用。对于连续未使用的内存块,通常内存管 理器需要将其合并成一块,以便可以满足后续的较大内存分配要求。毕竟,目前的编程模式 都要求申请的内存块是连续的。

第三:垃圾收集操作有很多不同的策略和实现方式,以实现性能、实时性、额外开销等各方 面的平衡。C++中这个不是重点。

在作者文档中,提到一个new与delete例子,非常有意思,这里引用过来。

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

delete ptr;
}

这里存在两个问题:

  • 中间省略部分若抛出异常,则导致delete ptr得不到执行。

  • 更重要的,这个代码不符合 C++ 的惯用法。在 C++ 里,这种情况下有 99% 的可能性不应该使用堆内存分配,而应使用栈内存分配。

第二点非常重要,于是作者给出了一个更常见、也更合理的作法,分配和释放不在一个函数里:

bar *make_bar() {
bar *ptr = nullptr;
try {
ptr = new bar();
} catch (...) {
delete ptr;
throw;
}
return ptr;
}
// 独立出函数 分配和释放不在一个函数里
void foo1() {
cout << "method 2" << endl;
bar *ptr = make_bar();
delete ptr;
}

2.2 栈

函数调用、本地变量入栈出栈会取决于计算机的试剂架构,原理都是后进先出。栈是向上增长,在包括 x86 在内的大部分计算机体系架构中,栈的增长方向是低地址,因而上方意味着低地址

本地变量所需的内存就在栈上,跟函数执行所需的其他数据在一起。当函数执行完成之后,这些内存也就自然而然释放掉了。因此得出栈的分配与释放:

  • 分配

移动一下栈指针

  • 释放

函数执行结束时移动一下栈指针

POD类型:本地变量是简单类型,C++ 里称之为 POD 类型(Plain Old Data)。

对于有构造和析构函数的非 POD 类型,栈上的内存分配也同样有效,只不过 C++ 编译器会在生 栈上的分配极为简单,移动一下栈指针而已。栈上的释放也极为简单,函数执行结束时移动一下栈指针即可。由于后进先出的执行过程,不可能出现内存碎片。成代码的合适位置,插入对构造和析构函数的调用。

栈展开:编译器会自动调用析构函数,包括在函数执行发生异常的情况。在发生异常时对析构函数的调用,还有一个专门的术语,叫栈展开(stack unwinding)。

在 C++ 里,所有的变量缺省都是值语义——如果不使用 * 和 & 的话,变量不会像 Java 或Python 一样引用一个堆上的对象。对于像智能指针这样的类型,你写 ptr->call() 和ptr.get(),语法上都是对的,并且 -> 和 . 有着不同的语法作用。而在大部分其他语言里,访问成员只用 .,但在作用上实际等价于 C++ 的 ->。这种值语义和引用语义的区别,是 C++ 的特点,也是它的复杂性的一个来源。

2.3 RAII

C++ 支持将对象存储在栈上面。但是,在很多情况下,对象不能,或不应该,存储在栈 上。比如:

  • 对象很大;

  • 对象的大小在编译时不能确定;

  • 对象是函数的返回值,但由于特殊的原因,不应使用对象的值返回。

实际例子如下:

enum class shape_type {
circle,
triangle,
rectangle,
};

class shape {
public:
shape() { cout << "shape" << endl; }

virtual void print() {
cout << "I am shape" << endl;
}

virtual ~shape() {}
};
class circle : public shape {
public:
circle() { cout << "circle" << endl; }

void print() {
cout << "I am circle" << endl;
}
};
class triangle : public shape {
public:
triangle() { cout << "triangle" << endl; }
void print() {
cout << "I am triangle" << endl;
}
};
class rectangle : public shape {
public:
rectangle() { cout << "rectangle" << endl; }
void print() {
cout << "I am rectangle" << endl;
}
};
// 利用多态 上转 如果返回值为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();
}
}

int main() {
shape *sp = create_shape(shape_type::circle);
sp->print();
delete sp;
return 0;
}

函数返回值在这里需要注意,只能为指针,而不能是值类型,当把shape* 改为shape的时候,会引发对象切片(object slicing)。

例如:

class shape {
int foo;
};

class circle : public shape {
int bar;
};

因此,B类型的对象有两个数据成员:foo和bar。

调用如下:

circle b;

shape a = b;

编译器不会报错,但结果多半是错的。然后,circle中关于成员bar的信息在shape中丢失。

那么,我们怎样才能确保,在使用 create_shape 的返回值时不会发生内存泄漏呢?

答案就在析构函数和它的栈展开行为上。我们只需要把这个返回值放到一个本地变量里,并确保其析构函数会删除该对象即可。一个简单的实现如下所示:

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(create_shape(shape_type::circle));
ptr.get()->print();
}

对于上述代码,当ptr_为空指针的时候,delete是合法的。

在析构函数里做必要的清理工作,这就是 RAII 的基本用法。这种清理并不限于释放内存,也可以是:

  • 关闭文件

    fstream 的析构就会这么做

  • 释放同步锁,

    例如:使用lock_guard代替mutex直接操作。

  • 释放其他重要的系统资源

内存管理之堆、栈、RAII_栈指针_02