1 C++资源管理
C++资源管理是确保程序运行效率和稳定性的关键。资源管理涉及变量、参数的存储和生命周期控制,以及动态内存的分配和释放。C++通过一套内存管理机制来实现资源的有效分配和管理。
1.1 内存管理
为适用不同场景,C++提供了多种内存管理方式,以适用不同的使用场景。
1.1.1 内存分区
栈:自动分配和释放,存储静态局部变量、函数参数、返回值等,栈向下增长;
堆:手动分配和释放,用于程序运行时动态内存分配,堆向上增长;
数据段:用于存储全局数据和静态数据,即全局变量或static修饰的变量;
代码段:可执行的代码(以二进制形式存储)和只读常量;
内存映射段:是高效的I/O隐射方式,用于装载一个共享的动态内存库,用户可使用系统接口创建共享内存,做进程间通信。
1.1.2 内存分配方式
1.1.2.1 自动分配
由栈管理的内存可进行自动内存管理,由编译器自动处理。如局部变量和函数参数等,当函数调用时被创建,其生命周期与函数执行时间相同,当函数返回时,这些变量自动销毁,内存自动释放。
1.1.2.2 手动分配
由堆管理的内存需进行手动分配,由程序员进行控制。在C++中通过new和delete操作符进行动态内存管理。
-
new/delete操作内置类型
申请和释放单个元素的空间,使用new和delete操作符,申请和释放连续的空间,使用new[]和delete[]。对用内置类型而言,用malloc和new,除了用法不同,没有什么区别。
-
new/delete操作自定义类型
申请空间时,malloc只开空间,new既开空间又调用构造函数初始化(先调用operator new开空间,再调用构造进行初始化);释放空间时,delete会调用析构函数(先调用析构函数,再调用operator delete),free不会
1.1.3 内存管理问题
内存泄漏: 内存泄漏发生在分配了内存但未正确释放的情况下。
悬空指针: 野指针是指向已经释放或无效内存的指针,使用野指针可能导致程序奔溃或未定义行为。
内存碎片: 在堆上频繁的使用new和delete分配和释放内存可能会导致内存碎片,影响程序性能,使用内存池或大块内存分配策略可以减少碎片。
1.2 RALL原则
RALL是为高效管理内存资源提出的一种编程范式,利用对象的生命周期管理资源的获取和释放。RALL(Resource Acquisition is Initialization)资源获取即初始化。核心思想是将资源的获取与对象的初始化绑定,将资源的释放与对象的销毁绑定。这样做的目的是确保在任何情况下,一旦资源被成功获取,它最终都会被正确释放,从而避免资源泄漏和重复释放的问题。
1.2.1 工作原理
在C++中,对象的生命周期是明确的:对象在创建时构造函数被调用,在对象生命周期结束时析构函数被调用。RAII利用这一特性,通过在构造函数中获取资源,在析构函数中释放资源,来管理资源的生命周期。其基本工作流程是:创建对象时,先开辟空间,再调用构造进行初始化,对象销毁时,先调用析构函数,再释放空间。
1.2.2 堆上内存的深浅拷贝问题
1.2.2.1 浅拷贝(shallow copy)
在浅拷贝中,只复制对象中的值,而不复制指向动态分配内存的指针。这意味着两个对象共享相同的内存资源。如果其中一个对象修改了共享的资源,另一个对象也会受到影响。
#include <iostream>
class ShallowCopyExample {
public:
int *data;
ShallowCopyExample(const ShallowCopyExample &other) {
// 浅拷贝
data = other.data;
}
};
int main() {
ShallowCopyExample obj1;
obj1.data = new int(42);
ShallowCopyExample obj2 = obj1; // 浅拷贝
// 修改obj2的data,将影响obj1
*(obj2.data) = 84;
std::cout << *(obj1.data) << std::endl; // 输出 84
std::cout << *(obj2.data) << std::endl; // 输出 84
delete obj1.data;
return 0;
}
1.2.2.2 深拷贝(Deep copy)
在深拷贝中,不仅复制对象的值,还复制指向动态分配内存的指针所指向的实际数据。这样,两个对象将拥有彼此独立的内存副本,修改一个对象不会影响另一个对象。
#include <iostream>
class DeepCopyExample {
public:
int *data;
DeepCopyExample(const DeepCopyExample &other) {
// 深拷贝
data = new int(*(other.data));
}
~DeepCopyExample() {
delete data;
}
};
int main() {
DeepCopyExample obj1;
obj1.data = new int(42);
DeepCopyExample obj2 = obj1; // 深拷贝
// 修改obj2的data,不会影响obj1
*(obj2.data) = 84;
std::cout << *(obj1.data) << std::endl; // 输出 42
std::cout << *(obj2.data) << std::endl; // 输出 84
delete obj1.data;
delete obj2.data;
return 0;
}
1.2.2.3 两种拷贝的区别
- 在未定义显示拷贝构造函数的情况下,系统会调用默认的拷贝函数——即浅拷贝,它能够完成成员的一一复制。当数据成员中没有指针时,浅拷贝是可行的;但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针将指向同一个地址,当对象快结束时,会调用两次析构函数,而导致指针悬挂现象,所以,此时,必须采用深拷贝。
- 深拷贝与浅拷贝的区别就在于深拷贝会在堆内存中另外申请空间来储存数据,从而也就解决了指针悬挂的问题。简而言之,当数据成员中有指针时,必须要用深拷贝。
1.2.3 RALL原则和构造函数
1.2.3.1 三法则
- 析构函数:如果类中存在指针(即堆上资源),你在类中需要手动定义了析构函数,这种情况下可能还需要定义复制构造函数和赋值运算符。
- 拷贝构造函数:拷贝构造主要用途是复制对象,确保类中指针在拷贝构造函数中进行深拷贝,以避免资源共享问题,完成新资源副本的创建。
- 赋值运算符重载:确保在赋值运算符重载中进行深拷贝和资源释放,防止资源泄漏。
#include <iostream>
class MyClass {
public:
// 构造函数
MyClass(int value) {
data = new int(value);
std::cout << "Constructor" << std::endl;
}
// 复制构造函数
MyClass(const MyClass& other) {
// 深拷贝资源
data = new int(*(other.data));
std::cout << "Copy Constructor" << std::endl;
}
// 赋值运算符重载
MyClass& operator=(const MyClass& other) {
if (this != &other) {
// 释放已有资源
delete data;
// 深拷贝资源
data = new int(*(other.data));
}
std::cout << "Copy Assignment Operator" << std::endl;
return *this;
}
// 析构函数
~MyClass() {
// 释放资源
delete data;
std::cout << "Destructor" << std::endl;
}
// 显示数据
void displayData() const {
std::cout << "Data: " << *data << std::endl;
}
private:
int* data; // 示例成员,假设是一个动态分配的整数
};
int main() {
// 创建对象1
MyClass obj1(42);
obj1.displayData();
// 使用复制构造函数创建对象2
MyClass obj2 = obj1;
obj2.displayData();
// 使用赋值运算符创建对象3
MyClass obj3(100);
obj3 = obj1;
obj3.displayData();
return 0;
}
1.2.3.2 五法则
在C++11及以上的标准中,为了解决大数据对象拷贝时资源消耗的问题,引入了移动语义,通过转移资源所有权的方式,完成新对象的创建,这种情况下不在需要堆对象进行深拷贝。而移动语义主要是基于右值引用来实现的。
右值引用:
我们通常说的变量、解引用的指针,这种可以取地址,可以赋值,可以在赋值号左边或者右边的值称为左值,而右值,通常是字面常量,函数返回值、表达式的返回值,是一个即将被销毁的临时变量,不能进行取地址操作,且只能出现在赋值号右侧。
//左值
int* p=new int(0);
int b=1;
int a=b;
const int c=3;
//右值
int&& ri=10; //10不能出现在赋值号左侧
int&& ri1=x+y; //同理x+y也不行
int&& ri2=fun(x,y);
左值引用和右值引用就是分别给左值和右值取别名,如上述ri、ri1、ri2。左值和右值在引用的时候有如下特点:
- 左值引用只能引用左值
int a=10;
int& ra1=a;//左值引用
//int& ra2=10; //10是右值,不能给左值引用
- const左值引用可以引用左值,也可以引用右值(右值通常不可修改,使用可以用const左值引用)
const int& ra3=10;//const左值引用右值
const int& ra4=a;//const左值引用左值
- 右值只能引用右值,左值可以通过move来转换为右值进行引用
int&& ri1=10;//右值引用右值
int&& ri2=std::move(a);//右值引用move的左值
- const右值引用:右值是不能取地址的,但是通过右值引用给右值起别名后,导致右值有了特定的存储位置,是可以去到地址的,这时右值被右值引用后变成了左值,为了避免这种情况下发生对右值的修改,这个时候就要用const右值引用
int&& ri3=20;
ri3++;
const int&& ri4=30;
//ri4++;//不可修改
说清楚右值和右值引用后,再来讨论右值引用和移动构造的关系。
对于函数需要返回操作结果的问题,通常有两种处理方式:设置返回值和通过左值引用设置输出型参数。
-
设置返回值的情况:返回值出了函数作用域就会被销毁,因此,需先有个临时变量去接受函数的返回值,然后再将返回值赋值给主函数的接受对象,这个过程涉及多次拷贝,销毁较大,效率较低。
-
设置输出型参数的情况:通过左值引用设置输出型参数的方式可以避免拷贝的问题,但是会导致函数参数过多,函数定义臃肿。(目前只知道这些,知道的可以补充)。
针对上述情况,右值引用就发挥了其价值,右值引用可以直接接受函数返回值,通过移动对象所有权的方式,避免中间的一系列拷贝操作,大大提高新能。对于自定义的类对象,通过定义拷贝构造函数来实现右值引用,实现性能优化的效果。
基于上述内容,又有了两个新的规则,将“三法则”扩展为“五法则”:
-
移动构造函数:移动构造用于转移对象资源的所有权,而不是复制,可以避免不必要的资源复制,定义一个移动构造函数,以支持资源的高效转移。
-
移动赋值运算符重载:定义一个移动赋值运算符,以支持资源的高效转移。
#include <iostream> class MyClass { public: // 构造函数 MyClass(int value) : data(new int(value)) { std::cout << "Constructor" << std::endl; } // 移动构造函数 MyClass(MyClass&& other) noexcept : data(other.data) { other.data = nullptr; // 确保源对象处于有效但未指定的状态 std::cout << "Move Constructor" << std::endl; } // 移动赋值运算符重载 MyClass& operator=(MyClass&& other) noexcept { if (this != &other) { delete data; // 释放已有资源 data = other.data; other.data = nullptr; // 确保源对象处于有效但未指定的状态 } std::cout << "Move Assignment Operator" << std::endl; return *this; } // 析构函数 ~MyClass() { delete data; std::cout << "Destructor" << std::endl; } // 显示数据 void displayData() const { std::cout << "Data: " << (data ? *data : 0) << std::endl; } private: int* data; // 示例成员,假设是一个动态分配的整数 }; int main() { // 创建对象1 MyClass obj1(42); obj1.displayData(); // 移动构造函数创建对象2 MyClass obj2 = std::move(obj1); obj2.displayData(); // 移动赋值运算符创建对象3 MyClass obj3(100); obj3 = std::move(obj2); obj3.displayData(); return 0; }
这五法则确保了对于拥有资源管理责任的类,它们能够正确地进行资源的获取、释放和转移,从而实现了更安全和更高效的编程。RAII的使用通常与智能指针、容器等C++标准库组件一起,以提供更好的资源管理。
[注:]noexcept
是一个C++11引入的关键字,用于指示一个函数是否可能抛出异常。noexcept
并不是完全禁止函数抛出异常,而是告诉编译器在异常发生时如何处理。如果函数确实抛出异常,而 noexcept
有一个 false
的参数,编译器会调用 std::terminate
来终止程序。
1.2.4 RALL的优点
资源管理: 利用对象的生命周期自动管理资源,减少了手动管理资源的复杂性;
异常安全: 即使在代码执行过程中发生异常,由于析构函数总是会被调用,资源也能被正确释放。
1.3 RALL原则的应用
RALL的典型应用是智能指针和内存池,用于安全的进行堆上内存的分配和释放。
1.3.1 智能指针
智能指针是C++标准库提供的一种模板类,是RALL原则的具体实现,用于自动管理动态分配的内存,以防止内存泄漏和其他与动态内存分配相关的问题。C++标准库提供了四种智能指针:auto_ptr、unique_ptr、shared_ptr、weak_ptr。其中后三种是C++11支持的,并且第一个已经在C++11弃用。
1.3.1.1 auto_ptr
auto_ptr采用管理权转移的方法进行赋值和拷贝构造,假设原先有一个auto_ptr对象p1,要通过p1构造p2,当拷贝构造完成后,用于拷贝构造传参的对象p1中管理资源的指针会被更改为nullptr,赋值也一样,假设p2=p1,p1中资源的管理权会转移给p2,p2原本的资源会被释放。转移管理权的方式容易出现悬空指针的问题,auto_ptr在C++11中已经被摒弃,很多公司命令禁止使用。
1.3.1.2 unique_ptr
独占对象所有权,直接将拷贝构造和赋值禁止,不存在浅拷贝多次释放同一块空间的问题。相比于shared_ptr,由于没有引用计数,性能较好。虽然unique_ptr禁止拷贝和赋值,但可以利用move通过返回值的方式实现拷贝。
unique_ptr<T> ptr_a(new T);
unique_ptr<T> ptr_b=std::move(ptr_a);
1.3.1.3 shared_ptr
共享对象的所有权,通过引用计数的方式较好的解决了拷贝和赋值的问题,相对于unique_ptr,性能较差。share_ptr指向同一个对象,当进行拷贝和赋值时,通过应用计数来实现,当最后一个shared_ptr释放时,资源才会释放。
- shared_ptr环形引用问题:
//循环引用
struct ListNode
{
shared_ptr<ListNode> _pre;
shared_ptr<ListNode> _next;
};
int main(){
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
node1->_next=node2;
node2->_pre=node1;
cout<<"node1引用计数:"<<node1.use_count()<<endl;
cout<<"node2引用计数:"<<node2.use_count()<<endl;
}
1.3.1.4 weak_ptr
不增加引用计数,为解决shared_ptr环形引用的问题而提出,weak_ptr不参与资源的管理和释放,可以使用shared_ptr对象来构造weak_ptr对象,但是不能直接使用指针来构造weak_ptr对象,在weak_ptr中,也没有operator*函数和operator->成员函数,不具有一般指针的行为,因此,weak_ptr严格意义上并不是智能指针,weak_ptr的出现,就是为了解决shared_ptr的循环引用问题。
- 环形引用问题解决方案:
//weak_ptr不管资源的释放
struct ListNode
{
weak_ptr<ListNode> _pre;
weak_ptr<ListNode> _next;
};
int main(){
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
node1->_next=node2;
node2->_pre=node1;
cout<<"node1引用计数:"<<node1.use_count()<<endl;
cout<<"node2引用计数:"<<node2.use_count()<<endl;
}
1.3.2 内存池
内存池(Memory Pool)是一种用于优化动态内存分配和释放操作的技术。它的基本原理是在程序启动时或在明确的时机预先分配一大块内存,然后将这块内存分割成多个固定大小的小块,存储在自由列表(free list)中,当程序需要分配内存时,直接从自由列表中获取一个内存块,这样可以显著减少对操作系统内存分配器的调用次数,从而提高内存分配的效率。
1.3.2.1 内存池的工作原理
内存池的工作原理可以分为以下几个步骤:
- 预分配内存:在程序开始时,预先分配一块较大的内存区域。
- 管理空闲块:使用链表、栈或数组等数据结构管理可用内存块。
- 分配和释放:提供分配和释放接口,让用户从内存池中获取和释放内存。
- 回收机制:当内存块被释放时,将其返回到内存池中,便于后续使用。
#include <iostream>
#include <vector>
#include <cassert>
class MemoryPool {
public:
MemoryPool(size_t blockSize, size_t blockCount)
: blockSize(blockSize), blockCount(blockCount), freeBlocks(blockCount) {
// 分配内存池
pool = ::operator new(blockSize * blockCount);
char* current = static_cast<char*>(pool);
// 初始化空闲块列表
for (size_t i = 0; i < blockCount; ++i) {
freeBlocks[i] = current + i * blockSize;
}
}
~MemoryPool() {
// 释放内存池
::operator delete(pool);
}
void* allocate() {
if (freeBlocks.empty()) {
return nullptr; // 没有可用的块
}
// 从空闲块列表中取出一个块
char* block = freeBlocks.back();
freeBlocks.pop_back();
return block;
}
void deallocate(void* block) {
assert(block != nullptr); // 确保要释放的块不是nullptr
// 添加到空闲块列表
freeBlocks.push_back(static_cast<char*>(block));
}
size_t getBlockSize() const {
return blockSize;
}
size_t getBlockCount() const {
return blockCount;
}
size_t getFreeBlockCount() const {
return freeBlocks.size();
}
private:
size_t blockSize; // 每个内存块的大小
size_t blockCount; // 内存池中的块数量
void* pool; // 内存池的起始地址
std::vector<char*> freeBlocks; // 存储空闲块的列表
};
// 示例使用
int main() {
const size_t BLOCK_SIZE = 32; // 每个块32字节
const size_t BLOCK_COUNT = 10; // 总共10个块
MemoryPool pool(BLOCK_SIZE, BLOCK_COUNT); // 创建内存池
// 分配内存块
void* block1 = pool.allocate();
void* block2 = pool.allocate();
std::cout << "Allocated blocks: " << block1 << ", " << block2 << std::endl;
std::cout << "Free blocks: " << pool.getFreeBlockCount() << std::endl;
// 释放内存块
pool.deallocate(block1);
pool.deallocate(block2);
std::cout << "Free blocks after deallocation: " << pool.getFreeBlockCount() << std::endl;
return 0;
}
1.3.2.2 内存池的优势
- 性能提升:通过减少系统调用,提高内存分配和释放的速度。
- 内存碎片减少:通过统一管理,减少内存碎片的问题。
- 简化内存管理:可以设计为自动回收机制,降低内存泄漏的风险。
1.3.2.3 内存池的劣势
- 内存浪费:如果分配的块未被充分利用,可能会造成内存浪费。
- 复杂性增加:需要额外的代码管理内存池,增加了系统的复杂性。
参考资料
1.C / C++ 内存管理_c和c++的动态管理内存方法-CSDN博客
2.【C进阶】动态内存管理(2)_(char *)malloc(100)-CSDN博客
3.【C++】右值引用(极详细版)-CSDN博客
4.C++:智能指针_c++智能指针-CSDN博客
5.C++ — 智能指针 - 流水灯 - 博客园 (cnblogs.com)
6.【c++复习笔记】——智能指针详细解析(智能指针的使用,原理分析)-CSDN博客
7.c++深拷贝和浅拷贝-CSDN博客