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

C++ std::shared_ptr 的引用计数原理

2023-05-305.4k 阅读

C++ std::shared_ptr 的引用计数原理

什么是 std::shared_ptr

在C++ 中,std::shared_ptr 是智能指针的一种,它提供了一种自动管理动态分配对象生命周期的机制。std::shared_ptr 通过引用计数的方式来决定所指向对象的销毁时机。当指向某个对象的最后一个 std::shared_ptr 被销毁时,该对象也会被自动释放。这种机制极大地简化了内存管理,有效地避免了内存泄漏等问题。

引用计数的基本概念

引用计数是一种跟踪对象被引用次数的技术。对于 std::shared_ptr 而言,每个 std::shared_ptr 对象都维护着一个引用计数,该计数记录了当前有多少个 std::shared_ptr 对象指向同一个动态分配的对象。每当创建一个新的 std::shared_ptr 指向某个对象时,引用计数会增加;而当一个 std::shared_ptr 被销毁(例如超出作用域)时,引用计数会减少。当引用计数变为 0 时,意味着没有任何 std::shared_ptr 再指向该对象,此时该对象就会被释放。

引用计数的实现方式

  1. 独立的控制块
    • std::shared_ptr 通常通过一个独立的控制块来管理引用计数。这个控制块不仅包含引用计数,还可能包含其他信息,例如弱引用计数(用于 std::weak_ptr,后面会详细介绍)等。当使用 std::make_shared 或者直接通过 std::shared_ptr 的构造函数来创建一个 std::shared_ptr 时,会同时分配一个控制块。
    • 例如,假设有以下代码:
#include <iostream>
#include <memory>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructor" << std::endl; }
    ~MyClass() { std::cout << "MyClass destructor" << std::endl; }
};

int main() {
    std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>();
    std::shared_ptr<MyClass> ptr2 = ptr1;
    return 0;
}
  • 在上述代码中,std::make_shared<MyClass>() 不仅分配了 MyClass 对象的内存,还创建了一个控制块。ptr1 指向 MyClass 对象,并且控制块中的引用计数初始化为 1。当 std::shared_ptr<MyClass> ptr2 = ptr1; 执行时,ptr2 也指向了同一个 MyClass 对象,控制块中的引用计数增加到 2。当 ptr1ptr2 超出作用域时,引用计数会依次减少,当引用计数变为 0 时,MyClass 对象和控制块都会被释放。
  1. 控制块的内存分配
    • 控制块的内存分配方式有多种。在 std::make_shared 的情况下,控制块和对象的内存通常是在一次内存分配中完成的,这被称为“一次分配”策略。这种策略可以提高内存分配效率,减少内存碎片。
    • 例如:
#include <iostream>
#include <memory>

class BigObject {
    char data[1024 * 1024];
public:
    BigObject() { std::cout << "BigObject constructor" << std::endl; }
    ~BigObject() { std::cout << "BigObject destructor" << std::endl; }
};

int main() {
    auto sp1 = std::make_shared<BigObject>();
    return 0;
}
  • 这里,std::make_shared<BigObject>() 会分配一块足够大的内存,既包含 BigObject 对象本身的内存,也包含控制块的内存。这种方式相比于分别分配对象和控制块的内存,减少了内存分配的次数,提高了效率。

  • 而当通过 std::shared_ptr 的构造函数直接传递一个裸指针时,可能会采用不同的内存分配策略。例如:

#include <iostream>
#include <memory>

class SmallObject {
public:
    SmallObject() { std::cout << "SmallObject constructor" << std::endl; }
    ~SmallObject() { std::cout << "SmallObject destructor" << std::endl; }
};

int main() {
    SmallObject* rawPtr = new SmallObject();
    std::shared_ptr<SmallObject> sp2(rawPtr);
    return 0;
}
  • 在这种情况下,对象和控制块的内存可能是分开分配的。这种灵活性在某些场景下是必要的,例如当你需要对对象的内存分配进行特殊控制时。

引用计数的线程安全性

  1. 基本线程安全特性
    • std::shared_ptr 的引用计数操作在多线程环境下是基本线程安全的。这意味着在不同线程中对同一个 std::shared_ptr 进行引用计数的增减操作不会导致数据竞争。例如:
#include <iostream>
#include <memory>
#include <thread>

class ThreadSafeClass {
public:
    ThreadSafeClass() { std::cout << "ThreadSafeClass constructor" << std::endl; }
    ~ThreadSafeClass() { std::cout << "ThreadSafeClass destructor" << std::endl; }
};

void threadFunction(std::shared_ptr<ThreadSafeClass>& sharedPtr) {
    std::shared_ptr<ThreadSafeClass> localPtr = sharedPtr;
    // 这里可以对 localPtr 进行操作,引用计数的增减是线程安全的
}

int main() {
    std::shared_ptr<ThreadSafeClass> globalPtr = std::make_shared<ThreadSafeClass>();
    std::thread t1(threadFunction, std::ref(globalPtr));
    std::thread t2(threadFunction, std::ref(globalPtr));
    t1.join();
    t2.join();
    return 0;
}
  • 在上述代码中,t1t2 线程都对 globalPtr 进行操作,std::shared_ptr 的引用计数操作保证了不会出现数据竞争问题。
  1. 注意事项
    • 然而,虽然引用计数操作是线程安全的,但对 std::shared_ptr 所指向对象的访问本身并不是线程安全的。如果多个线程同时访问 std::shared_ptr 所指向对象的成员变量或成员函数,可能会导致数据竞争。例如:
#include <iostream>
#include <memory>
#include <thread>

class UnsafeClass {
public:
    int value;
    UnsafeClass() : value(0) { std::cout << "UnsafeClass constructor" << std::endl; }
    ~UnsafeClass() { std::cout << "UnsafeClass destructor" << std::endl; }
};

void accessObject(std::shared_ptr<UnsafeClass>& sharedPtr) {
    for (int i = 0; i < 1000; ++i) {
        sharedPtr->value++;
    }
}

int main() {
    std::shared_ptr<UnsafeClass> globalPtr = std::make_shared<UnsafeClass>();
    std::thread t1(accessObject, std::ref(globalPtr));
    std::thread t2(accessObject, std::ref(globalPtr));
    t1.join();
    t2.join();
    std::cout << "Final value: " << globalPtr->value << std::endl;
    return 0;
}
  • 在这个例子中,t1t2 线程同时对 UnsafeClass 对象的 value 成员变量进行递增操作,由于没有同步机制,这会导致数据竞争,最终 value 的值可能并不是预期的 2000。为了避免这种情况,需要使用同步机制,如互斥锁等。

引用计数与循环引用

  1. 循环引用的产生
    • 循环引用是 std::shared_ptr 使用中可能出现的一个问题。当两个或多个 std::shared_ptr 对象相互引用,形成一个环时,就会产生循环引用。例如:
#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> ptrToB;
    A() { std::cout << "A constructor" << std::endl; }
    ~A() { std::cout << "A destructor" << std::endl; }
};

class B {
public:
    std::shared_ptr<A> ptrToA;
    B() { std::cout << "B constructor" << std::endl; }
    ~B() { std::cout << "B destructor" << std::endl; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();
    a->ptrToB = b;
    b->ptrToA = a;
    return 0;
}
  • 在上述代码中,a 指向 A 对象,b 指向 B 对象,然后 a->ptrToB 使得 A 对象中的 std::shared_ptr 指向 B 对象,b->ptrToA 使得 B 对象中的 std::shared_ptr 指向 A 对象,形成了循环引用。
  1. 循环引用的影响

    • 由于循环引用的存在,A 对象和 B 对象的引用计数永远不会变为 0。当 ab 超出作用域时,A 对象和 B 对象的引用计数只会从 2 减到 1,因为它们相互引用。这就导致 AB 对象无法被释放,从而造成内存泄漏。
  2. 解决循环引用的方法 - 使用 std::weak_ptr

    • std::weak_ptr 是解决循环引用问题的有效手段。std::weak_ptr 不增加引用计数,它只是观察 std::shared_ptr 所指向的对象。当 std::shared_ptr 所指向的对象被释放时,std::weak_ptr 会自动变为空。
    • 对上述代码进行修改,使用 std::weak_ptr 来打破循环引用:
#include <iostream>
#include <memory>

class B;

class A {
public:
    std::weak_ptr<B> ptrToB;
    A() { std::cout << "A constructor" << std::endl; }
    ~A() { std::cout << "A destructor" << std::endl; }
};

class B {
public:
    std::weak_ptr<A> ptrToA;
    B() { std::cout << "B constructor" << std::endl; }
    ~B() { std::cout << "B destructor" << std::endl; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();
    a->ptrToB = b;
    b->ptrToA = a;
    return 0;
}
  • 在这个修改后的代码中,A 中的 ptrToBB 中的 ptrToA 都变为 std::weak_ptr。当 ab 超出作用域时,AB 对象的引用计数会减为 0,从而被正确释放,避免了内存泄漏。

引用计数与自定义删除器

  1. 自定义删除器的概念

    • std::shared_ptr 允许用户提供自定义删除器。默认情况下,当引用计数变为 0 时,std::shared_ptr 会调用 delete 来释放所指向的对象。但在某些情况下,需要使用自定义的释放逻辑。例如,当对象是通过 malloc 分配的,就需要使用 free 来释放;或者当对象涉及到资源管理(如文件句柄、数据库连接等),需要特殊的关闭操作。
    • 自定义删除器是一个可调用对象(函数指针、函数对象或 lambda 表达式),它接受一个指向对象的指针,并负责释放该对象。
  2. 使用自定义删除器的示例 - 函数指针

#include <iostream>
#include <memory>

void customDelete(void* ptr) {
    std::cout << "Custom delete function" << std::endl;
    free(ptr);
}

int main() {
    void* rawPtr = std::malloc(100);
    std::shared_ptr<void> sp(rawPtr, customDelete);
    return 0;
}
  • 在上述代码中,std::shared_ptr<void> sp(rawPtr, customDelete); 使用了自定义删除器 customDelete。当 sp 超出作用域,引用计数变为 0 时,会调用 customDelete 函数来释放 rawPtr 所指向的内存。
  1. 使用自定义删除器的示例 - lambda 表达式
#include <iostream>
#include <memory>
#include <fstream>

class FileWrapper {
public:
    std::fstream file;
    FileWrapper(const std::string& filename) : file(filename, std::ios::out) {
        if (!file) {
            std::cerr << "Failed to open file" << std::endl;
        }
    }
};

int main() {
    std::shared_ptr<FileWrapper> filePtr(new FileWrapper("test.txt"), [](FileWrapper* ptr) {
        ptr->file.close();
        delete ptr;
    });
    // 在 filePtr 超出作用域时,会调用 lambda 表达式中的自定义删除逻辑
    return 0;
}
  • 这里,std::shared_ptr<FileWrapper> 使用了一个 lambda 表达式作为自定义删除器。当 filePtr 超出作用域时,lambda 表达式会先关闭文件,然后释放 FileWrapper 对象。

引用计数的性能考量

  1. 引用计数操作的开销
    • 虽然 std::shared_ptr 极大地简化了内存管理,但引用计数操作本身是有开销的。每次 std::shared_ptr 的构造、赋值或销毁操作,都需要对引用计数进行原子操作(以保证线程安全)。这些原子操作在性能敏感的场景下可能会成为瓶颈。
    • 例如,在一个频繁创建和销毁 std::shared_ptr 的循环中:
#include <iostream>
#include <memory>
#include <chrono>

class SimpleClass {
public:
    SimpleClass() { }
    ~SimpleClass() { }
};

int main() {
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; ++i) {
        std::shared_ptr<SimpleClass> sp = std::make_shared<SimpleClass>();
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Duration: " << duration << " ms" << std::endl;
    return 0;
}
  • 在这个例子中,频繁的引用计数操作会增加循环执行的时间。如果对性能要求极高,可以考虑使用其他内存管理方式,如对象池等。
  1. 减少引用计数开销的方法
    • 一种减少引用计数开销的方法是尽量减少 std::shared_ptr 的创建和销毁次数。例如,可以使用对象池来复用对象,而不是每次都创建新的 std::shared_ptr。另外,在性能敏感的代码段中,避免不必要的 std::shared_ptr 赋值操作,因为这也会导致引用计数的变化。

    • 例如,假设有一个对象池的实现:

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

class Object {
public:
    Object() { std::cout << "Object constructor" << std::endl; }
    ~Object() { std::cout << "Object destructor" << std::endl; }
};

class ObjectPool {
private:
    std::queue<std::shared_ptr<Object>> pool;
    std::vector<std::shared_ptr<Object>> allObjects;
public:
    ObjectPool(int initialSize) {
        for (int i = 0; i < initialSize; ++i) {
            auto obj = std::make_shared<Object>();
            pool.push(obj);
            allObjects.push_back(obj);
        }
    }

    std::shared_ptr<Object> getObject() {
        if (pool.empty()) {
            auto newObj = std::make_shared<Object>();
            allObjects.push_back(newObj);
            return newObj;
        }
        auto obj = pool.front();
        pool.pop();
        return obj;
    }

    void returnObject(std::shared_ptr<Object> obj) {
        pool.push(obj);
    }
};

int main() {
    ObjectPool pool(10);
    auto obj1 = pool.getObject();
    auto obj2 = pool.getObject();
    pool.returnObject(obj1);
    return 0;
}
  • 在这个对象池的实现中,通过复用 std::shared_ptr 对象,减少了引用计数的创建和销毁次数,从而在一定程度上提高了性能。

引用计数与动态类型转换

  1. 使用 std::dynamic_pointer_cast 进行动态类型转换
    • 在使用 std::shared_ptr 时,有时需要进行动态类型转换。std::dynamic_pointer_cast 可以实现这一功能。它不仅进行类型转换,还会正确处理引用计数。
    • 例如,假设有一个继承体系:
#include <iostream>
#include <memory>

class Base {
public:
    virtual void print() { std::cout << "Base" << std::endl; }
    virtual ~Base() { }
};

class Derived : public Base {
public:
    void print() override { std::cout << "Derived" << std::endl; }
};

int main() {
    std::shared_ptr<Base> basePtr = std::make_shared<Derived>();
    std::shared_ptr<Derived> derivedPtr = std::dynamic_pointer_cast<Derived>(basePtr);
    if (derivedPtr) {
        derivedPtr->print();
    }
    return 0;
}
  • 在上述代码中,std::dynamic_pointer_cast<Derived>(basePtr)basePtrstd::shared_ptr<Base> 转换为 std::shared_ptr<Derived>。如果转换成功,derivedPtr 会指向 Base 指针所指向的 Derived 对象,并且引用计数会被正确处理。
  1. 动态类型转换与引用计数的关系
    • std::dynamic_pointer_cast 的实现会保持原 std::shared_ptr 的引用计数不变。如果转换成功,新的 std::shared_ptr 会与原 std::shared_ptr 共享控制块,引用计数增加。如果转换失败,会返回一个空的 std::shared_ptr,不会影响原 std::shared_ptr 的引用计数。

    • 例如:

#include <iostream>
#include <memory>

class Base {
public:
    virtual void print() { std::cout << "Base" << std::endl; }
    virtual ~Base() { }
};

class Derived1 : public Base {
public:
    void print() override { std::cout << "Derived1" << std::endl; }
};

class Derived2 : public Base {
public:
    void print() override { std::cout << "Derived2" << std::endl; }
};

int main() {
    std::shared_ptr<Base> basePtr = std::make_shared<Derived1>();
    std::shared_ptr<Derived2> derived2Ptr = std::dynamic_pointer_cast<Derived2>(basePtr);
    if (derived2Ptr) {
        derived2Ptr->print();
    } else {
        std::cout << "Dynamic cast failed" << std::endl;
    }
    return 0;
}
  • 在这个例子中,std::dynamic_pointer_cast<Derived2>(basePtr) 由于 basePtr 实际指向的是 Derived1 对象,转换失败,derived2Ptr 为空,basePtr 的引用计数不受影响。

引用计数在实际项目中的应用场景

  1. 模块间对象传递
    • 在大型项目中,不同模块之间经常需要传递对象。使用 std::shared_ptr 可以方便地管理对象的生命周期,避免在模块边界处出现内存泄漏。例如,一个图形渲染模块可能会将一个渲染资源对象传递给另一个特效处理模块。
#include <iostream>
#include <memory>

class RenderResource {
public:
    RenderResource() { std::cout << "RenderResource constructor" << std::endl; }
    ~RenderResource() { std::cout << "RenderResource destructor" << std::endl; }
};

class EffectModule {
public:
    void processEffect(std::shared_ptr<RenderResource> resource) {
        // 处理特效
        std::cout << "Processing effect with resource" << std::endl;
    }
};

class RenderModule {
public:
    std::shared_ptr<RenderResource> createResource() {
        return std::make_shared<RenderResource>();
    }
};

int main() {
    RenderModule renderModule;
    EffectModule effectModule;
    auto resource = renderModule.createResource();
    effectModule.processEffect(resource);
    return 0;
}
  • 在这个例子中,RenderModule 创建了一个 RenderResource 对象,并通过 std::shared_ptr 传递给 EffectModule。两个模块都不需要关心资源的释放,std::shared_ptr 会自动管理其生命周期。
  1. 容器中存储对象
    • 当在容器(如 std::vectorstd::list 等)中存储对象时,std::shared_ptr 可以保证对象在容器中的生命周期管理。例如,一个游戏开发项目中,可能有一个容器存储各种游戏对象:
#include <iostream>
#include <memory>
#include <vector>

class GameObject {
public:
    GameObject() { std::cout << "GameObject constructor" << std::endl; }
    ~GameObject() { std::cout << "GameObject destructor" << std::endl; }
};

int main() {
    std::vector<std::shared_ptr<GameObject>> gameObjects;
    gameObjects.push_back(std::make_shared<GameObject>());
    gameObjects.push_back(std::make_shared<GameObject>());
    // 当 gameObjects 超出作用域时,所有 GameObject 对象会被正确释放
    return 0;
}
  • 在这个例子中,std::vector<std::shared_ptr<GameObject>> 存储了 GameObject 对象的 std::shared_ptr。当 gameObjects 超出作用域时,std::shared_ptr 的引用计数机制会确保所有 GameObject 对象被正确释放。
  1. 资源管理
    • 对于一些需要特殊资源管理的对象,如数据库连接、网络套接字等,std::shared_ptr 结合自定义删除器可以有效地管理资源的生命周期。例如:
#include <iostream>
#include <memory>
#include <mysql/mysql.h>

class MySQLConnection {
public:
    MYSQL* conn;
    MySQLConnection() {
        conn = mysql_init(nullptr);
        if (!conn) {
            std::cerr << "mysql_init() failed" << std::endl;
        }
    }
    ~MySQLConnection() {
        mysql_close(conn);
    }
};

void customMySQLDelete(MySQLConnection* conn) {
    mysql_close(conn->conn);
    delete conn;
}

int main() {
    std::shared_ptr<MySQLConnection> mysqlConn(new MySQLConnection(), customMySQLDelete);
    // 使用 mysqlConn 进行数据库操作
    return 0;
}
  • 在这个例子中,std::shared_ptr<MySQLConnection> 使用自定义删除器 customMySQLDelete 来正确关闭 MySQL 连接并释放对象。这样可以确保在 std::shared_ptr 生命周期结束时,数据库连接资源被正确释放。

通过深入理解 std::shared_ptr 的引用计数原理,开发者可以更加有效地使用它来管理内存和资源,避免常见的内存管理问题,提高程序的稳定性和性能。在实际项目中,根据不同的场景合理运用 std::shared_ptr,结合自定义删除器、线程同步等技术,可以构建出高效、可靠的 C++ 程序。同时,要注意 std::shared_ptr 的性能开销以及循环引用等问题,在合适的地方采用优化策略和解决方案,以满足项目的需求。