C++:内存管理
1.C++的内存分布
在现代操作系统(Linux / Windows / macOS)和常见 64 位/32 位程序模型下,应用的虚拟地址空间通常可以被划分为若干段(从低地址到高地址,实际地址范围由 OS + ASLR 决定):
1.1总体运行时的内存分布
//
低地址
┌─────────────────────────────┐
│ 程序代码区(text) │ ← 只读,存放机器指令
├─────────────────────────────┤
│ 只读常量/只读数据(rodata) │ ← 字符串字面量、const 全局等
├─────────────────────────────┤
│ 初始化数据段(.data) │ ← 已初始化的全局/静态变量
├─────────────────────────────┤
│ 未初始化数据段(.bss) │ ← 未初始化或为 0 的全局/静态变量
├─────────────────────────────┤
│ 堆(heap) ↑ │ ← 动态分配(malloc/new),向高地址扩张(通常)
│ │
│ (动态分配区) │
│ │
├─────────────────────────────┤
│ [ 映射区:mmap 文件/共享库 ]│ ← 通常在堆上方或在高地址部分映射
├─────────────────────────────┤
│ 栈(stack) ↓ │ ← 每个线程通常有自己的栈,向低地址扩张(通常)
└─────────────────────────────┘
1.2分区详解
1.2.1代码区
存放:可执行文件的机器代码(函数、指令)
权限:通常 可执行、只读(避免被修改)。
生命周期:程序加载到内存后一直存在直到进程结束。
说明:多个同程序进程可共享该段(节省物理内存)。JIT 或自修改代码会有特殊处理(需要可写可执行页面,通常不推荐)。
1.2.2只读常量区
存放:字符串字面量(“hello”)、const 全局常量等
权限:只读(写入会导致段错误)。
生命周期:程序整个生命周期。
示例
// const char* s = "hello"; // "hello" 存在
1.2.3数据段与 BSS 段
存放:
.data(已初始化数据段):存放带初始值的全局/静态变量,例如 int g = 5;
.bss(未初始化/零初始化):存放未显式初始化的全局/静态变量,或初始化为 0 的变量(节约可执行文件大小)
生命周期:程序启动→结束
访问:和代码区类似,地址固定,方便调试与外部工具查看(如 readelf、objdump)
1.2.4堆(Heap / 动态区)
存放:动态分配的内存(malloc / calloc / realloc / new)。
生命周期:从分配到释放(程序员或库负责释放),否则为内存泄漏直到进程结束。
增长方向(典型):向高地址增长(brk/sbrk 调整程序 break),但现代 allocator 也会使用 mmap 在任意位置分配内存(尤其是大块或线程/共享内存)。
分配器:glibc 的 malloc、tcmalloc、jemalloc、mimalloc 等实现不同策略(bins、slab、ptmalloc),影响性能与碎片。
特点:
- 比栈慢(有锁、元数据处理)
- 大对象通常通过匿名 mmap 分配(便于释放回操作系统)
- 存在碎片(内存被分成很多小块导致无法连续使用)
示例:
//
int* p = new int(10); // C++ heap(调用 operator new)
void* q = malloc(1024); // C heap
1.2.5栈(Stack)
存放:函数局部变量、函数参数(按 ABI)、返回地址、保存的寄存器(callee/caller-saved)、帧指针(可选)等。
生命周期:进入函数时分配,离开函数时自动释放。
增长方向(典型):向低地址增长(某些体系可反向)。
特点:
- 分配/释放快速(在指针上增加/减少)
- 空间有限(线程栈大小通常是几百 KB 到几 MB),递归或大局部数组易导致栈溢出(stack overflow)
- 栈帧(stack frame)布局受 ABI 和编译器优化影响(有无 frame pointer)
示例:
//
void foo() {
int a = 10; // 存放在栈
int arr[1000]; // 大数组也在栈(易溢出)
}
1.3例题🌰
//
#include <stdio.h>
#include <stdlib.h>
int g_val = 10;
static int g_static = 20;
void Func()
{
static int s_val = 30;
int a = 40;
int arr[5] = {1,2,3,4,5};
char str1[] = "hello";
const char* str2 = "world";
int* p1 = (int*)malloc(sizeof(int));
int* p2 = new int(50);
}
选项:
A. 栈
B. 堆
C. 数据段(静态区)
D. 代码段(常量区)
g_val 在哪里? ____
g_static 在哪里? ____
s_val 在哪里? ____
a 在哪里? ____
arr 在哪里? ____
str1 在哪里? ____
*str1 在哪里? ____
str2 在哪里? ____
*str2 在哪里? ____
p1 在哪里? ____
*p1 在哪里? ____
p2 在哪里? ____
*p2 在哪里? ____
答案:
g_val → C 数据段(静态区)
g_static → C 数据段(静态区)
s_val → C 数据段(静态区)
a → A 栈
arr → A 栈
str1 → A 栈
*str1 → A 栈
str2 → A 栈
*str2 → D 代码段(常量区)
p1 → A 栈
*p1 → B 堆
p2 → A 栈
*p2 → B 堆
2.C语言中动态内存管理方式:malloc/calloc/realloc/free
在 C 语言中,动态内存分配发生在“堆区”,由程序员手动申请、手动释放。
主要通过以下 4 个函数完成:
| 函数 | 作用 |
|---|---|
| malloc | 只申请内存 |
| calloc | 申请并初始化 |
| realloc | 调整已申请内存的大小 |
| free | 释放内存 |
需要包含头文件:
#include <stdlib.h>
3.C++中内存管理方式
C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力,而且使用起来比较麻烦,因此C++又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理。
在 C++ 中,new 和 delete 既可以用于内置类型(int、double、char 等),也可以用于 自定义类型(类、结构体)。两者在“内存分配阶段”是相同的,但在“对象构造与析构阶段”存在本质区别,这正是很多初学者容易混淆的地方。
3.1new/delete操作的内置类型
//
int* p = new int(10);
delete p;
这行代码在底层实际做了两件事:
第一步,通过 operator new 在堆上申请一块 sizeof(int) 大小的内存;
第二步,把值 10 直接写入这块内存中。
由于 int 属于内置类型,它没有构造函数和析构函数,因此:
new int(10) 只做“分配内存 + 简单初始化”
delete p 只做“释放内存”,不会执行任何析构逻辑
同理,数组形式也是如此:
int* arr = new int[5]{1,2,3,4,5};
delete[] arr;
这里 new[] 连续申请 5 个 int 的空间并初始化,delete[]只是一次性释放这片堆内存。整个过程中 没有任何构造与析构行为,只是纯粹的内存管理。
3.2new/delete操作的自定义类型
接下来是 自定义类型(类对象) 的情况,这是 new/delete 真正体现 C++ 面向对象特性的地方。
//
class Test
{
public:
Test()
{
cout << "构造函数" << endl;
p = new int(10);
}
~Test()
{
cout << "析构函数" << endl;
delete p;
}
private:
int* p;
};
当你执行:
Test* t = new Test;
此时 new 做了三件事:
第一步:调用 operator new(sizeof(Test)) 在堆上申请一块足够大的原始内存;
第二步:在这块内存上调用 Test 的构造函数,对对象进行初始化;
第三步:返回这块内存的首地址,并转为 Test* 类型。
当你执行:
delete t;
delete 同样会依次完成两件关键步骤:
第一步:先调用 t->~Test(),执行析构函数,释放对象内部申请的资源(例如 p 指向的内存);
第二步:再调用 operator delete(t),真正把这块堆内存归还给系统。
这正是 自定义类型与内置类型使用 new/delete 的本质区别:
- 内置类型:只有“分配内存 + 释放内存”
- 自定义类型:在此基础上 额外包含构造和析构过程
如果是对象数组,差异会更加明显:
Test* arr = new Test[3];
delete[] arr;
执行流程是:
new Test[3]:先申请一块能容纳 3 个Test对象的连续内存,然后 依次调用 3 次构造函数delete[] arr:先 依次调用 3 次析构函数,再统一释放整块内存
如果错误的写成:
delete arr; // 错误写法
就会导致:
- 只调用第 1 个元素的析构函数
- 其余对象的资源无法释放
- 形成内存泄漏甚至程序崩溃
因此有一条必须牢记的铁律:
new 对应 delete,new[] 对应 delete[],永远不能混用。
再从“底层本质”来看,不论你是对内置类型还是自定义类型使用 new,最终都会落到两个函数上:
void* operator new(size_t size);
void operator delete(void* p);
区别仅在于:
- 内置类型:
new之后 不会触发构造函数 - 自定义类型:
new之后 一定会调用构造函数 - 内置类型:
delete时 不会触发析构函数 - 自定义类型:
delete时 一定会调用析构函数
3.3malloc/free和new/delete的区别
也正因为这个特性,malloc/free 无法替代 new/delete 来创建类对象。比如:
Test* t = (Test*)malloc(sizeof(Test));
free(t);
这种写法:
- 不会调用构造函数
- 不会调用析构函数
- 对于内部有动态资源管理的类来说是严重错误用法
而:
Test* t = new Test;
delete t;
则完全符合 C++ 的对象生命周期管理模型,这也是 C++ 必须引入new/delete的根本原因。
malloc/free和new/delete的共同点是:都是从堆上申请空间,并且需要用户手动释放。
不同的地方是:
- malloc和free是
函数,new和delete是操作符 - malloc申请的空间
不会初始化,new会初始化 - malloc申请空间时,需要手动计算空间大小并传递,new只需在其后跟上空间的类型即可, 如果是多个对象,[]中指定对象个数即可
- malloc的返回值为void*, 在使用时
必须强转,new不需要,因为new后跟的是空间的类型 - malloc申请空间失败时,返回的是
NULL,因此使用时必须判空,new不需要,但是new需要捕获异常 - 申请自定义类型对象时,
malloc/free只会开辟空间,不会调用构造函数与析构函数,而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理释放
4.定位new表达式(placement-new)
4.1定位new语法
定位new的语法如下:
new (address) Type(arguments);
address:指向已经分配好的内存地址。
Type:要构造的类型。
arguments:构造函数参数。
举例🌰:
#include <iostream>
#include <new> // placement new 需要包含这个头文件
struct MyClass {
int x;
MyClass(int val) : x(val) { std::cout << "构造函数调用\n"; }
~MyClass() { std::cout << "析构函数调用\n"; }
};
int main() {
char buffer[sizeof(MyClass)]; // 在栈上预留一块内存
MyClass* p = new (buffer) MyClass(10); // 在 buffer 上构造对象
std::cout << "p->x = " << p->x << std::endl;
p->~MyClass(); // 手动调用析构函数
return 0;
}
输出:
构造函数调用
p->x = 10
析构函数调用
关键点说明:
-
不会分配内存
定位new并不分配内存,它只是在给定的内存地址上调用构造函数。如果内存不足或地址无效,可能导致严重错误。 -
手动调用析构函数
因为对象没有通过普通new分配,delete不能释放它,必须手动调用析构函数:
p->~MyClass();
-
常用于自定义内存管理
对象池(Object Pool)
内存对齐优化
嵌入式系统或性能敏感场景 -
注意对齐要求
放置对象的内存必须满足该类型的对齐要求,否则行为未定义。可使用alignas保证对齐:
alignas(MyClass) char buffer[sizeof(MyClass)];
4.2与普通new的区别
| 特性 | 普通 new | 定位 new |
|---|---|---|
| 内存分配 | 自动在堆上分配 | 使用用户提供的内存 |
| 构造函数调用 | 自动调用 | 同样会调用构造函数 |
| 析构函数调用 | 自动调用(delete) | 需手动调用 |
| 适用场景 | 常规动态对象 | 自定义内存管理、高性能需求 |
小结
定位 new 的核心作用是:在已有内存上构造对象,而不是分配新内存。
它常用于高性能场景或特殊内存管理需求。
使用时需注意手动调用析构函数,并保证内存对齐正确。
🌈本篇关于C++内层管理的讲解到此结束!我们下篇再见👋👋👋



884

被折叠的 条评论
为什么被折叠?



