C与C++内存管理详解:malloc、new、delete与RAII

C与C++内存管理详解:malloc、new、delete与RAII
Lichermione内存管理贯穿C与C++程序的整个生命周期。变量存放在哪里、对象何时开始存在、动态内存由谁释放、构造失败时怎样回收空间,这些问题直接影响程序的正确性、性能与稳定性。
本文从进程虚拟地址空间讲起,系统比较malloc/calloc/realloc/free与new/delete,分析operator new/operator delete、对象数组、定位new、异常安全、内存泄漏与RAII,并通过可编译示例观察对象构造和析构过程。
一、先区分几个容易混淆的概念
1.1 内存、存储与对象
一段可用字节不一定已经包含一个合法的C++对象。
1 | void* raw = std::malloc(sizeof(std::string)); |
此时获得的是原始存储,std::string构造函数尚未执行,不能直接把它当作已经存在的字符串对象使用。
C++对象通常涉及两个层次:
- 获得满足大小和对齐要求的存储;
- 在存储中开始对象生命周期并完成初始化。
释放时则反过来:
- 结束对象生命周期,执行必要的析构;
- 归还存储。
1.2 栈、堆与存储期
日常开发常说“栈对象”和“堆对象”,但C++标准更关注对象的存储期:
- 自动存储期;
- 静态存储期;
- 线程存储期;
- 动态存储期。
编译器可能把自动变量放入寄存器、优化掉或重排,因此“局部变量一定实际位于硬件栈中”不是语言层面的绝对保证。
1.3 虚拟地址不等于物理内存
现代操作系统通常为每个进程提供独立的虚拟地址空间。程序看到的是虚拟地址,操作系统再通过页表把虚拟页映射到物理内存、文件或交换空间。
因此:
- 成功保留一大段虚拟地址不代表所有物理页已经实际分配;
- 地址空间足够不代表分配一定成功;
- 内存上限还受进程限制、容器限制、系统提交策略等影响。
二、典型进程虚拟地址空间
2.1 常见区域
一个常见的用户态进程地址空间可能包含:
1 | 高地址 |
这只是便于理解的典型布局,不是C或C++标准强制规定的固定图。
2.2 “栈向下、堆向上”不是标准保证
许多传统平台上的主线程栈向低地址增长,传统brk堆向高地址增长,但:
- C/C++语言标准不规定增长方向;
- 大块动态分配可能直接使用内存映射;
- 不同线程有各自的栈;
- 分配器可以维护多个arena和缓存;
- 地址空间随机化会改变实际位置。
面试时可以先说明常见实现,再强调这是操作系统和ABI细节。
2.3 变量与对象分别在哪里
1 | int globalValue = 1; |
常见实现中可以这样理解:
| 名称 | 自身的存储期或典型区域 | 它指向或包含的数据 |
|---|---|---|
globalValue |
静态存储期,数据区域 | 整数值 |
staticGlobalValue |
静态存储期 | 整数值 |
staticLocalValue |
静态存储期 | 整数值 |
localValue |
自动存储期 | 整数值 |
numbers |
自动存储期 | 10个连续int |
localText |
自动存储期 | 可修改字符数组 |
literalPointer |
指针本身是自动变量 | 指向静态存储期字符串字面量 |
dynamicPointer |
指针本身是自动变量 | 指向动态分配的存储 |
指针变量放在哪里与它指向的数据放在哪里是两个问题。
2.4 字符数组与字符串字面量
1 | char text[] = "abcd"; |
text是包含5个字符的数组:
1 | 'a' 'b' 'c' 'd' '\0' |
可以修改数组内容:
1 | text[0] = 'A'; |
而:
1 | const char* pointer = "abcd"; |
pointer指向字符串字面量。字符串字面量不能被程序修改,C++中应使用const char*,而不是旧代码中的char*。
三、sizeof与strlen
3.1 数组大小
1 | int numbers[10] = {}; |
1 | sizeof(numbers) == 10 * sizeof(int) |
如果当前平台sizeof(int) == 4,结果是40字节,但可移植代码不应把int大小永远写死为4。
3.2 字符数组
1 | char text[] = "abcd"; |
1 | sizeof(text) // 5,包含'\0' |
3.3 字符指针
1 | const char* pointer = "abcd"; |
1 | sizeof(pointer) // 指针本身的大小 |
64位数据模型中指针常为8字节,32位中常为4字节,但具体值由平台决定。
3.4 数组传参会退化
1 | void function(int values[]) |
形参values实际是int*,sizeof(values)得到指针大小,不是原数组大小。
需要长度时应显式传入,或使用std::array、std::vector、模板数组引用等更安全接口。
四、C语言动态内存管理
4.1 malloc
1 |
|
malloc的特点:
- 参数是需要的字节数;
- 返回
void*; - 失败时返回空指针;
- 成功时返回满足相应基本对齐要求的存储;
- 分配内容未初始化,读取前必须先写入有效值;
- 不调用C++构造函数。
在C语言中,void*可以隐式转换为对象指针,不建议强制转换malloc返回值,因为强转可能掩盖忘记包含<stdlib.h>等错误。
4.2 C++中使用malloc
C++不允许void*隐式转换为其他对象指针:
1 | int* values = static_cast<int*>( |
但普通C++业务代码更应优先使用标准容器和RAII,而不是手工malloc。
4.3 calloc
1 | int* values = calloc(10, sizeof(*values)); |
calloc接收元素个数和单个元素大小,并把分配得到的所有字节清零。
对于现代常见平台的整数类型,全零字节表示整数零。但从抽象标准角度,不应把“全比特零”无条件推广到所有可能类型的语义零值。
4.4 realloc
1 | int* resized = realloc(values, 20 * sizeof(*values)); |
realloc尝试调整已有分配块大小:
- 可能原地扩展;
- 可能申请新块、复制原内容并释放旧块;
- 新旧范围重叠部分的数据会被保留;
- 扩大的新区域内容未初始化;
- 返回地址可能改变。
4.5 realloc的安全写法
错误写法:
1 | values = realloc(values, newCount * sizeof(*values)); |
如果失败,realloc返回NULL,直接覆盖values会丢失原指针,造成泄漏。
正确写法:
1 | int* temporary = realloc(values, |
成功时旧指针可能已经失效,只能继续使用新返回值;失败时旧分配保持有效。
4.6 防止大小乘法溢出
1 | size_t bytes = count * sizeof(*values); |
如果count非常大,乘法可能溢出并变成较小数值,随后写入count个元素会越界。
1 | if (count > SIZE_MAX / sizeof(*values)) |
使用SIZE_MAX通常需要合适的标准头文件,例如<stdint.h>或<stdint.h>相关实现支持;在C++中可使用std::numeric_limits<std::size_t>::max()。
4.7 free
1 | free(values); |
规则:
- 只能释放空指针,或由兼容分配函数返回且尚未释放的指针;
free(NULL)安全且无操作;- 不能释放局部数组、全局对象或字符串字面量;
- 不能重复释放;
- 释放后不能继续解引用旧指针。
把一个局部指针设为空只能避免该变量继续误用,其他指向同一块存储的别名仍然可能悬空。
4.8 不要对一般C++对象使用realloc
realloc按原始字节处理存储,不会调用构造、移动和析构函数。
1 | std::string* strings = /* ... */; |
对于具有非平凡生命周期的C++对象,应使用std::vector、正确的分配器或显式构造迁移逻辑。
五、C++的new与delete
5.1 单个内置类型
1 | int* first = new int; // 默认初始化,值不确定 |
new不是“自动把内存清零”。初始化结果取决于类型与初始化语法。
5.2 动态数组
1 | int* first = new int[10]; // 元素值不确定 |
单对象与数组必须匹配:
1 | new T <-> delete pointer |
混用属于未定义行为,即使元素是int并且某次运行没有崩溃也不合法。
5.3 自定义类型
1 | class Widget |
new Widget会在获得存储后调用构造函数,delete会先调用析构函数再释放存储。
5.4 对象数组
1 | Widget* widgets = new Widget[3]; |
通常表现为:
- 获得足够存储;
- 按下标从小到大构造元素;
delete[]按相反顺序析构元素;- 释放数组存储。
如果构造第k个元素时抛出异常,已经成功构造的前面元素会被析构,数组存储也会按规则回收。
5.5 数组长度信息
实现为了正确析构数组,可能在分配块旁保存元素数量等元数据,俗称array cookie。
但cookie的存在、位置与格式属于实现细节。程序不能通过数组指针可移植地推断元素个数,必须自行保存长度或使用容器。
5.6 delete nullptr
1 | Widget* pointer = nullptr; |
两者都安全且无操作,因此析构或清理代码通常不需要先写多余的空指针判断。
5.7 分配失败
普通new失败时默认抛出std::bad_alloc:
1 | try |
使用std::nothrow时返回空指针:
1 |
|
普通代码不一定要在分配点捕获bad_alloc;如果当前层无法恢复,可以让异常传播到统一错误处理边界。
六、malloc/free与new/delete对比
| 对比项 | malloc/free |
new/delete |
|---|---|---|
| 语言来源 | C标准库函数 | C++表达式/运算符语法 |
| 大小 | 手动传字节数 | 由类型与数量计算 |
| 返回类型 | void* |
对应类型指针 |
| 构造函数 | 不调用 | new表达式调用 |
| 析构函数 | 不调用 | delete表达式调用 |
| 默认失败方式 | 返回空指针 | 抛出std::bad_alloc |
| 初始化 | 原始字节,calloc清零 |
按C++初始化语法执行 |
| 扩容 | realloc |
没有对应的renew |
| 自定义分配 | 替换/封装分配器 | 可重载分配函数 |
共同点是:它们都可能从某种动态存储来源获得空间,手工使用时都需要匹配释放。
现代C++的首选通常不是裸new/delete,而是容器、智能指针和RAII对象。
七、不要混用分配与释放家族
下面都是错误的:
1 | int* first = static_cast<int*>(std::malloc(sizeof(int))); |
必须按来源匹配:
1 | malloc/calloc/realloc -> free |
定位new是一个特殊组合,后文单独说明。
八、operator new与operator delete
8.1 new表达式与operator new不是一回事
1 | Widget* pointer = new Widget(10); |
这是new表达式,它大致完成:
- 调用合适的分配函数获得原始存储;
- 在存储中调用
Widget构造函数; - 返回指向已构造对象的指针。
而:
1 | void* raw = ::operator new(sizeof(Widget)); |
只获得原始存储,不会自动构造Widget。
8.2 标准不要求底层必须调用malloc
许多标准库实现的全局operator new最终使用malloc或操作系统分配器,但C++标准不强制这种实现方式。
可移植结论是:
- 成功时返回满足要求的原始存储;
- 普通抛异常版本失败时抛出
std::bad_alloc; operator delete接收相应指针并释放存储。
不要把某个版本CRT源码中的内部调用当作所有平台的语言规则。
8.3 直接使用全局分配函数
1 |
|
这与malloc/free一样只管理原始存储,不会构造或析构具体对象。
8.4 构造函数抛异常时
1 | Widget* pointer = new Widget(arguments); |
如果存储分配成功但构造函数抛出异常,new表达式会调用匹配的释放函数回收那块存储,然后继续传播异常。
对象没有成功完成构造,因此不会对该未完成对象执行普通析构函数;已经完成构造的基类和成员子对象会按异常展开规则销毁。
8.5 delete表达式的主要步骤
1 | delete pointer; |
对于非空有效指针,大致过程是:
- 调用对象析构函数;
- 调用匹配的释放函数归还对象存储。
指针必须来自兼容的new表达式并且尚未释放,否则行为未定义。
8.6 std::new_handler
全局分配函数在失败时可能调用当前new_handler:
1 |
|
一个有意义的处理器应当:
- 释放预留内存;
- 降低资源使用;
- 安装新的处理器;
- 抛出异常;
- 或终止程序。
如果什么条件也不改变就返回,分配器可能不断重试。
九、类专属operator new/delete
9.1 基本形式
1 | class Node |
1 | Node* node = new Node; |
查找分配函数时会考虑类专属版本。
9.2 典型用途
- 固定大小对象池;
- 高频小对象分配优化;
- 调试与统计;
- 特殊硬件或共享内存;
- 框架级分配策略。
9.3 类专属分配函数仍只管理存储
Node::operator new不负责调用Node构造函数。它返回原始存储后,new Node表达式继续执行构造。
Node::operator delete也不负责主动调用析构函数;delete node表达式会先完成析构,再进入释放函数。
9.4 构造失败时需要匹配释放函数
如果类定义了特殊形式的operator new,应同时考虑构造失败时编译器如何找到匹配的operator delete,否则可能造成复杂的资源问题。
生产级重载还需要考虑:
- 数组版本;
- 对齐要求;
- 不抛异常版本;
- 线程安全;
- 大小参数;
- C++14的sized deallocation;
- C++17的过对齐分配。
9.5 不要为了练习随意替换全局分配器
全局operator new/delete影响范围很大,还可能被运行库、第三方库和静态初始化使用。没有完整的线程安全、递归保护、对齐和错误处理设计时,不应在普通项目中随意替换。
十、new[]与delete[]实现要点
10.1 数组分配函数
1 | Widget* array = new Widget[count]; |
实现会调用合适的operator new[]获取足够存储,再构造各元素。
标准不保证operator new[]一定通过普通operator new实现,也不保证operator delete[]一定调用普通operator delete。它们是可以独立替换或重载的分配函数。
10.2 部分构造失败
假设构造第4个元素时抛出异常:
1 | 元素0:已构造 |
语言会析构已经成功构造的元素2、1、0,然后释放数组存储。调用者不会拿到数组指针。
10.3 为什么不能用free释放对象数组
free不会执行元素析构,也不遵循new[]分配元数据和对应释放函数的规则。即使底层实现碰巧使用相同系统堆,混用仍然是未定义行为。
十一、定位new(placement new)
11.1 基本概念
定位new在调用者提供的原始存储中构造对象,不负责新分配一块普通动态存储。
1 |
|
11.2 完整生命周期
1 | void* raw = ::operator new(sizeof(Widget)); |
定位new不会在对象销毁时自动知道应该把存储归还给谁,因此调用者必须:
- 显式调用析构函数;
- 使用与原始存储来源匹配的方式释放存储。
11.3 栈上原始缓冲区
1 |
|
storage具有自动存储期,不需要也不能对它调用operator delete。
alignas(Widget)非常重要,否则缓冲区地址可能不满足Widget的对齐要求。
11.4 为什么不能直接强转后使用
1 | void* raw = std::malloc(sizeof(Widget)); |
对于需要构造的类型,仅仅转换指针没有开始正常对象生命周期。必须在合适存储中执行构造。
11.5 显式析构语法
1 | object->~Widget(); |
这只执行析构函数,不释放存储。之后不能继续把原地址当作该对象使用,除非按对象生命周期规则重新构造。
11.6 使用场景
- 内存池;
- STL分配器;
- 容器预留存储;
- variant式存储;
- 高频对象复用;
- 共享内存中的对象布局。
11.7 过对齐类型
1 | struct alignas(64) CacheLineObject |
普通malloc在C++11中不一定满足扩展对齐类型的要求。处理过对齐类型需要平台对齐分配接口、自定义分配器,或C++17对齐分配支持。
十二、常见内存错误
12.1 内存泄漏
1 | void function() |
函数结束后指针变量消失,但动态对象仍然占用资源,程序失去了释放它的正常路径。
12.2 重复释放
1 | delete pointer; |
第二次释放属于未定义行为。
把当前变量设为nullptr能避免它自己重复释放:
1 | delete pointer; |
但其他别名指针仍可能悬空。
12.3 释放后使用
1 | delete pointer; |
释放后继续访问属于use-after-free,是高危未定义行为。
12.4 越界访问
1 | int* values = new int[10]; |
合法下标是0到9。越界可能破坏分配器元数据或其他对象。
12.5 释放方式不匹配
1 | new[] -> delete |
全部属于未定义行为。
12.6 返回局部对象地址
1 | int* wrong() |
函数返回后局部对象生命周期结束,返回值是悬空指针。它不是堆泄漏,但同样属于严重生命周期错误。
12.7 未初始化内存读取
1 | int* value = new int; |
new int没有给整数确定值。应使用new int{}或在读取前赋值。
12.8 大小计算错误
1 | int* values = malloc(count); |
如果意图分配count个int,应计算count * sizeof(*values)并检查溢出。
十三、内存泄漏与资源泄漏
13.1 什么是内存泄漏
程序已经不再需要某块动态内存,却没有释放它,或者已经失去能够释放它的指针,这就是内存泄漏。
泄漏不是物理内存“消失”,而是资源仍被进程占用却无法正常复用或归还。
13.2 长期运行服务为什么更怕泄漏
短命令行程序退出后,操作系统通常会回收进程资源;但后台服务可能运行数月。即使每个请求只泄漏几十字节,累计后也可能导致:
- 常驻内存不断增长;
- 分配延迟增加;
- 频繁换页;
- OOM终止;
- 服务响应变慢或不可用。
13.3 系统资源泄漏
不仅堆内存会泄漏,下面资源同样需要管理:
- 文件描述符;
- Socket;
- 管道;
- 互斥锁;
- 数据库连接;
- GPU资源;
- 共享内存;
- 线程和进程句柄。
资源耗尽可能比内存耗尽更快发生。
13.4 高内存占用不一定是泄漏
内存池、缓存、分配器arena和延迟归还策略可能让进程常驻内存保持较高,即使对象已经逻辑释放。
判断泄漏需要结合:
- 是否仍有正常所有权路径;
- 内存是否会被复用;
- 长时间压力下是否持续无界增长;
- 工具报告中的可达性和分配栈;
- 业务缓存上限。
十四、异常安全与RAII
14.1 手工清理的异常问题
1 | void function() |
如果中间抛出异常,delete[]不会执行。
14.2 使用std::vector
1 | void function() |
异常展开时values自动析构并释放内存。
14.3 使用std::unique_ptr
C++11:
1 |
|
C++14以后优先使用:
1 | auto object = std::make_unique<Widget>(arguments); |
make_unique不是C++11标准库功能,使用时要明确项目标准版本。
14.4 自定义删除器管理C资源
1 |
|
即使函数提前返回,文件也会自动关闭。
14.5 shared_ptr也可能产生逻辑泄漏
两个对象如果通过std::shared_ptr相互强引用,引用计数永远不会归零。
1 | A --shared_ptr--> B |
应根据所有权关系让一侧使用std::weak_ptr打破环。
RAII不是“用了智能指针就永远不会泄漏”,还需要正确建模所有权。
十五、内存检测工具
15.1 编译器警告
1 | g++ -std=c++11 -Wall -Wextra -Wpedantic program.cpp |
警告能发现部分错误,但无法覆盖所有运行时内存问题。
15.2 AddressSanitizer
1 | g++ -std=c++11 -g -O1 \ |
常用于检测:
- 堆越界;
- 栈越界;
- 释放后使用;
- 重复释放;
- 部分内存泄漏;
- 多种未定义行为。
15.3 Valgrind
Linux下可以使用:
1 | valgrind --leak-check=full \ |
Valgrind通常不需要重新编译插桩,但运行速度明显变慢。
15.4 Windows工具
常见方案包括:
- Visual Studio诊断工具;
- CRT调试堆;
- AddressSanitizer支持;
- 第三方VLD等工具。
工具只能帮助定位,良好的所有权设计和RAII才是第一道防线。
十六、一次申请4 GiB内存的问题
16.1 4 GiB是多少
1 | const std::size_t fourGiB = |
4 GiB等于2^32字节。0xffffffff只是2^32 - 1。
16.2 32位进程为什么通常不行
32位指针理论虚拟地址范围总共只有4 GiB,还要容纳:
- 程序代码;
- 共享库;
- 栈;
- 映射区;
- 内核保留区域;
- 其他动态分配。
因此几乎不可能获得一块连续4 GiB用户空间分配。
16.3 64位进程也不保证成功
64位地址空间足够大,但分配仍可能因以下原因失败:
- 物理内存和交换空间限制;
- 进程或容器内存上限;
- 地址空间碎片;
- 提交限制;
- 分配器限制;
- 操作系统策略。
16.4 分配成功不等于页面都已提交
某些系统采用内存过量承诺。new char[fourGiB]返回成功时,可能只是获得虚拟地址范围;真正逐页写入时才触发物理页分配,并可能在后续失败。
不要在普通开发机上为了验证概念盲目申请并写满4 GiB。
16.5 安全的概念演示
1 |
|
该示例只计算大小,不实际消耗巨量内存。
十七、综合示例:RAII、对象数组与定位new
下面的程序同时展示:
std::unique_ptr管理单对象和数组;new[]构造多个对象;delete[]逆序析构;- 对齐的栈上原始存储;
- 定位
new构造对象; - 显式析构;
- 对象存活计数。
1 |
|
预期输出:
1 | construct 1 |
17.1 单对象RAII
one离开作用域时自动执行delete,即使中途抛出异常也不会泄漏。
17.2 数组RAII
std::unique_ptr<Tracker[]>会使用delete[],两个数组元素按构造相反顺序析构。
17.3 动态整数数组初始化
1 | new int[4]{1, 2, 3, 4} |
使用列表初始化给每个元素明确值,求和结果为10。
17.4 对齐存储
alignas(Tracker)保证字节数组起始地址满足Tracker对齐要求,仅有sizeof(Tracker)并不足以保证对齐。
17.5 定位构造与显式析构
定位new在storage中构造编号9的对象。因为存储不是普通new表达式获得的,不能对placed执行delete,只能显式析构;字节数组本身会随作用域结束自动回收。
十八、常见面试问题
18.1 C/C++程序的内存区域有哪些
常见进程实现可分为代码与只读数据、已初始化数据、BSS、动态分配区域、内存映射区和栈等。具体布局与增长方向由平台和操作系统决定,不是C++标准固定图。
18.2 指针在栈上,指向的数据一定也在栈上吗
不一定。指针变量本身可能具有自动存储期,但可以指向动态内存、静态对象、映射文件或其他有效对象。
18.3 malloc、calloc和realloc有什么区别
malloc按字节数分配未初始化存储;calloc接收数量与元素大小并清零所有字节;realloc调整已有分配大小,可能移动地址,失败时原分配仍有效。
18.4 realloc为什么要用临时指针接收
失败时它返回空指针但不会释放原块。直接覆盖原变量会丢失原地址并造成泄漏。
18.5 malloc/free与new/delete最核心的区别是什么
malloc/free只管理原始存储;new/delete表达式把存储分配与C++对象构造、析构语义结合起来。失败处理、类型信息和初始化语法也不同。
18.6 new int是否会初始化为零
不会保证。new int默认初始化,值不确定;new int()或new int{}会进行值初始化并得到零。
18.7 为什么new[]必须配合delete[]
数组表达式需要按规则析构所有元素,并使用匹配的数组释放函数和可能的实现元数据。混用属于未定义行为。
18.8 new和operator new有什么区别
new是完整表达式,负责获取存储并构造对象;operator new是分配函数,只返回原始存储,不调用对象构造函数。
18.9 delete和operator delete有什么区别
delete表达式先执行对象析构,再调用释放函数;直接调用operator delete只归还原始存储,不会自动析构对象。
18.10 operator new一定调用malloc吗
不一定。很多实现这样做,但标准只规定其可观察语义,不规定必须基于malloc实现。
18.11 构造函数抛异常后分配的内存会泄漏吗
普通new表达式会调用匹配的释放函数回收已经获得的对象存储,已构造的成员和基类也会按异常展开规则销毁。类专属特殊分配重载仍需正确提供匹配释放形式。
18.12 什么是placement new
它在调用者提供的原始存储中开始对象生命周期并调用构造函数,不额外执行普通动态分配。调用者随后需要显式析构并按原存储来源进行回收。
18.13 定位new为什么需要考虑对齐
对象地址必须满足类型对齐要求。仅准备足够字节数不能保证地址对齐,可使用alignas(T)或合适的分配器。
18.14 什么是内存泄漏
已经不再需要的动态内存没有被释放,或程序丢失了释放它的所有权路径,导致资源持续被占用。
18.15 如何避免内存泄漏
优先使用RAII、标准容器、智能指针和明确所有权模型;配合代码审查、异常安全设计、编译器警告、Sanitizer和Valgrind等工具。
18.16 64位程序一次申请4 GiB一定成功吗
不一定。地址空间更大只是必要条件之一,还受物理内存、提交限制、容器配额、分配器、碎片和操作系统策略影响。
十九、编码建议
19.1 优先使用自动对象
1 | Widget widget; |
能由作用域自动管理时,不要无意义地写成动态对象。
19.2 动态序列优先使用容器
1 | std::vector<int> values(count); |
优于:
1 | int* values = new int[count]; |
容器会管理长度、容量、释放、复制、移动和异常安全。
19.3 独占对象优先使用unique_ptr
1 | std::unique_ptr<Widget> pointer(new Widget); |
C++14以后优先使用std::make_unique。
19.4 共享所有权必须有真实语义
不要因为“不知道谁释放”就全部改成shared_ptr。共享所有权会增加控制块、原子计数和环引用风险。
19.5 裸指针优先表达非拥有观察
一个裸指针如果只是观察对象,应在接口和注释中说明它不负责释放。真正所有权交给RAII类型。
19.6 分配与释放靠近并封装
如果必须调用C接口,尽快把获得的资源包装进RAII对象,减少裸资源在业务代码中的传播范围。
19.7 不要依赖偶然运行结果
以下错误可能暂时“不崩”:
new[]配delete;- 读取未初始化值;
- 释放后访问;
- 重复释放;
- 访问越界。
它们仍然是未定义行为,优化级别、编译器或输入变化后可能立即暴露。
二十、总结
本篇的核心结论如下:
- 典型地址空间图是平台实现,不是C++标准固定布局;
- 指针变量的位置与指向对象的位置必须分开分析;
sizeof查询对象或类型大小,strlen扫描字符串内容;malloc分配未初始化原始存储,calloc把字节清零;realloc可能改变地址,失败时原块仍有效,应使用临时指针;- 大小乘法必须考虑
size_t溢出; new int不保证为零,new int{}会值初始化;new/delete和new[]/delete[]必须严格匹配;malloc/free不能与new/delete交叉使用;new表达式负责分配与构造,operator new只负责原始存储;- 标准不要求
operator new必须通过malloc实现; - 对象数组部分构造失败时,已构造元素会自动回滚;
- placement new在已有存储中构造对象,需要显式析构并正确处理存储来源;
- 原始存储必须同时满足对象的大小和对齐要求;
- 内存泄漏之外,还要关注文件、Socket和锁等系统资源泄漏;
- 高常驻内存不必然等于泄漏,应结合所有权、复用和增长趋势判断;
- RAII、标准容器和智能指针是现代C++预防资源泄漏的核心;
- AddressSanitizer、UndefinedBehaviorSanitizer和Valgrind用于发现问题,但不能代替正确设计;
- 64位地址空间允许表示4 GiB大小,但不保证实际分配或写入成功。
真正可靠的内存管理不是“记得在最后写delete”,而是让资源所有权在类型系统和对象生命周期中得到清晰表达,使清理动作自动、唯一并且具备异常安全性。