小学生学C++编程语法知识(C++中new和delete的实现原理)
要理解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只有一个pp里面只有一个地址那它怎么知道这块内存到底有多大比如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就把这两个动作拆开了例如allocatorStudent 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 底层思想对象生命周期 ↕ 内存生命周期对象的“出生和死亡”与内存的“申请和释放”是两件相关但不同的事情。