
1. 项目概述为什么我们需要std::make_shared在C的世界里内存管理一直是开发者需要直面的核心挑战。从C语言时代的手动malloc/free到C早期的new/delete再到现代C的智能指针我们一直在追求更安全、更高效、更不易出错的内存管理方式。std::shared_ptr作为智能指针家族中的重要成员解决了资源所有权的共享问题但它并非完美。一个典型的、容易出错的写法是void risky_function() { std::shared_ptrMyClass sp1(new MyClass(42)); // 潜在的内存泄漏风险点 // ... 一些可能抛出异常的操作 std::shared_ptrMyClass sp2 sp1; // 如果上面抛异常这里不会执行 }在上面的代码中new MyClass(42)会先分配内存并构造对象然后将原始指针传递给std::shared_ptr的构造函数。问题在于new表达式和shared_ptr构造是两个独立的步骤。如果在new成功之后、shared_ptr接管所有权之前发生了异常比如在MyClass构造函数内部或者在其他初始化代码中那么new分配的内存将无法被释放导致内存泄漏。std::make_shared就是为了解决这类问题而生的。它并非一个独立的类而是一个函数模板是C11标准库为std::shared_ptr量身打造的“工厂函数”。它的核心价值在于将内存分配和对象构造以及shared_ptr控制块的创建合并为一个不可分割的原子操作。这意味着要么全部成功要么全部失败不存在中间状态从而从根本上杜绝了上述场景下的内存泄漏风险。对于任何使用C11及以上版本进行开发的程序员无论是从事高性能服务器后端、游戏引擎、嵌入式系统还是桌面应用理解并熟练运用std::make_shared都是提升代码健壮性和性能的关键一步。它不仅关乎安全性更在内存布局和运行时效率上带来了显著的优化。2. 核心原理与优势深度剖析2.1 原子操作与异常安全让我们深入理解std::make_shared如何实现异常安全。其函数签名大致如下templatetypename T, typename... Args std::shared_ptrT make_shared(Args... args);当调用auto sp std::make_sharedMyClass(arg1, arg2);时标准库的实现需要完成以下工作分配一块足够大的内存用于容纳MyClass对象本身以及shared_ptr的控制块包含引用计数、弱引用计数、删除器等。在这块内存的适当位置使用参数arg1, arg2就地构造in-place constructMyClass对象。在同一块内存的另一部分构造shared_ptr的控制块。返回一个已经设置好、指向新构造对象的shared_ptr。这个过程在逻辑上是原子的。编译器/标准库实现会确保如果步骤2对象构造或步骤3控制块构造抛出异常那么步骤1中分配的所有内存都会被正确、自动地清理不会留下任何垃圾。这与使用new表达式后再构造shared_ptr的“两步走”过程有本质区别。注意这里说的“原子性”指的是操作在异常安全意义上的不可分割性并非多线程语境下的原子操作。make_shared本身在多线程环境下调用并不是线程安全的但返回的shared_ptr的引用计数操作是原子的。2.2 内存布局优化与性能提升这是make_shared另一个极其重要的优势常被称为“一次分配”优化。传统shared_ptrT(new T)的内存布局new T在堆上分配一块内存我们称之为内存块A用于存放T对象。构造shared_ptr在堆上另一块独立的内存内存块B中分配并构造控制块。shared_ptr内部持有两个指针一个指向对象内存块A一个指向控制块内存块B。内存布局 (shared_ptr(new T)): [堆] 内存块A: T对象数据 [堆] 内存块B: 控制块 (引用计数、弱引用计数、删除器...) shared_ptr内部: 指针1 - 内存块A, 指针2 - 内存块Bmake_shared的内存布局单次分配一次性在堆上分配一块更大的连续内存。组合布局在这块连续内存中将控制块和T对象紧挨着存放。通常控制块在前对象数据在后。内存布局 (make_sharedT()): [堆] 单块连续内存: [ 控制块 | T对象数据 ] shared_ptr内部: 指针1 - T对象数据区, 指针2 - 控制块区 (两者在同一大块内存内)这种优化带来的好处是立竿见影的提升分配速度减少了一次对系统堆内存分配器的调用。堆分配malloc或new是比较昂贵的操作涉及寻找合适大小的空闲内存块、更新内存管理数据结构等。减少一次分配意味着减少了一次系统调用和锁竞争如果分配器是线程安全的对性能敏感的应用提升明显。提高内存局部性对象和控制块位于同一缓存行Cache Line或相邻缓存行的概率大大增加。当shared_ptr被拷贝或访问时处理器更有可能在一次缓存读取中同时加载控制块和对象的部分数据减少了缓存未命中Cache Miss从而提升了访问效率。减少内存碎片减少了小内存块的分配次数有助于缓解内存碎片问题。降低内存占用虽然分配的总内存量相近但单次分配通常会有一些管理开销如内存对齐的填充字节、分配器自身的元数据。两次独立分配会产生两份这样的开销而一次分配只产生一份。2.3 代码简洁性与最佳实践从代码风格上看make_shared让意图更清晰避免了裸new的出现符合现代C“避免手动管理资源”的哲学。// 更清晰更安全通常也更高效 auto sp1 std::make_sharedWidget(width, height, “name”); // 对比需要重复类型Widget且存在潜在风险 std::shared_ptrWidget sp2(new Widget(width, height, “name”));使用auto关键字配合make_shared既保证了类型安全又使代码简洁。这已经成为C11/14之后的推荐写法被收录在《Effective Modern C》等权威资料中。3. 使用详解、限制与边界情况3.1 基本用法与参数传递std::make_shared的用法非常直观它完美支持了C11的可变参数模板和完美转发。#include memory #include string class MyClass { public: MyClass(int a, const std::string b, double c) { /* ... */ } // ... }; int main() { // 传递构造函数的参数 auto sp1 std::make_sharedMyClass(42, “hello”, 3.14); // 如果构造函数是explicit的也没问题 struct ExplicitClass { explicit ExplicitClass(int x) {} }; auto sp2 std::make_sharedExplicitClass(100); // 正确 // 构造一个默认初始化的对象 auto sp3 std::make_sharedint(); // 指向一个值初始化的int (值为0) auto sp4 std::make_sharedint(10); // 指向一个值为10的int // 对于聚合类Aggregate可以使用列表初始化 (C11起) struct Point { int x; int y; }; auto sp5 std::make_sharedPoint(Point{1, 2}); // 传统方式 // C20 起make_shared 直接支持列表初始化 // auto sp5 std::make_sharedPoint({1, 2}); // C20 return 0; }make_shared通过Args... args将参数完美转发给T的构造函数。这意味着它支持移动语义如果传入的是右值则会调用移动构造函数效率更高。3.2 无法使用std::make_shared的典型场景尽管make_shared优势显著但它并非万能。在以下几种情况下你不得不或者应该选择直接使用std::shared_ptr的构造函数。1. 需要自定义删除器Custom Deleter或分配器Allocatorstd::make_shared使用的是std::allocate_shared的默认版本它使用new和delete进行内存管理。如果你的对象需要特殊的清理逻辑如关闭文件句柄、调用特定API释放资源或需要使用自定义的内存池分配器make_shared无法满足。// 场景管理一个用fopen打开的C文件句柄 void file_deleter(FILE* fp) { if (fp) fclose(fp); } // 错误make_shared无法指定删除器 // auto sp_wrong std::make_sharedFILE(fopen(“data.txt”, “r”)); // 正确使用shared_ptr构造函数 std::shared_ptrFILE sp_correct(fopen(“data.txt”, “r”), file_deleter);2. 需要大括号初始化列表在C20之前在C20标准之前std::make_shared无法直接使用大括号{}初始化列表来构造对象因为函数模板参数推导无法区分std::initializer_list和其他类型。你需要一个变通方法。#include vector #include memory // C11/14/17 中的限制 auto sp_vec std::make_sharedstd::vectorint(3, 10); // 这调用的是 vector(size_type count, const T value) // 结果是包含三个10的vector{10, 10, 10} // 而我们想用列表初始化得到 {3, 10} 是做不到的。 // 变通方案先创建临时对象或使用auto推导类型 auto init_list {3, 10}; auto sp_vec2 std::make_sharedstd::vectorint(init_list); // 可行 // 或者直接使用new表达式 std::shared_ptrstd::vectorint sp_vec3(new std::vectorint{3, 10});3. 对象需要在其构造函数中获取shared_ptr即enable_shared_from_this这是一个微妙但重要的场景。如果一个类继承自std::enable_shared_from_this并且在其构造函数中需要调用shared_from_this()那么必须确保对象在构造时已经被一个shared_ptr所管理。class SelfAware : public std::enable_shared_from_thisSelfAware { public: SelfAware() { // 错误此时对象还没有被shared_ptr管理。 // auto sp shared_from_this(); // 抛出std::bad_weak_ptr异常 } void init() { // 正确在构造完成后通过一个成员函数来获取 auto sp shared_from_this(); // ... 使用sp } }; int main() { // 使用make_shared构造 auto obj std::make_sharedSelfAware(); obj-init(); // 安全 return 0; }使用make_shared时对象在构造完成前控制块虽然已分配但指向对象的shared_ptr尚未完全就绪。因此在构造函数内部调用shared_from_this()是未定义行为通常抛异常。解决方案是将这类操作移到构造函数之外的一个初始化函数中。4. 对内存释放有特殊时序要求由于make_shared将对象和控制块的内存绑定在一起一次性分配它们也会在一起释放。但这带来了一个副作用对象内存的释放时机被延迟了。对于shared_ptr(new T)当所有shared_ptr引用计数归零时对象被销毁调用析构函数其占用的内存内存块A立即释放。控制块内存内存块B会等到所有weak_ptr引用也归零后才释放。对于make_sharedT当所有shared_ptr引用计数归零时对象被销毁调用析构函数但对象所占用的那片内存不会立即归还给系统因为它和控制块还在同一大块内存里。这块内存要等到所有weak_ptr引用也归零后才会被整体释放。如果你的对象非常大或者你希望在对象析构后尽快释放其内存以供重用那么使用make_shared可能会暂时“占用”着那块内存即使对象本身已不存在。在这种情况下使用shared_ptr(new T)可能是更合适的选择。3.3 与std::allocate_shared的关系std::make_shared可以看作是std::allocate_shared的一个特化便捷版本。std::allocate_shared允许你指定一个自定义分配器Allocator而make_shared使用默认的std::allocator。它们的核心优化一次分配是相同的。template class T, class Alloc, class... Args shared_ptrT allocate_shared( const Alloc alloc, Args... args );当你需要自定义内存分配策略时应使用allocate_shared。4. 性能对比与实测考量理论需要实践验证。我们来分析一下在不同场景下make_shared与shared_ptr(new)的性能差异。1. 分配性能如前所述make_shared减少了一次堆分配这在频繁创建小对象的场景下如事件对象、网络数据包会带来显著的性能提升。有基准测试表明在密集创建和销毁shared_ptr的循环中make_shared能带来10%到30%不等的速度提升具体取决于编译器、标准库实现和对象大小。2. 内存占用对于小对象两次分配带来的额外开销每个内存块的管理元数据、对齐填充可能比对象本身还大。make_shared的一次分配显著减少了这种开销。例如在64位系统上一次堆分配可能至少有16字节的额外开销两次就是32字节。对于一个只有几个字节的struct这节省的比例是巨大的。3. 缓存友好性在访问shared_ptr所指向的对象时程序很可能也需要操作控制块例如拷贝shared_ptr会增加引用计数。如果两者内存位置接近CPU缓存命中率更高。这对于多线程环境下频繁引用的共享数据尤其有益。4. 何时差异不大一次性创建长期持有如果shared_ptr在程序生命周期内只创建一次之后就是简单的拷贝和传递那么初始创建的优化带来的整体收益微乎其微。对象非常大当对象本身的大小比如一个巨大的缓冲区远大于控制块和分配开销时一次分配与两次分配的性能差异占比会变小。此时3.2节第4点提到的内存释放延迟问题可能更值得关注。实操心得在大多数日常开发场景中尤其是对象生命周期管理复杂、创建频率不确定的情况下无脑优先使用std::make_shared是一个非常好的默认选择。它用极小的认知成本换来了异常安全和性能提升的双重好处。只有在遇到明确的不适用场景自定义删除器、C20前的复杂列表初始化、对内存释放时序有严苛要求时才退而使用shared_ptr的构造函数。5. 常见问题、陷阱与排查技巧即使理解了原理在实际使用中还是会遇到一些坑。下面是一些常见问题及解决方法。5.1 控制块与weak_ptr的生命周期陷阱这是使用make_shared时最需要警惕的一点。我们通过一个例子来看#include memory #include iostream class ResourceHeavy { public: ResourceHeavy() { std::cout “ResourceHeavy acquired\n”; } ~ResourceHeavy() { std::cout “ResourceHeavy released\n”; } char big_data[1024 * 1024]; // 1MB 数据 }; int main() { std::weak_ptrResourceHeavy wp; { auto sp std::make_sharedResourceHeavy(); // 一次分配包含对象和控制块 wp sp; // weak_ptr指向控制块 std::cout “Shared ptr out of scope...\n”; } // 此处sp析构引用计数归零调用~ResourceHeavy() std::cout “但对象内存还未释放因为weak_ptr wp还活着。\n”; std::cout “此时对象已死析构函数已调用但1MB的big_data内存还被占着。\n”; // 只有当wp也析构或被重置所有内存才会释放。 return 0; }输出可能类似于ResourceHeavy acquired Shared ptr out of scope... ResourceHeavy released 但对象内存还未释放因为weak_ptr wp还活着。 此时对象已死析构函数已调用但1MB的big_data内存还被占着。排查与解决问题识别如果你的程序内存使用量居高不下但通过工具如Valgrind、heaptrack检测又没有“真正的”内存泄漏即未释放的内存块就需要怀疑是否是weak_ptr延长了make_shared分配的整体内存块的生命周期。工具辅助使用weak_ptr的use_count()和expired()方法可以辅助判断。但更有效的是使用内存分析工具观察内存块的分配和释放点。设计规避在持有大对象的场景下仔细评估weak_ptr的使用周期。如果可能在最后一个shared_ptr释放后及时将相关的weak_ptr重置wp.reset()。或者如果内存及时释放是关键需求考虑放弃make_shared改用shared_ptr(new T)。5.2 构造函数访问权限问题make_shared作为一个非友元的外部函数需要访问类的构造函数。如果构造函数是private或protected的make_shared将无法编译。class PrivateCtor { private: PrivateCtor(int x) {} // 私有构造函数 friend class Factory; // 只授权给Factory类 public: // ... }; int main() { // 错误make_shared无法调用私有构造函数 // auto p std::make_sharedPrivateCtor(42); return 0; }解决方案如果该类设计为只能通过shared_ptr管理且你拥有类的修改权可以为std::make_shared或更通用的std::allocate_shared添加友元声明。但这通常不是好的设计因为它向标准库的具体实现细节敞开了大门。更常见的做法是提供一个静态工厂函数在内部使用new创建对象然后返回shared_ptr。class PrivateCtor { private: PrivateCtor(int x) {} public: static std::shared_ptrPrivateCtor create(int x) { return std::shared_ptrPrivateCtor(new PrivateCtor(x)); } };5.3 与std::unique_ptr的构造区别新手有时会混淆make_shared和make_uniqueC14引入的适用场景。记住make_unique纯粹是为了异常安全和代码简洁它没有make_shared那种“一次分配”的优化因为unique_ptr不需要独立的控制块。对于unique_ptrmake_unique和unique_ptr(new T)在内存布局和性能上基本没有区别。5.4 调试与排查技巧观察引用计数在调试复杂的所有权关系时可以临时使用sp.use_count()来查看shared_ptr的引用计数。但注意这在多线程环境下只是一个瞬间快照主要用于调试。类型推导错误使用auto时确保make_shared构造的对象类型是你期望的。特别是当构造函数有多个重载或涉及隐式转换时。auto sp std::make_sharedbool(10); // sp 的类型是 shared_ptrbool指向的bool值为true (因为10 ! 0)内存泄漏检测工具即使使用了智能指针误用依然可能导致循环引用需用weak_ptr打破。工具如Valgrind、AddressSanitizer、Visual Studio Diagnostic Tools等是检测这类问题的利器。它们能帮你确认内存是否真的因为make_shared和weak_ptr的机制而延迟释放还是因为循环引用导致的永久泄漏。6. 在现代C中的演进与替代方案C14引入了std::make_unique补全了智能指针的工厂函数家族。C17和C20对智能指针和内存管理又有进一步增强。C17的std::make_shared和std::make_unique对数组的完全支持早期需要通过std::shared_ptrT[]和自定义删除器来管理数组现在可以直接使用make_sharedT[](size)。// C17 起 auto arr_sp std::make_sharedint[](10); // 指向一个有10个int的数组 auto arr_up std::make_uniqueint[](10);C20的std::make_shared对于聚合体的列表初始化如3.2节所述C20解决了make_shared直接使用初始化列表的问题。// C20 struct Agg { int a; std::string b; }; auto sp std::make_sharedAgg({42, “hello”}); // 合法替代方案std::allocate_shared当默认的内存分配策略不满足需求时例如需要使用内存池、栈分配器、或特定的对齐内存std::allocate_shared是你的武器。更高级的场景自定义工厂模式对于构造过程异常复杂或需要根据条件返回不同类型对象多态的场景一个返回shared_ptr的工厂函数比直接使用make_shared更灵活。std::make_shared是现代C资源管理工具箱中的一件精良武器。它通过将安全性、效率性和简洁性三者结合极大地简化了shared_ptr的使用。理解其“一次分配”的原理、知晓其延迟释放的副作用、并清楚其不适用的边界能让你在项目中游刃有余地做出正确选择。我的经验是在项目初期就确立“优先使用make_shared”的编码规范能让团队避免大量低级的内存管理错误把精力集中在更重要的业务逻辑上。当你在代码审查中看到一个裸new被传递给shared_ptr时第一时间应该想到这里是否能用make_shared替代这个习惯本身就是向更健壮、更现代的C迈进的一大步。