目录
一、前言
在C++学习中最常见的核心问题是各个变量的生存周期以及如何预防内存泄漏和溢出问题,因此在此做总结归纳,阅读完本文你将会了解C++中各个变量所分布的存储空间以及对应的生命周期,同时学会RAII能让我们的程序具备高耦合、低内聚,从而出现更少的问题。
二、基础概念
生命周期:对象在程序中的时间段,包括创建、使用、销毁。
内存空间:在C++中内存分为4个区,分别是栈 、堆 (堆 、自由存储区 ------动态内存)、全局/静态存储区 和常量存储区。
- 堆:由程序员手动申请和释放,(使用malloc申请,free释放),生命周期与作用域无关,取决于是否显示释放。
cpp
int* p = new int(20);
//使用后要进行释放
delete p;
- 栈:主要用于函数内部局部变量,调用完毕后则立刻释放。
- 自由存储区:由new申请的内存块,使用delete来结束周期,C++11中使用,支持构造/析构,是类型安全的。
- 全局/静态存储区:主要用于存储全局变量和静态变量。
- 常量存储区:存储常量和常量函数,定义后不能修改。
补充:
全局对象:定义在所有函数之外的对象
静态对象:用static关键字修饰的对象。
一个典型的程序内存布局包括以下几个段:
- 代码段(Text Segment):存放可执行指令。
- 数据段(Data Segment):存放已初始化的全局变量和静态变量。
- BSS段(BSS Segment):存放未初始化的全局变量和静态变量。
- 堆(Heap):动态分配的内存区域。
- 栈(Stack):局部变量、函数参数等。
三、生命周期
3.1. 栈(Stack)
- 分配方式:自动分配,函数调用时分配局部变量
- 生命周期:与函数/作用域绑定,离开作用域自动释放
- 特点 :
- 大小有限(通常1-8MB)
- 分配/释放速度快
- 后进先出(LIFO)结构
3.2 堆/自由存储区(Heap/Free Store)
- 分配方式:手动分配(new/malloc)
- 释放方式:手动释放(delete/free)或智能指针自动释放
- 生命周期:由程序员控制,直到显式释放
- 特点 :
- 大小受系统虚拟内存限制
- 分配/释放速度慢
- 可能产生内存碎片
- 内存泄漏风险 :
- 忘记释放:程序运行期间持续占用内存
- 异常安全:异常可能导致delete不执行
- 程序结束时操作系统会回收,但不应依赖于此
- 最佳实践:使用智能指针(unique_ptr/shared_ptr)
3.3 全局/静态存储区
- 分配时机:程序启动前(main之前)
- 释放时机:程序结束后(main之后)
- 初始化 :
- 基本类型:零初始化
- 类类型:调用构造函数
- 存储内容 :
- 全局变量
- 静态局部变量
- 静态成员变量
- 线程安全:C++11保证静态局部变量初始化线程安全
3.4 常量存储区
- 存储内容 :
- 字符串字面量
- 编译时常量表达式
- 特点 :
- 通常是只读的
- 生命周期同程序
- 尝试修改会导致未定义行为
四、堆和栈的区别
| / | 内存管理 | 分配效率 | 生命周期 | 多线程 | 对象大小 |
|---|---|---|---|---|---|
| 堆 | 手动管理 | 分配灵活,有内存泄漏风险 | 需手动释放内存 | 不是线程安全 | 堆空间更大,适合存储大对象。 |
| 栈 | 自动管理 | 性能上更快 | 栈变量随作用域结束销毁 | 线程安全 | 栈空间内存较小,适合小对象分配,递归过深和大对象会溢出 |
五、预防处理方式
未正确管理内存会导致:野指针、内存泄漏
- 野指针:指向无效地址的指针,访问会导致未定义行为。常见成因有
释放后使用、越界访问、未初始化指针、多线程竞争。
cpp
// 1. 释放后使用
int* p1 = new int(10);
delete p1; // 释放内存
*p1 = 20; // ❌ 野指针:访问已释放内存
// 2. 越界访问导致
int arr[5];
int* p2 = &arr[5]; // ❌ 越界指针
*p2 = 100; // 未定义行为
// 3. 未初始化指针
int* p3; // ❌ 未初始化
*p3 = 30; // 使用随机地址
// 4. 多线程竞态条件
int* shared = new int(0);
// 线程1
delete shared; // 释放
// 线程2(同时执行)
*shared = 40; // ❌ 野指针访问
cpp
// 1. 释放后未置空
int* ptr = new int(10);
delete ptr; // ptr成为野指针
*ptr = 20; // 危险:访问已释放内存
// 2. 返回局部变量地址
int* createLocal() {
int value = 42;
return &value; // 函数返回后value被销毁
}
// 3. 多线程竞争释放
void threadFunc(int* p) { delete p; }
int* shared = new int(100);
std::thread t1(threadFunc, shared);
std::thread t2(threadFunc, shared); // 双重释放
- 内存泄漏:已分配的内存未被释放,程序失去对内存的控制。
cpp
// 1. 显式泄漏
void leakExample() {
int* ptr = new int[100]; // 忘记delete[]
// 函数结束,ptr被销毁,但堆内存仍在
}
// 2. 隐式泄漏 - 异常导致
void riskyFunction() {
int* resource = new Resource();
someOperation(); // 可能抛出异常
delete resource; // 异常时不会执行
}
// 3. 容器泄漏
void containerLeak() {
std::vector<int*> vec;
vec.push_back(new int(1));
vec.push_back(new int(2));
// vec清空时,元素未被删除
}
// 4. 循环引用(智能指针)
struct Node {
std::shared_ptr<Node> next;
// std::weak_ptr<Node> next; // 应使用weak_ptr打破循环
};
预防内存泄漏:
内存泄漏的原因是使用后未及时delete指针,避免这个情况可以使用C++11标准的共享指针,它具有共享所有权和引用计数的特点。
智能指针采用RAII原则,离开作用域后能够自动管理内存资源。
cpp
// unique_ptr:移动语义,独占所有权
auto ptr1 = std::make_unique<int>(10);
// auto ptr2 = ptr1; // ❌ 编译错误,不能复制
auto ptr2 = std::move(ptr1); // ✅ 转移所有权
// shared_ptr:引用计数
auto sp1 = std::make_shared<int>(20);
auto sp2 = sp1; // ✅ 引用计数+1
auto sp3 = sp2; // ✅ 引用计数+1,现在计数=3
// 当sp1、sp2、sp3都销毁时,引用计数归0,内存释放
// weak_ptr:不增加计数
auto shared = std::make_shared<int>(30);
std::weak_ptr<int> weak = shared; // 引用计数仍为1
if (auto locked = weak.lock()) { // 尝试获取shared_ptr
// 使用locked...
}
同时使用make_shared和make_unique工厂函数来进行创建智能指针,可以有效减少内存分配。
cpp
// 与直接new的对比
auto p1 = std::make_shared<Widget>(1, 2.0, "hello"); // 一次内存分配
std::shared_ptr<Widget> p2(new Widget(1, 2.0, "hello")); // 两次内存分配
预防野指针:
野指针是由于访问未被定义的内存,导致未定义问题引发程序崩溃。可以使用释放后置空,或者使用智能指针。
智能指针采用资源获取即构造方法,同时对所有权进行管理和自动析构,确保指针失效后不会使用,从根本上消除了野指针问题。。