十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

三.C++ 内存管理(进阶)(一)

三.C++ 内存管理(进阶)(一) C 内存管理高频考点详解3.1–3.9面向嵌入式/后端求职与考研每节含「原理 代码验证 内存可视化 面试追问」。3.1 new/delete 与 malloc/free一句话malloc/free 只搬运字节new/delete 经营的是对象——区别的根子在构造与析构。区别对照维度malloc/freenew/delete本质C 标准库函数cstdlibC 关键字/运算符返回类型void*必须强转类型化指针T*无需强转大小计算手动sizeof(T)*n编译器按类型自动计算初始化只给原始内存不构造分配后调用构造函数失败行为返回NULL默认抛std::bad_alloc能否重载不能operator new/delete可重载内存来源堆默认operator new底层也调 malloc配套扩容有 realloc无需手动 new-拷贝-delete代码验证构造/析构是否被调用#include cstdio #include cstdlib struct A { A() { printf(ctor\n); } ~A() { printf(dtor\n); } int x; }; int main() { printf(-- malloc --\n); A* p1 (A*)malloc(sizeof(A)); // 只分配无 ctor free(p1); // 只释放无 dtor printf(-- new --\n); A* p2 new A; // operator new ctor delete p2; // dtor operator delete } // 输出-- malloc -- / -- new -- / ctor / dtor可见 malloc/free 全程不碰构造析构。若类里持有文件句柄、互斥锁、堆内存free掉一个 new 出来的对象这些资源就永远泄漏了。为什么绝对不能混用new Obj → [operator new 分配] → [构造函数] ← 对象活了 free(p) → [直接归还内存] ✗ 析构没跑 → 资源泄漏 malloc → [给原始内存无构造] delete p → [调析构(对象根本没构造!)] → [operator delete] ✔ UBnew配free析构函数不执行 → 句柄/内存/锁泄漏。malloc配delete对一块从未构造的内存调析构 → 未定义行为UB。new T[n]配delete非delete[]见 3.2同样 UB。铁律new/delete、new[]/delete[]、malloc/free 三对严格配对绝不交叉。面试追问与细节构造函数抛异常怎么办new 表达式会自动调用对应的operator delete回收已分配的原始内存不会泄漏这是比 malloc 更安全的设计。free(NULL) 合法吗合法什么都不做delete 空指针也安全。但 delete 同一非空指针两次是 UB。malloc(0) 行为返回一个唯一的、可 free 的非空指针或 NULL由实现决定别对它解引用。嵌入式禁异常环境用new(std::nothrow) T失败返回nullptr而非抛异常判空风格与 malloc 一致这是资源受限场景的常见选择。数组分配new T[n]对应delete[]malloc 则是malloc(sizeof(T)*n)C 的 realloc 能原地或搬迁扩容C 没有对应运算符扩容容器需自行新分配→拷贝构造→析构旧对象→释放。内存来源其实相同默认 operator new 在多数标准库里最终也调用 malloc/free所以二者通常从同一堆分配真正的差异不在从哪拿内存而在是否把内存变成对象。3.2 delete 与 delete[]一句话new[] 会偷偷记下数组长度delete[] 靠它逐个析构用错则只析构第一个其余泄漏行为未定义。array cookie 机制可视化new A[5] 实际分配 ┌──────┬──────┬──────┬──────┬──────┬──────┐ │ n5 │ A[0] │ A[1] │ A[2] │ A[3] │ A[4] │ └──────┴──────┴──────┴──────┴──────┴──────┘ ↑cookie ↑返回给你的指针 p 指向这里 delete[] p: 读 cookie 得 5 → 逆序调 ~A() 共5次 → 连 cookie 一起释放 delete p: 只把 p 当单个对象 → 只 ~A()[0] → A[1..4] 泄漏 释放地址错 → UBcookie元素计数只在析构非平凡non-trivial时编译器才必须保存但平凡类型混用没事是实现巧合标准层面一律 UB绝不能依赖。代码验证析构次数#include cstdio struct A { A() { printf(ctor %p\n, (void*)this); } ~A() { printf(dtor %p\n, (void*)this); } }; int main() { A* p new A[5]; // delete p; // 错只打印 1 次 dtor4 个对象泄漏UB delete[] p; // 对打印 5 次 dtor }逆序析构与构造顺序数组元素按0→n-1依次构造按n-1→0逆序析构与栈上对象后构造先析构的规则一致目的是正确处理元素间的依赖关系。面试追问为什么需要 cookie因为delete[]只拿到首地址必须知道要调几次析构、释放多大块这个数字只能在 new[] 时藏在分配块头部。为什么 int 数组常常没问题int 析构平凡什么都不用做且多数实现的 malloc 头已记录块大小释放能对上但这是编译器放过你换成带资源的类或换个平台立刻崩溃。能不能写delete[5] p非法长度由 cookie 提供不由你传。自定义 operator new[] 要注意什么编译器可能要求额外空间存放 cookie你重载的分配函数收到的大小已包含它不要假设请求多少就正好多少。为什么必须逆序析构后构造的对象可能依赖先构造的对象如数组元素构造时用到了前面建立的资源逆序释放与栈对象、成员变量的销毁规则保持一致避免引用到已销毁对象。和 std::vector 的关系vector 内部是一次分配容纳多个元素的原始内存 placement new 逐个构造 显式逆序析构 一次释放它管理的正是数组生命周期却不要求元素有默认构造也因此不能用 delete[] 去释放 vector 的内部缓冲。内存对齐new[] 返回的地址同样满足类型要求的对齐通常 16 字节重载 operator new 时若手动 malloc必须保证最大对齐否则在要求对齐访存的架构上会触发未定义行为。3.3 new operator 与 operator new一句话new operator 是编译器掌控、不可重载的三步流程operator new 只是其中可被替换的第一步分配器。三步拆解T* p new T(args); 等价于 ① void* raw operator new(sizeof(T)); // 只分配原始内存可重载 ② new(raw) T(args); // placement new 调构造函数 ③ T* p static_castT*(raw); // 返回类型化指针 对应 delete先调 ~T()再 operator delete(raw)new operatornew 表达式operator new分配函数谁控制编译器不可重载程序员可全局/按类重载职责分配构造转型一整套只要到 sizeof 字节类似 malloc失败抛 bad_alloc抛 bad_alloc可重载改行为对应delete 表达式operator delete重载 operator new 统计堆分配#include cstdlib #include new #include cstdio void* operator new(size_t n) { void* p malloc(n); if (!p) throw std::bad_alloc{}; printf(alloc %zu bytes %p\n, n, p); return p; } void operator delete(void* p) noexcept { free(p); }重载全局版本会影响整个程序所有 new重载类成员版本则只对该类及其派生类生效常用来做对象内存池。placement new在已有内存上构造char buf[sizeof(A)]; // 栈上一块原始内存 A* p new(buf) A; // 不分配只在 buf 上构造 p-~A(); // 必须手动调析构绝不能 delete p嵌入式高频场景在固定物理地址外设 SDRAM、静态内存池、双核共享内存上构造对象全靠 placement new。它也是std::vector扩容时先 reserve 备用容量、后逐元素 placement 构造的底层手段避免提前调用无参构造。面试追问new_handler 是什么当 operator new 分配失败会先调用std::set_new_handler注册的回调尝试释放缓存、再试一次回调为空或仍失败才抛 bad_alloc。nothrow newnew(std::nothrow) T内部捕获 bad_alloc 改为返回 nullptr。重载时签名要求类成员 operator new 必须是静态的此时对象还没构造没有 this第一参数为 size_t可带额外参数即 placement 形式。全局重载的风险重载全局operator new会影响程序中所有new包括标准库内部的分配必须同时正确重载配对的operator delete并保证线程安全与对齐否则极难排查工程上更推荐只重载类成员版本做内存池。placement new 配对问题它不分配内存所以不能也不该 delete正确姿势是手动调用p-~T()析构若那块内存是 new 出来的再单独释放底层内存。标准库容器vector 扩容、optional、variant正是靠它实现内存与对象生命周期分离。数组版本operator new[]负责数组原始内存可能比 n*sizeof(T) 多申请 cookie 空间它与 placement new 结合时同样只构造不分配。一句话记忆new 表达式是采购员施工队一条龙operator new 只是批地皮的地皮批完后施工队构造函数才进场。3.4 堆与栈一句话栈是编译器自动打理的速记本草稿纸堆是手动申请、空间大但易乱的仓库。进程内存布局可视化高地址 0xffffffff ┌───────────────┐ │ 内核空间 │ ├───────────────┤ │ 栈 stack ↓ │ 局部变量/参数/返回地址自动管理向低地址长 │ ↓ │ 每次函数调用压入一个栈帧 │ 空闲 │ │ ↑ │ │ 堆 heap ↑ │ new/malloc手动管理向高地址长 ├───────────────┤ │ .bss(未初始化) │ │ .data(已初始化)│ 全局/静态 │ .rodata(常量) │ │ .text(代码) │ 低地址 0x00000000对照栈 stack堆 heap管理编译器自动作用域结束即回收手动 new/delete方向高→低低→高速度极快移动栈指针一条指令慢找空闲块、维护空闲链表、易碎片大小小Win≈1MB / Linux≈8MB大受虚拟内存限制碎片无严格后进先出频繁分配释放产生外部碎片生命期函数/作用域直到手动释放典型故障栈溢出深递归、大数组泄漏、野指针、重复释放代码验证生长方向#include cstdio int main() { int a 1, b 2; int* h1 new int; int* h2 new int; printf(stack: a%p b%p 后定义的b地址更低? %d\n, (void*)a,(void*)b, ba); printf(heap : h1%p h2%p 后申请的h2地址更高? %d\n, (void*)h1,(void*)h2, h2h1); delete h1; delete h2; }深挖与嵌入式提醒栈帧里有什么函数参数、返回地址、保存的寄存器、局部变量编译器靠栈帧指针 EBP/RBP 寻址。递归每层压一帧无终止条件就会栈溢出。堆由谁管理不是操作系统直接管而是 C 运行库的分配器glibc 的 ptmalloc、高性能场景的 tcmalloc/jemalloc它们向 OS 批量批发内存再零售给程序。嵌入式裸机/FreeRTOS 任务栈常只有 1–8KB大数组、深递归务必放堆或静态区栈向下溢出会悄无声息踩坏相邻任务栈或 TCB是最难定位的崩溃之一。高性能固件常用固定大小内存池替代 malloc杜绝碎片并保证最坏执行时间可预测。栈大小可调Linux 用ulimit -s查看/设置主线程栈Windows 可在链接选项改默认 1MB每个 pthread/CreateThread 线程还能单独指定栈大小。栈不是越大越好——多线程时每线程一份栈过大会挤占虚拟地址空间。为什么栈快、堆慢栈分配只需把栈指针减去一个编译期已知的偏移函数返回再加回来无任何查找堆要在空闲块链表/红黑树里找合适块、切分、记账、处理合并与碎片开销高且不确定。这也是实时系统偏爱栈对象和内存池的根本原因。3.5 程序内存五区模型一句话代码、常量、全局静态、堆、栈各居其位判断变量在哪看生命周期由谁管而不是看它写在函数里还是函数外。五区对照区域存什么权限谁释放关键特征.text 代码区机器指令只读可执行OS多进程共享.rodata 常量区字符串字面量、const 常量只读OS改它触发段错误.data已初始化全局/静态可读写OS占可执行文件体积.bss未初始化/置0全局静态可读写OS不占文件体积启动清零heap 堆new/malloc可读写手动见 3.4stack 栈局部变量、参数、返回地址可读写自动见 3.4代码验证各变量归属#include cstdio int g_init 1; // .data int g_zero; // .bss const int C 9; // .rodata int main() { static int s; // .bss不是栈 int local 0; // 栈 int* p new int; // *p 在堆指针 p 本身在栈 const char* str hello; // hello 在 .rodata指针 str 在栈 printf(data%p bss%p static%p\n, g_init,g_zero,s); printf(stack%p heap%p rodata%p\n, local,p,(void*)str); delete p; }两大高频易错点int* pnew int[10];10 个 int 在堆但变量p一个地址值在栈函数返回后 p 消失若没把地址传出去或没释放堆内存就泄漏了。函数内static int x;虽写在函数里、作用域局部但它位于 .bss生命周期到程序结束且只初始化一次多次调用共享同一份——这是线程安全单例、函数内持久计数器的基础。深挖为什么 .bss 不占文件体积它全是 0只需在 ELF 头记录需要多大一块清零内存启动代码统一 zero-fill比真的存一堆 0 节省磁盘。为什么改字符串字面量会崩.rodata经 MMU 映射为只读页写入触发缺页异常现代系统直接段错误旧平台可能能改但仍是 UB。const 一定在 .rodata 吗不一定局部 const 通常仍在栈上只是编译器禁止你改只有静态存储期的常量才进 .rodata。堆内存从哪来小块由分配器在已向系统申请的 arena 中切分不够时通过brk/sbrk抬升堆顶或用mmap匿名映射大块内存free 后大块可能munmap还给操作系统。段权限与安全.text 是 r-x不可写防代码注入、.rodata 是 r–、数据/堆/栈是 rw-现代系统靠 MMU 页表强制这些权限这也是栈溢出攻击要绕过 NX/栈保护的原因。extern 全局变量定义只能有一次占 .data/.bss其他文件用extern声明只是引用不另分配内存——这是多文件编译下判断定义 vs 声明的关键。3.6 手写 memcpy含内存重叠一句话memcpy 的考点从来不是 for 循环而是源和目的重叠时怎么办——那正是 memcpy 与 memmove 的分界线。重叠问题可视化情况Adst 在 src 左侧且重叠 情况Bdst 在 src 右侧且重叠 src: [a][b][c][d] src:[a][b][c][d] dst: [ ][ ][ ][ ] dst: [?][?][?][?] 低→高拷贝dst[0]src[0]a 安全 低→高会先覆盖还没读的 src 必须从末尾反向拷贝判据src dst srcn目标压在源的后半段时必须从高地址向低地址拷其余情况正向。实现即 memmove 语义#include cstddef void* my_memmove(void* dst, const void* src, size_t n) { auto* d (unsigned char*)dst; auto* s (const unsigned char*)src; if (s d d s n) // 危险重叠从后往前 for (size_t i n; i-- 0;) d[i] s[i]; else // 普通从前往后 for (size_t i 0; i n; i) d[i] s[i]; return dst; // 返回目的地址支持链式 }面试加分点为什么用unsigned char*逐字节拷贝绕开类型与对齐问题保证任何类型都能搬。对齐与速度字节循环最通用但慢高性能实现先按字4/8 字节批量拷贝、剩余字节补齐。非对齐访问在 ARM/MIPS 上可能直接硬 faultx86 容忍但较慢。为什么返回 dst与标准库一致可作表达式嵌套使用。size_t 而非 int长度无符号且匹配地址位宽避免大内存溢出。memcpy vs memmove标准 memcpy不保证重叠安全重叠必须 memmoveglibc 高版本 memcpy 遇到部分重叠会直接 assert 崩溃别心存侥幸。反过来若你能向编译器/读者保证两块内存绝不重叠可用 C99 的restrict关键字标注指针编译器据此做向量化、并行等更激进优化这也是标准库内部 memcpy 比手写循环快数倍的原因之一。和 strcpy 的区别strcpy 遇到\0就停且会补结尾零只适合字符串memcpy 按长度精确搬运任意二进制数据结构体、图像、网络报文不看内容。返回值为何重要返回 dst 使memcpy(dst2, memcpy(dst1, src, n), n)这类链式/嵌套调用成为可能也方便函数直接返回目标缓冲区。性能优化思路真实库glibc、musl会按地址对齐情况先用字节处理头部到对齐边界再用 SIMD/字宽指令批量搬移最后处理尾部不足一个字宽的零头在大块拷贝上比朴素字节循环快一个数量级。嵌入式DMA 缓冲区、外设寄存器、多线程共享内存的搬运要加volatile否则可能被编译器优化掉或重排用 DMA 时还要注意 cache 一致性clean/invalidate否则内存里看到的是旧数据。3.7 main 之前与之后一句话main 不是程序真正的入口和终点——之前有加载器、CRT 和全局构造之后有析构和 atexit 注册的收尾。完整生命周期可执行文件启动 ├─ OS 加载器把 .text/.data 映射进内存.bss 清零建立栈 ├─ C 运行库 _start / __libc_start_main │ ├─ 初始化堆(malloc)、标准 IO、环境变量 │ ├─ .init 段__attribute__((constructor)) 函数 │ └─ 全局/静态对象构造同一翻译单元内按定义顺序 ├─ main() ← 你以为的入口 │ └─ return / exit() └─ main 之后 ├─ atexit(fn) 注册的函数注册顺序的逆序执行 ├─ 全局/静态对象析构构造的逆序 └─ .fini 段__attribute__((destructor))代码验证#include cstdio #include cstdlib struct Glob { Glob(){puts(global ctor);} ~Glob(){puts(global dtor);} } g; void before() __attribute__((constructor)); void before(){ puts(before main); } void after() { puts(atexit handler); } int main() { puts(main); atexit(after); return 0; } // 顺序global ctor → before main → main → atexit handler → global dtor要点与易错跨文件全局构造顺序未定义同一文件内全局对象按定义先后构造但不同 .cpp 之间的初始化顺序标准不保证“静态初始化顺序惨案”。绝不要让一个全局对象在构造时依赖另一个翻译单元里的全局对象否则可能用到尚未构造的对象。解决办法是首次使用时局部静态构造Meyers 单例。atexit 可注册多个按注册的逆序栈式执行exit()会走这些收尾而_exit()直接退出、不刷缓冲不析构。return 与 exitmain 里 return n 等价于 exit(n)都会触发上述清理。嵌入式差异裸机固件从上电 reset handler 进入启动汇编startup_*.s负责拷 .data 到 RAM、清 .bss、调用__libc_init_array逐个执行 constructor最后才调 main且裸机 main 通常是死循环、永不返回理解这一流程才能看懂启动文件与链接脚本。动态链接的额外一步使用动态库.so/.dll时main 之前还要由动态链接器 ld-linux 完成库加载与重定位并执行各动态库自己的初始化段这也是全局对象跨 .so 依赖时更容易踩初始化顺序坑的原因。单例安全构造要规避跨文件全局构造顺序未定义标准做法是函数内static T instance;——C11 起保证其初始化线程安全且首次调用才构造既延迟到确定需要时又顺序可控。exit 家族辨析exit()正常走 atexit/析构/刷流_exit()/_Exit()直接陷入内核不清理、不刷 stdio 缓冲printf 的内容可能丢失abort()则异常终止并可能生成 core dump。3.8 现代 C 内存管理RAII 与智能指针一句话现代 C 的目标是看不见 delete——用 RAII 把资源绑给对象生命周期用智能指针替代裸所有权。RAII资源堆内存、文件、锁、socket在构造函数获取、析构函数释放对象出作用域时析构自动执行即使抛异常、栈展开时也能释放从语言机制上杜绝忘记释放。class FileGuard { FILE* f; public: explicit FileGuard(const char* p): f(std::fopen(p,r)) {} ~FileGuard(){ if(f) std::fclose(f); } // 出作用域/异常都关 FILE* get() const { return f; } };三种智能指针对照unique_ptrshared_ptrweak_ptr所有权独占共享引用计数不拥有拷贝禁止只能 move允许计数1可拷贝不增计数开销近乎零控制块强弱计数删除器依附 shared_ptr用途默认首选确实共享所有权打破循环引用、观察者/缓存线程安全安全计数操作原子所指对象本身仍需外部加锁—循环引用泄漏与修复#include memory struct Node { std::shared_ptrNode next; }; auto astd::make_sharedNode(), bstd::make_sharedNode(); a-nextb; b-nexta; // a↔b 互相持有计数永不到0 → 泄漏修复把其中一方改成 weak_ptr a ──shared──▶ b a ◀──weak──── b weak 不增计数a 归零释放后 b 随之释放struct Node { std::weak_ptrNode next; }; // 使用时if (auto sp next.lock()) { ... } 先提升为 shared_ptr 再用经验法则与追问默认unique_ptr确需共享才升级shared_ptr用weak_ptr断环。优先make_unique/make_shared异常安全、make_shared还把对象与控制块合并为一次分配省一次 malloc、缓存更友好代价是弱引用存活时整块内存都不能释放。自定义删除器shared_ptrFILE可传fclose作删除器用来管理非 new 资源。enable_shared_from_this对象内部要把自己的 shared_ptr 交出去时继承它避免凭空造出第二个独立控制块。业务代码里不应出现裸 delete裸 new 也只应藏在工厂或容器内部。常见事故野/悬空指针、重复释放、new[] 配 delete、异常路径泄漏、对齐违例。控制块与异常安全追问控制块里有什么shared_ptr 第一次创建时分配一个控制块记录强引用数、弱引用数、删除器、分配器强引用归 0 时销毁对象弱引用也归 0 时才释放控制块本身。为什么推荐 make_shared一是异常安全f(shared_ptrT(new T), g())这种写法可能因求值顺序泄漏而make_shared把 new 包在内部二是它把对象和控制块合并成一次分配省一次 malloc、局部性更好。代价是 weak_ptr 存活时整块内存都无法归还。别用裸指针构造两次 shared_ptrshared_ptrT a(p); shared_ptrT b(p);会建两个独立控制块导致同一对象被 delete 两次要共享请拷贝 a或用 enable_shared_from_this。unique_ptr 为何零开销它独占、不需要计数默认就等于一个裸指针大小析构自动 delete是替代T*所有权的首选。3.9 Python 内存管理对照一句话Python 把 3.1–3.8 的手动活儿全自动做了——一切皆对象、变量是引用靠引用计数为主、分代 GC 兜底循环引用。核心机制引用计数主每个对象记着有多少引用指向我归 0 立即回收可即时释放、无停顿。分代 GC辅引用计数解不掉循环引用GC 周期性扫描 0/1/2 三代新对象在 0 代活过一轮升一代越老扫描频率越低。小整数池[-5,256]在解释器启动时预建并全局复用。字符串 intern短字符串、符合标识符规则的字符串可能共享同一对象。可变 vs 不可变int/str/tuple/frozenset 不可变修改实为新建对象list/dict/set 可变原地修改。代码验证import sys, copy a 1; b 1 print(a is b) # True小整数缓存CPython 实现细节勿依赖 print(a b) # True判等永远用 x [] print(sys.getrefcount(x)) # 引用计数含本次传参故比实际多1 def f(lst): lst.append(99) # 可变对象函数内原地改外部可见 f(x); print(x) # [99] s [[1]] d1 copy.copy(s); d2 copy.deepcopy(s) s[0].append(2) print(d1) # [[1, 2]] 浅拷贝内层列表仍共享 print(d2) # [[1]] 深拷贝完全独立与 C 对照 易错话题CPython变量本质具名内存/值指向对象的引用回收手动/RAII/智能指针引用计数 分代 GC 自动函数传参值/引用/指针一律传对象引用可变对象可被原地改相等判断/ 指针比较比值is比 id身份泄漏主因配对错误、异常路径全局容器只增不减、循环引用且定义__del__判等用不要用isa is b对小整数为 True 纯属缓存巧合超出 256 即为 False。Python 也会泄漏全局 list/dict 不断 append 却不清理循环引用且类自定义了__del__旧版 GC 无法判定回收顺序。排查用标准库tracemalloc对比快照或gc模块强制回收。省内存__slots__取消每实例的__dict__在创建海量小对象时显著降低内存weakref建立不阻止回收的弱引用适合缓存。多线程与 GILCPU 密集型多线程受 GIL 限制无法真并行应用multiprocessing或 C 扩展IO 密集型在等待时释放 GIL多线程才划算。
返回列表