1. 项目概述为什么我们需要make_shared在C的世界里内存管理一直是开发者绕不开的核心议题尤其是当你从C语言转向C或者开始接触现代CC11及以后时。传统的new和delete操作符虽然给了我们极大的控制权但也像一把双刃剑稍有不慎就会导致内存泄漏、悬垂指针或者双重释放这些令人头疼的问题。我自己在早期项目里没少为这些内存问题熬夜调试。后来智能指针的出现特别是std::shared_ptr像是一道曙光它通过引用计数自动管理对象的生命周期极大地减轻了我们的心智负担。但很快我发现直接使用std::shared_ptrT(new T(args...))这种方式依然存在一些性能和内存布局上的“暗坑”。直到std::make_shared这个工具进入我的视野我才真正体会到现代C在内存管理上追求的“艺术”——在安全、效率和便利性之间寻找精妙的平衡。简单来说std::make_shared是一个模板函数它的核心任务是在动态内存堆中构造一个指定类型T的对象并返回一个管理该对象的std::shared_ptrT。它把对象的内存分配和构造以及shared_ptr控制块的内存分配和初始化这两个原本可能分离的步骤融合成了一个原子操作。这不仅仅是语法上的简化更带来了实质性的性能提升和更强的异常安全性。对于任何正在使用或计划使用shared_ptr的C开发者无论是开发高性能服务器、游戏引擎还是嵌入式系统深入理解make_shared的机制、优势与局限都是提升代码质量和运行效率的必修课。接下来我将结合多年的实战经验为你层层剥开make_shared的内核。2.make_shared的核心机制与优势剖析要理解make_shared为什么好我们得先看看在没有它的时候我们通常是怎么创建一个被shared_ptr管理的对象。2.1 传统方式的潜在问题典型的做法是std::shared_ptrMyClass sp(new MyClass(arg1, arg2));这行代码看起来没问题但它隐藏了两个关键步骤new MyClass(arg1, arg2)在堆上分配一块足够容纳MyClass对象的内存并调用构造函数进行初始化。我们称这块内存为“对象数据块”。std::shared_ptrMyClass的构造函数被调用它需要另一块独立的内存来存放“控制块”。控制块里至少包含两个引用计数强引用和弱引用和一个指向对象数据块的指针或删除器等信息。这里就暴露了第一个问题两次独立的内存分配。一次给对象一次给控制块。内存分配尤其是系统调用如malloc是相对昂贵的操作频繁的分配释放会造成内存碎片影响性能。更棘手的是第二个问题异常安全性。考虑一个更复杂的场景void process(const std::shared_ptrMyClass sp1, const std::shared_ptrMyClass sp2); process(std::shared_ptrMyClass(new MyClass(“foo”)), std::shared_ptrMyClass(new MyClass(“bar”)));在C17之前函数参数的求值顺序是未指定的unspecified。编译器可能会生成这样的执行序列分配MyClass(“foo”)的内存。构造MyClass(“foo”)对象。分配MyClass(“bar”)的内存。构造MyClass(“bar”)对象。构造第一个shared_ptr的控制块。构造第二个shared_ptr的控制块。如果在步骤4构造第二个对象时抛出了异常比如内存不足或者构造函数本身抛出异常那么步骤1中已经成功构造的MyClass(“foo”)对象就发生了内存泄漏因为还没有任何shared_ptr来管理它无法自动释放。这就是典型的异常不安全代码。2.2make_shared如何解决这些问题std::make_shared的声明大致如下templatetypename T, typename... Args std::shared_ptrT make_shared(Args... args);它的内部实现可以理解为执行了一个“合并分配”的操作单次内存分配make_shared会向内存管理器申请一块连续的内存。这块内存的大小足以同时容纳T类型的对象数据以及shared_ptr所需的控制块信息引用计数、弱引用计数、指向对象的指针、删除器等。就地构造Placement New在这块连续内存的“对象数据区”使用提供的参数args...通过就地构造placement new的方式直接构建T类型的对象。初始化控制块在同一块内存的“控制块区”初始化引用计数通常强引用和弱引用都初始化为1。返回shared_ptr构造一个shared_ptr其内部指针指向刚创建的对象而其控制块指针则指向同一块内存中的控制块区域。这个过程带来了两大核心优势性能提升将两次内存分配合并为一次不仅减少了分配开销还改善了内存的局部性Locality。对象和控制块在物理内存上紧挨着CPU缓存命中率更高访问效率更优。这对于性能敏感的应用至关重要。强异常安全保证make_shared的调用是一个原子操作。要么它成功完成返回一个有效的shared_ptr要么在分配内存或构造对象的过程中抛出异常此时没有任何资源被分配或对象被构造不会发生内存泄漏。这完美解决了上面提到的传统方式的异常安全问题。注意这里说的“强异常安全保证”是指make_shared函数本身。如果T的构造函数在构造过程中抛出异常make_shared会保证已分配的内存被正确释放不会泄漏。这比“new表达式”在组合成shared_ptr时可能发生的泄漏要安全得多。2.3 一个简单的对比示例让我们通过一个简单的例子来感受一下区别class Widget { public: Widget(int x, const std::string s) : data(x), name(s) { std::cout “Widget constructed.” std::endl; } ~Widget() { std::cout “Widget destroyed.” std::endl; } private: int data; std::string name; }; // 方式一传统方式潜在两次分配 auto sp1 std::shared_ptrWidget(new Widget(42, “Hello”)); // 可能的执行顺序1.分配Widget内存 - 2.构造Widget - 3.分配控制块内存 - 4.初始化控制块 // 方式二使用make_shared一次分配 auto sp2 std::make_sharedWidget(42, “World”); // 执行顺序1.分配一块Widget控制块内存 - 2.在内存的Widget区域构造对象 - 3.在同一内存的控制块区域初始化计数在实际运行中你或许无法直观看到分配次数的区别但在高并发或频繁创建对象的场景下其性能差异会通过压测明显体现出来。3.make_shared的适用场景与经典用法理解了make_shared的原理和优势后我们来看看在哪些场景下应该优先使用它以及一些经典的使用模式。3.1 优先使用make_shared的场景根据《Effective Modern C》中的建议以及我个人的项目经验在绝大多数情况下应该优先使用make_shared而非直接使用new来创建shared_ptr。这几乎可以成为一条编码准则。默认的对象创建当你需要创建一个新的、由shared_ptr管理的对象并且不需要自定义删除器、分配器或者指定特定的内存地址如来自内存池时make_shared是最佳选择。// 好简洁、高效、安全 auto config std::make_sharedAppConfig(“config.json”); auto connection std::make_sharedDatabaseConnection(dbHost, dbPort); auto task std::make_sharedAsyncTask([](){ /* … */ });容器中存储shared_ptr在std::vectorstd::shared_ptrEmployee或std::mapint, std::shared_ptrResource这样的容器中插入新元素时使用emplace_back配合make_shared可以避免不必要的拷贝或移动效率最高。std::vectorstd::shared_ptrEmployee team; team.reserve(10); // 预分配空间避免多次重分配 for (int i 0; i 10; i) { // 直接在容器内构造无需创建临时shared_ptr team.emplace_back(std::make_sharedEmployee(“Emp”, i)); }工厂函数返回值设计工厂模式时返回make_shared创建的对象是标准做法。class Product; class ConcreteProductA : public Product { /* … */ }; class ConcreteProductB : public Product { /* … */ }; std::shared_ptrProduct createProduct(ProductType type) { switch(type) { case TypeA: return std::make_sharedConcreteProductA(); case TypeB: return std::make_sharedConcreteProductB(); default: return nullptr; } }保证异常安全的复杂表达式如前所述在需要多个临时shared_ptr参与运算或作为函数参数时make_shared能提供更强的异常安全保证。3.2 需要避免或不能使用make_shared的场景尽管make_shared很优秀但它并非银弹在以下几种情况下你需要谨慎使用或选择其他方式需要自定义删除器Deleter或分配器Allocatormake_shared的语法不支持直接指定删除器或分配器。如果你管理的资源不是通过普通的new分配的比如是malloc分配的或是需要调用特定API释放的文件句柄、网络套接字你必须使用shared_ptr的构造函数。// 场景管理一个由C库函数分配的结构体 struct CData { /* … */ }; CData* c_data_alloc(); void c_data_free(CData*); // 错误无法指定删除器 // auto sp std::make_sharedCData(); // CData可能没有合适的构造函数 // 正确使用构造函数并传入自定义删除器 auto sp std::shared_ptrCData(c_data_alloc(), [](CData* p){ c_data_free(p); });对象需要大括号初始化列表Braced-init-list这是make_shared的一个语法限制。如果你的构造函数需要接收一个std::initializer_list或者你想使用大括号初始化来调用构造函数make_shared无法直接推导类型。class Widget { public: Widget(std::initializer_listint list) { /* … */ } Widget(int a, int b) { /* … */ } }; // 错误无法编译make_shared无法推导出initializer_list // auto sp1 std::make_sharedWidget({1, 2, 3, 4}); // 正确显式创建临时initializer_list或使用构造函数 auto initList {1, 2, 3, 4}; auto sp2 std::make_sharedWidget(initList); // 可行但多了一次拷贝 auto sp3 std::shared_ptrWidget(new Widget{1, 2, 3, 4}); // 直接使用new // 对于普通构造函数大括号初始化也可能有问题 // auto sp4 std::make_sharedWidget({5, 6}); // 可能编译错误或调用initializer_list构造函数 auto sp5 std::make_sharedWidget(5, 6); // 正确调用Widget(int, int)类定义了私有的或删除的new/delete操作符有些类例如单例模式实现、或需要严格内存管理的类会禁止在堆上直接创建。make_shared内部需要调用operator new因此无法用于这样的类。不过这类设计本身通常也不期望使用shared_ptr。对内存释放时机有极端要求高级场景这是make_shared一个非常重要的局限性我们将在下一章详细讨论。实操心得在项目中我通常会建立一个代码审查规则看到shared_ptrT(new T(...))就亮黄灯要求作者解释为什么不使用make_shared。大多数情况下作者只是不知道或者忘记了改为make_shared后代码立刻变得更安全、更清晰。对于上述几种例外情况则需要特别注释说明原因。4. 深入陷阱make_shared的内存延迟释放问题这是make_shared最微妙、也最容易踩坑的一个方面。理解了它你才算真正掌握了make_shared。4.1 问题的本质对象与控制块的生命周期绑定回忆一下make_shared的内存布局对象数据和控制块位于同一块连续的内存中。这块内存的释放时机是由shared_ptr的控制块来决定的。具体来说当最后一个指向该对象的shared_ptr强引用被销毁或重置时对象数据部分的析构函数会被调用但整块内存包含对象数据和控制块并不会立即释放。只有当最后一个指向该控制块的weak_ptr弱引用也被销毁时整块内存才会被真正释放回系统。这是因为weak_ptr需要访问控制块来检查对象是否还存活通过lock()或expired()。只要还有weak_ptr存在控制块就必须保持有效。4.2 与传统方式的对比我们通过一个例子来对比// 传统方式shared_ptr(new T) { std::shared_ptrBigObject sp(new BigObject); // 分配两次内存 std::weak_ptrBigObject wp sp; sp.reset(); // 强引用计数为0BigObject析构函数被调用对象内存立即释放。 // 此时wp仍然存在但wp只持有控制块的弱引用。 // 对象数据BigObject占用的那块内存已经还给系统可以立即被重用。 // 控制块的内存会等到wp离开作用域后再释放。 } // 方式二make_shared { auto sp std::make_sharedBigObject(); // 分配一次内存合并 std::weak_ptrBigObject wp sp; sp.reset(); // 强引用计数为0BigObject析构函数被调用。 // 但是对象数据BigObject占用的内存和控制块内存是同一块无法分割。 // 只要还有一个weak_ptrwp存在整块内存包括已析构的BigObject区域都不能释放。 // BigObject区域虽然逻辑上“空了”但物理上仍被占用直到wp离开作用域。 }假设BigObject是一个占用大量内存的类例如内部有一个几十MB的std::vector。在传统方式下当sp.reset()后这几十MB的内存会立刻释放系统内存压力得到缓解。而在make_shared方式下只要还有一个weak_ptr指着它这几十MB的内存就会一直被“僵尸对象”占着无法用于其他分配直到最后一个weak_ptr消失。4.3 何时这会成为问题在大多数场景下这不是问题。对象通常不大或者weak_ptr的生命周期不会太长。但在以下特定场景中你需要警惕对象体积巨大你管理的对象本身占用内存很大例如大缓存块、大图像数据、大型矩阵。弱引用生命周期长存在一些长期存活的weak_ptr例如被缓存起来用于延迟加载、或在全局/长生命周期对象中持有。内存敏感型应用应用运行在内存紧张的嵌入式环境或者是一个需要长时间运行、对内存碎片和利用率极其敏感的高性能服务。在这种情况下使用make_shared可能导致内存的“有效利用率”在某个时间段内降低因为已析构对象的内存无法及时回收。4.4 解决方案与权衡如果经过评估这确实是你项目中的瓶颈你有以下几种选择换回传统方式使用std::shared_ptrT(new T(...))。这牺牲了make_shared的性能和异常安全优势换取了内存的及时释放。这是一个典型的“空间换时间”更准确说是“内存及时性换分配性能”的权衡。// 当对象巨大且weak_ptr可能长存时考虑这样做 std::shared_ptrHugeCache sp(new HugeCache(size)); std::weak_ptrHugeCache wp sp; // ... 当sp重置后HugeCache的内存能立即释放使用std::allocate_shared这是make_shared的分配器感知版本。你可以通过自定义分配器来实现更复杂的内存管理策略例如使用内存池。但在解决“延迟释放”问题上它和make_shared本质相同因为对象和控制块依然由分配器一次性分配。MyPoolAllocator alloc; auto sp std::allocate_sharedWidget(alloc, args...);设计模式调整审视你的设计。是否真的需要这么大的对象由一个shared_ptr直接管理能否将大数据和生命周期控制逻辑分离例如使用shared_ptr管理一个包含std::unique_ptr或原始指针指向大数据的轻量级句柄对象。注意事项不要过早优化。在项目初期或非关键路径上优先使用make_shared以获得更好的代码安全性和一般情况下的性能。只有在性能剖析Profiling工具明确显示此处内存驻留成为瓶颈后才考虑为了内存及时性而放弃make_shared。我见过很多团队因为过度担心这个“陷阱”而在所有地方都避免使用make_shared这无异于因噎废食。5.make_shared的进阶话题与最佳实践掌握了基本用法和核心陷阱后我们再来探讨一些进阶话题帮助你更得心应手地使用这个工具。5.1 与std::make_unique的关系C14引入了std::make_unique它之于std::unique_ptr就如同make_shared之于shared_ptr。它同样提供了创建对象的便利语法和异常安全保证但因为它管理的是独占所有权的资源所以不存在控制块合并分配的问题。最佳实践统一你的对象创建语法。当需要独占所有权时使用auto up std::make_uniqueT(args...);当需要共享所有权时使用auto sp std::make_sharedT(args...);只有当make_xxx系列函数不适用时如需要自定义删除器才直接使用智能指针的构造函数。这种一致性让代码意图更清晰也减少了错误。5.2 性能优化的细微之处make_shared的性能优势在微基准测试中可能不明显但在大规模、高频率创建对象的系统中其收益是显著的。除了减少分配次数还有以下几点缓存友好性对象和控制块相邻意味着当shared_ptr被解引用访问对象时控制块信息有很大可能已经在CPU缓存中减少了缓存未命中Cache Miss。减少TLB压力一次分配意味着只占用一个虚拟内存页或连续页减少了转换后备缓冲器TLB的负担。在编写高性能组件如自定义容器、网络库、游戏对象管理系统时将这些细节纳入考量是资深工程师的标志。5.3 自定义类的配合为了让你的类能更好地与make_shared协作请确保构造函数的异常安全性既然make_shared提供了强异常安全保证你的构造函数也应该尽量做到。避免在构造函数中做可能失败且会泄漏资源的操作。如果必须请使用智能指针管理成员资源或遵循RAII原则。考虑将构造函数设为私有并提供工厂函数如果你希望强制用户使用智能指针来管理你的类实例例如禁止在栈上创建一个常见的模式是class MyClass { public: templatetypename... Args static std::shared_ptrMyClass create(Args... args) { return std::make_sharedMyClass(std::forwardArgs(args)...); } // ... 其他公有接口 private: MyClass(/* args */) { /* ... */ } // 构造函数私有 // 禁用拷贝和赋值根据需求 MyClass(const MyClass) delete; MyClass operator(const MyClass) delete; }; // 用户只能这样创建对象 auto obj MyClass::create(arg1, arg2);5.4 调试与排查技巧当使用make_shared遇到问题时以下技巧可能帮到你内存泄漏检测像Valgrind、AddressSanitizer这样的工具对make_shared创建的对象同样有效。它们报告的是最终整块内存的泄漏。记住由于对象和控制块合并泄漏报告的大小是两者之和。弱引用导致的“伪泄漏”如果你发现某个对象的内存迟迟不释放首先检查是否有weak_ptr还在某处持有。可以使用调试器观察控制块中的弱引用计数。类型推导错误当make_shared的模板参数推导出现意外时特别是涉及大括号初始化或重载时尝试显式指定模板参数或者回到shared_ptr构造函数看看是否能编译这有助于定位问题。// 如果make_shared编译失败试试这个 auto sp std::make_sharedWidget(arg); // 错误 // 显式指定类型或检查构造函数 auto sp2 std::shared_ptrWidget(new Widget(arg)); // 正确吗6. 总结与个人体会回顾std::make_shared它绝不仅仅是一个语法糖。它是现代C哲学的一个缩影通过库和编译器的协作在零开销抽象的原则下为开发者提供更安全、更高效的编程工具。它将内存分配次数减半提供了更强的异常安全保证并改善了数据局部性。在我经历过的多个大型C项目中将代码库中的shared_ptr(new ...)逐步替换为make_shared往往能带来可观的性能提升尤其是在对象创建频繁的模块同时显著减少了因异常安全漏洞导致的偶发性Bug。当然我们也必须清醒地认识到它的局限性特别是对象与控制块内存绑定释放的特性。在管理生命周期可能很长、体积巨大的对象时我们需要多做一层考量。最后我的建议是将std::make_shared作为创建shared_ptr的默认首选方式。把它变成一种肌肉记忆。只有在遇到需要自定义删除器、使用初始化列表、或者经过严格性能剖析证实“延迟释放”成为关键瓶颈的特定场景时才选择其他方案。同时积极使用std::make_unique来管理独占资源。这套“make_xxx”组合拳能让你在C内存管理的道路上走得更加稳健和高效。内存管理是C的基石也是其强大与复杂之所在。make_shared这样的工具正是帮助我们驯服这头“猛兽”的利器之一。理解它、善用它你的C代码将会更加健壮和优雅。