堆、栈、RAII

0 阅读7分钟

为什么要学习cpp内存管理?

内存管理是C++ 语言的灵魂与核心竞争力,是区分 “会写 C++ 语法” 和 “真正懂 C++、能用好 C++” 的核心分水岭,更是 C++ 开发职业发展的必经之路。它不是可选的语法知识点,而是掌控 C++、发挥其极致能力、规避致命问题的底层根基。

为什么cpp开发要频繁使用指针?

指针是 C++ 的核心特性,其本质是存储内存地址的变量。C++ 工程频繁使用指针,不是因为 “语法复杂”,而是因为指针能解决其他语法(如引用、对象本身)无法解决的核心问题 —— 它是 C++ 实现动态内存管理、多态、高性能、底层操作的唯一或最优工具。

一、基本概念

内存栈 :栈是函数调用栈的简称,是操作系统为每个线程预分配的一块连续内存区域,用于存储函数调用过程中的临时数据,由编译器自动管理,无需人工干预。

内存堆 :堆是进程虚拟地址空间中,专门用于运行时动态内存分配的区域,其生命周期完全由程序员控制,灵活性极高但也极易出错。

RAII(Resource Acquisition Is Initialization) :直译为 “资源获取即初始化”,是 C++ 管理资源(内存、文件句柄、锁等)的核心范式,从根本上解决了手动管理堆内存的痛点(内存泄漏、异常安全、野指针)。

二、栈(stack)

栈是自动管理的 临时工作区,它有以下几个核心的特点:

  • 自动生命周期:数据随函数调用进入作用域时分配,随函数返回 / 作用域结束时自动销毁,完全无需手动释放。
  • 极高效率:分配 / 释放仅需移动栈指针(单 CPU 指令),无需系统调用,速度极快。
  • 空间有限:编译期固定大小(Linux 默认 8MB,Windows 默认 1MB),存储大对象或深度递归易触发栈溢出(Stack Overflow)
  • 内存连续:物理地址与虚拟地址均连续,无内存碎片问题。
  • 线程私有:每个线程有独立的栈,天然线程安全。

每次函数调用,编译器会在栈上压入一个 栈帧(Stack Frame),存储:

  • 函数的参数
  • 函数内的局部变量
  • 函数的返回地址(调用结束后回到哪里)

来看一段示例代码,来说明 C++ 里函数调用、本地变量是如何使用栈的。当然,这一过程取决于计算机的实际架构,具体细节可能有所不同,但原理上都是相通的,都会使用一个后进先出的结构。

void foo(int n)
{
  …
}

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

int main()
{
  …
  bar(42);
  …
}

这段代码执行过程中的栈变化 :

image.png

在示例中,栈是向上增长的。在包括 x86 在内的大部分计算机体系架构中,栈的增长方向是低地址,因而上方意味着低地址。任何一个函数,根据架构的约定,只能使用进入函数时栈指针向上部分的栈空间。当函数调用另外一个函数时,会把参数也压入栈里,然后把下一行汇编指令的地址压入栈,并跳转到新的函数。新的函数进入后,首先做一些必须的保存工作,然后会调整栈指针,分配出本地变量所需的空间,随后执行函数中的代码,并在执行完毕之后,根据调用者压入栈的地址,返回到调用者未执行的代码中继续执行。

三、堆(heap)

与栈不同,堆是需要手动管理的动态存储区,其特点如下:

  • 手动生命周期:内存分配后,直到主动释放前始终有效,需手动调用 delete/free 回收。
  • 空间巨大:64 位系统下几乎无上限,仅受限于系统物理内存 + 交换空间,可存储超大对象。
  • 效率较低:分配需调用运行时库 / 系统调用,可能触发锁竞争、内存整理,速度慢于栈。
  • 内存可能不连续:频繁分配释放易产生内存碎片,降低内存利用率。
  • 进程共享:所有线程共享同一个堆,多线程分配需考虑线程安全。

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

  • new 和 delete 操作的区域是 free store
  • malloc 和 free 操作的区域是 heap

但 new 和 delete 通常底层使用 malloc 和 free 来实现,所以 free store 也是 heap。鉴于对其区分的实际意义并不大。

C++ 出现内存泄漏的根本原因:C++ 的堆内存生命周期完全由程序员手动控制,语言本身没有自动垃圾回收(GC)机制,内存的分配(new/malloc)释放(delete/free) 必须严格配对;任何导致配对失败、内存引用丢失的操作,都会引发内存泄漏。

下面展示了一个简单的分配过程,熟悉操作系统知识的可能很容易理解:

image.png

image.png

四、RAII:资源管理的 “现代 C++ 圣经”

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

  • 对象很大;
  • 对象的大小在编译时不能确定;
  • 对象是函数的返回值,但由于特殊的原因,不应使用对象的值返回。

常见情况之一是,在工厂方法或其他面向对象编程的情况下,返回值类型是基类(的指针或引用)。下面的例子,是对工厂方法的简单演示:

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 的子类,圆啊,三角形啊,矩形啊,等等。这种情况下,函数的返回值只能是指针或其变体形式。如果返回类型是 shape,实际却返回一个 circle,编译器不会报错,但结果多半是错的。这种现象叫对象切片(object slicing),是 C++ 特有的一种编码错误。这种错误不是语法错误,而是一个对象复制相关的语义错误,也算是 C++ 的一个陷阱了,大家需要小心这个问题,同时这也是C++指针的一大用处。

为了在使用 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_wrapper(
    create_shape(…));
  …
}

这段代码是 C++ 中 RAII 思想的一个极简实现,核心目的是自动管理堆内存,避免内存泄漏。它可以看作是 std::unique_ptr 的一个 “雏形版本”。

这个类的作用是 “包裹” 一个 shape* 类型的堆指针,用对象的生命周期绑定堆内存的生命周期—— 对象创建时接管指针,对象销毁时自动释放内存。

构造函数:

explicit shape_wrapper(shape* ptr = nullptr) : ptr_(ptr) {}
  • explicit 关键字:禁止隐式转换,防止类似 shape_wrapper w = new shape(); 这样的意外写法,必须显式调用构造函数(如 shape_wrapper w(new shape());),避免指针被意外 “接管”。
  • 参数 shape* ptr = nullptr:接受一个 shape 类型的堆指针,默认值为 nullptr(空指针)。
  • 初始化列表 : ptr_(ptr) :将传入的指针赋值给成员变量 ptr_,完成 “资源接管”。

析构函数

~shape_wrapper() { delete ptr_; }

这是 RAII 的核心

  • shape_wrapper 对象离开作用域(如函数结束、提前 return、异常抛出)时,编译器会自动调用析构函数。
  • 析构函数中执行 delete ptr_,自动释放 ptr_ 指向的堆内存,无需手动调用 delete

小结

这里介绍了 C++ 里内存管理的一些基本概念,其中栈是 C++ 里最“自然”的内存使用方式,并且,使用基于栈和析构函数的 RAII,可以有效地对包括堆内存在内的系统资源进行统一管理。