这篇笔记将详细解释关于C++中堆与栈内存以及从堆中实例化和从栈中实例化的区别与联系
一、核心基础概念:什么是堆、栈、堆栈?
1、栈(Stack)
栈是编译器自动管理的内存区域,遵循后进先出的(LIFO)规则,是程序最基础的内存空间
栈存储的内容主要有:局部变量、函数参数、栈实例化对象、函数返回地址
核心特性:
自动分配、自动释放,无需人工干预
空间极小(系统默认仅几MB)
分配速度极快,内存连续,缓存友好
生命周期严格绑定代码作用域
{}注意:上述提到的“空间极小”这个特性,后续会涉及到“内存溢出”,以及堆的内存相对较大的优势,望读者在此多留意。
栈的空间极小,这在嵌入式开发中,尤其是涉及UI人机交互界面的开发中,才要尤其注意这个问题,不然很容易出现栈溢出的问题,这个问题是十分隐患的。
由于上述提及到栈溢出,所以我不得不说一下要如何检测发现“栈溢出”
(一)、先分清:堆溢出和栈溢出
- 栈溢出:局部变量、递归把栈撑爆,程序直接闪退 / 段错误
- 堆溢出:
new/malloc开辟的堆空间,写超了边界(数组越界、缓冲区越写越大) 堆溢出极其隐蔽,编译不报错、运行不一定立刻崩溃,属于最难查的内存 BUG。
(二)、堆溢出的典型现象(肉眼判断)
- 程序偶尔崩溃、随机崩溃(不是必现)
- 变量值莫名被篡改、逻辑突然乱掉
free/delete的时候直接报错、断言失败- 内存越用越高,伴随隐性内存破坏
原理:堆内存是一块块碎片化管理的,你写超当前堆块,会覆盖下一块堆的管理头信息,破坏堆链表,后期释放时才炸。
(三)、四种实用检测方法
1. 代码自查(最快)
重点检查:
- 堆数组是否下标越界
strcpy、memcpy不做长度限制- 动态申请空间过小,但写入数据过大
2. IDE 调试(VS/CLion)
堆溢出普通断点很难查,但可以看:
- 崩溃是否发生在
delete / free内部 - 调用栈显示 heap corruption = 明确堆溢出
3. 最强神器:AddressSanitizer(推荐学者使用)
检测堆溢出最强、百分百命中编译加参数:
-fsanitize=address能精准查出:
- 堆缓冲区溢出
- 堆释放后野指针使用
- 重复释放、内存泄漏
比普通调试强无数倍,学生 / 工程开发首选。
接下来我对最强神器:AddressSanitizer(ASAN)进行举例说明,以方便学者理解:
(1). 什么场景属于「堆溢出」
你
new/malloc申请了10 字节,但是你写了 20 字节,越界覆盖后面堆内存。普通运行:大概率不报错、偶尔闪退、诡异 BUG(极难查)
开 ASAN:直接精准报行数、越界大小
(2). 错误代码(存在堆溢出)
#include <iostream> using namespace std; int main() { // 只申请 10 个字节堆内存 char *p = new char[10]; // 堆溢出:越界写入!写入 20 个字符 for(int i = 0; i < 20; i++) { p[i] = 'a'; } delete[] p; return 0; }上述的代码的问题:
- 堆空间只有10 字节
- 循环硬写0~19,直接踩坏后面的堆管理块 →标准堆溢出
如果开启 ASAN 编译(检测堆溢出神器),将会是这样的效果:
g++ main.cpp -o main -fsanitize=address ./mainASAN 直接精准爆出错误:
ERROR: AddressSanitizer: heap-buffer-overflow明确告诉你:
- heap-buffer-overflow→ 堆缓冲区溢出
- 精确到第几行代码越界
- 告诉你:申请 10 字节、却访问了第 10/11... 位
接下来我再补充两个ASAN 专属可检测的堆错误举例
举例一:堆释放后野指针(Use After Free)
// 在堆上申请10字节连续内存,p指针(栈上)指向这块堆内存 char *p = new char[10]; // 手动释放p指向的堆内存,堆内存已归还给系统,彻底失效 delete[] p; // 【严重错误:Use After Free】 // 内存已经释放,属于非法悬空堆内存,强行写入数据 // 普通编译大概率不报错,开启ASAN精准检测出:Use After Free(释放后复用) p[0] = 'x';ASAN 直接报:Use After Free
举例 2:重复释放堆内存(Double Free)
// 堆上开辟10字节内存,指针p绑定该堆空间 char *p = new char[10]; // 第一次释放:正常释放堆内存,堆空间已回收 delete[] p; // 【严重错误:Double Free】 // 内存已释放,再次对同一块内存执行释放操作 // 破坏堆内存管理链表,开启ASAN精准检测出:Double Free(重复释放) delete[] p;ASAN 直接报:Double Free
4. 嵌入式 / 单片机堆溢出检测
嵌入式裸机 / RTOS 堆溢出特征:
- 随机死机、任务卡死
- 内存池数据错乱
检测方式:
- 堆内存水印标记
原理
单片机初始化时,在每一块堆内存首尾手动写入固定标记值(水印)。 正常运行:水印不变。 堆溢出越界:会擦掉、篡改水印 → 判定堆溢出。
伪代码示例
// 1. 申请堆内存 char *buf = (char*)malloc(32); // 2. 手动打【首尾水印】 buf[-4] = 0xAA; // 头部水印 buf[32] = 0xBB; // 尾部水印 // 3. 故意堆溢出(越界写) for(int i=0; i < 64; i++){ buf[i] = 1; } // 4. 巡检判断 if(buf[-4] != 0xAA || buf[32] != 0xBB) { // 水印被篡改 = 发生堆溢出 }- 监控堆首尾校验值是否被篡改
- 查看剩余堆内存持续异常下降(隐性堆溢出特征举例如下)
while(1) { // 实时读取系统剩余堆大小(RTOS自带API) int freeHeap = xPortGetFreeHeapSize(); // 如果剩余堆持续断崖式下跌、不回升 // 大概率:堆越界破坏堆链表 / 隐性内存泄漏 }- 内存池数据错乱(典型堆溢出 BUG)举例如下:
- 你申请堆内存存传感器数据
- 堆越界,踩坏旁边的内存池控制块
- 现象:传感器数值随机跳变、任务频繁卡死、偶尔死机
- 无代码报错,属于嵌入式最典型隐形堆溢出
最后总结堆溢出的特征:
- PC 端堆溢出:ASAN 直接报行数、精准定位
- 单片机堆溢出:无报错、随机死机、数据乱跳,只能靠水印校验 + 剩余内存监控排查
2、堆
堆是程序员手动管理的内存,由操作系统管理。
- 存放:
new/malloc出来的数据、堆实例化对象本体。 - 分配释放:手动。
new申请;delete释放。不 delete 就内存泄漏。 - 大小:非常大,远大于栈,受物理内存限制。
- 分配规则:没有固定顺序,随意申请、随意释放。
- 速度:比栈慢,要调用系统接口。
- 风险:内存泄漏、野指针、内存碎片。
Dog* p = new Dog(); // p这个指针变量本身在栈; // new出来的Dog对象本体,放在堆。 delete p;❗重点:指针变量本身是局部变量,存在栈;指针指向的数据,才在堆。
| 对比维度 | 栈 Stack | 堆 Heap |
|---|---|---|
| 管理者 | 编译器自动管理 | 程序员手动管理 new /delete |
| 生命周期 | 离开作用域{}自动回收 | 直到手动 delete 才释放 |
| 空间大小 | 小(几 MB) | 很大,取决于内存 |
| 分配速度 | 很快 | 较慢 |
| 存放内容 | 局部变量、函数参数、栈对象 | new 创建的对象、动态内存 |
| 内存特征 | 连续,后进先出 | 分散,容易产生碎片 |
| 主要风险 | 栈溢出 | 内存泄漏、野指针 |
3、“堆栈” 到底是什么?
- 汇编 / 操作系统术语:堆栈 = Stack,单指栈。
- C/C++ 中文口语 “堆栈”:很多人偷懒,把堆 + 栈合在一起叫堆栈,是口语叫法,这时候它同时指代两块内存。
⚠️做题、看书时要区分语境:
- 教材讲操作系统:堆栈 = stack,就是栈
- C++ 面向对象口语:“堆栈上实例化对象”= 区分栈实例、堆实例。
通俗比喻
- 栈:酒店客房。进入函数办理入住,出函数自动退房;房间数量有限,住不下大件行李。
- 堆:自己买房子。需要自己申请购买,不用了必须自己卖房;房子数量多,但是手续麻烦,忘了卖房就一直占资源。
二、两种对象实例化方式(核心实操)
1. 栈中实例化对象(局部对象)
代码写法:
Dog dog1; // 栈实例化对象
核心特点:
对象本体完全存储在栈内存
出当前代码作用域
{},系统自动调用析构函数、回收内存成员访问方式:.(点运算符)
无内存泄漏风险,代码简洁
2. 堆中实例化对象(动态对象)
Dog* dog2 = new Dog(); // 堆实例化对象 delete dog2; // 必须手动释放内存 dog2 = nullptr; // 置空避免野指针核心特点:
指针变量
dog2:本身存储在栈内存
new Dog()创建的对象本体:存储在堆内存生命周期不受
{}限制,不执行 delete 则永久占用内存成员访问方式:->(箭头运算符)
必须手动释放,否则造成内存泄漏、野指针问题
三、栈、堆实例化对象 优缺点对比
1、栈实例化Dog dog1;
✅优点
- 速度快:栈内存分配仅移动栈指针,开销极小。
- 不用手动释放,离开大括号自动调用析构回收内存,不会内存泄漏。
- 使用简单,不需要指针,直接用
.访问成员,代码简洁。 - 内存连续,缓存友好。
❌缺点
- 栈空间容量很小(通常只有几 MB),不能存放体积很大的对象。
- 生命周期受作用域
{}严格限制:出了当前大括号,对象直接销毁,不能跨函数返回对象本体。 - 不能动态创建:编译阶段就要确定对象大小,运行时不能灵活决定要不要创建对象。
2、堆实例化Dog *dog2 = new Dog();
对象本体在堆;指针变量本身在栈,必须手动
delete释放
✅优点
- 堆内存空间很大,可以创建体积巨大的对象。
- 生命周期灵活,不受
{}作用域约束:只要不 delete,对象就存活,可以跨函数返回、长期保存。 - 运行时动态创建:程序运行过程中,根据条件决定是否 new 对象。
- 适合动态数组、链表、树等复杂数据结构。
❌缺点
- 速度比栈慢:new 需要向操作系统申请堆内存,有一定开销。
- 必须手动 delete 释放,忘记释放 →内存泄漏。
delete之后如果继续使用指针,产生野指针,程序崩溃。- 使用指针,要用
->访问,代码稍微复杂;还需要注意判空。 - 频繁 new/delete 容易产生内存碎片。
| 对比项 | 栈实例化(局部对象) | 堆实例化(new) |
|---|---|---|
| 分配速度 | 快 | 较慢 |
| 内存大小 | 很小,不适合大对象 | 空间大,支持大对象 |
| 内存释放 | 自动,出{}销毁 | 手动 delete,否则内存泄漏 |
| 生命周期 | 绑定作用域{} | 自由,直到 delete |
| 使用方式 | .运算符 | 指针-> |
| 风险 | 几乎无泄漏风险 | 内存泄漏、野指针、内存碎片 |
| 适用场景 | 小对象、临时局部变量 | 大对象、需要跨函数存活、动态创建 |
- 对象不大、只在当前函数内部使用 →优先栈实例化。
- 对象很大,或者需要跨函数保存、运行时才确定是否创建 →用堆 new,记得 delete。
补充:C++11 以后推荐用智能指针
std::unique_ptr管理堆对象,就不用手动写 delete,规避内存泄漏问题。
我是那个一直在奔跑路上的小小_扫地僧,愿我们能够携手同行,一起成长,顶峰相见!
敬请关注,持续更新中~~~