C++20高级编程 第七章 内存管理

发布时间 2024-01-10 23:02:15作者: Mesonoxian

第七章 内存管理


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 <类型名>}

经验法则:每写一行通过new关键字分配内存的代码,并且使用非智能指针的指针,就应该有一行用delete关键字释放内存对应的代码.

使用malloc函数进行内存分配的格式为:

<类型名>* + 指针名 = (<类型名>*)malloc(sizeof(<类型名>))

注意:每次对new关键字的使用都会分配一块内存.
因而可以试试下面的代码(bushi)

for (;true;)
	new int;

一般来说,涉及到对象的内存分配时,不能使用malloc函数.因为malloc函数只负责分配内存,而不考虑对象的构造.

相对的,要释放一块内存,则可以使用 delete关键字free函数进行释放.

通常而言,使用delete关键字进行内存释放的格式为:

delete+<指针名>

使用free进行内存释放的格式为:

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进行分配时,数组的空间将会被分配在自由存储区.声明的语法通常为:

<类型名>*+<数组名>{new <类型名>[<范围>]}

如果需要nothrow的效果,则变为:

<类型名>*+<数组名>{new(nothrow)<类型名>[<范围>]}

与之对应的,在数组的内存释放时,使用delete[].

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() 函数.它比直接创建std::shared_ptr更高效.

由于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个元素:

*(p+N)

指针p1与p2之间的元素数:

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操作,那么程序可能会释放重新分配给另一个对象的内存.