MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ std::shared_ptr 的内存共享机制

2024-07-224.6k 阅读

C++ std::shared_ptr 的内存共享机制

一、std::shared_ptr 简介

在 C++ 编程中,内存管理一直是一个重要且复杂的问题。传统的手动内存管理方式,如使用 newdelete 操作符,容易引发内存泄漏、悬空指针等问题。std::shared_ptr 作为 C++ 标准库中智能指针的一种,旨在简化动态内存管理,提供一种安全、高效的内存共享机制。

std::shared_ptr 是一个模板类,定义在 <memory> 头文件中。它通过引用计数的方式来管理动态分配的对象。当一个 std::shared_ptr 对象指向一个动态分配的对象时,该对象的引用计数会增加。当 std::shared_ptr 对象被销毁(例如超出作用域或被显式释放)时,引用计数会减少。当引用计数降为 0 时,动态分配的对象会被自动释放,从而避免了内存泄漏。

二、std::shared_ptr 的基本使用

2.1 创建 std::shared_ptr 对象

创建 std::shared_ptr 对象最常见的方式是使用 std::make_shared 函数。std::make_shared 函数会在堆上分配一个对象,并返回一个指向该对象的 std::shared_ptr。这种方式不仅简洁,而且通常效率更高,因为它一次性分配了对象和控制块(用于存储引用计数等信息)的内存。

#include <iostream>
#include <memory>

int main() {
    // 使用 std::make_shared 创建 std::shared_ptr
    std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
    std::cout << "ptr1 指向的值: " << *ptr1 << std::endl;

    return 0;
}

在上述代码中,std::make_shared<int>(42) 创建了一个动态分配的 int 类型对象,初始值为 42,并返回一个 std::shared_ptr<int> 指向该对象。通过 *ptr1 可以访问对象的值。

除了 std::make_shared,也可以直接使用 std::shared_ptr 的构造函数来创建对象,但这种方式需要手动传入 new 表达式分配的指针。

#include <iostream>
#include <memory>

int main() {
    int* rawPtr = new int(100);
    std::shared_ptr<int> ptr2(rawPtr);
    std::cout << "ptr2 指向的值: " << *ptr2 << std::endl;

    return 0;
}

不过,这种直接使用构造函数的方式存在一些潜在风险,比如如果在传递 rawPtrstd::shared_ptr 构造函数之前发生异常,rawPtr 可能无法正确释放,从而导致内存泄漏。因此,推荐优先使用 std::make_shared

2.2 拷贝和赋值

std::shared_ptr 支持拷贝构造和赋值操作。当进行拷贝或赋值时,引用计数会相应增加。

#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
    std::shared_ptr<int> ptr2 = ptr1; // 拷贝构造,引用计数增加
    std::shared_ptr<int> ptr3;
    ptr3 = ptr2; // 赋值操作,引用计数增加

    std::cout << "ptr1 的引用计数: " << ptr1.use_count() << std::endl;
    std::cout << "ptr2 的引用计数: " << ptr2.use_count() << std::endl;
    std::cout << "ptr3 的引用计数: " << ptr3.use_count() << std::endl;

    return 0;
}

在这段代码中,ptr2 通过拷贝构造从 ptr1 初始化,ptr3 通过赋值操作从 ptr2 获取值。每次拷贝或赋值,引用计数都会增加。use_count 成员函数用于获取当前对象的引用计数。

2.3 释放对象

std::shared_ptr 对象被销毁时,引用计数会减少。当引用计数降为 0 时,指向的对象会被自动释放。

#include <iostream>
#include <memory>

void testSharedPtr() {
    std::shared_ptr<int> ptr = std::make_shared<int>(42);
    std::cout << "testSharedPtr 内 ptr 的引用计数: " << ptr.use_count() << std::endl;
} // ptr 在此处超出作用域,引用计数减为 0,对象被释放

int main() {
    testSharedPtr();
    return 0;
}

testSharedPtr 函数中,ptr 在函数结束时超出作用域,其引用计数降为 0,指向的 int 对象被自动释放。

三、std::shared_ptr 的实现原理

3.1 引用计数

std::shared_ptr 的核心机制是引用计数。每个 std::shared_ptr 对象都维护一个指向控制块的指针,控制块中包含了引用计数、指向动态分配对象的指针以及可能的其他信息(如删除器等)。 当创建一个新的 std::shared_ptr 对象指向某个对象时,会创建一个新的控制块,并将引用计数初始化为 1。当进行拷贝构造或赋值操作时,引用计数会增加。当 std::shared_ptr 对象被销毁时,引用计数会减少。当引用计数降为 0 时,控制块会释放指向的动态分配对象,并销毁自身。

3.2 控制块

控制块的结构对于理解 std::shared_ptr 的工作原理至关重要。虽然标准并没有规定控制块的具体实现细节,但通常它至少包含以下几个部分:

  1. 引用计数:记录当前有多少个 std::shared_ptr 对象指向同一个动态分配的对象。
  2. 指向对象的指针:指向实际在堆上分配的对象。
  3. 删除器:用于释放对象的函数或函数对象。默认情况下,删除器使用 delete 操作符来释放对象,但用户可以自定义删除器。
  4. 弱引用计数(如果存在):用于 std::weak_ptr 的相关操作,这部分内容将在后续关于 std::weak_ptr 的章节中详细介绍。

3.3 线程安全性

在多线程环境下,std::shared_ptr 的引用计数操作是线程安全的。这意味着多个线程可以同时对 std::shared_ptr 进行拷贝、赋值和销毁操作,而不会出现数据竞争问题。然而,对于指向对象的实际访问,仍然需要适当的同步机制,因为对象本身的访问并不是线程安全的。 例如:

#include <iostream>
#include <memory>
#include <thread>
#include <mutex>

std::mutex mtx;
std::shared_ptr<int> sharedData;

void threadFunction() {
    std::shared_ptr<int> localPtr;
    {
        std::lock_guard<std::mutex> lock(mtx);
        localPtr = sharedData;
    }
    if (localPtr) {
        std::cout << "线程中访问到的值: " << *localPtr << std::endl;
    }
}

int main() {
    sharedData = std::make_shared<int>(42);

    std::thread t1(threadFunction);
    std::thread t2(threadFunction);

    t1.join();
    t2.join();

    return 0;
}

在上述代码中,虽然 std::shared_ptr 的引用计数操作是线程安全的,但为了确保多个线程安全地访问 sharedData 指向的对象,使用了 std::mutex 进行同步。

四、自定义删除器

std::shared_ptr 允许用户自定义删除器,这在一些特殊情况下非常有用。例如,当动态分配的对象需要使用特定的释放函数(而不是 delete 操作符)时,或者当对象分配的资源需要特殊的清理操作时。

4.1 函数指针作为删除器

可以使用函数指针作为自定义删除器。以下是一个示例,假设有一个动态分配的数组,需要使用 delete[] 来释放:

#include <iostream>
#include <memory>

void customDelete(int* ptr) {
    std::cout << "使用自定义删除器释放内存" << std::endl;
    delete[] ptr;
}

int main() {
    int* arr = new int[5]{1, 2, 3, 4, 5};
    std::shared_ptr<int> ptr(arr, customDelete);

    return 0;
}

在上述代码中,std::shared_ptr<int> ptr(arr, customDelete) 创建了一个 std::shared_ptr 对象,使用 customDelete 函数作为删除器。当 ptr 被销毁时,会调用 customDelete 函数来释放 arr 所指向的数组内存。

4.2 函数对象作为删除器

也可以使用函数对象(重载了 () 运算符的类对象)作为删除器。这种方式更加灵活,因为函数对象可以携带状态信息。

#include <iostream>
#include <memory>
#include <fstream>

class FileCloser {
public:
    void operator()(std::fstream* file) {
        std::cout << "关闭文件" << std::endl;
        file->close();
        delete file;
    }
};

int main() {
    std::fstream* file = new std::fstream("test.txt", std::ios::out);
    std::shared_ptr<std::fstream> filePtr(file, FileCloser());

    return 0;
}

在这个例子中,FileCloser 类的对象作为删除器,在 std::shared_ptr 销毁时关闭并释放文件对象。

五、std::shared_ptr 与其他智能指针的关系

5.1 std::unique_ptr

std::unique_ptr 也是 C++ 标准库中的智能指针。与 std::shared_ptr 不同,std::unique_ptr 采用独占式所有权模型,即一个对象只能由一个 std::unique_ptr 指向。当 std::unique_ptr 对象被销毁时,它会自动释放所指向的对象。

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> uniquePtr = std::make_unique<int>(42);
    // std::unique_ptr<int> anotherUniquePtr = uniquePtr; // 编译错误,不能拷贝
    std::unique_ptr<int> movedPtr = std::move(uniquePtr); // 可以移动

    return 0;
}

std::unique_ptr 没有拷贝构造函数和赋值运算符,只能通过移动语义来转移所有权。这使得 std::unique_ptr 在性能上更轻量级,适合于不需要共享对象所有权的场景。

5.2 std::weak_ptr

std::weak_ptr 是一种弱引用智能指针,它与 std::shared_ptr 密切相关。std::weak_ptr 可以指向由 std::shared_ptr 管理的对象,但不会增加对象的引用计数。std::weak_ptr 主要用于解决 std::shared_ptr 可能出现的循环引用问题。

#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> bPtr;
    ~A() { std::cout << "A 被销毁" << std::endl; }
};

class B {
public:
    std::weak_ptr<A> aWeakPtr;
    ~B() { std::cout << "B 被销毁" << std::endl; }
};

int main() {
    std::shared_ptr<A> aPtr = std::make_shared<A>();
    std::shared_ptr<B> bPtr = std::make_shared<B>();

    aPtr->bPtr = bPtr;
    bPtr->aWeakPtr = aPtr;

    return 0;
}

在上述代码中,如果 B 中使用 std::shared_ptr<A> 而不是 std::weak_ptr<A>,就会形成循环引用,导致 AB 对象无法被正确释放。而使用 std::weak_ptr 可以避免这种情况,当 aPtrbPtr 超出作用域时,AB 对象会被正确销毁。

六、std::shared_ptr 的性能考量

虽然 std::shared_ptr 提供了方便的内存共享机制,但在性能方面也需要一些考量。

6.1 内存开销

std::shared_ptr 除了需要存储指向对象的指针外,还需要维护控制块。控制块中包含引用计数等信息,这会增加额外的内存开销。特别是在使用 std::make_shared 时,虽然一次性分配了对象和控制块的内存,但如果对象本身很小,这种额外的开销可能相对较大。

6.2 引用计数操作的开销

每次进行拷贝、赋值或销毁 std::shared_ptr 对象时,都需要对引用计数进行原子操作。原子操作虽然保证了线程安全性,但在单线程环境下,这种额外的开销可能是不必要的。因此,在性能敏感的单线程场景中,std::unique_ptr 可能是更好的选择,因为它没有引用计数的开销。

6.3 缓存局部性

由于 std::shared_ptr 的控制块和对象可能位于不同的内存位置,这可能会影响缓存局部性。在频繁访问对象的场景中,这种缓存不友好的特性可能会导致性能下降。相比之下,std::unique_ptr 直接指向对象,在缓存局部性方面可能更有优势。

七、避免 std::shared_ptr 的常见错误

7.1 避免手动释放已经由 std::shared_ptr 管理的对象

一旦对象由 std::shared_ptr 管理,就不应该手动使用 delete 操作符来释放它,否则会导致未定义行为。

#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> ptr = std::make_shared<int>(42);
    int* rawPtr = ptr.get();
    // delete rawPtr; // 错误,不要手动释放由 std::shared_ptr 管理的对象
    return 0;
}

在上述代码中,如果取消注释 delete rawPtr,就会导致未定义行为,因为 ptr 已经负责管理对象的生命周期。

7.2 避免循环引用

如前文所述,循环引用会导致对象无法被正确释放。在设计类之间的关系时,要特别注意避免形成 std::shared_ptr 的循环引用。可以使用 std::weak_ptr 来打破循环引用。

7.3 注意 std::shared_ptr 作为函数参数的传递方式

std::shared_ptr 作为函数参数传递时,要注意传递方式。如果函数内部需要持有对象的所有权,应该通过值传递或 std::move 传递,而不是通过引用传递。

#include <iostream>
#include <memory>

void function(std::shared_ptr<int> param) {
    std::cout << "函数内 param 的引用计数: " << param.use_count() << std::endl;
}

int main() {
    std::shared_ptr<int> ptr = std::make_shared<int>(42);
    function(ptr);
    std::cout << "函数外 ptr 的引用计数: " << ptr.use_count() << std::endl;

    return 0;
}

在上述代码中,通过值传递 ptrfunction 函数,function 函数内部会增加引用计数。如果通过引用传递,函数内部不会增加引用计数,可能会导致意外的结果。

八、在实际项目中的应用场景

std::shared_ptr 在实际项目中有广泛的应用场景。

8.1 资源管理

在管理动态分配的资源(如文件句柄、网络连接等)时,std::shared_ptr 可以确保资源在不再被使用时被正确释放。例如,在一个多模块的应用程序中,不同模块可能需要共享一个文件对象,std::shared_ptr 可以方便地实现这种共享,并保证文件在所有模块都不再使用时被关闭。

8.2 对象生命周期管理

在面向对象编程中,当一个对象的生命周期需要由多个其他对象共享控制时,std::shared_ptr 非常有用。例如,在一个图形渲染引擎中,多个渲染组件可能需要共享一个纹理对象,std::shared_ptr 可以确保纹理对象在所有需要它的组件都不再使用时被释放。

8.3 容器中存储动态对象

std::vectorstd::list 等容器中存储动态对象时,std::shared_ptr 可以简化对象的管理。容器中的 std::shared_ptr 对象可以自动处理对象的拷贝、移动和销毁,避免了手动管理内存的复杂性。

#include <iostream>
#include <memory>
#include <vector>

class MyClass {
public:
    MyClass(int value) : data(value) { std::cout << "MyClass 构造: " << data << std::endl; }
    ~MyClass() { std::cout << "MyClass 析构: " << data << std::endl; }
    int data;
};

int main() {
    std::vector<std::shared_ptr<MyClass>> vec;
    vec.push_back(std::make_shared<MyClass>(1));
    vec.push_back(std::make_shared<MyClass>(2));

    return 0;
}

在上述代码中,std::vector 中存储了 std::shared_ptr<MyClass>,当 vec 超出作用域时,其中的 MyClass 对象会被自动释放。

通过深入理解 std::shared_ptr 的内存共享机制、使用方法、实现原理以及性能考量等方面,开发者可以在 C++ 编程中更加安全、高效地管理动态内存,避免常见的内存管理问题,提高代码的质量和可维护性。同时,合理地选择 std::shared_ptr 与其他智能指针(如 std::unique_ptrstd::weak_ptr)配合使用,能够更好地满足不同场景下的需求。在实际项目中,充分利用 std::shared_ptr 的特性,可以有效地管理资源和对象生命周期,提升程序的稳定性和性能。