在C++开发与面试中,智能指针是贯穿基础与进阶的核心考点,更是解决内存泄漏、空悬指针等经典难题的关键工具。对于大厂后端、客户端等岗位,这几乎是必问的题目,直接考察开发者的内存管理能力与C++基本功。
本文整合梳理了C++智能指针的核心知识点与高频面试题,涵盖基础概念、unique_ptr专项、shared_ptr专项等五大分类,共39个问题及详细解析。无论你是正在备战春招秋招,还是想巩固基础,这篇文章都能帮你快速吃透智能指针,轻松应对面试官的深度追问。欢迎到云栈社区的C/C++板块交流更多技术细节。
一、基础概念类
1、什么是动态内存?
动态内存是程序运行时在堆(heap)上按需分配的内存。它与编译期确定大小的静态内存、函数栈上的自动内存不同,其核心特点是生命周期由程序员手动管理,可以灵活分配和释放任意大小的内存。
2、为什么要引入智能指针?(动态内存的核心问题)
用裸指针(raw pointer)管理动态内存时,极易出现三大致命问题:
- 内存泄漏:分配内存后忘记调用
delete/delete[],堆内存无法释放,长期运行会耗尽系统资源;
- 空悬指针/野指针:内存已释放,但指针未置空,后续访问会触发非法内存访问,导致程序崩溃;
- 重复释放:多个裸指针指向同一内存,多次释放会触发未定义行为;
- 生命周期混乱:复杂场景下(如多线程、循环引用),难以精准把控对象的销毁时机。
智能指针的本质是封装了裸指针的类模板,它利用C++的RAII(资源获取即初始化)机制,在智能指针对象生命周期结束时,自动调用析构函数释放其管理的内存,从根源上解决了裸指针的管理难题。
3、智能指针 vs 裸指针:核心优势
相比裸指针,智能指针的核心优点集中在安全性、易用性、可读性:
- 自动内存管理:无需手动调用
delete,析构函数自动释放资源,彻底避免内存泄漏;
- 杜绝空悬/重复释放:通过引用计数(shared_ptr)、独占所有权(unique_ptr)等机制,严格管控内存访问,避免非法操作;
- 语义清晰:
shared_ptr(共享)、unique_ptr(独占)、weak_ptr(弱引用)的命名直接体现所有权语义,代码可读性大幅提升;
- 支持自定义删除器:可灵活指定内存释放方式(如释放数组、文件句柄、网络连接等非堆内存资源),适配复杂场景;
- 线程安全(部分):
shared_ptr的引用计数操作是原子的,多线程环境下共享指针的拷贝/赋值安全(但对象本身的访问仍需加锁)。
4、shared_ptr、unique_ptr、weak_ptr 核心区别
C++11标准库提供了三种核心智能指针,它们的适用场景和特性差异显著:
| 特性 |
std::unique_ptr |
std::shared_ptr |
std::weak_ptr |
| 所有权模式 |
独占式(同一时间仅一个指针管理对象) |
共享式(多个指针可共同管理同一对象) |
弱引用(不拥有对象所有权,仅观测) |
| 引用计数 |
无引用计数 |
维护强引用计数(记录共享指针数量) |
维护弱引用计数,不影响强引用计数 |
| 拷贝/赋值 |
禁止拷贝,仅支持移动语义 |
支持拷贝/赋值,拷贝时强引用计数+1 |
支持拷贝/赋值,仅弱引用计数变化 |
| 性能开销 |
零开销(和裸指针几乎一致) |
有轻微开销(维护引用计数、原子操作) |
开销与shared_ptr相当,仅用于观测 |
| 核心用途 |
独占资源、高性能场景、替代裸指针 |
共享资源、多所有者场景 |
解决shared_ptr循环引用、观测对象存活状态 |
| 空悬指针检测 |
无(需手动判断) |
无(需配合weak_ptr) |
支持(通过lock()检测对象是否存活) |
5、什么是RAII?它和智能指针的关系是什么?
RAII是C++内存管理的核心思想,全称Resource Acquisition Is Initialization。它的核心逻辑很简单——利用对象的生命周期来绑定资源的获取与释放。简单说,资源(不管是内存、文件句柄还是锁)在对象构造时申请,在对象析构时自动释放,无需程序员手动干预,从根源上避免资源泄漏。
而智能指针,正是RAII思想最典型、最常用的落地实现。它本质是封装了裸指针的类模板,我们不用手动调用delete,只要智能指针对象走出作用域,其析构函数就会自动触发,释放掉它所管理的资源,完美契合RAII“自动管理”的核心需求。
6、C++11之前有智能指针吗?有什么问题?
C++98其实已经有了智能指针的雏形——std::auto_ptr,但它的设计存在严重缺陷,实用性很低,这也是C++11彻底将其废弃,并用unique_ptr、shared_ptr替代它的核心原因。
它的问题主要集中在三点,每一点都可能引发致命bug:
- 拷贝行为不合理:拷贝
auto_ptr时会直接转移所有权(比如auto_ptr B = A后,A就变成空指针),后续再访问A就会触发空悬指针异常。
- 不支持数组管理:用
auto_ptr<int[]>管理数组时,析构只会调用delete而非delete[],导致数组内存泄漏。
- 无法放入标准容器(比如
vector、list),其拷贝语义不符合容器对元素的要求,放入后会出现不可预期的行为。
7、智能指针是线程安全的吗?
这是面试中高频且易踩坑的问题,答案不能简单说“是”或“否”,要分场景具体分析。
首先,shared_ptr的引用计数操作是线程安全的。它的强引用计数、弱引用计数增减都是原子操作,多线程环境下,对同一个shared_ptr进行拷贝、赋值、析构,不会出现计数错乱的问题。
但要注意两个关键点:
- 智能指针不保证其管理的“对象本身”是线程安全的,多线程同时读写对象的数据,仍需手动加锁。
unique_ptr无引用计数,多线程下如果同时转移它的所有权(比如std::move),需要手动做同步处理,否则会引发数据竞争,导致未定义行为。
8、为什么推荐用std::make_shared/std::make_unique,而非直接new?
实际开发和面试中,都会强调优先用make系列函数创建智能指针,原因有三:
- 内存分配更高效:
make_shared会一次性分配“对象内存”和shared_ptr的控制块内存,只触发一次内存分配;而new + shared_ptr构造,会先new分配对象内存,再单独分配控制块内存,触发两次内存分配,效率更低。
- 异常安全:假设我们写
std::shared_ptr<int> sp(new int(10), 自定义删除器),如果new成功,但shared_ptr构造时抛异常(比如内存不足),此时new出来的int对象没有被智能指针接管,会直接导致内存泄漏;而make_shared是原子性分配操作,要么全部成功,要么全部失败,不会出现这种泄漏问题。
- 代码更简洁、更安全:
make系列函数无需重复写对象类型(比如auto sp = make_shared<int>(10)),避免裸指针暴露,也减少了代码冗余,降低误写风险。
// 不推荐:两次内存分配,异常不安全
std::shared_ptr<int> sp(new int(10));
// 推荐:一次分配,简洁安全
auto sp = std::make_shared<int>(10);
9、智能指针可以管理非堆内存吗?
很多人误以为智能指针只能管理堆内存(new出来的内存),其实不然。只要配合自定义删除器,智能指针可以灵活管理各种非堆内存资源,比如文件句柄、网络连接、互斥锁等。核心是通过删除器指定“资源释放方式”。
智能指针的核心作用是“自动管理资源”,而不是“自动管理堆内存”,堆内存只是最常见的场景。删除器的本质是一个可调用对象(函数指针、lambda、仿函数),智能指针析构时,会调用这个删除器,而非默认的delete,从而实现非堆资源的自动释放。
示例(管理文件句柄):
#include<memory>
#include<cstdio>
// 自定义删除器:关闭文件(而非释放堆内存)
struct FileCloser {
void operator()(FILE* fp) const {
if (fp) fclose(fp);
}
};
int main(){
// 用shared_ptr管理文件句柄,析构时自动调用FileCloser关闭文件
std::shared_ptr<FILE> fp(fopen("test.txt", "w"), FileCloser());
return 0;
}
二、unique_ptr 专项
10、函数内创建unique_ptr,能否直接返回?
可以,且是C++中返回独占资源的标准写法,核心依赖C++的移动语义和返回值优化(RVO/NRVO)。
原理
unique_ptr禁止拷贝,但支持移动;
- 函数返回局部
unique_ptr时,编译器会自动触发移动语义(C++17及以后),或通过返回值优化直接构造到调用方的对象中,无需手动std::move;
- 手动写
return std::move(ptr)在C++11/14中兼容,C++17后可省略。
代码示例:
#include<memory>
class MyClass {};
// 函数内创建unique_ptr并返回
std::unique_ptr<MyClass> createObject(){
auto ptr = std::make_unique<MyClass>();
return ptr; // 自动移动,无需std::move
}
int main(){
auto obj = createObject(); // 调用方接管所有权
return 0;
}
11、unique_ptr 所有权转移:从A到B
unique_ptr是独占式指针,所有权转移必须通过移动语义,有两种常用方式,推荐使用std::move。
方式1:std::move(推荐)
语义清晰,原子操作,无异常风险,是标准写法。
std::unique_ptr<MyClass> A = std::make_unique<MyClass>();
// 转移所有权:A置空,B接管原对象
std::unique_ptr<MyClass> B = std::move(A);
方式2:release() + reset()
手动交出指针并接管,存在异常泄漏风险(若reset()前抛异常,裸指针丢失),仅特殊场景使用。
std::unique_ptr<MyClass> A = std::make_unique<MyClass>();
std::unique_ptr<MyClass> B;
// release()交出指针(A置空),reset()接管
B.reset(A.release());
12、unique_ptr 为什么禁止拷贝?如何实现的?
unique_ptr的核心定位是“独占式智能指针”,顾名思义,它要求同一时间,只能有一个unique_ptr对象管理某块资源——这也是它禁止拷贝的根本原因。如果允许拷贝,就会出现两个unique_ptr指向同一资源,析构时都会调用delete,导致重复释放,引发未定义行为。
其底层实现非常简单,就是通过C++11的“删除函数”特性,显式删除拷贝构造函数和拷贝赋值运算符,阻止用户进行拷贝操作;同时,保留移动构造函数和移动赋值运算符,允许通过std::move转移所有权(转移后原unique_ptr置空,仍保证“独占”)。
// 简化实现
template <typename T>
class unique_ptr {
public:
unique_ptr(const unique_ptr&) = delete; // 删除拷贝构造,禁止拷贝
unique_ptr& operator=(const unique_ptr&) = delete; // 删除拷贝赋值,禁止拷贝
// 支持移动构造/赋值,允许所有权转移
unique_ptr(unique_ptr&&) = default;
unique_ptr& operator=(unique_ptr&&) = default;
};
13、unique_ptr 如何管理数组?和shared_ptr 管理数组有什么区别?
unique_ptr对数组的支持很原生,而shared_ptr对数组的支持则有一个演进过程。
对于unique_ptr,C++11就提供了数组特化版本std::unique_ptr<T[]>,无需额外操作,析构时会自动调用delete[]释放数组,还支持[]运算符直接访问数组元素,使用起来和普通数组几乎一致。
而shared_ptr的情况稍复杂:C++17之前,它没有原生的数组特化版本,只能手动指定删除器(比如std::default_delete<T[]>),否则析构时会调用delete而非delete[],导致数组内存泄漏;C++17之后,shared_ptr也支持了数组特化std::shared_ptr<T[]>,自动适配delete[]。但相比unique_ptr,它管理数组的场景并不常见——毕竟数组大多是独占场景,用unique_ptr更高效。
示例:
// unique_ptr管理数组(C++11及以后原生支持)
std::unique_ptr<int[]> arr = std::make_unique<int[]>(5);
arr[0] = 10; // 支持[]访问,析构自动delete[]
// shared_ptr管理数组(C++17及以后支持)
std::shared_ptr<int[]> arr2 = std::make_shared<int[]>(5);
arr2[1] = 20;
// C++17之前shared_ptr管理数组(需手动指定删除器)
std::shared_ptr<int[]> arr3(new int[5], std::default_delete<int[]>());
14、unique_ptr 的release()和reset()有什么区别?
这两个是unique_ptr的常用成员函数,核心区别在于“是否释放资源”和“是否交出所有权”。一句话总结:release()只交权、不释放;reset()只释放、不交权(可接管新资源)。
具体来说:
release()的作用是“交出当前管理的资源所有权”,它会返回指向该资源的裸指针,同时将自身置空,但不会释放资源——后续需要程序员手动delete这个裸指针,否则会导致内存泄漏。
reset()的作用是“释放当前管理的资源”,如果传入新的裸指针,它会释放原有资源后,接管新资源;如果无参调用,它会释放原有资源,并将自身置空——全程不需要程序员手动干预资源释放。
示例:
std::unique_ptr<int> p = std::make_unique<int>(10);
int* raw = p.release(); // p置空,raw接管对象,需手动delete raw
p.reset(new int(20)); // 释放原对象(若有),接管新对象
p.reset(); // 释放对象,p置空
15、函数参数传递unique_ptr 有哪些方式?各有什么优缺点?
unique_ptr禁止拷贝,所以传递参数的方式和普通对象不同,核心有三种方式,关键看是否需要转移所有权:
- 传值(移动语义):函数原型为
void func(std::unique_ptr<int> p)。这种方式会直接转移所有权,调用函数时必须用std::move,转移后实参置空。优点是语义清晰(明确表示“函数接管资源”),缺点是调用方会失去资源所有权。
- 传左值引用:函数原型为
void func(std::unique_ptr<int>& p)。这种方式不转移所有权,函数内部可以修改这个unique_ptr(比如reset、release),调用方无需move。优点是灵活,缺点是函数内部可能意外修改指针状态。
- 传const左值引用:函数原型为
void func(const std::unique_ptr<int>& p)。这种方式既不转移所有权,也不允许函数内部修改指针,只能通过指针访问资源(只读)。优点是最安全,缺点是灵活性最低。
示例:
// 传值:转移所有权,函数接管资源
void takeOwnership(std::unique_ptr<int> p){}
// 传左值引用:不转移所有权,可修改指针
void modifyPtr(std::unique_ptr<int>& p){ p.reset(); }
int main(){
auto p = std::make_unique<int>(10);
takeOwnership(std::move(p)); // 必须move,p置空
modifyPtr(p); // p已空,reset后仍为空
return 0;
}
16、unique_ptr 可以作为容器的元素吗?
答案是可以,而且是容器管理“独占式资源”的首选方式——比如一个vector存储多个动态分配的对象,每个对象只能被一个指针管理,此时用vector<unique_ptr<T>>最合适。
很多人会疑惑:unique_ptr禁止拷贝,而容器的push_back、emplace_back等操作默认需要拷贝,为什么还能放入容器?核心原因是容器支持移动语义,我们可以通过std::move将unique_ptr的所有权转移到容器中,转移后原unique_ptr置空,容器中的元素成为资源的唯一管理者。
需要注意的是,放入容器后,不能对容器进行需要拷贝元素的操作,但可以进行移动操作。
#include<vector>
#include<memory>
int main(){
std::vector<std::unique_ptr<int>> vec;
// 方式1:直接move插入临时对象
vec.push_back(std::make_unique<int>(10));
// 方式2:move插入已存在的unique_ptr(转移所有权)
auto p = std::make_unique<int>(20);
vec.push_back(std::move(p)); // p置空,资源进入容器
return 0;
}
17、unique_ptr 和裸指针的性能差距有多大?
这是unique_ptr的核心优势之一——和裸指针几乎没有性能差距,是“零开销抽象”。
所谓零开销,是指unique_ptr在运行时的内存占用、访问速度,和裸指针完全一致:它的底层只封装了一个裸指针,没有额外的成员变量(比如引用计数),所有的语法检查(比如禁止拷贝)都是在编译期完成的,运行时不会增加任何额外的计算开销。
这也是为什么在高性能场景下,我们推荐用unique_ptr替代裸指针——既能享受智能指针的自动内存管理,避免内存泄漏,又能保证程序的运行效率,不会带来任何性能负担。
18、如何让unique_ptr 支持自定义删除器?删除器会影响unique_ptr 的大小吗?
unique_ptr支持自定义删除器,而且实现方式很灵活。删除器是否影响它的大小,取决于删除器是否“有状态”。
首先说自定义删除器的实现:只需在unique_ptr的模板参数中,指定删除器的类型,然后在构造unique_ptr时,传入删除器对象即可。删除器可以是函数指针、lambda表达式、仿函数。
再来说大小影响:如果是“无状态删除器”(比如无捕获的lambda、无成员变量的仿函数),不会增加unique_ptr的大小,它的大小和裸指针一致(通常是8字节,64位系统);如果是“有状态删除器”(比如捕获了变量的lambda、有成员变量的仿函数),unique_ptr的大小会增加,增加的大小等于删除器自身的大小——因为它需要存储删除器的状态。
示例:
// 无状态删除器(无捕获lambda):大小和裸指针一致(8字节)
auto del = [](int* p) { delete p; };
std::unique_ptr<int, decltype(del)> p(new int(10), del);
// 有状态删除器(有成员变量的仿函数):大小=裸指针大小+成员变量大小(8+4=12字节)
struct Del { int x; void operator()(int* p){ delete p; } };
std::unique_ptr<int, Del> p2(new int(10), Del{1});
三、shared_ptr 专项
19、shared_ptr 底层实现原理
shared_ptr的核心是引用计数机制,底层实现包含两个核心部分:
- 托管指针:指向实际管理的对象;
- 控制块(Control Block):独立分配的内存块,包含:
- 强引用计数(use_count):记录当前有多少个
shared_ptr指向该对象,计数为0时销毁对象;
- 弱引用计数(weak_count):记录
weak_ptr的数量,计数为0时销毁控制块;
- 自定义删除器、分配器等附加信息。
核心规则:
- 拷贝
shared_ptr:强引用计数+1;
- 析构/赋值
shared_ptr:强引用计数-1;
- 强引用计数归0:自动调用删除器销毁对象;
- 弱引用计数归0:自动销毁控制块。
补充:shared_ptr的引用计数操作是原子的,保证多线程下的线程安全,但对象本身的读写仍需手动加锁。
20、shared_ptr 的引用计数存储在哪里?为什么不直接存在shared_ptr 对象中?
shared_ptr的引用计数,并不是存储在shared_ptr对象本身,而是存储在一个独立分配的“控制块(Control Block)”中。
为什么不存在对象中?核心原因是“共享”:shared_ptr的设计初衷是“多个指针共享同一资源”,如果每个shared_ptr对象都单独存储引用计数,那么多个shared_ptr之间的计数无法同步。比如,当我们拷贝一个shared_ptr时,需要让所有共享该资源的shared_ptr的引用计数都加1,若计数存在对象中,根本无法实现这种同步。
而控制块是独立于shared_ptr对象的内存块,所有共享同一资源的shared_ptr、weak_ptr,都会通过指针指向同一个控制块,这样就保证了所有指针的计数一致。
21、shared_ptr 的use_count()方法有什么用?使用时需要注意什么?
use_count()是shared_ptr的常用方法,作用是返回当前管理资源的“强引用计数”,也就是有多少个shared_ptr正指向该资源。它的主要用途是调试,而非业务逻辑开发。
使用时必须注意两个关键陷阱:
use_count()的返回值可能不准确。尤其是在多线程环境下,use_count()的调用不是原子操作,可能刚获取到计数,其他线程就修改了计数,导致获取到的是“过期值”。
- 绝对不能依赖
use_count()的返回值做业务逻辑判断。比如不能写if (sp.use_count() == 1) { ... }来判断当前指针是唯一管理者——因为多线程环境下,计数可能瞬间变化,同时这种写法也违背了shared_ptr“共享所有权”的设计理念。正确的做法是用weak_ptr配合lock()判断资源是否存活。
22、为什么不能用一个裸指针初始化多个shared_ptr?会引发什么问题?
这是shared_ptr最常见的使用陷阱之一,核心问题是“重复释放”,而根源在于“每个shared_ptr都会创建独立的控制块”。
具体来说:当我们用一个裸指针p,分别初始化两个shared_ptr(sp1和sp2)时,sp1会基于p创建一个控制块,强引用计数设为1;sp2也会基于同一个p,创建另一个独立的控制块,强引用计数也设为1。这两个shared_ptr完全独立。
当sp1和sp2走出作用域时,都会触发析构,导致同一资源被释放两次,触发未定义行为,大概率导致程序崩溃。
错误示例:
int* p = new int(10);
std::shared_ptr<int> sp1(p);
std::shared_ptr<int> sp2(p); // 错误:两个独立控制块,析构重复释放
正确做法:用make_shared创建,或通过已有shared_ptr拷贝(共享同一个控制块):
auto sp1 = std::make_shared<int>(10);
std::shared_ptr<int> sp2 = sp1; // 共享控制块,计数+1,安全
23、shared_ptr 循环引用的本质是什么?除了weak_ptr,还有其他解决方法吗?
shared_ptr的循环引用,本质是“环状的强引用关系”,导致所有对象的强引用计数永远无法归0,最终引发内存泄漏。
简单理解:两个或多个对象,各自用shared_ptr持有对方的指针,形成一个闭环。此时,每个对象的强引用计数至少为1,当外部所有指向这些对象的shared_ptr析构后,闭环内的对象彼此引用,计数无法减为0,资源永远无法释放。
weak_ptr是最优雅、最安全的解决方案——将闭环中的一个shared_ptr替换为weak_ptr,weak_ptr不增加强引用计数,打破环状强引用。
除了weak_ptr,还有两种解决方案,但都有弊端:
- 手动打破循环:在合适的时机将其中一个
shared_ptr置空,但依赖手动操作,易出错。
- 用裸指针替代其中一个
shared_ptr:裸指针不增加引用计数,也能打破循环,但失去了智能指针的自动管理优势,易引发空悬指针、内存泄漏。
因此,weak_ptr是最优解。
24、shared_ptr 支持数组吗?如何正确管理数组?
shared_ptr对数组的支持,经历了从“不支持”到“原生支持”的演进,核心是“析构方式”的适配。
- C++17之前:
shared_ptr没有原生的数组特化版本,要管理数组,必须手动指定删除器(如std::default_delete<T[]>),否则会导致内存泄漏。
- C++17及以后:
shared_ptr新增了数组特化版本std::shared_ptr<T[]>,原生支持数组管理,析构时会自动调用delete[],还支持[]运算符直接访问数组元素。
需要注意的是,shared_ptr管理数组的场景并不多——数组大多是独占式使用,用unique_ptr管理更高效。
示例(C++17):
// 数组特化,自动delete[],支持[]访问
std::shared_ptr<int[]> sp = std::make_shared<int[]>(5);
sp[0] = 10; // 直接访问数组元素
25、shared_ptr 的reset()方法有什么作用?和赋值有什么区别?
shared_ptr的reset()和赋值操作,都能改变指针所管理的资源,但两者的语义、行为有明显区别。
reset():核心作用是“释放当前管理的资源,并(可选)接管新资源”。无参调用时,会将当前shared_ptr置空,同时强引用计数减1;传入参数时,会先释放原有资源,再接管新资源。
- 赋值操作(比如
sp1 = sp2):核心作用是“放弃原有资源的共享权,转而共享新资源”。赋值时,先将当前shared_ptr的强引用计数减1(若归0,释放原有资源),再将其指向新资源的控制块,同时将新资源的强引用计数加1。
核心区别:reset()可以“无新资源”(仅释放原有资源),而赋值操作必须“有新资源”;reset()可传入裸指针接管新资源,赋值操作只能关联已有shared_ptr管理的资源。
示例:
auto sp1 = std::make_shared<int>(10);
auto sp2 = std::make_shared<int>(20);
sp1.reset(); // 无参:释放10的资源,sp1置空
sp1 = sp2; // 赋值:释放原有资源(已空),共享20的资源,计数+1
26、多线程环境下,shared_ptr 的拷贝和赋值是安全的吗?对象的读写呢?
核心结论可以总结为:shared_ptr的“指针本身操作”安全,但其管理的“对象操作”不安全。
具体来说,shared_ptr的拷贝、赋值、析构,这些操作的核心是“修改控制块中的引用计数”,而引用计数操作是原子性的——多线程同时对同一个shared_ptr进行这些操作,不会出现计数错乱、资源泄漏的问题,这部分是线程安全的。
但要注意,shared_ptr的线程安全,仅局限于“指针本身的操作”,不包括它所管理的“对象本身”。如果多线程同时通过shared_ptr读写对象的数据,此时没有任何同步机制,会引发数据竞争,导致未定义行为。
因此,多线程环境下使用shared_ptr,若涉及对象读写,必须手动加锁;若仅涉及shared_ptr的拷贝、赋值,无需加锁。
27、shared_ptr 的性能瓶颈在哪里?如何优化?
shared_ptr相比unique_ptr,存在一定的性能开销,优化方案围绕“减少开销”展开。
性能瓶颈:
- 控制块的内存分配开销:
shared_ptr需要单独分配控制块,相比unique_ptr多了一次内存分配。
- 引用计数的原子操作开销:多线程环境下,原子操作比普通加减操作更耗时。
- 拷贝操作的开销:
shared_ptr的拷贝需要修改引用计数,相比unique_ptr的移动更耗时。
优化方案:
- 优先使用
make_shared创建shared_ptr:一次性分配对象内存和控制块内存,减少一次分配。
- 减少不必要的拷贝:尽量用
const std::shared_ptr<T>&传递参数,避免拷贝。
- 高性能场景替换为
unique_ptr:如果资源是独占式使用,优先用unique_ptr,消除引用计数开销。
四、weak_ptr 专项
28、weak_ptr 解决循环引用问题
循环引用的成因
shared_ptr的循环引用是指:两个或多个对象通过shared_ptr相互持有,形成环状引用,导致强引用计数永远无法归0,对象和控制块无法释放,最终引发内存泄漏。
代码示例:
#include<iostream>
#include<memory>
class B;
class A {
public:
std::shared_ptr<B> b_ptr;
A() { std::cout << "A 构造函数" << std::endl; }
~A() { std::cout << "A 析构函数" << std::endl; }
};
class B {
public:
std::shared_ptr<A> a_ptr;
B() { std::cout << "B 构造函数" << std::endl; }
~B() { std::cout << "B 析构函数" << std::endl; }
};
int main(){
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
// 构建循环引用
a->b_ptr = b;
b->a_ptr = a;
// 打印引用计数:均为2,永远无法归0
std::cout << "a的引用计数:" << a.use_count() << std::endl;
std::cout << "b的引用计数:" << b.use_count() << std::endl;
return 0;
}
运行结果:仅输出构造函数,析构函数未执行,内存泄漏。
解决方案:weak_ptr
将循环引用中的一个shared_ptr替换为weak_ptr,因为weak_ptr是弱引用,不会增加强引用计数,从而打破环状结构。当外部shared_ptr释放后,强引用计数归0,对象正常销毁。
修改后代码:
class B;
class A {
public:
std::shared_ptr<B> b_ptr; // 保持shared_ptr
A() { std::cout << "A 构造函数" << std::endl; }
~A() { std::cout << "A 析构函数" << std::endl; }
};
class B {
public:
std::weak_ptr<A> a_ptr; // 替换为weak_ptr,不增加强引用计数
B() { std::cout << "B 构造函数" << std::endl; }
~B() { std::cout << "B 析构函数" << std::endl; }
};
运行结果:构造、析构函数均执行,内存正常释放。
29、weak_ptr 解决空悬指针问题
多线程场景下,若一个线程通过shared_ptr销毁对象,另一个线程的裸指针/shared_ptr会变成空悬指针。weak_ptr可安全检测对象存活状态:
weak_ptr不控制对象生命周期,仅观测;
- 通过
lock()方法:若对象存活,返回有效的shared_ptr;若对象已销毁,返回空shared_ptr;
- 避免了空悬指针的非法访问。
代码示例:
#include<iostream>
#include<memory>
#include<thread>
void observe(std::weak_ptr<int> wp){
// 尝试提升为shared_ptr
if (auto sp = wp.lock()) {
std::cout << "对象存活,值为:" << *sp << std::endl;
} else {
std::cout << "对象已销毁,空悬指针" << std::endl;
}
}
int main(){
std::shared_ptr<int> sp = std::make_shared<int>(10);
std::weak_ptr<int> wp = sp;
std::thread t1(observe, wp);
t1.join();
sp.reset(); // 销毁对象
std::thread t2(observe, wp);
t2.join();
return 0;
}
30、weak_ptr 如何从shared_ptr 构造?构造后会影响引用计数吗?
weak_ptr的构造必须依赖shared_ptr,且构造后不会影响shared_ptr的强引用计数——这是weak_ptr最核心的特性。
具体构造方式有两种:一是直接用shared_ptr对象构造(如std::weak_ptr<int> wp = sp);二是用已有的weak_ptr对象拷贝构造。
关键在于引用计数的变化:构造weak_ptr时,只会增加控制块中的“弱引用计数”,不会改变“强引用计数”。因此,weak_ptr的构造、拷贝,不会影响资源的存活。
示例:
auto sp = std::make_shared<int>(10);
std::weak_ptr<int> wp1 = sp; // 构造,弱计数+1,强计数仍为1
std::weak_ptr<int> wp2 = wp1; // 拷贝,弱计数+1,强计数还是1
std::cout << sp.use_count(); // 输出1,强计数不变
31、weak_ptr 的lock()方法返回什么?使用时的核心逻辑是什么?
lock()是weak_ptr最核心的成员函数,作用是“安全地获取weak_ptr所观测资源的强引用指针”,返回值是std::shared_ptr<T>。核心逻辑是“先检测资源是否存活,再决定是否返回有效指针”。
具体逻辑:第一步,检测资源是否存活(强引用计数是否大于0);第二步,如果资源存活,返回一个有效的shared_ptr(此时强引用计数加1);如果资源已销毁,返回一个空的shared_ptr。
lock()是原子操作,多线程环境下使用安全——即使在检测资源存活后、返回shared_ptr前,资源被其他线程销毁,lock()也能保证返回空指针。
示例:
std::weak_ptr<int> wp;
{
auto sp = std::make_shared<int>(10);
wp = sp; // wp观测sp管理的资源
} // sp析构,资源销毁,强计数归0
auto sp = wp.lock(); // 资源已销毁,返回空shared_ptr
if (!sp) std::cout << "对象已销毁" << std::endl;
32、weak_ptr 的expired()和use_count()有什么区别?
expired()和use_count()都是用于检测资源存活状态的方法,但两者的效率、用途、安全性有明显区别。
expired():作用是“判断所观测的资源是否存活”,返回bool值(true表示资源已销毁)。
use_count():作用是“返回所观测资源的强引用计数”,返回值是size_t类型。
核心区别:
- 效率和原子性:
expired()是原子操作,效率极高;use_count()不是原子操作(部分实现),效率较低,多线程环境下可能获取到过期值。
- 用途:
expired()适合用于“判断资源是否存活”的业务逻辑;use_count()主要用于调试,不适合业务逻辑。
推荐用法:优先使用expired()判断资源是否存活,若返回false(资源存活),再用lock()获取shared_ptr访问资源;尽量避免使用use_count()。
33、weak_ptr 可以直接访问管理的对象吗?为什么?
不可以。weak_ptr没有重载*和->运算符,无法直接解引用访问所观测的对象。这是由它的设计定位决定的——weak_ptr是“弱引用”,不拥有资源的所有权,无法控制资源的生命周期。
背后的核心原因是“安全”:如果允许weak_ptr直接访问对象,会引发空悬指针异常。因为weak_ptr不增加强引用计数,无法保证访问对象时,对象仍然存活。
正确的访问方式是:通过lock()方法将weak_ptr提升为shared_ptr,只有提升成功,才能通过shared_ptr访问对象。
五、综合应用与陷阱题
34、智能指针可以管理this指针吗?有什么坑?如何解决?
智能指针可以管理this指针,但绝对不能直接用this指针初始化shared_ptr——这是一个高频陷阱,核心坑是“重复释放”。解决方案是让类继承std::enable_shared_from_this。
坑在哪里:如果在类的成员函数中,直接用this指针初始化shared_ptr(比如return std::shared_ptr<A>(this)),此时会创建一个新的控制块。而如果这个类的对象本身就是由另一个shared_ptr管理的,那么就会出现两个shared_ptr指向同一个this,且拥有独立的控制块,析构时会导致重复释放。
解决方案:让类继承std::enable_shared_from_this<T>(T是类名),然后在成员函数中,通过shared_from_this()方法获取shared_ptr——这个方法会返回一个共享当前对象控制块的shared_ptr,不会创建新的控制块。
错误示例:
class A {
public:
std::shared_ptr<A> getSelf(){ return std::shared_ptr<A>(this); } // 错误:独立控制块
};
auto sp1 = std::make_shared<A>();
auto sp2 = sp1->getSelf(); // 两个控制块,析构重复释放
正确示例:
class A : public std::enable_shared_from_this<A> {
public:
std::shared_ptr<A> getSelf(){ return shared_from_this(); }
};
auto sp1 = std::make_shared<A>();
auto sp2 = sp1->getSelf(); // 共享控制块,安全
35、为什么不建议混用智能指针和裸指针?
混用会引发三大问题:
- 重复释放:裸指针和智能指针分别管理同一对象,析构时重复释放;
- 空悬指针:智能指针释放对象后,裸指针变成空悬指针;
- 生命周期混乱:难以把控对象销毁时机,引发未定义行为。
原则:一旦用智能指针管理对象,就不再使用裸指针,仅通过智能指针访问。
36、智能指针可以管理静态/栈上的对象吗?会有什么问题?
不建议。静态/栈上对象的生命周期由编译器自动管理,智能指针析构时会调用delete释放,引发未定义行为。
错误示例:
int a = 10; // 栈上对象
std::shared_ptr<int> sp(&a); // 析构时delete栈内存,崩溃
六、进阶原理与扩展
37、 shared_ptr 的控制块什么时候销毁?
控制块的销毁条件是弱引用计数归0,而非强引用计数:
- 强引用计数归0:销毁对象;
- 弱引用计数归0:销毁控制块;
若存在weak_ptr指向对象,即使对象已销毁,控制块仍会保留,直到所有weak_ptr析构。
38、unique_ptr 为什么可以作为函数返回值,而不会导致编译错误?
依赖C++的移动语义和返回值优化(RVO/NRVO):
- 局部
unique_ptr是右值,函数返回时自动触发移动构造,将所有权转移给调用方;
- 编译器的返回值优化会直接在调用方的内存空间构造
unique_ptr,无移动开销,效率极高。
39、C++17 对智能指针有哪些重要改进?
核心改进有三点:
shared_ptr支持数组特化,可直接管理数组,自动调用delete[];
std::make_shared支持数组,简化数组管理;
- 优化了
unique_ptr的返回值语义,无需std::move即可直接返回局部unique_ptr,兼容性更强。