C++20高级编程 第七章 内存管理
第七章 内存管理
C++内存机制
C++内存重要两类区域:栈区,自由存储区
一般而言,直接通过变量声明方式声明的变量内存都会在栈区中.
例如:
unsigned int arr[20];
int num;
char word;
std::string str;
std::vector<int>weights;
而通过动态分配方式,通过指针索引的内存会在自由存储区中.
例如:
unsigned int* arr{new unsigned int[20]};
int* num = (int*)malloc(sizeof(int));
char* word = new char;
std::string* str = new std::string;
std::vector<int>weights;
经验法则:每次声明一个指针变量时,务必立即用适当的指针或nullptr进行初始化.
内存泄漏(Memory Leak): 指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果
内存越界: 在对内存进行操作的时候,读写范围超过了目标内存的范围
几种内存越界的情况:
- 定义指针的时候未初始化,所以指针指向的时一块随机值,用户并不一定有访问权限.
- 分配到的内存比实际上使用的内存要小.
- 使用下标访问数组时,下标错误
- 内存已经被释放了,但仍指针来使用这块内存.
分配与释放
要分配一块内存,可以使用 new关键字 或 malloc函数 进行分配.
通常而言,使用new关键字进行内存分配的格式为:
经验法则:每写一行通过new关键字分配内存的代码,并且使用非智能指针的指针,就应该有一行用delete关键字释放内存对应的代码.
使用malloc函数进行内存分配的格式为:
注意:每次对new关键字的使用都会分配一块内存.
因而可以试试下面的代码(bushi)for (;true;) new int;
一般来说,涉及到对象的内存分配时,不能使用malloc函数.因为malloc函数只负责分配内存,而不考虑对象的构造.
相对的,要释放一块内存,则可以使用 delete关键字 或 free函数进行释放.
通常而言,使用delete关键字进行内存释放的格式为:
使用free进行内存释放的格式为:
类似于malloc函数,涉及到对象的内存释放时,不能使用free函数.因为free函数只负责释放内存,而不考虑对象的析构.
注意:建议在释放指针的内存后,将指针重新设置为nullptr,这样就不会在无意中使用一个指向已释放内存的指针
数组的内存
一般数组情况
当程序为数组分配内存时,分配的是连续的内存块,每一块的大小足以容纳数组的单个元素.
例如:
int arr[5];
这样的操作将会分配一块连续的五个int型变量大小的内存.
不过,这种基本类型数组的单个元素并未初始化,也就是说,他们包含内存中该位置的任意内容.(一般为-858993460,即0xcccccccc)
在栈上创建数组时,可以使用初始化列表提供初始化元素:
int arr1[5]{1,2,3,4,5};
int arr2[5]{1,2}//剩下的空间将初始化为0
int arr3[5]{0}//将全部为0
int arr4[5]{}//唯一的那个0也可以省略
而在使用new进行分配时,数组的空间将会被分配在自由存储区.声明的语法通常为:
如果需要nothrow的效果,则变为:
与之对应的,在数组的内存释放时,使用delete[].
注意:总是使用delete释放通过new分配的内存,总是使用delete[] 释放通过new[] 分配的内存
多维数组情况
在多维数组中,数组实际上被视为子数组的数组来处理.
例如一个int[3][3]的数组,其便被解释为一个有3个子数组的数组,每个子数组由3个int型变量组成.三个子数组按下标顺序排列,同时内部保持有序.
在栈区中的子数组,在物理上存储连续.而在自由存储区的多维数组,其子数组仅在逻辑上连续.
因而,像下面这样的操作,是不合法的:
T** board { new T[i][j] };//非法的
使用指针
指针很容易被滥用,有时候对其的使用时迷惑的.下面给出一个例子说明:
char* p { (char*)'0' };//错误的
这条语句实际上声明了一个指向内存地址48的char*型指针,而这个位置我们并不知道它具体用于什么用途,这种使用方式可能也是违背开发者初心的.
这是一个非常危险的操作.
从某种意义上来说,数组可以算是一种特殊的指针.
下面假设有一个数组arr[2]:
int arr[2];
那么,在函数传参时,可以采用下面三种等价的方式
void func1(int* arr);
void func2(int arr[]);
void func3(int arr[2]);
也可以采取"按引用"的方式,但是其必须知道数组大小,且不直观.
void func4(int (&arr)[2]);
为了避免显式标明数组大小,也可以通过使用函数模板来让编译器自动推断基于栈的数组大小.
template<size_t N>void func5(int (&arr)[N]);
数组都可以理解为指针,但是并非所有指针都是数组.
指针本身是没有意义的,其只是一个保存所指向变量地址的变量.
内存分配异常
一般来说,在大部分情况下,内存分配都会是正常的.但是,我们无法保证所有情况下,都有足够的内存用于分配.
在使用new进行分配时,失败时通常会抛出异常.而malloc分配失败时通常会返回nullptr.
因而可以使用下面的结构来检查内存分配情况:
p = (T*)malloc(sizeof(T));
if(!p)
throw;
new也存在一个无异常的版本,该版本效果与malloc相似:
T* p {new(nothrow) T};
智能指针
如前所述,内存管理是C++常见的错误和bug来源,许多这类bug都来自动态内存分配和指针的使用.
智能指针可帮助管理动态分配的内存,这是避免内存泄漏建议采取的技术
注意:应使用unique_ptr用作默认智能指针,仅当真正需要共享资源时,才使用shared_ptr
警告:永远不要讲资源分配结果赋值给原始指针.无论使用哪种方法,都应当立即将资源指针存储在智能指针unique_ptr或者shared_ptr中,或者使用其他RAII(Resource Acquisition Is Initialization,资源获取即初始化)类.
unique_ptr
unique_ptr 拥有资源的唯一所有权.当unique_ptr被销毁或重置时,资源将自动释放.
作为经验法则,总是应该将动态分配的有唯一所有者的资源保存在unique_ptr的实例中.
为了创建一个unique_ptr,可以使用辅助函数 std::make_unique() 来创建.
下面是一个使用std::unique_ptr的实例:
std::unique_ptr<T>ptr { new T() };
也可以使用std::make_unique()来创建:
auto ptr { std::make_unique<T>() };
智能指针的一个优势是,其使用方式和原始指针相似.
ptr->func();
(*ptr).func();//二者等价
而为了获得unique_ptr底层的原始指针,可以使用get()方法.
例如:当遇到需要参数的函数,可以用到 get() 方法:
void func(T* p);
T(ptr.get());
也可以使用 reset() 方法,释放unique_ptr的底层指针,并根据需要将其改成另一个恶指针.
ptr.reset();//直接释放
ptr.reset(new T());//释放后更改
还可以用 release() 断开unique_ptr与底层指针的链接.
release() 方法返回资源的底层指针,然后将智能指针设置为nullptr.
T* p{ ptr.release() };
delete p;
p = nullptr;
由于unique_ptr代表唯一拥有权,因而无法复制它.但是可以通过移动语义将其移动到另一个.
std::move 工具函数可以用于显式移动unique_ptr的所有权.
std::unique_ptr<T>ptr{ std::make_unique<T>(T()) };
std::unique_ptr<T>pointer{ std::move(ptr) };
由于数组可以视为一种特殊的指针,因而unique_ptr也可以用于储存C风格的数组.
下面是一个实例.
auto arr{ std::make_unique<int[]>(10) };
arr[1]=123;
默认情况下,unique_ptr使用标准new和delete运算符来分配和释放内存.
shared_ptr
shared_ptr的用法与unique_ptr类似.可以通过 std::make_shared
由于shared_ptr是允许所有权共享的,因而可以通过赋值的方式将其复制.
std::shared_ptr<T>ptr(std::make_shared<T>(T()));
std::shared_ptr<T>pointer = ptr;
与unique_ptr类似,shared_ptr也支持get()和reset()方法.唯一的区别在于,当调用reset()时,仅在最后一个shared_ptr销毁或重置时,才彻底释放底层资源.
shared_ptr不支持release()函数.可以通过 use_count() 方法检索共享同一资料的shared_ptr实例数量.
std::shared_ptr<T>share{ std::make_shared<T>(T()) };
auto p = share;
std::cout<<share.use_count();//2
auto ptr = p;
auto pointer = share;
std::cout<<share.use_count();//4
作为一般概念,引用计数(reference counting) 用于跟踪正在使用的某个类的实例或者某个特定对象的个数.当引用计数降为0时,资源不再有其他所有者,因而智能指针将释放资源.
引用计数的智能指针解决了双重释放的问题.
shared_ptr支持所谓的 别名.这允许一个shared_ptr与另一个shared_ptr共享一个指针(拥有的指针),但指向不同的对象.
例如,可以使用一个shared_ptr拥有一个对象本身的同时,指向该对象的成员:
class Foo{
public:
Foo(int value) : m_data{value}{}
int m_data;
};
auto foo{std::make_shared<Foo>(42)};
auto aliasing {std::shared_ptr<int>{foo,&foo->m_data}};
std::cout<<foo.use_count;//2
仅当两个shared_ptr(foo和aliasing)都销毁时,才销毁Foo对象.
"拥有的指针" 用于引用计数,对指针解引用或者get()时,才将返回 "存储的指针".
weak_ptr
weak_ptr可包含由shared_ptr管理的资源的引用.weak_ptr不拥有这个资源,所以不能阻止shared_ptr释放资源.weak_ptr销毁时不会销毁它所指向的资源.
然而,正因如此,weak_ptr可以用于判断资源是否已被关联的shared_ptr释放了.
weak_ptr的构造函数要求将一个shared_ptr或者另一个weak_ptr作为参数.
std::shared_ptr<T> ptr{ std::make_shared<T>(T()) };
std::weak_ptr<T> wp{ ptr };
std::weak_ptr<T> wpointer { wp };
weak_ptr中也有use_count方法,但是该方法反映的是其所指shared_ptr的use_count()值.
std::cout<<wp.use_count();//1
为了访问weak_ptr中保存的指针,需要将weak_ptr转换为shared_ptr.这又两种方法.
- 使用weak_ptr实例的 lock() 方法,该方法返回一个shared_ptr.
auto sp = wp.lock();
std::cout << ptr.use_count();//2
- 创建一个新的shared_ptr实例
auto sp = std::shared_ptr<int>(wp);
std::cout << ptr.use_count();//2
enable_shared_from_this
首先,这是一项高级功能.std::enable_shared_from_this派生的类允许对象调用方法,以安全地返回指向自己的shared_ptr或weak_ptr.
enable_shared_from_this类为派生类提供了两个方法:
- shared_from_this():返回一个shared_ptr,它共享对象所有权.
- weak_from_this():返回一个weak_ptr,它跟踪对象的所有权.
下面是使用其的实例:
class Foo:public std::enable_shared_from_this<Foo>{
public:
std::shared_ptr<Foo>getPointer(){
return this->shared_from_this();
}
};
int main()
{
auto ptr1 { std::make_shared<Foo>() };
auto ptr2 { ptr1->getPointer() };
std::cout << ptr2.use_count();//2
return 0;
}
auto_ptr
auto_ptr在涉及到标准库容器时常常无法正常使用,因而C++17后完全移除了auto_ptr.
在这里我们将其列出是为了告知切勿再使用它.
底层内存操作
指针运算
指针p所指对象后第N个元素:
指针p1与p2之间的元素数:
垃圾回收
与C#和Java不同,在C++中并不存在现成的垃圾回收机制.运行库会在某时刻自动清理没有任何引用的对象.
在C++中事项真正的垃圾回收是可能的,但不容易.
标记(mark) 与 清扫(sweep) 是一种垃圾回收的方法.使用这种方法的垃圾回收器定期检查程序中的每个指针,并将指针引用的内存标记为仍在使用.在每一轮周期结束时,未标记的内存视为没有在使用,因而被释放.
对象池
对象池是回收的代名词,使用对象池的理想情况是:在一段时间里,需要使用大量同类型的对象,且创建每个对象都会有所开销.
常见的内存陷阱
- 数据缓冲区分配不足以及内存访问越界
一般常出现在C风格字符串的使用中.一种合适的处理方法就是使用C++风格的字符串替代C风格字符串的使用.
- 内存泄漏:即分配了内存,但是没有释放.
内存泄漏可能来自程序员之间的沟通不畅或糟糕的代码文档.
内存泄漏很难追查,因为不能轻松地在内存中查看哪些对象在使用,以及最初把对象分配到了内存的哪里.
如果使用Microsoft Visual C++,其调试库内建了对内存泄漏检测的支持.该内存泄漏检测功能默认情况下没有使用,除非创建的是MFC项目.要在其他项目中启用它,需要在代码开头添加头文件相关的宏指令并重新定义new运算符.
#define _CRTDBG_MAP_ALLOC
#include <cstdlib>
#include <crtdbg.h>
#ifdef _DEBUG
#ifndef DEG_NEW
#define DBG_NEW new ( _NORMAL_BLOCK, __FILE__, __LINE__ )
#define new DBG_NEW
#endif
#endif
最后,需要在main()函数的第一行中添加下面这行代码:
_CrtSetDbgFlag( _CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF );
一般而言,在控制台中,会出现类似于如下的信息:
Detected memory leaks!
Dumping objects ->
project.cpp(23) : {84} normal block at 0x0000029084556EB0, 4 bytes long.
Data: < > CD CD CD CD
project.cpp(22) : {83} normal block at 0x0000029084557070, 4 bytes long.
Data: < > CD CD CD CD
project.cpp(21) : {82} normal block at 0x00000290845573B0, 4 bytes long.
Data: < > CD CD CD CD
project.cpp(20) : {81} normal block at 0x0000029084556970, 4 bytes long.
Data: < > CD CD CD CD
Object dump complete.
其中,文件名后面的括号中的数字为内存泄漏的行号.大括号之间的数字是内存分配的计数器,表明出现开始后的内存分配次数.
可以使用_CrtSetBreakAlloc()在调试进行时执行到特定分配次数时中断.
例如:
_CrtSetBreakAlloc(109);
这行代码将会让程序在第109次内存分配时停下.
- 双重释放和无效指针
通过delete释放某个指针关联的内存时,这个内存就可以由程序的其他部分使用了.然而,无法禁止再次使用这个指针,这个指针成为 悬空指针(dangling pointer).
如果第二次在同一个指针上执行delete操作,那么程序可能会释放重新分配给另一个对象的内存.