C++内存管理
一、堆和栈的区别
栈和堆都是用于存储程序数据的内存区域。
1. 栈(stack)
- 生命周期:局部变量的生命周期 由函数的执行周期决定,即函数调用开始分配,函数返回自动释放。
- 管理方式:由编译器自动分配和回收。
- 优点:分配速度快,效率高。
- 存储内容:局部变量、函数参数&返回地址等函数调用信息。
2. 堆(heap)
- 生命周期:变量的生命周期由程序员显式控制,可以通过
new/delete
或malloc/free
来分配和释放。 - 管理方式:程序员手动管理,一旦忘记释放会造成内存泄漏。
- 缺点:分配和释放较慢,因为需要手动操作&操作系统参与。
- 存储内容:程序运行时动态分配的数据。
二、内存分区
程序运行时,内存大致分为以下几个区域(从低地址到高地址),每个区域负责不同的任务:
1. 代码区(text segment)
- 存储程序的机器码(指令)。
- 只读,防止代码被修改。
2. 常量区(rodata)
- 存放如字符串常量 “ABCD” 等不可修改的数据。
- 属于静态存储区的一部分。
3. 全局/静态区(.data 和 .bss)
- .data:存储已初始化的全局变量和静态变量。
- .bss:存储未初始化的全局变量和静态变量。
- 生命周期:程序整个运行期间都存在。
4. 堆区(heap)
- 用于动态分配的内存(如
new
或malloc
)。 - 由程序员手动分配与释放内存。
- 向高地址方向增长。
5. 栈区(stack)
- 存储函数的局部变量、参数、返回值等。
- 由编译器自动分配与释放。
- 向低地址方向增长。
三、内存泄漏 & 如何避免
1. 什么是内存泄漏
- 定义:内存泄漏是指程序在申请内存后,由于程序设计上的疏忽或错误,忘记释放这块内存,导致这段内存一直被占用但再也不会被使用。
- 本质:不是内存本身丢失,而是程序对这段内存的控制权丢失了,结果就是这块内存无法再使用,造成了浪费。
- 检测工具:常用的内存泄漏检测工具包括:Valgrind、mtrace 等。
2. 内存泄漏的分类
- 堆内存泄露(Heap Leak)
- 常见于使用
malloc
、new
等动态分配内存后,没有及时使用free
或delete
释放。如果这段内存不再被引用,也没有被释放,就会造成堆内存泄漏。
- 常见于使用
- 系统资源泄漏(Resource Leak)
- 程序使用系统分配的资源比如文件句柄(FILE*)、socket 连接、图像对象(Bitmap)、窗口句柄等系统资源没有被显式释放,也会导致系统资源泄漏,使系统运行效率降低,甚至变得不稳定。
- 未将析构函数声明为虚函数造成的泄漏
- 如果使用多态(即父类指针指向子类对象),但父类没有将析构函数定义为
virtual
,则在通过父类指针删除对象时,子类的析构函数不会被调用,导致子类分配的资源没有被释放。 - 如果一个类要作为父类被继承,必须把析构函数声明为 virtual,否则通过父类指针 delete 子类对象时,只会调用父类析构函数,造成资源泄漏。 例如:
- 如果使用多态(即父类指针指向子类对象),但父类没有将析构函数定义为
class Base {
~Base() { ... } // 析构函数,不是 virtual
};
class Derived : public Base {
~Derived() { ... } // 析构函数,释放子类资源
};
Base* b = new Derived();
delete b; // 问题出在这里
如果 Base 的析构函数 不是 virtual,就会发生如下情况:
Base* b = new Derived();
delete b; // ❌ 只调用 ~Base(),不会调用 ~Derived()
结果就是:
- 子类中自己分配的内存(比如
new
出来的数组)不会被释放。 - 程序就出现了所谓的内存泄漏。
如何解决?将父类的析构函数设为 virtual!
class Base {
public:
virtual ~Base() { ... } // ✅ virtual
};
class Derived : public Base {
public:
~Derived() { ... } // 子类自己的析构逻辑
};
这样就不会造成内存泄露了。
Base* b = new Derived();
delete b; // ✅ 会调用 ~Derived() 然后再调用 ~Base()
总结:
是否需要写 virtual |
说明 |
---|---|
父类的析构函数 | 必须写 virtual,否则通过父类指针 delete 子类会内存泄漏 |
子类的析构函数 | 可以不写 virtual,因为继承自父类就已经是虚函数了 |
3. 什么操作容易导致内存泄漏
- 指针错误:指针被改写或丢失。
- 忘记释放:申请的内存在不再使用时没有调用对应释放函数。
- 异常退出:有时函数提前 return 或发生异常,中间资源释放代码没有执行。
4. 如何防止内存泄漏
- 配对使用:谁申请的内存,谁负责释放(new/delete,malloc/free)。
- 构造/析构管理:在构造函数申请资源,在析构函数中统一释放资源。
- 智能指针(如
std::unique_ptr
,std::shared_ptr
):自动管理内存,生命周期自动绑定到对象上。
5. 构造函数,析构函数要设为虚函数吗,为什么?
- 析构函数:
析构函数需要。 当派生类对象中有内存需要回收时,如果析构函数不是虚函数,不会触发动态绑定,只会调用基类析构函数,导致派生类资源无法释放,造成内存泄漏。 - 构造函数:
构造函数不需要。 虚函数调用是在部分信息下完成工作的机制,允许我们只知道接口而不知道对象的确切类型。 要创建⼀个对象,你需要知道对象的完整信息(包含对象的确切类型)。
四、智能指针分类
智能指针就是一个封装了普通指针的类,自动帮你管理内存资源,在不用时自动释放,防止忘记 delete 造成内存泄漏,防止多次释放同一内存。它们定义在 C++11 的头文件 < memory > 中。
1. std::unique_ptr
独占指针
- 特点:
- 独占所有权,一个对象只能被一个
unique_ptr
拥有。 - 无法复制,只能移动(通过
std::move()
) - 自动释放所指向的内存。
- 独占所有权,一个对象只能被一个
#include <memory>
std::unique_ptr<int> ptr = std::make_unique<int>(42);
- 用
make_unique
创建,语法简洁安全。 - 一般用于:
- 独占资源
- 局部使用
- 保证对象不会被多个指针共享
独占指针举例:
#include <iostream>
#include <memory>
class Dog {
public:
Dog() { std::cout << "Dog created\n"; }
~Dog() { std::cout << "Dog destroyed\n"; }
void bark() { std::cout << "Woof!\n"; }
};
int main() {
std::unique_ptr<Dog> dog1 = std::make_unique<Dog>(); // 创建
dog1->bark();
// std::unique_ptr<Dog> dog2 = dog1; // ❌ 错误:不能复制
std::unique_ptr<Dog> dog2 = std::move(dog1); // ✅ 可以移动
if (!dog1) std::cout << "dog1 is null\n";
dog2->bark(); // 现在 dog2 拥有资源
}
输出:
Dog created
Woof!
dog1 is null
Woof!
Dog destroyed
在 main() 函数结束时,dog2 离开作用域:C++ 会自动调用 dog2 的析构函数,而 unique_ptr
的析构函数内部会调用 delete
去释放它管理的对象。这句输出:Dog destroyed
说明:dog2 在主函数结束时被销毁,自动释放了堆上的 Dog 对象。
**unique_ptr 的好处就在于 不需要你手动 delete,它会在生命周期结束时自动释放所拥有的资源,避免内存泄漏。**但注意:
如果你在函数中 new 了对象但没有用智能指针接管它的生命周期,那就需要你自己 delete,否则就内存泄漏了.
PS:
. (变量类型为对象)和 -> (变量类型为指针) 用于访问对象的成员,和是否是 class / struct 无关; ::` 用于访问作用域中的静态成员、类型名、常量等。class 和 struct 都可以用。
2. std::shared_ptr
共享指针
- 特点:
- 引用计数机制,多指针可以共享同一对象。
- 每多一个
shared_ptr
拷贝,引用计数 +1;每销毁一个,引用计数 -1;引用计数为 0 时,资源释放。 - 易用但要注意循环引用问题(见
weak_ptr
)。
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
std::shared_ptr<int> ptr2 = ptr1; // 引用计数 +1
共享指针举例:
#include <iostream>
#include <memory>
class Cat {
public:
Cat() { std::cout << "Cat created\n"; }
~Cat() { std::cout << "Cat destroyed\n"; }
void meow() { std::cout << "Meow!\n"; }
};
int main() {
std::shared_ptr<Cat> cat1 = std::make_shared<Cat>(); // 创建对象,count = 1
{
std::shared_ptr<Cat> cat2 = cat1; // count = 2
std::cout << "Use count: " << cat1.use_count() << '\n';
cat2->meow();
} // cat2 出作用域,引用计数 -1,count = 1
std::cout << "Use count after cat2: " << cat1.use_count() << '\n';
cat1->meow();
// cat1 要出作用域,count 从 1 减到 0,此时调用 delete -> 析构
}
输出:
Cat created
Use count: 2
Meow!
Use count after cat2: 1
Meow!
Cat destroyed
3. std::weak_ptr
弱引用指针
- 特点:
- 用于解决
shared_ptr
循环引用的问题 - 它不增加引用计数,不会影响资源释放。
- 用于观察
shared_ptr
是否还有效,通过.lock()
转换成shared_ptr
使用。
- 用于解决
std::shared_ptr<int> sharedPtr = std::make_shared<int>(42);
std::weak_ptr<int> weakPtr = sharedPtr; // 不增加引用计数
使用:
if (auto sp = weakPtr.lock()) {
// 成功获得 shared_ptr,可以使用资源
}
弱引用指针举例:
#include <iostream>
#include <memory>
class Person {
public:
std::shared_ptr<Person> friendPtr;
~Person() { std::cout << "Person destroyed\n"; }
};
int main() {
std::shared_ptr<Person> p1 = std::make_shared<Person>();
std::shared_ptr<Person> p2 = std::make_shared<Person>();
// 错误方式:会导致循环引用
// p1->friendPtr = p2;
// p2->friendPtr = p1;
// 正确方式:用 weak_ptr 打破循环
std::weak_ptr<Person> weak = p1;
if (auto sp = weak.lock()) { // 尝试获得 shared_ptr
std::cout << "Accessed person through weak_ptr\n";
}
return 0;
}
输出:
Accessed person through weak_ptr
Person destroyed
Person destroyed
什么是循环引用:
如果两个 shared_ptr
互相持有对方,就会造成引用计数永远不为 0,资源永远无法释放 → 内存泄漏!
总结:
类型 | 所有权 | 引用计数 | 可复制 | 适用场景 |
---|---|---|---|---|
unique_ptr |
独占 | ❌ | ❌ | 独占资源 |
shared_ptr |
共享 | ✅ | ✅ | 多个共享拥有者 |
weak_ptr |
观察者 | ✅(不+1) | ✅ | 避免循环引用,观察 shared_ptr |
五、如何避免野指针
1. 什么是野指针
野指针指的是:指向一块已经释放或者无效的内存地址的指针。 这种指针如果被错误使用,可能会导致:程序崩溃(如 segmentation fault),数据损坏,不可预测的行为(Undefined Behavior)。
2. 野指针产生原因 & 避免方法
- 释放后没有置空指针:
int* ptr = new int;
delete ptr;
// 这时 ptr 成为野指针,仍然指向已释放的内存
虽然原来指向的地址内存已经释放,但指针 ptr
仍然指向原来的地址,这就可能误操作一块无效内存。
避免方法:
delete ptr;
ptr = nullptr; // 将指针设为 nullptr,表示不再指向任何有效内存
- 返回局部变量的地址:
int* createInt() {
int x = 10;
return &x; // 错误!x 是局部变量,函数结束后内存被销毁
}
x
是局部变量,函数结束后其内存在栈上被回收,但指针返回了这块无效地址。
避免方法:
- 不返回局部变量的地址。
- 使用 new 在堆上申请内存后返回,或者使用引用传参。
- 函数参数的指针被释放:
void foo(int* ptr) {
delete ptr; // 在这里释放
}
int main() {
int* ptr = new int;
foo(ptr); // 调用函数释放 ptr
// 这里 ptr 成为了野指针,仍然可以访问已释放的内存
}
虽然在 foo()
中释放了内存,但 main()
中的 ptr
并不知道这件事,还在使用这块已释放的内存。
避免方法:
- 函数不要释放由调用者传入的内存。
- 或者释放后及时在调用处也将指针设为 nullptr。
3. 避免野指针的原则
delete
后一定记得ptr = nullptr
。- 不返回局部变量的地址。
- 避免函数内部释放外部传入的指针,除非有明确的管理约定。
- 可考虑使用智能指针(如
std::unique_ptr
,std::shared_ptr
)来自动管理生命周期。
4. delete ptr
中到底发生了什么
假设我们有下面这段代码:
int* ptr = new int(42); // 第一步
delete ptr; // 第二步
第一步:int* ptr = new int(42)
这个语句分两件事:
- 在堆区分配一个
int
,值是 42,
假设分配到了地址0x1000
,可以理解为:
堆内存地址:0x1000
存放的值: 42
- 在栈区上创建一个变量
ptr
,它是一个“指针变量”,
假设ptr
本身的地址是0x2000
,但它存放的是0x1000
,即堆内存的地址:
栈内存地址:0x2000
变量名: ptr
内容: 0x1000
第二步:delete ptr;
这个语句只做一件事:
让系统回收 ptr 指向的那块堆内存(即地址 0x1000
的 42
)。也就是说,堆上的值 42
被释放掉了(该内存地址会被标记为“可用”,原来的值 42
一般还在,但是你不能保证它还在,也不能再用它),这块内存以后就不能再安全访问了。
但是重要的一点:ptr
这个变量本身还在,它还保留着 0x1000
这个地址,也就是说:堆内存的那块空间(0x1000
)变成“废地”,但 ptr
这个“钥匙”还握在你手里,如果你继续用 *ptr = 100;
就相当于你在废地上盖房子 —— 没人知道会发生什么!
六、野指针 vs 悬浮指针
1. 定义对比:
名称 | 定义说明 |
---|---|
野指针 | 指的是指向已经被释放或无效地址的指针。常见于 delete 后,指针本身未置为 nullptr ,仍然指向原地址。 |
悬浮指针(悬浮引用) | 指的是引用(或指针)指向一个局部变量,当函数返回后,该局部变量已被销毁,引用仍然存在,就称为悬浮引用。 |
2. 核心区别:
分类维度 | 野指针 | 悬浮指针(引用) |
---|---|---|
所涉类型 | 指针类型 | 引用类型(也可能是指针) |
本质问题 | 内存释放后指针未清空,仍然访问原地址 | 局部变量生命周期结束,引用仍在使用它 |
风险表现 | 访问无效内存:程序崩溃、数据损坏 | 访问已销毁对象,行为未定义(数据异常) |
悬浮指针例子:
int& foo() {
int x = 10;
return x; // ❌ 错误:返回了局部变量的引用
}
int main() {
int& ref = foo(); // ref 是悬浮引用
cout << ref << endl; // ❌ 错误:访问已销毁变量
}
3. 产生原因:
类型 | 原因 |
---|---|
野指针 | 由于没有正确管理指针生命周期,特别是释放后未将指针置为 nullptr |
悬浮指针 | 函数返回了对局部变量的引用或指针,超出了变量的作用域 |
4. 如何避免悬浮指针:
- 不要返回局部变量的引用或指针:若要返回引用或指针,应使用动态分配的内存(如 new)或将变量声明为静态
- 若要返回引用或指针,应使用动态分配的内存(如
new
)或将变量声明为静态:
// 正确示例(静态变量)
int& foo() {
static int x = 10;
return x; // 把该变量作为引用返回,因为函数的返回类型是 int&。
}
5. 总结
野指针是“指向已释放内存的指针”,悬浮指针/引用是“引用了生命周期已结束的变量”。两者都会导致未定义行为,但来源不同,解决方式也不同。
七、内存对齐
1. 定义
内存对齐是指:数据在内存中的起始地址必须是某个特定“整数倍”地址,这个“整数”叫对齐值(alignment)。
举例:
如果一个 int 是 4 字节,很多 CPU 要求这个 int 的起始地址必须是 4 的倍数,比如
0x1000
、0x1004
、0x1008
,而不能从 0x1001 或 0x1003 开始。这种规则叫“自然对齐”:一个数据的对齐值一般是它的大小。
为什么有这种限制:
因为 CPU 读取内存不是一个字节一个字节读的,它是一段一段读取,如果你的数据没有放在“合适”的边界上,CPU 要么:
- 读两次(性能下降)
- 报错(有些系统,如 SPARC)
- 甚至程序崩溃(极端情况)
2. 为什么需要内存对齐
本质原因是 提高 CPU 访问内存的效率。
- 大多数 CPU 访问内存是按“对齐边界”进行的
- 不对齐意味着每次都要额外读取和合并数据 → 读两次 → 慢
- CPU 缓存(cache line)也是按对齐方式加载的
- 不对齐会让 CPU 缓存系统效率变差,甚至多次加载 → 增加开销
- 高位地址对齐优化访问顺序
- 高对齐要求能更快定位数据 → 地址计算更高效
- 某些平台要求严格对齐(如 SPARC、ARM)
- 否则程序会直接 crash!