C++智能指针性能优化:深入解析std::make_shared原理与应用

📅2026/7/14 4:26:45 👁️次浏览
C++智能指针性能优化:深入解析std::make_shared原理与应用
1. 项目概述今天我们来深入聊聊C智能指针家族里一个既基础又关键的“效率工具”——std::make_shared。如果你已经用上了C11或更高版本并且开始接触std::shared_ptr那么std::make_shared就是你绕不开的一个话题。它绝不仅仅是“另一种创建shared_ptr的方式”那么简单。很多朋友在面试或者代码评审时被问到“为什么推荐用make_shared而不是直接new”往往只能答出“性能更好”这个模糊的结论但具体好在哪里、背后原理是什么、有没有什么坑可能就说不清楚了。我自己在早期项目里也踩过一些坑比如内存释放的时机问题、构造函数访问权限问题等都是血泪教训。这篇文章我们就从std::make_shared的用法实例出发掰开揉碎了讲清楚它的工作原理、性能优势、使用场景以及那些你必须知道的注意事项和陷阱目标是让你看完后不仅能正确使用更能理解其设计哲学在合适的场景做出最合适的选择。2.std::make_shared的核心价值与工作原理2.1 从“两次分配”到“一次分配”的进化要理解std::make_shared的价值我们得先看看传统的std::shared_ptrT(new T(args...))是怎么工作的。当你写下std::shared_ptrMyClass sp(new MyClass(42));这行代码时底层至少发生了两次独立的内存分配第一次分配new MyClass(42)表达式在堆上分配了一块足够容纳MyClass对象的内存并调用构造函数进行初始化。这块内存我们称为“对象数据块”。第二次分配std::shared_ptr的构造函数需要为这个MyClass对象创建一个“控制块”。这个控制块是shared_ptr实现引用计数的核心里面通常包含至少两个原子计数器use_count和weak_count可能还有自定义删除器、分配器等元数据。这又是一次独立的内存分配。两次分配意味着两次系统调用如malloc两次可能失败的点以及更关键的是——这两块内存在地理位置上很可能是不连续的。CPU缓存不友好内存碎片也可能增加。而std::make_sharedMyClass(42)的魔法就在于它通常只进行一次内存分配。标准库的实现会计算sizeof(MyClass) sizeof(ControlBlock)的总大小然后一次性申请一块足够大的连续内存。在这块内存的头部布置控制块紧接着在控制块后面通过偏移量布置MyClass对象本身。这种“合二为一”的布局就是其性能优势的根源。注意C标准“建议”但并未强制要求实现进行单次分配。不过在实践中所有主流的标准库实现如GCC的libstdc、Clang的libc、MSVC的STL都采用了这种优化。你可以放心依赖这一特性。2.2 异常安全性的关键保障这是一个容易被忽视但极其重要的优点。考虑下面这个函数调用void foo(std::shared_ptrMyClass sp, int priority); foo(std::shared_ptrMyClass(new MyClass(“resource”)), calculatePriority());在C17之前函数参数的求值顺序是未指定的。编译器可能会生成这样的执行序列new MyClass(“resource”)– 分配内存并构造对象。calculatePriority()– 计算第二个参数假设这里抛出了一个异常。std::shared_ptrMyClass(…)– 构造智能指针。问题出在第2步。如果calculatePriority()抛出了异常那么第1步已经成功分配的MyClass对象就永远无法被第3步的shared_ptr接管了。因为shared_ptr的构造函数还没来得及执行也就没有机会在栈展开时释放这块内存。这就是一个经典的内存泄漏。而使用std::make_shared可以完美避免这个问题foo(std::make_sharedMyClass(“resource”), calculatePriority());无论参数求值顺序如何std::make_sharedMyClass(“resource”)都是一个完整的函数调用。它要么完全成功分配内存、构造对象、构造控制块、返回shared_ptr要么完全失败任何一步出异常已分配的资源都会被妥善清理。这种“全有或全无”的语义提供了强大的异常安全性。2.3 语法简洁性与类型安全make_shared利用了C11的变长模板参数和完美转发使得创建对象变得异常简洁。你不需要重复书写类型T编译器会自动推导。// 更简洁避免重复类型‘Widget’ auto sp1 std::make_sharedWidget(arg1, arg2, arg3); // vs std::shared_ptrWidget sp2(new Widget(arg1, arg2, arg3));使用auto不仅能减少打字更重要的是能防止一些因类型不匹配导致的隐晦错误。此外它杜绝了使用裸指针new的原始方式从源头上促进了RAII资源获取即初始化风格让代码更安全、更现代。3.std::make_shared的详细用法与实例解析3.1 基础对象构造C11起这是最常用的情况用于构造非数组类型的对象。#include memory #include iostream #include string class MyData { public: MyData(int id, const std::string name) : m_id(id), m_name(name) { std::cout “MyData constructed: “ m_id “, “ m_name std::endl; } ~MyData() { std::cout “MyData destroyed: “ m_id std::endl; } void print() const { std::cout “ID: “ m_id “, Name: “ m_name std::endl; } private: int m_id; std::string m_name; }; int main() { // 用法1传递构造参数 auto data1 std::make_sharedMyData(1, “Alice”); data1-print(); // 输出ID: 1, Name: Alice // 用法2默认构造如果MyData有默认构造函数 // 假设MyData有默认构造函数 MyData() default; // auto data2 std::make_sharedMyData(); // 需要MyData::MyData()存在 // 用法3完美转发参数支持移动语义 std::string tempName “Bob”; auto data3 std::make_sharedMyData(2, std::move(tempName)); // 移动构造避免拷贝 // 此时tempName状态是未指定的通常为空 }关键点make_shared的参数会通过std::forward完美转发给T的构造函数。这意味着左值被拷贝右值被移动效率最优。3.2 数组类型的支持C20起在C20之前std::make_shared不支持直接创建数组如int[]或MyClass[]这是一个很大的限制。C20极大地扩展了其能力新增了多个重载。3.2.1 创建未知边界数组Unbounded Array使用T[]语法并指定大小N。数组元素会进行值初始化对于内置类型如int就是零初始化。// C20 及以上 #include memory #include iostream int main() { // 创建一个管理10个int的数组所有元素值初始化为0 std::shared_ptrint[] arr1 std::make_sharedint[](10); for (int i 0; i 10; i) { std::cout arr1[i] “ “; // 输出: 0 0 0 0 0 0 0 0 0 0 } std::cout std::endl; // 创建一个管理5个std::string的数组每个string被默认构造空字符串 std::shared_ptrstd::string[] arr2 std::make_sharedstd::string[](5); arr2[0] “Hello”; std::cout arr2[0] std::endl; // 输出: Hello std::cout arr2[1].empty() std::endl; // 输出: 1 (true) // 多维数组创建一个动态的二维数组 [5][3]每个int初始化为0 std::shared_ptrint[][3] arr3 std::make_sharedint[][3](5); arr3[2][1] 42; std::cout arr3[2][1] std::endl; // 输出: 42 }3.2.2 创建已知边界数组Bounded Array使用T[N]语法。此时不需要也不能在make_shared的参数中指定大小大小由模板参数N决定。// C20 及以上 #include memory int main() { // 创建一个管理固定大小128的short数组所有元素值初始化为0 std::shared_ptrshort[128] arr4 std::make_sharedshort[128](); arr4[10] 100; // 创建一个三维固定数组 [2][3][4]所有int初始化为0 std::shared_ptrint[2][3][4] arr5 std::make_sharedint[2][3][4](); arr5[1][2][3] 999; }3.2.3 指定初始化值C20的make_shared还允许你为数组的每个元素指定一个相同的初始值。// C20 及以上 #include memory #include iostream #include vector int main() { // 创建一个256个double的数组每个元素初始化为2.0 std::shared_ptrdouble[] arr6 std::make_shareddouble[](256, 2.0); std::cout arr6[100] std::endl; // 输出: 2 // 创建一个7行的二维数组每行是一个包含2个double的数组每个元素初始化为{3.0, 4.0} // 注意第二个参数u会被用于初始化每一个子数组元素。 // 这里u是初始化列表{3.0, 4.0}用于构造每个double[2] std::shared_ptrdouble[][2] arr7 std::make_shareddouble[][2](7, {3.0, 4.0}); std::cout arr7[3][0] “, “ arr7[3][1] std::endl; // 输出: 3, 4 // 创建一个管理4个std::vectorint的数组每个vector用{5, 6}初始化 std::shared_ptrstd::vectorint[] arr8 std::make_sharedstd::vectorint[](4, std::vectorint{5, 6}); std::cout arr8[0].size() std::endl; // 输出: 2 std::cout arr8[0][1] std::endl; // 输出: 6 }对于已知边界数组指定初始值的语法略有不同// 已知边界数组指定所有元素初始化为1.0 std::shared_ptrfloat[512] arr9 std::make_sharedfloat[512](1.0f);3.3make_shared_for_overwriteC20起这是一个容易被忽略但很有用的补充。普通的make_shared会对对象进行值初始化对于类类型调用默认构造函数对于内置类型初始化为0。但有时我们只需要分配内存然后立即填充例如从文件或网络读取数据不希望付出一次初始化的开销。make_shared_for_overwrite就是干这个的——它进行默认初始化。对于内置类型这意味着不初始化其值是未定义的。#include memory #include iostream #include cstring struct POD { int x; int y; }; int main() { // 使用 make_shared_for_overwrite 创建对象默认初始化 auto sp1 std::make_shared_for_overwritePOD(); // sp1-x 和 sp1-y 的值是未定义的垃圾值 std::cout sp1-x “ “ sp1-y std::endl; // 危险输出不确定。 // 立即用有效数据覆盖 sp1-x 10; sp1-y 20; // 对于数组同样适用 auto arr1 std::make_shared_for_overwriteint[](100); // arr1[0] 到 arr1[99] 都是未定义的垃圾值 // 通常紧接着会进行内存操作如 std::memset(arr1.get(), 0, 100 * sizeof(int)); // 手动置零 // 或从某处读取数据填充到 arr1.get() 指向的内存 // 对比使用普通的 make_shared会进行零初始化 auto arr2 std::make_sharedint[](100); // arr2[0] 到 arr2[99] 的值都是 0 std::cout arr2[0] std::endl; // 安全输出 0 }重要警告make_shared_for_overwrite只应在你确定会立即覆盖所有数据的情况下使用。读取未初始化的内存是未定义行为可能导致程序崩溃或产生不可预测的结果。在性能敏感的批量内存分配场景如缓冲区、临时存储中它可以避免不必要的清零操作带来性能提升。4.std::make_shared的局限性、陷阱与最佳实践尽管std::make_shared优点众多但它并非银弹。理解其局限性是避免踩坑的关键。4.1 无法使用自定义删除器或分配器std::shared_ptr的构造函数允许你传入一个自定义删除器deleter和一个自定义分配器allocator。这在管理非new分配的资源时非常有用例如管理用malloc分配的内存、文件句柄、网络套接字等。// 自定义删除器用于释放malloc分配的内存 struct FreeDeleter { void operator()(void* p) const { std::free(p); } }; void* raw_mem std::malloc(1024); // 使用shared_ptr构造函数可以指定删除器 std::shared_ptrchar sp1(static_castchar*(raw_mem), FreeDeleter()); // 但是make_shared 无法做到这一点 // auto sp2 std::make_sharedchar(...); // 无法指定删除器它总是使用 ::deletestd::make_shared的设计将对象分配、构造与控制块分配绑定在一起并使用标准的::new和::delete。因此如果你需要自定义的内存管理策略就必须使用std::shared_ptr的构造函数。C标准库提供了std::allocate_shared来支持自定义分配器但它仍然使用默认的delete语义。对于完全自定义的分配/释放对构造函数是唯一选择。4.2 构造函数必须是可访问的std::make_shared作为一个非成员函数模板在构造对象时它要求所选用的构造函数在调用点是可访问的通常是public。而std::shared_ptrT(new T(...))的new表达式其权限检查发生在T的构造上下文中。class Secret { private: Secret(int x) {} // 私有构造函数 friend class SecretFactory; public: // ... }; class SecretFactory { public: static std::shared_ptrSecret create(int x) { // 方法A使用new在SecretFactory的成员函数中可以访问Secret的私有构造函数 return std::shared_ptrSecret(new Secret(x)); // 正确 // 方法B使用make_shared调用点仍在SecretFactory内但make_shared函数体“外部”无法访问私有构造 // return std::make_sharedSecret(x); // 编译错误‘Secret::Secret(int)’ is private } };这是因为new Secret(x)这个表达式是在SecretFactory::create的上下文中求值的因此可以访问私有成员。而std::make_sharedSecret(x)的调用虽然发生在create函数内但Secret对象的实际构造发生在std::make_shared的函数模板实例内部那个位置并没有Secret类的友元关系。这是一个细微但重要的区别。4.3 内存生命周期与std::weak_ptr的交互这是std::make_shared最微妙、也最容易产生困惑的一个点涉及到内存的延迟释放。回忆一下make_shared的单次分配特性对象和控制块位于同一块连续内存中。std::shared_ptr的引用计数use_count降为0时管理的对象会被析构调用析构函数但对象所占用的内存是否立即释放取决于是否有std::weak_ptr存在。对于shared_ptr(new T)对象内存和控制块内存是分开的。当use_count为0时对象立即被销毁其内存通过delete立即归还给堆分配器。控制块内存则会等到所有指向它的weak_ptr也都被销毁后即weak_count也为0时才释放。对于make_shared对象内存和控制块内存是一体的。当use_count为0时对象被析构析构函数被调用但承载对象的那部分内存并不能立即释放因为控制块还需要被剩余的weak_ptr使用weak_ptr需要查询控制块来判断对象是否还存活。直到最后一个weak_ptr也被销毁整块连续内存包含对象存储区和控制块才会被一次性释放。这意味着什么假设你有一个非常大的对象BigObject例如内部有一个几十MB的数组。你用make_shared创建了它。auto bigObj std::make_sharedBigObject(); // BigObject 占用大量内存 std::weak_ptrBigObject weakObs bigObj; // 创建一个weak_ptr观察它 bigObj.reset(); // 最后一个shared_ptr释放use_count变为0 // 此时BigObject的析构函数被调用对象生命周期结束。 // 但是由于weakObs还存在控制块必须保持活跃。 // 结果BigObject对象所占用的那几十MB内存虽然对象已死但内存无法回收 // 直到 weakObs 也离开作用域或被reset内存才全部释放。而在shared_ptr(new BigObject)的情况下对象内存会在reset()时立即释放仅留下很小的控制块内存等待weak_ptr。最佳实践建议如果对象很小几个到几百个字节或者你确定不会大量、长期持有其对应的weak_ptr那么make_shared的单次分配和异常安全优势是首选。如果对象非常大例如超过几KB并且你的设计模式中会存在长期存活的weak_ptr比如观察者模式、缓存等那么使用shared_ptr(new T)可能更有利于及时释放大块内存避免不必要的内存占用。你需要在这两者之间做出权衡。4.4 与std::enable_shared_from_this的配合当一个类继承自std::enable_shared_from_this时它可以从this指针安全地生成一个指向自身的shared_ptr。这里有一个关键要求对象必须已经被一个shared_ptr管理。使用make_shared与enable_shared_from_this配合是安全的但需要注意在构造函数内部不能调用shared_from_this()因为此时shared_ptr尚未完全构造好。class GoodCitizen : public std::enable_shared_from_thisGoodCitizen { public: static std::shared_ptrGoodCitizen create() { // 安全且推荐的方式 return std::make_sharedGoodCitizen(); } void doSomething() { // 安全地获取指向自身的shared_ptr auto self shared_from_this(); // ... 使用self例如放入回调或容器 } private: GoodCitizen() { // 错误构造函数内对象还未被shared_ptr完全接管。 // auto self shared_from_this(); // 抛出 std::bad_weak_ptr 异常 } }; int main() { auto obj GoodCitizen::create(); // 使用make_shared创建 obj-doSomething(); // 正确 }make_shared在构造过程中会正确设置enable_shared_from_this内部的weak_this指针因此后续调用shared_from_this()不会有问题。5. 性能对比与选型决策指南让我们通过一个更系统的视角来总结何时该用make_shared何时该用shared_ptr构造函数。特性/场景std::make_sharedT(args...)std::shared_ptrT(new T(args...))内存分配次数通常1次对象控制块合并至少2次对象1次控制块1次分配开销更低内存局部性更好更高可能产生更多碎片异常安全性强保证全有或全无可能泄漏如果new和shared_ptr构造之间发生异常语法简洁性更简洁支持auto稍显冗长自定义删除器不支持支持可管理任意资源自定义分配器通过std::allocate_shared支持通过构造函数支持构造函数访问权限要求构造函数在调用点可访问通常为public在new表达式上下文检查权限可用于友元或私有构造大对象长期weak_ptr可能延迟释放对象内存导致内存占用高对象内存可立即释放仅控制块延迟释放C20数组支持完全支持值初始化/指定初始化shared_ptrT[]支持C17起但需手动new T[N]默认初始化C20通过make_shared_for_overwrite支持通过new不初始化或new T值初始化来间接支持决策流程建议首选std::make_shared在大多数情况下特别是对于中小型对象、追求代码简洁和异常安全、且没有自定义删除器需求的场景make_shared是默认的最佳选择。考虑shared_ptr构造函数需要自定义删除器或分配器时。对象的构造函数是私有的或受保护的且无法/不想改为公开同时你有权在友元或成员函数内使用new。对象非常大并且你的架构中存在长期存活的std::weak_ptr你希望对象内存能及时释放。你需要使用C17的shared_ptr数组语法但编译器尚未支持C20虽然现在C20已比较普及但在一些旧环境仍需注意。考虑std::make_shared_for_overwrite当你需要分配一块内存并打算立即用其他数据如IO读取完全覆盖它希望避免不必要的值初始化开销时。6. 常见问题排查与实战技巧6.1 循环引用问题依然存在这是一个常见的误解。make_shared只改变了内存分配的方式并没有解决shared_ptr固有的循环引用问题。如果两个对象互相持有对方的shared_ptr或者形成环状引用即使使用make_shared它们也不会被释放。解决方案仍然是使用weak_ptr来打破强引用环。struct Node { // std::shared_ptrNode next; // 错误会导致循环引用 std::weak_ptrNode next; // 正确使用weak_ptr观察 // ... 其他数据 }; auto node1 std::make_sharedNode(); auto node2 std::make_sharedNode(); node1-next node2; node2-next node1; // 现在可以安全地形成环不会内存泄漏6.2 调试与内存检查当你怀疑智能指针有内存泄漏或引用计数问题时可以借助以下方法使用.use_count()在调试时输出use_count观察其变化。注意use_count通常用于调试不应作为程序逻辑的依据。自定义删除器进行调试给shared_ptr构造函数传入一个自定义删除器在删除器中打印日志可以明确知道对象何时被销毁。auto sp std::shared_ptrMyClass(new MyClass, [](MyClass* p) { std::cout “Deleting MyClass at “ p std::endl; delete p; });Valgrind / AddressSanitizer这些工具对于检测内存泄漏、非法访问依然有效。make_shared的单块内存分配对这些工具是透明的。6.3 与std::unique_ptr的对比选择std::make_shared对应的是std::make_uniqueC14引入。选择智能指针的首要原则是所有权语义独占所有权使用std::unique_ptr。它更轻量无控制块开销不可拷贝移动语义明确。用std::make_unique创建。共享所有权使用std::shared_ptr。当多个部分需要共同管理同一个对象的生命周期时使用。用std::make_shared创建除非遇到上述限制。不要因为方便就滥用shared_ptr。清晰的独占所有权能使代码更容易理解和维护。6.4 在容器中的使用在标准容器如std::vector,std::map中存储shared_ptr时使用make_shared可以提升性能并简化代码。std::vectorstd::shared_ptrEmployee employees; // 使用emplace_back直接构造避免临时对象 employees.emplace_back(std::make_sharedEmployee(“John”, 101, 50000.0)); // 比 push_back(std::shared_ptrEmployee(new Employee(...))) 更高效emplace_back配合make_shared可以直接在容器内存中构造shared_ptr避免了先构造临时对象再移动或拷贝的开销。6.5 类型推导与auto的完美搭档make_shared与auto关键字是绝配它们共同推动了C的现代编码风格。// 清晰、简洁、安全 auto config std::make_sharedAppConfig(loadConfigFromFile()); auto task std::make_sharedAsyncTask(callback, timeout);这避免了繁琐的类型重复也让代码在面对类型变更时更具弹性。