1️⃣ 背景

C++ 中,动态内存的管理通过运算符 new/ delete 来完成的:

  • new:在动态内存中为对象分配空间并返回一个指向该对象的指针。
  • delete:接收一个动态对象的指针,销毁该对象,并释放与之相关的内存。

动态内存的使用很容易出现问题,因为确保在正确的时间释放内存是极其困难的 !!!

  • 忘记释放内存:内存泄露
  • 释放一块仍有指针引用的内存:产生引用非法内存的指针

为了更安全地使用动态内存,新标准库提供了两种智能指针来管理动态内存:

智能指针的行为类似于常规指针,区别在于它自动释放所指向的对象

  • shared_ptr:允许多个指针指向同一个对象。
  • unique_ptr:只允许单个指针独占一个对象。

2️⃣ shared_ptr 类

概念

std::shared_ptr<T> 是一个类模板,采用 RAII 手法,它的对象行为像指针,但是它可以记录有多少个对象共享它所管理的内存对象。

RAII

使用

经常通过如下两种方式创建 std::shared_ptr 对象:

  • auto p = std::shared_ptr<T>(new T)
  • auto p = std::make_shared<T>(T{})

原理

1
2
element_type*  _M_ptr;         // Contained pointer.
__shared_count<_Lp> _M_refcount; // Reference counter.

shared_ptr 在内部维护两个指针成员:

  • _M_ptr:指向所管理的对象
  • _M_refcount:指向控制块,包括引用计数(strong_count)、弱引用计数(weak_count)、删除器(Deleter)、分配器(Alloctor)

因为 shared_ptr 之间共享相同的内存对象,因此引用计数的存储位于堆上

生命周期

  • strong_count 变为 0 时,会通过指向对象的指针释放掉对象 T,导致对象 T 被析构。
  • 但是控制块的释放要看 weak_count
    • weak_count 也为 0,释放控制块。
    • weak_count 不为 0,保留控制块。

手写 std::shared_ptr<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
template <typename T>
class shared_ptr {
public:
shared_ptr(T* ptr = nullptr)
: m_ptr(ptr)
, m_refCount(new int(1))
{}

~shared_ptr() {
release();
}

shared_ptr(const shared_ptr& other)
: m_ptr(other.m_ptr)
, m_refCount(other.m_refCount)
{
(*m_refCount)++;
}

shared_ptr& operator=(const shared_ptr& other) {
if (this != &other) {
release();
m_ptr = other.m_ptr;
m_refCount = other.m_refCount;
(*m_refCount)++;
}
return *this;
}

T& operator*() const {
return *m_ptr;
}

T* operator->() const {
return m_ptr;
}

int use_count() const {
return *m_refCount;
}

private:
void release() {
(*m_refCount)--;
if (*m_refCount == 0) {
delete m_ptr;
delete m_refCount;
}
}

T* m_ptr;
int* m_refCount;
};

常考点

1️⃣ std::make_shared 的优点

  • 效率性
    • std::make_shared 通过一次内存分配,既可以同时创建对象和控制块
    • std::shared_ptr(new T(args...)) 需要进行两次内存分配才可以。多次内存分配不仅会增加时间开销,还可能导致内存碎片化,降低内存的使用效率 。
  • 安全性
    • std::make_shared对象的构造智能指针的初始化合并为一个操作,减少了显式使用 new 的需要,从而降低了因异常导致内存泄漏的风险 。

    • std::shared_ptr(new T(args...)) 安全性不高,以下代码可以进行解释:

      1
      2
      3
      4
      5
      6
      void processWidget(std::shared_ptr<MyClass> ptr,  priority()) {
      // 处理逻辑
      }

      // 代码中执行以下函数
      processWidget(std::shared_ptr<MyClass>(new Myclass), priority());
      • C++ 编译器并不一定按照固定顺序从左至右进行调用,这会导致再执行 new MyClass 之后,资源已经分配,但是此时先执行 priority(),而 priority() 中出现了异常,此时就会发生异常泄露。

2️⃣ std::make_shared 的缺点

std::make_shared 会直接分配一整块大内存,里面存放着控制块,对象 T。

  • strong_count 为 0 时:
    • 对象的析构函数会执行
    • 但是控制块不可以释放
    • 由于对象和控制块在同一块内存,这块内存就不能被 free,导致包含对象大小的内存仍然不可使用。

3️⃣ shared_ptr 的线程安全问题

  • 针对引用计数更新, 线程安全

    这里只讨论对 shared_ptr 进行拷贝的情况

    由于此操作读写的是引用计数,而引用计数的更新是原子操作,因此这种情况是线程安全的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    std::shared_ptr<int> p = std::make_shared<int>(0);
    constexpr int N = 10000;
    std::vector<std::shared_ptr<int>> sp_arr1(N);
    std::vector<std::shared_ptr<int>> sp_arr2(N);

    void increment_count(std::vector<std::shared_ptr<int>>& sp_arr) {
    for (int i = 0; i < N; i++) {
    sp_arr[i] = p;
    }
    }

    std::thread t1(increment_count, std::ref(sp_arr1));
    std::thread t2(increment_count, std::ref(sp_arr2));
    t1.join();
    t2.join();
    std::cout<< p.use_count() << std::endl; // always 20001
  • 针对修改内存区域,线程不安全

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    std::shared_ptr<int> p = std::make_shared<int>(0);
    void modify_memory() {
    for (int i = 0; i < 10000; i++) {
    (*p)++;
    }
    }

    std::thread t1(modify_memory);
    std::thread t2(modify_memory);
    t1.join();
    t2.join();
    std::cout << "Final value of p: " << *p << std::endl; // possible result: 16171, not 20000
  • 针对 shared_ptr 对象的指向,线程不安全

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    std::shared_ptr<int> sp = std::make_shared<int>(1);
    auto modify_sp_self = [&sp]() {
    for (int i = 0; i < 1000000; ++i) {
    sp = std::make_shared<int>(i);
    }
    };

    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
    threads.emplace_back(modify_sp_self);
    }
    for (auto& t : threads) {
    t.join();
    }

4️⃣ std::enabled_shared_from_this

场景

当前类的成员函数通过该类的 this 的指针创建一个针对于当前类的 shared_ptr 对象,即 std::shared_ptr<当前类>(this)

当前类必须继承 std::enabled_shared_from_this,并且提供 shared_from_this 函数。

后果

否则会发生 double-free 问题。

代码示例

1
2
3
4
5
6
class A : public std::enable_shared_from_this<A> {
public:
void func() {
shared_from_this();
}
}

3️⃣ weak_ptr 类

概念

std::weak_ptr<T> 也是一个类模板,它是一种弱引用,指向 shared_ptr 所管理的对象,而不影响所指对象的生命周期。

这表明将 weak_ptr 绑定到一个 shared_ptr,不会改变 shared_ptr 的引用计数。不论是否有 weak_ptr 指向,一旦最后一个指向对象的 shared_ptr 被销毁,对象就会被释放。

相关操作

1️⃣ 读取引用对象

由于 weak_ptr 对它所绑定的 shared_ptr 所指向的对象是没有所有权的,因此不可以通过 weak_ptr 来进行解引用得到 shared_ptr 所指向的对象。这就导致若想要读取到引用对象,必须将 weak_ptr 转换成 shared_ptr

C++ 中提供了 lock 函数来实现该功能。

  • 如果对象存在,lock() 函数返回一个指向共享对象的 shared_ptr
  • 如果对象不存在,返回一个空 shared_ptr

2️⃣ 判断 weak_ptr 指向的对象是否存在

weak_ptr 提供了一个成员函数 expired() 来判断所指对象是否已经被释放。

  • 如果所指对象已被释放,expired() 返回 true
  • 如果所指对象未被释放,expired() 返回 false。

3️⃣ 作为 shared_ptr 构造函数的参数

std::weak_ptr 可以作为 std::shared_ptr 的构造函数参数,但如果std::weak_ptr 指向的对象已经被释放,那么 std::shared_ptr 的构造函数会抛出 std::bad_weak_ptr 异常。

使用场景

用于缓存

weak_ptr 可以用来缓存对象,当对象被销毁时,weak_ptr 也会自动失效,不会造成野指针。

假设我们有一个 Widget 类,我们需要从文件中加载 Widget 对象,但是 Widget 对象的加载是比较耗时的。下面这个代码,表示通过文件描述符 fd 加载一个 Widget 对象

1
std::shared_ptr<Widget> loadWidgetFromFile(int id); 

针对于代码优化,我们希望 Widget 对象可以缓存起来,当下次需要 Widget 对象时,可以直接从缓存中获取,而不需要重新加载。

这个时候,我们就可以使用 std::weak_ptr 来缓存 Widget 对象,实现快速访问。如以下代码所示:

1
2
3
4
5
6
7
8
9
std::shared_ptr<Widget> fastLoadWidget(int id) {
static std::unordered_map<int, std::weak_ptr<Widget>> cache;
auto objPtr = cache[id].lock();
if (!objPtr) {
objPtr = loadWidgetFromFile(id);
cache[id] = objPtr; // use std::shared_ptr to construct std::weak_ptr
}
return objPtr;
}
  • 当对应 id 的 Widget 对象已经被缓存时,cache[id].lock() 会返回一个指向Widget 对象的 std::shared_ptr
  • 否则,cache[id].lock()会返回一个空的 std::shared_ptr,这就需要重新加载 Widget 对象。

为什么不直接存储 std::shared_ptr 呢?

因为直接存储 std::shared_ptr 会导致缓存中的对象永远不会被销毁。

  • 这会导致 std::shared_ptr 的引用计数永远不会为 0。

所以使用 std::weak_ptr 不会增加对象的引用计数,因此,当缓存中的对象没有被其他地方引用时,std::weak_ptr 会自动失效,从而导致缓存中的对象被销毁。

避免循环引用

循环引用的定义

循环引用是指两个或多个对象之间通过 shared_ptr 相互引用,形成了一个环,导致它们的引用计数都不为 0,从而导致内存泄漏。

4️⃣ unique_ptr 类

概念

unique_ptr<T> 也是智能指针,同时也是一个类模板,它主要表示一种 “独占所有权” 的概念,表示不允许其他 unique_ptr 指向它所指向的对象。

原理

独占权

std::unique_ptr 独占性的实现主要依赖于禁止拷贝构造以及拷贝赋值运算符

1
2
unique_ptr(const unique_ptr&) = delete;
uniue_ptr& operator=(const unique_ptr&) = delete;

控制权转移

通过 std::move() 将当前 unique_ptr 对资源的所有权转移给另一个 unique_ptr

转移后,原来的 unique_ptr 将不再拥有对内存的控制权,将变为空指针。

1
2
std::unique_ptr<int> p1 = std::make_unique<int>(0);
std::unique_ptr<int> p2 = std::move(p1);

相关操作

通过 std::make_unique 创建 unique_ptr

这是一种更加异常安全的做法,同理于 std::make_shared

释放所有权

通过 release 函数返回对象的裸指针

1
2
3
4
unique_ptr<int> p1 = make_unique<int>(1);
int* a = p1.release();
std::cout<<*a<<std::endl;
delete a;

重置所有权

通过 reset 函数释放所有权,并返回 nullptr

1
2
3
unique_ptr<int> p1 = make_unique<int>(1);
p1.reset();
std::cout<<p1.get()<<std::endl;

参考资料