要理解C++ 动态内存、new[]、delete[]、内存分配器,甚至 STL 容器底层原理。
先告诉同学们,一个非常有趣的事实:
delete p表面上只拿到了一个地址,为什么它居然知道要释放多大的内存?因为真正负责管理内存的“内存管理员”,通常会在这块内存附近偷偷保存一些管理信息。
不过这里有一个非常重要的前提:
C++ 标准并没有规定
new/delete的底层内存布局必须是什么样。所以我们下面讲的是典型实现的原理,不是说所有编译器都一定这样实现。
一、先从一个最简单的问题开始
看:
int *p = new int;同学们可能会问:
new int到底发生了什么?
可以把它想成去仓库找房子。
程序 │ │ new int ↓ 内存管理员 │ ↓ 找一块能够放 int 的内存 │ ↓ 创建 int 对象 │ ↓ 返回地址最后:
int *p = new int;得到:
p ↓ ┌──────────┐ │ int 对象 │ └──────────┘二、new并不是一个普通函数
这是第一个非常重要的知识点。
很多初学者会认为:
new int就是调用某个叫new的函数。
实际上不是。
new是 C++ 的运算符/语言机制。
它背后通常会涉及:
operator new()例如可以粗略理解成:
int *p = new int;背后大概经历:
① operator new(sizeof(int)) ↓ ② 得到一块原始内存 ↓ ③ 在这块内存上构造 int 对象 ↓ ④ 返回对象地址所以:
new≈ “申请内存 + 创建对象”
三、那么operator new()是干什么的?
例如:
operator new(sizeof(int));可以理解为:
“内存管理员,请给我一块足够大的原始内存。”
它最终通常会调用更底层的内存管理机制,例如:
C++ new ↓ operator new ↓ 运行库的内存分配器 ↓ 操作系统/堆管理器 ↓ 得到内存注意:
不是每次new都直接向操作系统申请一块内存。
如果每个:
new int都跑到操作系统去申请,速度会非常慢。
实际上通常有更复杂的堆管理器、内存池、缓存机制。
四、那delete又做了什么?
假设:
int *p = new int; delete p;可以粗略理解成:
delete p ↓ 找到 p 指向的对象 ↓ 销毁对象 ↓ 释放这块内存对于一个简单的:
int对象的销毁非常简单。
但如果是:
class Student { public: ~Student() { cout << "Bye!"; } };那么:
Student *p = new Student; delete p;还需要调用:
Student::~Student()所以:
delete不只是“把内存扔掉”,它还负责对象生命周期的结束。
五、最神奇的问题来了
现在:
int *p = new int;然后:
delete p;同学们就会问:
🤔
delete只有一个p!
p里面只有一个地址!那它怎么知道这块内存到底有多大?
比如:
p = 0x1000它只知道:
地址 = 0x1000但是:
到底占4字节? 到底占40字节? 还是400字节?怎么办?
六、答案:真正的“内存管理员”知道
这里要理解一个重要区别:
p只是一个指针。
它本身通常只保存:
地址但是:
内存管理器手里有自己的账本。
可以想象成:
内存管理员的账本 地址 大小 ──────────────────────── 0x1000 32 字节 0x1050 128 字节 0x1200 64 字节当你:
delete p;实际上会把:
0x1000交给内存管理器。
内存管理器:
“哦,0x1000 是我之前分出去的一块32字节内存,我知道怎么处理。”
于是:
p ↓ 0x1000 ↓ 内存管理器 ↓ 找到对应的内存块 ↓ 释放七、但是“账本”放在哪里?
这里就非常有意思了。
典型的堆管理器可能会在内存块附近保存一些管理信息。
例如为了方便理解,可以想象:
内存块 管理信息 ↓ ┌──────────────┬───────────────────┐ │ size = 32 │ 用户数据 │ └──────────────┴───────────────────┘ ↑ p也就是说:
p ↓ 用户真正使用的区域而前面可能还有:
size 状态 链表指针 对齐信息 ……这些东西属于内存分配器的管理信息。
八、注意:这不意味着p[-1]就一定是大小
千万不要让小朋友产生这个误解:
int *p = new int; cout << p[-1]; // ❌不要这样猜C++ 标准并没有规定:
“new 出来的内存前面一定存着 size。”
具体怎么保存,是:
编译器
C++ 运行库
堆分配器
操作系统
等共同决定的实现细节。
我们为了理解原理,才画成:
┌─────────────┬──────────────┐ │ 管理信息 │ 用户数据 │ └─────────────┴──────────────┘ ↑ p九、那new[]更神奇
比如:
int *p = new int[10];这里发生了:
申请一大片空间 + 创建10个 int 对象内存可以想象成:
┌─────────────┬────────────────────────────┐ │ 管理信息 │ 10个 int 对象 │ │ │ │ │ count = 10 │ [ ][ ][ ][ ][ ][ ][ ][ ][ ][ ]│ └─────────────┴────────────────────────────┘ ↑ p这里的:
count = 10只是典型实现中可能存在的一种做法。
它让运行时知道:
“这里原来有10个对象。”
十、为什么delete[] p能知道是10个?
因为:
int *p = new int[10];典型实现可能在返回给你的指针附近记录:
元素数量 = 10所以:
delete[] p;大致可以理解为:
delete[] p ↓ 找到这块分配 ↓ 知道原来有10个对象 ↓ 依次销毁对象 ↓ 释放整块内存如果对象是:
Student那么尤其重要。
比如:
Student *p = new Student[10]; delete[] p;程序需要知道:
Student 1 → 析构 Student 2 → 析构 …… Student 10 → 析构所以new[]/delete[]的实现通常需要某种方式记录数组的信息。
十一、这就是为什么delete和delete[]不能混用
例如:
Student *p = new Student[10]; delete p; // ❌这相当于:
“我申请了10个人,但是我告诉系统只处理一个对象。”
这可能导致:
没有正确调用所有析构函数
内存释放方式不匹配
未定义行为
所以一定要:
Student *p = new Student[10]; delete[] p;十二、但是有一个特别重要的细节
很多教程会告诉同学们:
“
new[]会在数组前面保存数组长度,delete[]就从那里读取长度。”
这个说法:
用来理解非常好,但不能当成 C++ 标准规定。
实际上:
C++ 标准只规定行为,不规定编译器必须怎样实现。
有的实现可能:
size count ……存在某个隐藏区域。
有的实现可能采用其他方式。
有的类型还可能有更特殊的优化。
所以我们应该记住:
“数组长度需要被实现机制记住”是重要的原理;“一定存在某个固定位置保存 count”不是标准保证。
十三、那么delete到底“知道多少字节”?
这里还要纠正一个非常容易产生的误区。
我们说:
int *p = new int; delete p;并不是说:
delete自己通过sizeof(*p)算出内存块到底申请了多少字节。
实际上:
sizeof(*p)只能告诉编译器:
一个 int 对象有多大例如:
sizeof(int)可能是:
4但这和:
这次内存分配到底从堆管理器拿到了多大的块
不是完全一回事。
真正的内存块管理通常由:
operator delete ↓ 运行库/堆分配器负责。
十四、可以把整个过程画成一条流水线
这是理解new/delete最重要的一张图:
new Student │ ↓ ┌──────────────────┐ │ operator new │ └──────────────────┘ │ ↓ 内存分配器/堆 │ ↓ ┌───────────────────┐ │ 管理信息 + 内存块 │ └───────────────────┘ │ ↓ 构造 Student │ ↓ 返回 p然后:
delete p │ ↓ 析构 Student │ ↓ operator delete │ ↓ 堆内存管理器 │ ↓ 回收这块内存所以:
new是“申请 + 构造”,delete是“析构 + 释放”。
十五、一个非常重要的思想:对象 ≠ 内存
这是小朋友学习 C++ 时非常重要的一关。
例如:
Student *p = new Student;实际上可以分成:
第一步: 申请一块原始内存 第二步: 在这块内存上创建 Student 对象也就是:
内存 ↓ ┌────────────────┐ │ 一块原始空间 │ └────────────────┘ ↓ 构造 ┌────────────────┐ │ Student 对象 │ └────────────────┘而:
delete p;则是反过来:
Student 对象 ↓ 调用析构函数 ↓ 对象结束 ↓ 释放底层内存这也是为什么上一节我们讲的allocator很重要。
十六、allocator就把这两个动作拆开了
例如:
allocator<Student> a;先:
Student *p = a.allocate(1);此时:
有内存 但还没有 Student 对象然后:
a.construct(p);才创建对象。
最后:
a.destroy(p);销毁对象。
再:
a.deallocate(p, 1);释放内存。
于是整个生命周期变成:
allocate ↓ 原始内存 ↓ construct ↓ Student对象 ↓ destroy ↓ 对象消失 ↓ deallocate ↓ 内存归还这就把new/delete背后的秘密彻底拆开了。
十七、还有一个很容易被忽略的问题
同学们可能会问:
“如果
delete需要知道内存大小,那为什么我们写delete p的时候不用写大小?”
例如:
int *p = new int; delete p;而不是:
delete p, sizeof(int);原因就是:
内存管理器自己负责管理分配信息。
这也是内存分配器存在的重要意义。
你只需要告诉它:
“我要释放这个地址。”剩下的:
“这块内存属于谁?” “多大?” “怎么放回空闲区?”由内存管理器处理。
就像去图书馆还书:
你:我要还这本书。 管理员:好的,我知道这本书属于哪个书架。你不用告诉管理员:
“这本书是3厘米厚、27厘米宽、500克。”
十八、那如果delete了错误的指针呢?
例如:
int x = 10; int *p = &x; delete p; // ❌这是错误的。
因为:
x不是通过:
new申请出来的。
它可能在:
栈上。
而delete是用来释放:
new得到的动态内存。
所以:
new → delete是一对。
不能:
普通变量 → delete十九、再看一个“指针陷阱”
int *p = new int; delete p; cout << *p; // ❌为什么?
因为:
delete p;之后:
p ↓ 原来的内存已经归还这时候p仍然保存着那个地址,但那个地址已经不能再当成有效对象使用。
这种指针叫:
悬空指针(dangling pointer)
比较好的习惯是:
delete p; p = nullptr;这样:
p ↓ nullptr至少不会继续拿旧地址访问已经释放的对象。
二十、给同学们一个“终极版模型”
把今天所有知识串起来:
new │ ↓ ┌──────────────────┐ │ operator new │ └──────────────────┘ │ ↓ 内存分配器 │ ↓ ┌─────────────────────┐ │ 管理信息 + 内存空间 │ └─────────────────────┘ │ ↓ 构造对象 │ ↓ p然后:
delete p │ ↓ 析构对象 │ ↓ operator delete │ ↓ 内存分配器 │ ↓ 回收内存而数组:
new T[n] ↓ 申请空间 ↓ 创建 n 个对象 ↓ 记录/管理数组所需信息 ↓ 返回 p最后:
delete[] p ↓ 知道如何处理这组对象 ↓ 销毁对象 ↓ 释放整块内存二十一、最后送同学们一句最重要的话
如果只记住今天的内容,请记住:
🧠指针
p通常只知道“地址”,它并不负责记住整块内存有多大;真正负责记账的是内存分配器。
new负责申请内存并创建对象,delete负责销毁对象并把内存交还给分配器。对于
new[],实现机制还必须能够正确处理这一组对象,所以delete[]不能和delete混用。
这其实已经触及了一个非常重要的 C++ 底层思想:
对象生命周期 ↕ 内存生命周期对象的“出生和死亡”与内存的“申请和释放”是两件相关但不同的事情。