Re:思考·重建·记录 现代C++ C++11篇(六) 从 shared_ptr 到 weak_ptr:起底智能指针的引用计数与循环引用之痛


◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。
⭐️现代C++系列个人专栏: 插曲:现代C++
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录


概要&序論

这里是此方,好久不见。 本专栏是【主题曲:C++程序设计】专栏的补充篇【插曲:现代C++】。本系列将优先深度解析C++11标准,力求内容详实,无微不至。C++14~C++20的进阶内容将在后续间隔一段时间后连载。本期将重点讲解:智能指针的底层原理、RAII 设计思路、循环引用痛点及其解决方案等内容

一,探索_智能指针因何而生

下面程序中我们可以看到,new了以后,我们也delete了 ,但是因为抛异常导致后面的delete没有得到执行,所以就内存泄漏了,在异常章节,我们讲了一种解决方案:捕获到异常后delete内存,再把异常抛出。

但是这种方法有两个缺陷:

  1. 不方便。
  2. 一种场景:new本身也可能抛异常,连续的两个new和下面的Divide都可能会抛异常,让我们处理起来很麻烦。
cpp 复制代码
double Divide(int a, int b){
    if (b == 0){ throw "Divide by zero condition!";}
    else{ return (double)a / (double)b;}
}
void Func(){
    int* array1 = new int[10];//资源泄漏风险
    int* array2 = new int[10];//资源泄漏风险
    try{
        int len, time;
        cin >> len >> time;
        cout << Divide(len, time) << endl;
    }
    catch (...){
        cout << "delete []" << array1 << endl;
        cout << "delete []" << array2 << endl;
        delete[] array1;
        delete[] array2;
        throw; // 异常重新抛出,捕获到什么抛出什么
    }
    cout << "delete []" << array1 << endl;
    delete[] array1;
    cout << "delete []" << array2 << endl;
    delete[] array2;
}
int main(){
    try{
        Func();
    }
    catch (const char* errmsg){ 
    	cout << errmsg << endl;
    }
    catch (const exception& e){
        cout << e.what() << endl;
    }
    catch (...){
        cout << "未知异常" << endl;
    }
    return 0;
}

于是,为了解决这个问题,科学家们发明了RAII设计方法和智能指针。

二,RAII和智能指针的设计思路

2.1RAII设计思想

RAII,就是Resource Acquisition Is Initialization,翻译过来就叫:"资源获得即初始化"------显然是一个老外起的名字。

什么叫"资源获得即初始化 "呢?他是一种管理资源的类的设计思想,本质是一种利用对象生命周期来管理获取到的动态资源(这是核心),避免资源泄漏,(这里的资源可以是内存、文件指针、网络连接、互斥锁等等)的设计方法。

有人说,哎,此方呀,我还是听不懂,什么是"利用对象生命周期来管理获取到的动态资源"呢?

RAII在获取资源时把资源委托给一个对象,接着控制对资源的访问,资源在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源,这样保障了资源的正常释放,避免资源泄漏问题。

就是说:把资源释放的工作托管给一个对象,对象析构了,资源也就释放了。
科普:RAII设计思想在Java中也有使用

有人看到后一定要说了,哎呀此方啊,Java里面资源释放有gc了,怎么还要RAII呢?Java的RAII在线程安全中使用的非常广泛 ,而不是资源释放,我来举一个例子:

在多线程环境下执行 ++i 操作时,如果多个线程同时操作,会产生竞态条件 。为了保证操作的原子性 ,我们需要通过加锁来确保线程同步。

然而,手动加锁存在风险:**如果锁定的代码块 func() 抛出未捕获的异常,程序会直接跳出当前逻辑。**若解锁操作在异常点之后,锁将永远无法释放,从而导致死锁(坏锁)。

为了规避风险,Java采用了类似于RAII的 思想(C++这块也是一样的),将锁封装在局部对象的生命周期中,利用析构函数在作用域结束时自动解锁。

2.2智能指针是如何利用RAII设计思想的

我们用一个代码来帮助大家理解一下:

cpp 复制代码
//智能指针类除了满足RAII的设计思路,还要方便资源的访问,
//所以智能指针类还会想迭代器类一样,
//重载 operator*/operator->/operator[] 等
//运算符,方便访问资源。
template<class T>
class SmartPtr
{
public:
    // RAII
    SmartPtr(T* ptr)
        :_ptr(ptr)
    {}
    ~SmartPtr()
    {
        cout << "delete[] " << _ptr << endl;
        delete[] _ptr;
    }
    // 重载运算符,模拟指针的行为,方便访问资源
    T& operator*(){ return *_ptr;}
    T* operator->(){return _ptr;}
    T& operator[](size_t i){return _ptr[i];}
private:
    T* _ptr;
};
double Divide(int a, int b){
    // 当b == 0时抛出异常
    if (b == 0){
        throw "Divide by zero condition!";
    }
    else{ 
    	return (double)a / (double)b;
    }
}
void Func(){
    // 这里使用RAII的智能指针类管理new出来的数组以后,程序简单多了
    SmartPtr<int> sp1 = new int[10];
    SmartPtr<int> sp2 = new int[10];
    for (size_t i = 0; i < 10; i++){
        sp1[i] = sp2[i] = i;
    }

    int len, time;
    cin >> len >> time;
    cout << Divide(len, time) << endl;
    //不再需要手动释放资源
}
int main(){
    try
    {
        Func();
    }
    catch (const char* errmsg)
    {
        cout << errmsg << endl;
    }
    catch (const exception& e)
    {
        cout << e.what() << endl;
    }
    catch (...)
    {
        cout << "未知异常" << endl;
    }
    return 0;
}

智能指针也有两种写法,我们更加倾向于写法二:

cpp 复制代码
//写法一:
int* array4 = new int[10];   
SmartPtr<int> sp1(array1);
//写法二:
SmartPtr<int> sp4 = new int[10];

那么怎么使用智能指针呢?我们有一种和迭代器高度相似的方法去使用他们。

cpp 复制代码
	sp1[5] = 50;
    sp5->first = 1;
    sp5->second = 2;
    cout << sp1[5] << endl;

三,C++库中的智能指针历史演进与使用

C++标准库中的智能指针都在 <memory> 这个头文件下 面,我们包含 <memory> 就可以使用了,智能指针有好几种,除了 weak_ptr 他们都符合RAII和像指针一样访问的行为。

Tips:智能指针设计上首要解决的问题------拷贝问题

  • 智能指针的设计初衷 是"让委托方对象指向的资源和原指针指向的资源相同"------代理管理代理而非拥有 ),于是必须实现成浅拷贝
  • 但是浅拷贝又会带来一个问题 :按照 C++ 对象的生命周期,每个指针对象析构时都会调一次 delete, 对同一块内存 delete 两次,程序直接报 Double Free 错误并挂掉。

面试常考 :这四种智能指针的根本区别?解决拷贝问题的不同方法。

3.2auto_ptr------C++98的设计尝试

3.2.1auto_ptr的使用

auto_ptr 是C++98设计出来的智能指针,他的特点是拷贝时把被拷贝对象的资源的管理权转移给拷贝对象。

这是一个非常糟糕的设计因为他会被拷贝对象悬空 ,访问报错的问题,C++11设计出新的智能指针后,强烈建议不要使用 auto_ptr。 其他C++11出来之前很多公司也是明令禁止使用这个智能指针的。(部分主流编译器如VS2026在C++17及以上标准中将auto_ptr移除。你得调整到c++14及以下才能使用

如上,直接将p1的资源转移给p2,p1访问时报错。

3.2.2auto_ptr的原理

cpp 复制代码
template<class T>
class auto_ptr
{
public:
    auto_ptr(T* ptr)
        : _ptr(ptr)
    {}

    auto_ptr(auto_ptr<T>& sp)
        : _ptr(sp._ptr)
    {
        // 管理权转移
        sp._ptr = nullptr;
    }

    auto_ptr<T>& operator=(auto_ptr<T>& ap)
    {
        // 检测是否为自己给自己赋值
        if (this != &ap)
        {
            // 释放当前对象中资源
            if (_ptr)
                delete _ptr;

            // 转移ap中资源到当前对象中
            _ptr = ap._ptr;
            ap._ptr = NULL;
        }
        return *this;
    }

    ~auto_ptr()
    {
        if (_ptr)
        {
            cout << "delete:" << _ptr << endl;
            delete _ptr;
        }
    }

    // 像指针一样使用
    T& operator*()
    {
        return *_ptr;
    }

    T* operator->()
    {
        return _ptr;
    }

private:
    T* _ptr;
};

3.3unique_ptr------对auto_ptr的补救

3.3.1unique_ptr的使用

unique_ptr 是C++11设计出来的智能指针,他的名字翻译出来是唯一指针,他的特点的不支持拷贝,只支持移动。如果不需要拷贝的场景就非常建议使用他。

如上,直接在编译时告知:不可以使用unique_ptr进行拷贝。 支持移动,移动后的数据就不可以访问了。会报出警告。

auto_ptr和unique_ptr都悬空了为什么后者更好?

因为后者是程序员心甘情愿地去悬空 up1(指通过 move 显式转移);

前者是库里面的败笔,"隐式" 导致的悬空。

3.3.2unique_ptr的原理

cpp 复制代码
template<class T>
class unique_ptr
{
public:
    explicit unique_ptr(T* ptr)
        : _ptr(ptr)
    {}

    ~unique_ptr()
    {
        if (_ptr)
        {
            cout << "delete:" << _ptr << endl;
            delete _ptr;
        }
    }

    // 像指针一样使用
    T& operator*()
    {
        return *_ptr;
    }

    T* operator->()
    {
        return _ptr;
    }

    // 禁止拷贝
    unique_ptr(const unique_ptr<T>& sp) = delete;
    unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;

    // 移动构造(管理权转移)
    unique_ptr(unique_ptr<T>&& sp)
        : _ptr(sp._ptr)
    {
        sp._ptr = nullptr;
    }

    // 移动赋值
    unique_ptr<T>& operator=(unique_ptr<T>&& sp)
    {
        if (this != &sp)
        {
            if (_ptr)
                delete _ptr;
            _ptr = sp._ptr;
            sp._ptr = nullptr;
        }
        return *this;
    }

private:
    T* _ptr;
};

3.4shared_ptr------C++11的全新设计思路

shared_ptr 是C++11设计出来的智能指针,他的名字翻译出来是共享指针,他的特点是支持拷贝,也支持移动 。底层是用引用计数的方式实现的。如果需要拷贝的场景就需要使用他了。

如上,拷贝正常,析构正常。两个智能指针共同管理同一个资源。

小的注意事项 )shared_ptr的构造函数加了一个explicit,意思是这个构造函数不支持隐式类型转换(我在C++加强专栏里面会讲)。所以

  • 我们这样写是不可以的:shared_ptr<Date> ptr=new Date(2021,2,5);
    (构造一个对象在拷贝构造的过程中发生了隐式类型转换)
  • 必须这么写: shared_ptr<Date> ptr(new Date(2021,2,5));(直接调用构造没有触发隐式类型转换

四,share_ptr的底层原理与实现

(面试高频考点·本文重点内容)

建议每一位学习智能指针的求职者都能够做到手撕智能指针,而选择那一个智能指针,建议选择难度最高,最实用的share_ptr。

4.1手写一个最简洁的share_ptr

4.1.1引用计数的复习

引用计数的设计,一份资源就需要一个引用计数 ,所以引用计数才用静态成员的方式是无法实现的,要使用堆上动态开辟的方式 ,构造智能指针对象时来一份资源,就要new一个引用计数出来。

多个shared_ptr指向资源时就++引用计数,shared_ptr对象析构时就--引用计数,引用计数减到0时代表当前析构的shared_ptr是最后一个管理资源的对象,则析构资源。

引用计数我在string中讲过,这里直接拷贝过来:

4.1.2构造与拷贝构造

cpp 复制代码
//SharePtr.h
//SharePtr.h
#pragma once
#include<iostream>
#include<functional>
using namespace std;

namespace MySharePtr
{
	template <class T>
	class Share_ptr
	{
	private:
		T* _ptr;        // 实际管理的资源指针
		int* _pcount;   // 引用计数:必须是指针,才能让多个对象共享同一个计数器
	//在堆上开辟而不是全局静态
	public:
		// 构造函数 A:接收资源指针和自定义删除器
		template<class K>
		Share_ptr( T* ptr, const K& Delete)
			: _ptr(ptr)
			, _pcount(new int(1))  // 核心:在堆上开辟计数器,
			//初始值为 1,每创建第一个对象,就会开辟一个int的空间,来作为引用计数器
			, _Delete(Delete)      // 初始化删除器
		{}

		// 构造函数 B:普通构造,只接收资源指针
		Share_ptr( T* ptr)
			: _ptr(ptr)
			, _pcount(new int(1))  // 同上,新资源计数为 1
		{}

		// 拷贝构造函数:实现"资源共享"
		// 逻辑:将新对象的指针指向现有资源,并让引用计数自增
		Share_ptr(const Share_ptr& SharePtr)
			: _ptr(SharePtr._ptr)         // 浅拷贝:指向同一块资源
			, _pcount(SharePtr._pcount)   // 浅拷贝:指向同一个计数器地址
			, _Delete(SharePtr._Delete)   // 复制删除逻辑
		{
			(*_pcount)++;  // 关键:由于多了一个管理者,计数器内容 +1,
			//拷贝构造意味着一块资源被多个人管理,
		}
	};
}

4.1.3赋值运算符重载

cpp 复制代码
// 逻辑:处理"先解绑旧资源,再绑定新资源"的过程
const Share_ptr& operator=(Share_ptr<T>& SharePtr)
{
    // 检查是否为"指向同一资源"的赋值(不仅仅是自赋值,相同资源的两个对象赋值也应跳过)
    //自己给自己赋值不行,如果引用计数不为1还好,
	//引用计数为1的时候直接提前释放了,释放完之后两个指针都悬空了
	//自赋值检查不仅仅要看对象地址是否相同(this != &sp),
	//更要看它们管理的资源是否相同。
    if (_ptr != SharePtr._ptr)
    {
        // 2. 减扣当前对象原本管理的资源计数
        // 如果当前对象是该资源的最后一个管理者,则负责释放它
        if (--(*_pcount) == 0)
        {
            _Delete(_ptr);   // 使用定制删除器释放资源
            delete(_pcount); // 释放堆上的计数器变量
        }

        // 3. 接管新资源
        _ptr = SharePtr._ptr;         // 指向新资源
        _pcount = SharePtr._pcount;   // 指向新计数器
        _Delete = SharePtr._Delete;   // 同步删除器逻辑

        // 4. 新资源计数自增
        (*_pcount)++;
    }
    return *this; // 支持 a = b = c 的连续赋值
}

4.1.5完整代码示例

cpp 复制代码
//SharePtr.h
#pragma once
#include<iostream>
using namespace std;
namespace MySharePtr
{
	template <class T>
	class Share_ptr
	{
	private:
		T* _ptr;
		int* _pcount;
	public:
		Share_ptr(T* ptr)
			: _ptr(ptr)
			, _pcount(new int(1))
		{
		}

		Share_ptr(const Share_ptr& SharePtr)
			: _ptr(SharePtr._ptr)
			, _pcount(SharePtr._pcount)
		{
			(*_pcount)++;
		}

		~Share_ptr()
		{
			if (--(*_pcount) == 0)
			{
				cout << "Success Delete" << endl;
				delete _ptr;  
				delete _pcount;
			}
		}

		T* operator->()
		{
			return _ptr;
		}

		T operator*()
		{
			return *_ptr;
		}

		const Share_ptr& operator=(Share_ptr<T>& SharePtr)
		{
			if (_ptr != SharePtr._ptr)
			{
				if (--(*_pcount) == 0)
				{
					delete _ptr;
					delete _pcount;
				}
				_pcount = SharePtr._pcount;
				_ptr = SharePtr._ptr;
				(*_pcount)++;
			}
			return *this;
		}
	};
}

有细心的小伙伴在官方文档中发现了这个接口,这接口我决定放在加强内容种讲。加强内容在哪里?我目前连专栏还没有创建,不过总会有的。

4.2定制删除器

面试官让你写一个智能指针,你写不写定制删除器?你不要写定制删除器,除非让你写你才写。

4.2.1什么是定制删除器

为什么要用定制删除器

你交给智能指针什么,它的底层就必须知道如何销毁它。

智能指针默认仅用 delete 释放内存。但在处理 new[] 申请的数组(需 delete[])、fopen 打开的文件(需 fclose)或数据库连接等特殊资源时,默认行为会导致内存泄漏或系统崩溃。定制删除器赋予了智能指针通用性,使其能管理任何形式的资源回收。

什么是定制删除器及可传内容

它是资源引用计数归零时自动触发的可调用对象。代码层面,它可以是:

  • Lambda 表达式:最简洁,直接内联释放逻辑。
  • 仿函数:重载了 operator() 的类对象,适合复杂复用。
  • 函数指针:传统的 C 风格回调接口。
  • std::function:统一包装上述所有类型,增加灵活性。

4.2.2如何使用定制删除器

针对不同的智能指针:

对于普通的 unique_ptr ,默认的删除器是 std::default_delete,其内部执行的是 delete ptr。

而对于 unique_ptr<T[]>,它使用的默认删除器是 std::default_delete<T[]>,其内部执行的是 delete[] ptr。

对于普通的 shared_ptr ,其默认删除逻辑是通过内部模板实例化的,执行的是 delete ptr。

而对于 shared_ptr<T[ ]>(C++17引入),它在内部进行了数组特化,默认执行的是 delete[] ptr。

cpp 复制代码
struct Fclose {
    void operator()(FILE* ptr) {
        std::cout << "fclose: " << ptr << std::endl;
        fclose(ptr);
    }
};
template<class T>
void DeleteArrayFunc(T* ptr)
{
    delete[] ptr;
}

int main()
{
	//普通shared_ptr与shared_ptr<T[]>不需要传递定制删除器
    std::shared_ptr<Date> sp1(new Date);
    std::shared_ptr<Date[]> sp2(new Date[10]);

    std::unique_ptr<Date> up1(new Date);
    std::unique_ptr<Date[]> up2(new Date[10]);


    //传递lambda,最好用
    std::shared_ptr<Date> sp3(new Date[10], [](Date* ptr) { delete[] ptr; });
    //传递函数指针,最不好用
    std::shared_ptr<Date> sp4(new Date[5], DeleteArrayFunc<Date>);
    //传递函数对象,也可以
    std::shared_ptr<FILE> sp5(fopen("Test.cpp", "r"), Fclose());
    return 0;
}

4.2.3unique_ptr和shared_ptr的定制删除器区别

4.2.3.1设计思路的核心差异


4.2.3.2unique_ptr为什么不建议传递lambda作为定制删除器

为什么不能直接把 lambda 传递给模板参数?

  • 模板参数只能传递类型不能传递对象实例。

  • lambda 的类型是在运行时由编译器确定的一个 lambda + uuid 的一个类型。

  • 所以得先运行时推导出类型名称给 fcloseFunc,再用对象推导类型接口来推导得到。

  • 为什么不能用typeid?typeid接口返回的是一个字符串,不是类型

这指的就是通过 decltype 关键字,从一个已有的对象中"推导出"它的类型,从而满足模板对类型的严格要求。

cpp 复制代码
auto fcloseFunc = [](FILE* ptr) { fclose(ptr); };
std::unique_ptr<FILE, decltype(fcloseFunc)> up4(fopen("Test.cpp", "r"));
cpp 复制代码
auto fcloseFunc = [](FILE* ptr) { fclose(ptr); };

// 补全代码:注意构造函数的第二个参数必须传入 fcloseFunc 对象
std::unique_ptr<FILE, decltype(fcloseFunc)> up4(fopen("Test.cpp", "r"), fcloseFunc);

但是传递 lambda 又在底层有限制 。lambda 是不支持默认构造的 。unique_ptr 本来想要用这个类型去在底层去构造一个对象来删除,但是由于 lambda 的底层禁止了默认构造函数。所以我们这个时候必须再传递一个 lambda 的对象。 这样 unique_ptr 的底层会用拷贝构造的方式来生成删除器。

总之,不建议用lambda在unique里面

顺带一提,函数指针也不建议这么传递:

4.2.3给我们手写的shared_ptr添加定制删除器

cpp 复制代码
template <class T>
	class Share_ptr
	{
	private:
		T* _ptr;
		int* _pcount;
		//这里也是无奈之举:
		//1.首先定制删除器要在对象中存储,但是类型不知啊于是应该是模板参数类型,
		//但是构造函数的模板参数不能被搞过来。如果在类的模板参数中增加又会变成
		//unique_ptr的设计,于是使用包装器进行自由推导。
		//2.使用function后,这个定制删除器在不传递定制删除器的时候走构造函数列表
		//但是他不知道应该被初始化为什么类型,于是必须加上缺省值。
		function<void(T*)> _Delete = [](T* ptr) {delete ptr;};

	public:
		// 带删除器的构造:通过模板 K 支持 lambda、函数指针或仿函数
		template<class K>
		Share_ptr( T* ptr, const K& Delete)
			: _ptr(ptr)
			, _pcount(new int(1))
			, _Delete(Delete) 
		{}

		Share_ptr( T* ptr)
			: _ptr(ptr)
			, _pcount(new int(1))
		{}

		// 拷贝构造:保证删除器也能正确传递
		Share_ptr(const Share_ptr& SharePtr)
			: _ptr(SharePtr._ptr)
			, _pcount(SharePtr._pcount)
			, _Delete(SharePtr._Delete)
		{
			(*_pcount)++;
		}

		// 析构函数:智能释放的核心
		~Share_ptr()
		{
			// 逻辑:每次销毁一个对象,计数减 1
			// 只有当计数器归零时,才证明没有其他对象在用了
			if (--(*_pcount) == 0)
			{
				cout << "Success Delete" << endl;
				_Delete(_ptr);      // 执行具体的删除动作(可能是 delete,也可能是 delete[])
				delete(_pcount);    // 必须手动释放堆上的 int 计数器,防止内存泄漏
			}
			// 如果计数不为 0,说明还有其他 Share_ptr 指向这块内存,当前对象只需静静消失
		}

五,shared_ptr的三大问题

5.1内存碎片化问题与make_shared

shared_ptr 虽然好用,但无法替代 unique_ptr 的原因:

  1. 引用计数的消耗:shared_ptr 每一次拷贝都要原子性地增减引用计数,在多线程环境下这种同步操作是有性能开销的。

  2. 内存碎片化问题:shared_ptr 除了管理资源本身,还要额外在堆上分配一个"控制块"来存计数器和删除器。如果你频繁创建大量小对象,这种额外的堆分配会导致严重的内存碎片。

  3. 独占语义的需求:在很多场景(如 RAII 包装文件句柄)下,资源必须由唯一的主人管理,unique_ptr 的强制不可拷贝性在编译器层面就杜绝了逻辑错误。

这里我们谈一谈内存碎片化问题 ,这个问题我们会在内存池中详细讲解。简单来说,内存碎片化 是指系统内存虽然总量充足,但由于分配不连续,导致无法凑出足够大的连续空间来满足程序的需求。

它主要分为两种形态:

  1. 外碎片:大量散落在堆内存中的微小空闲块,每个都太小,无法装下任何新对象。
  2. 内碎片:为了对齐(Alignment)或最小分配限制,分配给你的内存比你实际申请的要多,多出来的部分被浪费了。
1,为什么日常代码"问题不大"?

在写小程序或短时间运行的脚本时,碎片化几乎是"隐身"的:

  • 生命周期短:程序运行几秒钟就结束了,操作系统会直接回收整块进程内存,碎片还没来得及堆积。
  • 规模小:申请的对象数量级有限,现代内存分配器(如 ptmalloc)完全压得住。

2,为什么在工程中"极具破坏性"?

在长期运行(7x24小时)的高并发服务器、嵌入式系统或大型游戏引擎中,它会变成一颗"慢速炸弹":

  1. 虚假的"内存不足"

    你的监控显示还有 2GB 剩余内存,但当你尝试申请一个连续的 50MB 缓冲区时,程序竟然直接抛出 bad_alloc 崩溃了。原因就是没有一块连续的 50MB 空间,内存被切得太碎了。

  2. 性能阴跌

    碎片化严重意味着数据在物理内存中分布极其凌乱。这会导致 CPU 缓存命中率(Cache Hit Rate) 大幅下降,原本顺序读取的操作变成了频繁的随机访问,程序运行速度会随着时间推移越来越慢。

  3. 内存膨胀

    为了寻找合适的空闲块,内存分配器不得不向操作系统申请更多的新页面。最终导致程序占用的虚拟内存远超其实际存储的数据量,引发系统级的页面置换,导致整机卡死。

工程中的生存法则 :为了对抗碎片化,大型工程通常会引入内存池或专门的分配器,核心思想就是"自己管理,减少频繁向系统讨要零碎内存"。

3,解决方案:make_shared
cpp 复制代码
 int main()
 {
     std::shared_ptr<Date> sp1(new Date(2024, 9, 11));
     //内存开辟好了之后,再把这个开辟好的内存的指针传递给智能指针,
     //再智能指针内部再开辟一个引用计数.
     //这就导致了引用计数和维护内存的位置分离 + 内存分散问题(一共开了两次)
     shared_ptr<Date> sp2 = make_shared<Date>(2024, 9, 11);
     //make_shared是直接把这个数据的内存和这个引用计数的内存放在一块儿一起开。
     //只开一次内存不分离。就会更好一点。
	 //它和make_pair很相似,但是它不只是方便那么简单
     return 0;
 }

5.2循环引用问题与weak_ptr

循环引用一般碰不到,但是碰到了就是大问题,必须识别出来然后尽可能避免

5.2.1循环引用问题

shared_ptr大多数情况下管理资源非常合适,支持RAII,也支持拷贝。但是在循环引用的场景下会导致资源没得到释放内存泄漏,所以我们要认识循环引用的场景和资源没释放的原因,并且学会使用weak_ptr解决这种问题。(这个问题出现的可能性不大,但是一旦出现了你不会解决就要完蛋)

cpp 复制代码
int main(){
    // 循环引用 -- 内存泄漏
    std::shared_ptr<ListNode> n1(new ListNode);
    std::shared_ptr<ListNode> n2(new ListNode);

    n1->_next = n2;
    n2->_prev = n1;
    return 0;
}

场景解析: 如上图示,经过这离奇的三步之后,我们成功触发了循环引用场景,于是:

  1. 右边的节点什么时候释放呢,左边节点中的_next管着呢,_next析构后,右边的节点就释放了。

  2. _next什么时候析构呢,_next是左边节点的成员,左边节点释放,_next就析构了。

  3. 左边节点什么时候释放呢,左边节点由右边节点中的_prev管着呢,_prev析构后,左边的节点就释放了。

  4. _prev什么时候析构呢,_prev是右边节点的成员,右边节点释放,_prev就析构了。

  5. 至此逻辑上成功形成回旋镖似的循环引用,谁都不会释放就形成了循环引用,导致内存泄漏

5.2.2循环引用问题的解决方案:weak_ptr

5.2.2.1weak_ptr的介绍与使用

weak_ptr不支持RAII,也不支持访问资源,所以我们看文档发现weak_ptr构造时不支持绑定到资源,只支持绑定到shared_ptr,绑定到shared_ptr时,不增加shared_ptr的引用计数 ,那么就可以解决上述的循环引用问题。

cpp 复制代码
template<class T>
class weak_ptr
{
public:
    weak_ptr()
    {}
    weak_ptr(const shared_ptr<T>& sp)
        :_ptr(sp.get())
    {}
    weak_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        _ptr = sp.get();

        return *this;
    }
private:
    T* _ptr = nullptr;
};

weak_ptr也没有重载operator*和operator->等,因为他不参与资源管理 ,那么如果他绑定的shared_ptr已经释放了资源,那么他去访问资源就是很危险的。weak_ptr支持expired检查指向的资源是否过期

use_count也可获取shared_ptr的引用计数 ,weak_ptr想访问资源时,可以调用lock返回一个管理资源的shared_ptr,如果资源已经被释放,返回的shared_ptr是一个空对象,如果资源没有释放,则通过返回的shared_ptr访问资源是安全的。

cpp 复制代码
#include <iostream>
#include <memory>

int main() {
    // 1. 初始化资源
    std::shared_ptr<int> sp = std::make_shared<int>(42);

    // 2. 绑定 weak_ptr (不增加引用计数)
    std::weak_ptr<int> wp = sp;

    // 查看引用计数: 此时只有 sp 在管理资源
    std::cout << "1. 引用计数: " << wp.use_count() << std::endl; // 输出 1

    // 3. 模拟访问资源:检查是否过期
    if (!wp.expired()) {
        // 使用 lock() 获取 shared_ptr
        std::shared_ptr<int> temp_sp = wp.lock();
        if (temp_sp) {
            std::cout << "2. 访问资源成功: " << *temp_sp << std::endl;
            std::cout << "3. 临时引用计数: " << wp.use_count() << std::endl; // 输出 2
        }
    }

    // 4. 释放资源
    sp.reset();

    // 5. 再次检查
    if (wp.expired()) {
        std::cout << "4. 资源已过期,无法访问。" << std::endl;

        // 即使调用 lock(),返回的也是空对象
        std::shared_ptr<int> empty_sp = wp.lock();
        if (empty_sp == nullptr) {
            std::cout << "5. lock() 返回了一个空的 shared_ptr。" << std::endl;
        }
    }
    return 0;
}

lock"资源锁" weak_ptr虽然不指向引用计数,也不管理资源,但是它必须指向引用计数来保证自己的安全

锁住资源的方式很简单:在里面新建一个shared_ptr用这个shared_ptr去指向这个资源,让weak_ptr和shared_ptr的声明周期保持一致。(也算是一种间接增加引用计数的方法了)

cpp 复制代码
std::shared_ptr<std::string> sp1 = std::make_shared<std::string>("hello");
std::weak_ptr<std::string> wp = sp1;
// 在大哥还在的时候,找一个新大哥
auto sp3 = wp.lock();  // sp3 就是新大哥
// 原来的大哥走了
sp1.reset();
// 新大哥还在,资源安全
std::cout << *sp3 << std::endl;  // "hello"
5.2.2.2weak_ptr解决循环引用问题

把ListNode结构体中的_next和_prev改成weak_ptr,weak_ptr绑定到shared_ptr时不会增加它的引用计数,_next和_prev不参与资源释放管理逻辑,就成功打破了循环引用,解决了这里的问题

cpp 复制代码
std::weak_ptr<ListNode> _next;
std::weak_ptr<ListNode> _prev;

从shareptr的实现到shareptr的缺陷再到weakptr的解决方案最后到weakptr的底层的设计------面试会步步深挖,

5.3.shared_ptr的线程安全问题

  • shared_ptr的引用计数对象在堆上,如果多个shared_ptr对象在多个线程中,进行shared_ptr的拷贝析构时会访问修改引用计数 ,就会存在线程安全问题,所以shared_ptr引用计数是需要加锁或者原子操作保证线程安全的。
  • shared_ptr指向的对象也是有线程安全的问题的,但是这个对象的线程安全问题不归shared_ptr管,它也管不了,应该有外层使用shared_ptr的人进行线程安全的控制。
  • 下面的程序会崩溃或者A资源没释放,bit::shared_ptr引用计数从int*改成atomic*就可以保证引用计数的线程安全问题,或者使用互斥锁加锁也可以。

智能指针本身是线程安全的,但是他指向的资源不是线程安全的。

cpp 复制代码
struct AA{
    int _a1 = 0;
    int _a2 = 0;
    ~AA(){ cout << "~AA()" << endl;}
};
int main(){
    bit::shared_ptr<AA> p(new AA);
    const size_t n = 100000;
    mutex mtx;
    auto func = [&](){
        for (size_t i = 0; i < n; ++i){
            // 这里智能指针拷贝++计数
            bit::shared_ptr<AA> copy(p);{
                unique_lock<mutex> lk(mtx);
                copy->_a1++;
                copy->_a2++;
            }
        }
    };
    thread t1(func);
    thread t2(func);
    t1.join();
    t2.join();
    cout << p->_a1 << endl;
    cout << p->_a2 << endl;
    cout << p.use_count() << endl;
    return 0;
}

六,C++11和boost中智能指针的关系

6.1什么是boost社区

  • Boost库是为C++语言标准库提供扩展的一些C++程序库的总称 ,Boost社区建立的初衷之一就是为C++的标准化工作提供可供参考的实现,Boost社区的发起人Dawes本人就是C++标准委员会的成员之一。在Boost库的开发中,Boost社区也在这个方向上取得了丰硕的成果,C++11及之后的新语法和库有很多都是从Boost中来的。

Scott Meyers的EffectiveC++最后一条款特地讲述了boost社区和boost库,非常推荐大家去看一看。

6.2boost社区为C++智能指针做了哪些贡献

  • C++98中产生了第一个智能指针auto_ptr。

  • C++ boost给出了更实用的scoped_ptr/scoped_array和shared_ptr/shared_array和weak_ptr等。

  • C++ TR1,引入了shared_ptr等,不过注意的是TR1并不是标准版。

  • C++11,引入了unique_ptr和shared_ptr和weak_ptr。需要注意的是unique_ptr对应boost的scoped_ptr。并且这些智能指针的实现原理是参考boost中的实现的。

七, 内存泄漏

7.1 什么是内存泄漏,内存泄漏的危害

什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存,一般是忘记释放或者发生异常释放程序未能执行导致的 。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

内存泄漏的危害:普通程序运行一会就结束了出现内存泄漏问题也不大,进程正常结束,页表的映射关系解除,物理内存也可以释放。长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务、长时间运行的客户端等等,不断出现内存泄漏会导致可用内存不断减少,各种功能响应越来越慢,最终卡死。

cpp 复制代码
int main(){
    // 申请一个1G未释放,这个程序多次运行也没啥危害
    // 因为程序马上就结束,进程结束各种资源也就回收了
    char* ptr = new char[1024 * 1024 * 1024];
    cout << (void*)ptr << endl;
    return 0;
}

7.2 如何检测内存泄漏(了解)

  • linux下内存泄漏检测linux下几款内存泄漏检测工具超链接
  • windows下使用第三方工具windows下的内存泄露检测工具VLD使用_winows内存泄漏检测工具-CSDN博客 超链接

7.3 如何避免内存泄漏

  • 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
  • 尽量使用智能指针来管理资源,如果自己场景比较特殊,采用RAII思想自己造个轮子管理。
  • 定期使用内存泄漏工具检测,尤其是每次项目快上线前,不过有些工具不够靠谱,或者是收费。
  • 总结一下:内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。

【主题曲:C++程序设计】完结撒花 ✿ 感谢您一直以来的收看,主线故事迎来了尾声,但是C++的路还有很长,很长很长。未来让我们共同进步,走的更远。バイバイ!

相关推荐
字节高级特工1 小时前
MySQL数据库基础与实战指南
数据库·c++·人工智能·后端·mysql·adb
郝学胜-神的一滴1 小时前
中级OpenGL教程 004:为几何体注入法线灵魂
c++·unity·游戏引擎·godot·图形渲染·opengl·unreal
晨非辰1 小时前
吃透C++两大默认成员函数:const成员函数、 & 取地址运算符重载
java·大数据·开发语言·c++·人工智能·后端·面试
我滴老baby1 小时前
多智能体协作系统设计当AI学会团队合作效率翻十倍
android·开发语言·人工智能
落雪寒窗-1 小时前
Python进阶核心路线(工程向)
开发语言·python
humcomm1 小时前
全栈开发技术栈的最新进展(2026年视角)
开发语言·架构
雪度娃娃2 小时前
创建型设计模式——建造者模式
c++·microsoft·设计模式·建造者模式
落羽的落羽2 小时前
【网络】TCP与UDP协议使用指南,Socket编程实现Echo服务
linux·服务器·网络·c++·网络协议·tcp/ip·机器学习
聆风吟º2 小时前
【C标准库】深入理解C语言pow函数:从入门到精通,一文搞定幂运算
c语言·开发语言·库函数·pow·幂运算