C/C++ 内存管理详解:从内存分布到 new/delete 底层原理

C/C++ 内存管理详解:从内存分布到 new/delete 底层原理

文章目录

一、C/C++ 程序的内存分布

学习 C/C++ 内存管理,首先要搞清楚一个问题

一个变量到底存在哪里

在一个典型的 C/C++ 程序中,可以把主要内存区域理解成

text 复制代码
高地址
┌─────────────────────┐
│       栈区          │
│  局部变量、函数参数  │
│  函数返回值等        │
│        ↓            │
├─────────────────────┤
│                     │
│     内存映射段       │
│                     │
├─────────────────────┤
│                     │
│       堆区           │
│  malloc/new 动态申请 │
│        ↑            │
├─────────────────────┤
│     数据段/静态区    │
│  全局变量、静态变量  │
├─────────────────────┤
│     代码段/常量区    │
│  程序代码、只读常量  │
└─────────────────────┘
低地址

其中最重要的是理解下面几个区域

1 栈区

栈主要存放

  • 非静态局部变量
  • 函数参数
  • 函数返回值等

栈的一个重要特点是

栈是向下增长的

例如

cpp 复制代码
void Test()
{
    int localVar = 1;
}

这里的 localVar 是普通局部变量,因此存储在栈区


2 堆区

堆主要用于程序运行过程中进行动态内存分配

例如

cpp 复制代码
int* p = (int*)malloc(sizeof(int));

这里 p 本身是一个局部变量

所以

text 复制代码
p

存储在栈区

而

text 复制代码
*p

所指向的动态申请出来的空间位于堆区

可以简单理解成

text 复制代码
栈
┌─────────────┐
│ p           │ ──────────┐
└─────────────┘           │
                          ↓
                       堆区
                    ┌─────────┐
                    │ int空间 │
                    └─────────┘

堆的另一个特点是

堆是向上增长的


3 数据段

数据段也叫静态区

主要存放

  • 全局变量
  • 静态变量

例如

cpp 复制代码
int globalVar = 1;

static int staticGlobalVar = 1;

void Test()
{
    static int staticVar = 1;
}

这三个变量都属于静态存储的数据

因此都位于数据段

这里尤其容易混淆的是

cpp 复制代码
static int staticVar = 1;

虽然 staticVar 写在函数内部

但它不是普通局部变量

因为它具有静态存储期

所以它存储在数据段,而不是栈区


4 代码段

代码段主要存放

  • 可执行代码
  • 只读常量

例如字符串常量

cpp 复制代码
"abcd"

通常位于只读常量区域


二、通过代码判断变量到底在哪里

看下面这段代码

cpp 复制代码
int globalVar = 1;
static int staticGlobalVar = 1;

void Test()
{
    static int staticVar = 1;

    int localVar = 1;

    int num1[10] = {1, 2, 3, 4};

    char char2[] = "abcd";

    const char* pChar3 = "abcd";

    int* ptr1 = (int*)malloc(sizeof(int) * 4);

    int* ptr2 = (int*)calloc(4, sizeof(int));

    int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);

    free(ptr1);
    free(ptr3);
}

判断这种题时,不要只看变量的类型

最重要的是看

变量本身存在哪里,以及变量指向的内容存在哪里


1 globalVar

cpp 复制代码
int globalVar = 1;

这是全局变量

所以

text 复制代码
globalVar → 数据段

2 staticGlobalVar

cpp 复制代码
static int staticGlobalVar = 1;

这是静态全局变量

所以

text 复制代码
staticGlobalVar → 数据段

3 staticVar

cpp 复制代码
static int staticVar = 1;

虽然它定义在函数内部

但是它是静态变量

所以

text 复制代码
staticVar → 数据段

4 localVar

cpp 复制代码
int localVar = 1;

这是普通局部变量

所以

text 复制代码
localVar → 栈区

5 num1

cpp 复制代码
int num1[10] = {1, 2, 3, 4};

num1 是函数内部定义的普通局部数组

因此数组本身位于栈区

text 复制代码
num1 → 栈区

数组中的元素也是这个数组空间的一部分

因此

text 复制代码
*num1

访问到的内容也在栈区


6 char2

cpp 复制代码
char char2[] = "abcd";

这里特别容易和字符串常量混淆

char2 是一个数组

它会直接创建一块数组空间,并把字符串内容复制进去

实际上相当于

text 复制代码
char2
┌───┬───┬───┬───┬────┐
│ a │ b │ c │ d │ \0 │
└───┴───┴───┴───┴────┘

因为 char2 是函数内部的普通局部数组

所以整个数组位于栈区

text 复制代码
char2 → 栈区
*char2 → 栈区中的 'a'

7 pChar3

cpp 复制代码
const char* pChar3 = "abcd";

这里必须把

text 复制代码
pChar3

和

text 复制代码
*pChar3

分开来看

pChar3 本身是一个局部变量

所以

text 复制代码
pChar3 → 栈区

但是它指向的是字符串常量

cpp 复制代码
"abcd"

字符串常量存放在代码段/常量区

所以

text 复制代码
*pChar3 → 常量区

可以画成

text 复制代码
栈区
┌──────────────┐
│ pChar3       │
└──────┬───────┘
       │
       ↓
常量区
┌──────────────┐
│ a b c d \0   │
└──────────────┘

这也是内存分布题中非常经典的考点

看指针变量在哪里不能直接推出指针指向的内容在哪里


8 ptr1

cpp 复制代码
int* ptr1 = (int*)malloc(sizeof(int) * 4);

这里存在两个东西

text 复制代码
ptr1

以及

text 复制代码
malloc申请出来的空间

ptr1 是函数内部的局部变量

所以

text 复制代码
ptr1 → 栈区

而 malloc 申请出来的空间位于堆区

所以

text 复制代码
*ptr1 → 堆区

可以理解成

text 复制代码
栈区
┌──────────┐
│ ptr1     │
└────┬─────┘
     │
     ↓
堆区
┌────────────────┐
│ int int int int │
└────────────────┘

三、C语言动态内存管理

C语言提供了四个非常重要的动态内存管理函数

cpp 复制代码
malloc
calloc
realloc
free

它们操作的主要对象都是堆上的动态内存


四、malloc

1 基本形式

cpp 复制代码
void* malloc(size_t size);

作用是

申请一块指定大小的连续内存空间

例如

cpp 复制代码
int* p = (int*)malloc(sizeof(int) * 10);

表示申请能够存放 10 个 int 的空间


2 malloc不会初始化

例如

cpp 复制代码
int* p = (int*)malloc(sizeof(int) * 10);

malloc 只是申请空间

不会主动把这块空间初始化为 0

因此其中的内容是未确定的


3 malloc申请失败

如果申请失败

cpp 复制代码
malloc

返回

cpp 复制代码
NULL

因此使用 malloc 时通常需要考虑空指针问题

cpp 复制代码
int* p = (int*)malloc(sizeof(int) * 10);

if (p == NULL)
{
    // 申请失败
}

五、calloc

1 基本形式

cpp 复制代码
void* calloc(size_t num, size_t size);

例如

cpp 复制代码
int* p = (int*)calloc(10, sizeof(int));

表示申请 10 个 int 大小的空间


2 calloc与malloc的核心区别

例如

cpp 复制代码
int* p1 = (int*)malloc(sizeof(int) * 10);

int* p2 = (int*)calloc(10, sizeof(int));

二者都申请了能够存放 10 个 int 的空间

但是 calloc 会对申请的空间进行初始化

这里初始化为 0

所以最核心的区别可以记成

text 复制代码
malloc
申请空间
不负责初始化

calloc
申请空间
并进行初始化

六、realloc

1 基本形式

cpp 复制代码
void* realloc(void* ptr, size_t size);

主要用于

调整一块已经申请的动态内存空间大小

例如

cpp 复制代码
int* p = (int*)malloc(sizeof(int) * 4);

p = (int*)realloc(p, sizeof(int) * 10);

原来空间可以存放 4 个 int

调整之后希望能够存放 10 个 int


2 realloc可能发生什么

当扩大空间时,如果原来的位置后面有足够的连续空间

那么可能直接在原来的位置扩展

如果后面没有足够空间

那么 realloc 可能重新找一块更大的空间

然后把原来的数据复制过去

最后释放原来的空间

所以

realloc之后原来的指针可能失效


3 realloc之后还需要free原指针吗

例如

cpp 复制代码
int* p2 = (int*)calloc(4, sizeof(int));

int* p3 = (int*)realloc(p2, sizeof(int) * 10);

free(p3);

如果 realloc 成功并返回了新的空间

那么原来的空间已经由 realloc 处理

因此不能再对原来的 p2 进行 free

正确的处理是最终释放 realloc 返回的指针

cpp 复制代码
free(p3);

如果直接写成

cpp 复制代码
free(p2);
free(p3);

就可能导致重复释放


七、free

free 用于释放动态申请的堆空间

例如

cpp 复制代码
int* p = (int*)malloc(sizeof(int) * 10);

free(p);

释放之后,这块动态内存就不能继续当成有效空间使用

通常还可以把指针置空

cpp 复制代码
free(p);
p = NULL;

这样可以降低后续误用悬空指针的风险


八、C++中的动态内存管理

C语言中的

cpp 复制代码
malloc
calloc
realloc
free

在 C++ 中依然可以使用

但是对于 C++ 的自定义类型来说,仅仅申请和释放内存是不够的

因此 C++ 提供了

cpp 复制代码
new
delete
new[]
delete[]

用于动态内存管理


九、new和delete

1 new申请单个对象

例如

cpp 复制代码
int* p = new int;

表示动态申请一个 int 对象


2 new申请并初始化

可以直接进行初始化

cpp 复制代码
int* p = new int(10);

表示

text 复制代码
申请一个int对象
并初始化为10

3 new申请连续空间

例如

cpp 复制代码
int* p = new int[3];

表示申请 3 个连续的 int 空间


4 delete释放单个对象

如果使用

cpp 复制代码
new

申请单个对象

就使用

cpp 复制代码
delete

释放

例如

cpp 复制代码
int* p = new int;

delete p;

5 delete\[\]释放数组

如果使用

cpp 复制代码
new[]

申请数组

就必须使用

cpp 复制代码
delete[]

释放

例如

cpp 复制代码
int* p = new int[3];

delete[] p;

最重要的匹配关系

text 复制代码
new       → delete
new[]     → delete[]

不能混用


十、自定义类型的new和delete

这是 C++ 动态内存管理中非常重要的一部分

例如

cpp 复制代码
class A
{
public:
    A(int a = 0)
        : _a(a)
    {
        cout << "A():" << this << endl;
    }

    ~A()
    {
        cout << "~A():" << this << endl;
    }

private:
    int _a;
};

创建对象

cpp 复制代码
A* p = new A(10);

这里并不只是简单地申请一块内存

还会调用构造函数

text 复制代码
申请空间
↓
调用A的构造函数
↓
对象构造完成

释放对象

cpp 复制代码
delete p;

则会先调用析构函数

然后释放空间

text 复制代码
调用A的析构函数
↓
释放对象空间

十一、malloc/free与new/delete最大的区别

对于内置类型

cpp 复制代码
int
char
double

malloc/free 和 new/delete 的行为比较接近

但对于自定义类型,区别非常明显

例如

cpp 复制代码
A* p1 = (A*)malloc(sizeof(A));

malloc 只负责

text 复制代码
申请空间

不会调用

text 复制代码
A的构造函数

释放时

cpp 复制代码
free(p1);

也不会调用

text 复制代码
A的析构函数

而

cpp 复制代码
A* p2 = new A(1);

会

text 复制代码
申请空间
↓
调用A的构造函数

释放

cpp 复制代码
delete p2;

会

text 复制代码
调用A的析构函数
↓
释放空间

所以最核心的一句话是

malloc/free只负责内存空间,new/delete除了管理空间,还负责对象的构造和析构


十二、operator new与operator delete

这里很容易把

cpp 复制代码
new

和

cpp 复制代码
operator new

混为一谈

实际上它们不是一回事


1 new是操作符

我们平时写

cpp 复制代码
A* p = new A(10);

这里的 new 是 C++ 的操作符

它负责完成整个对象创建过程


2 operator new是函数

cpp 复制代码
operator new

是系统提供的全局函数

它主要负责

申请原始内存空间

new 的底层会调用 operator new

可以简单理解成

text 复制代码
new
│
├── operator new
│      │
│      └── 申请内存空间
│
└── 调用构造函数

3 delete与operator delete

同样

cpp 复制代码
delete

是操作符

cpp 复制代码
operator delete

是函数

delete 的底层会通过 operator delete 来释放空间

可以理解成

text 复制代码
delete
│
├── 调用析构函数
│
└── operator delete
       │
       └── 释放内存空间

十三、operator new的底层实现

text 复制代码
operator new
      ↓
malloc申请空间
      ↓
申请成功
      ↓
直接返回

如果 malloc 申请失败

text 复制代码
malloc申请失败
      ↓
尝试执行空间不足的应对措施
      ↓
能够继续申请
      ↓
重新申请

如果仍然无法解决

text 复制代码
抛出std::bad_alloc异常

所以一个非常重要的区别是

text 复制代码
malloc申请失败
→ 返回NULL

new申请失败
→ 抛出异常

十四、operator delete的底层实现

operator delete 最终会通过类似 free 的机制释放空间

可以简单理解为

text 复制代码
delete
↓
operator delete
↓
释放空间

所以可以把两组关系记住

text 复制代码
operator new  → malloc
operator delete → free

这里说的是底层实现上的关系

不能因此认为

text 复制代码
new == malloc
delete == free

因为 new/delete 还承担对象构造和析构等工作


十五、new的底层原理

对于自定义类型

cpp 复制代码
A* p = new A(10);

可以拆成两个核心步骤

第一步

调用

cpp 复制代码
operator new

申请原始内存空间

第二步

在申请出来的空间上调用构造函数

完成对象构造

所以

text 复制代码
new A(10)
      ↓
operator new
      ↓
申请原始内存
      ↓
调用A(10)构造函数
      ↓
得到A对象

十六、delete的底层原理

对于

cpp 复制代码
delete p;

可以拆成两个核心步骤

第一步

调用析构函数

完成对象内部资源清理

第二步

调用

cpp 复制代码
operator delete

释放对象占用的空间

所以

text 复制代码
delete p
   ↓
调用析构函数
   ↓
operator delete
   ↓
释放内存

十七、new\[\]的底层原理

如果写

cpp 复制代码
A* p = new A[10];

表示申请 10 个 A 对象的空间

底层过程可以理解成

text 复制代码
new A[10]
      ↓
operator new[]
      ↓
申请10个对象所需要的空间
      ↓
依次调用10次构造函数
      ↓
10个对象构造完成

也就是说

new\[\]不仅申请连续空间,还需要完成多个对象的构造


十八、delete\[\]的底层原理

对应

cpp 复制代码
delete[] p;

需要完成

text 复制代码
调用10次析构函数
      ↓
清理10个对象中的资源
      ↓
operator delete[]
      ↓
释放整块空间

因此

cpp 复制代码
new A[10]

对应

cpp 复制代码
delete[] p;

而不是

cpp 复制代码
delete p;

十九、为什么new\[\]和delete\[\]必须匹配

例如

cpp 复制代码
A* p = new A[10];

delete[] p;

创建的是 10 个对象

所以释放时需要知道

text 复制代码
这里存在多个对象

然后逐个调用析构函数

因此必须使用

cpp 复制代码
delete[]

而不是普通的

cpp 复制代码
delete

对于自定义类型尤其需要注意这一点


二十、placement new

placement new 又叫

定位 new

它和普通 new 最大的区别是

它不是负责申请内存,而是在已经分配好的原始内存空间中调用构造函数初始化对象

基本形式

cpp 复制代码
new (place_address) type;

或者

cpp 复制代码
new (place_address) type(initializer-list);

其中

text 复制代码
place_address

必须是一个指针


1 为什么需要placement new

假设

cpp 复制代码
A* p1 = (A*)malloc(sizeof(A));

此时虽然已经获得了一块足够存放 A 的空间

但它还不能简单理解成一个完整构造好的 A 对象

因为

text 复制代码
A的构造函数还没有执行

这时可以使用

cpp 复制代码
new(p1) A;

在这块已经存在的内存上调用 A 的构造函数


2 完整过程

例如

cpp 复制代码
A* p1 = (A*)malloc(sizeof(A));

new(p1) A;

p1->~A();

free(p1);

整个过程是

text 复制代码
malloc
↓
获得原始内存
↓
placement new
↓
调用构造函数
↓
对象可以正常使用
↓
调用析构函数
↓
free释放原始内存

注意这里最后使用的是

cpp 复制代码
free

因为最开始的内存是通过

cpp 复制代码
malloc

申请的


二十一、placement new的典型使用场景

placement new 一般和

内存池

配合使用

内存池提前申请一大块原始内存

然后在这块原始内存中构造一个个对象

因为内存池提供的只是原始内存

并没有自动调用对象的构造函数

所以需要使用 placement new

可以理解为

text 复制代码
内存池
│
├── 原始内存
│
├── placement new
│       ↓
│    构造对象
│
└── placement new
        ↓
     构造对象

二十二、malloc/free与new/delete的完整区别

1 本质不同

text 复制代码
malloc/free → 函数
new/delete → 操作符

2 初始化不同

malloc

cpp 复制代码
int* p = (int*)malloc(sizeof(int));

不会主动进行对象初始化

new

cpp 复制代码
int* p = new int(10);

可以在申请空间的同时进行初始化


3 申请空间时的写法不同

malloc 需要自己计算字节数

例如

cpp 复制代码
int* p = (int*)malloc(sizeof(int) * 10);

new 直接指定类型

cpp 复制代码
int* p = new int[10];

因此 new 的表达方式更加直接


4 返回值不同

malloc 返回

cpp 复制代码
void*

在 C++ 中通常需要进行类型转换

例如

cpp 复制代码
int* p = (int*)malloc(sizeof(int));

new 则直接返回对应类型的指针

cpp 复制代码
int* p = new int;

不需要手动强转


5 申请失败时的处理不同

malloc

text 复制代码
申请失败
↓
返回NULL

new

text 复制代码
申请失败
↓
抛出异常

因此使用 new 时需要通过异常机制处理申请失败


6 对自定义类型的处理不同

malloc

cpp 复制代码
A* p = (A*)malloc(sizeof(A));

只负责申请空间

不会调用

text 复制代码
构造函数

free 也不会调用

text 复制代码
析构函数

而

cpp 复制代码
A* p = new A;

会

text 复制代码
申请空间
+
调用构造函数

对应

cpp 复制代码
delete p;

会

text 复制代码
调用析构函数
+
释放空间

二十三、几组最容易混淆的概念

1 指针和指针指向的空间不是一回事

例如

cpp 复制代码
int* p = new int;

这里

text 复制代码
p

是一个指针变量

如果它定义在函数内部,那么它本身位于栈区

而

text 复制代码
*p

访问的是 new 在堆区申请的空间

所以

text 复制代码
p   → 栈区
*p  → 堆区

2 数组和字符串常量不是一回事

cpp 复制代码
char str1[] = "abcd";

这是数组

数组空间由 str1 自己拥有

如果是函数中的局部数组,那么位于栈区

而

cpp 复制代码
const char* str2 = "abcd";

这里的 "abcd" 是字符串常量

指针变量 str2 本身可以位于栈区

但是它指向的是常量区

所以

text 复制代码
str1
→ 栈区

str2
→ 栈区

*str2
→ 常量区

3 static局部变量和普通局部变量不同

cpp 复制代码
void Test()
{
    int a = 10;

    static int b = 10;
}

虽然两个变量都写在函数内部

但是

text 复制代码
a → 栈区
b → 数据段

关键不在于它是不是写在函数内部

而在于它是不是静态变量


4 new和operator new不同

cpp 复制代码
new A;

中的 new 是操作符

而

cpp 复制代码
operator new(sizeof(A));

中的 operator new 是函数

new 完成的是完整的对象创建过程

operator new 主要负责原始内存空间的申请


二十四、底层调用关系

把整个体系串起来,可以得到下面这张关系

text 复制代码
C语言

malloc
  ↓
申请堆空间

free
  ↓
释放堆空间
text 复制代码
C++

new
 ↓
operator new
 ↓
申请原始空间
 ↓
调用构造函数
 ↓
对象创建完成
text 复制代码
C++

delete
 ↓
调用析构函数
 ↓
operator delete
 ↓
释放空间

数组则是

text 复制代码
new[]
 ↓
operator new[]
 ↓
申请连续空间
 ↓
调用N次构造函数

以及

text 复制代码
delete[]
 ↓
调用N次析构函数
 ↓
operator delete[]
 ↓
释放连续空间

二十五、几组代码的完整分析

代码一

cpp 复制代码
int* p = (int*)malloc(sizeof(int));

结果

text 复制代码
p本身
→ 如果是局部变量,则在栈区

malloc申请的int空间
→ 堆区

代码二

cpp 复制代码
int* p = new int(10);

结果

text 复制代码
p
→ 栈区

new出来的int对象
→ 堆区

对象值
→ 10

代码三

cpp 复制代码
A* p = new A(10);

执行过程

text 复制代码
operator new
↓
申请空间
↓
调用A(10)
↓
构造函数执行
↓
A对象创建完成

代码四

cpp 复制代码
delete p;

执行过程

text 复制代码
调用A的析构函数
↓
operator delete
↓
释放空间

代码五

cpp 复制代码
A* p = new A[10];

delete[] p;

创建过程

text 复制代码
申请10个A对象的空间
↓
调用10次构造函数

释放过程

text 复制代码
调用10次析构函数
↓
释放整块空间

代码六

cpp 复制代码
A* p = (A*)malloc(sizeof(A));

new(p) A;

p->~A();

free(p);

执行过程

text 复制代码
malloc
↓
申请原始内存
↓
placement new
↓
调用构造函数
↓
对象构造完成
↓
手动调用析构函数
↓
free
↓
释放原始内存

这里可以清楚地看到

申请内存和构造对象是两个不同的概念

text 复制代码
内存空间存在
≠
对象已经构造完成

这也是 placement new 存在的重要原因

相关推荐
C++ 老炮儿的技术栈1 小时前
不一样的数值交换
数据结构·c++·人工智能·算法·c·csdn开发云
C++ 老炮儿的技术栈1 小时前
main函数之后的调用
c语言·c++·人工智能·qt·mfc·c
知识分享小能手2 小时前
C++ 学习教程,从入门到精通,C++ 友元、异常和其他特性 — 详细知识点(15)
开发语言·c++·学习
꯭自꯭闭꯭2 小时前
DM7主备升级方案
linux·服务器·数据库
用户4107192013252 小时前
VMware 虚拟机Ubuntu 环境linux远程连接失败
linux
ebiobiz2 小时前
极海 APM32 使用 Nimmake 编译指南
c++·python·单片机·嵌入式硬件·mcu
青山木2 小时前
Hot 100 --- 编辑距离
java·数据结构·算法·leetcode·动态规划
朝朝辞暮i2 小时前
C++ 第 26 课:对象、指针与 ->
java·开发语言·c++
长弓三石2 小时前
把 AgentScope Harness 装进 RuoYi-Vue-Plus:纯 Java AI 平台的集成实践
java·人工智能·agent