内存泄漏、悬空指针、双重释放?C++智能指针一键搞定

0 阅读12分钟

今天我们聊聊在C++世界中一个不可避免的问题----内存管理。
想想那些年,你因为忘记delete而漏掉的内存,它们是不是在夜深人静时化作幽灵,在你的程序里飘荡,随着程序的持续运行,被泄漏的内存不断堆积,最终可能导致系统内存枯竭,程序无奈崩溃。所以如何避免资源的泄漏是我们必须面对的挑战。

原生指针的问题

在C++中,资源的申请与释放通常是手动管理的。比如new分配内存,delete释放内存。但因为一些疏忽或者意外可能会出现各种问题。

内存泄漏

  • 你和你的心爱之人表白,他/她同意了。(你new了一个对象)
  • 你去买水,你对象说在沙滩上等你。(对象在堆上)
  • 但你买完水直接回家了。(忘记delete)
  • 你把你的对象忘在了沙滩上。(未释放动态分配的内存,导致内存泄漏)

悬空指针

你把自己家的地址告诉了朋友,然后有天你想不开把自己家给拆了(delete),别人顺着你给的地址欢天喜地的来到你家,一进门...噗通一下掉坑里了。
简单的代码示例:

int main()
{
   int* ptr1 = new int(5);
   int* ptr2 = ptr1;
 
   delete ptr1;
   ptr1 = nullptr;
    
   std::cout << *ptr2 << std::endl;
   return 0;
}

上述代码中ptr1和ptr2指向同一块地址,然后这块地址被释放了,但ptr2还指向这块地址,导致指针指向已释放或未初始化的内存。

双重释放

int main()
{
   int* ptr1 = new int(5);
   int* ptr2 = ptr1; //p1和p2指向同一块内存

   delete ptr1; //第一次释放
   delete ptr2; //第二次释放,但p2指向的内存早已被释放

   return 0;
}

多个原始指针共享同一块动态内存时,若其中一个执行了delete,其他指针仍指向原地址,若再对它们执行delete则导致双重释放。

智能指针

为了解决这些问题,C++11引入了智能指针。智能指针利用RAII(Resource Acquisition Is Initialization)机制,将内存的管理和对象的生命周期绑定在一起。智能指针在构造时获取内存,在析构时自动释放,从而避免手动调用delete。

C++中的智能指针均定义在头文件中:

  • std::unique_ptr
  • std::shared_ptr
  • std::weak_ptr

std::unique_ptr

unique_ptr是C++11引入的智能指针,它是一种独占所有权的智能指针,保证同一时间内只能有一个 unique_ptr 实例拥有对某个对象的所有权。不能被拷贝,只能被移动,这是为了确保资源的独占性。

int main()
{
   std::unique_ptr<intu_ptr1(new int(5))// 指向int类型的unique指针
   std::unique_ptr<double> u_ptr2 = std::make_unique<double>(9.21); // 指向double类型的unique指针
    
   // std::unique_ptr<int> u_ptr3 = u_ptr1; // 不支持拷贝
   std::unique_ptr<int> u_ptr4 = std::move(u_ptr1); // 支持移动
    
   return 0;
}

在这段代码中我们直接使用new来初始化u1,在复杂表达式中(例如函数参数)可能导致内存泄漏,使用make_unique初始化u2可以确保如果发生异常,资源会被正确清理,不会泄漏。
我们尝试将u1赋值给u3而导致编译器报错,但我们可以通过std::move将u1的所有权交给u4,u1变为空指针。

除了使用std::move移交所有权,我们还可以使用release或reset将指针的所有权移交给另一个unique指针。

int main()
{
   std::unique_ptr<intu_ptr1(new int(5))// 指向int类型的unique指针
   std::unique_ptr<intu_ptr2(u_ptr1.release())// release返回u1的内置指针,并将u1置空
 
   std::unique_ptr<intu_ptr3(new int(6));
   u_ptr3.reset(u_ptr2.release()); // reset放弃原来的内置指针,指向u2的内置指针

   return 0;
}

unique_ptr有一个成员方法release,能够返回其内置指针,并将其置空。上述代码中我们将u1的内置指针移交给了u2;同样的,我们先放弃u3自己的内置指针,然后指向u2的内置指针。

unique_ptr不能被拷贝,但是有一个例外情况:可以作为函数返回值,将资源所有权移交给调用者。

std::unique_ptr<intcopy_unique(int val)
{
   return std::unique_ptr<int>(new int(val));
}

int main()
{
   int value = 10;
   std::unique_ptr<int> u_ptr1 = copy_unique(value);
   std::cout << *u_ptr1 << std::endl;

   return 0;
}

在上述代码中,copy_unique函数返回std::unique_ptr,将资源所有权移交给u1。

std::shared_ptr

shared_ptr是一种共享所有权的智能指针,允许多个shared_ptr实例共享对同一个对象的所有权。通过引用计数机制来管理资源的生命周期。

当一个shared_ptr指向对象时,引用计数加1;当shared_ptr离开作用域或被重新赋值时,引用计数减1,当引用计数为0时,对象会被自动释放。

int main()
{
   std::shared_ptr<ints_ptr1(new int(5));
   std::shared_ptr<int> s_ptr2 = s_ptr1; // 将s1赋值给s2,引用计数+1

   // 打印引用计数
   std::cout << "s1 use_count : " << s_ptr1.use_count() << std::endl;
   std::cout << "s2 use_count : " << s_ptr2.use_count() << std::endl;

   s_ptr2 = std::shared_ptr<int>(new int(6)); // s2引用计数为1
   std::cout << "s2 use_count : " << s_ptr2.use_count() << std::endl;

   return 0;
}

程序输出:

s1 use_count : 2
s2 use_count : 2
s2 use_count : 1

在这段代码中s1和s2共享一个对象,引用计数为2,当s2指向新的对象后,引用计数减1。

上述代码都是通过new生成的内置指针初始化生成shared_ptr,还可以使用make_shared直接构造shared_ptr对象,性能更高,异常安全性更强。

struct Data
{
   int m_age;
   std::string m_name;
   Data(int age, std::string name) :m_age(age), m_name(name) {}
   ~Data() { std::cout << "~Data()" << std::endl; }
};

int main()
{
   std::shared_ptr<Data> s_ptr1 = std::make_shared<Data>(11"xingxing");
   std::cout << s_ptr1->m_name << std::endl;

   return 0;
}

程序输出:

xingxing
~Data()

使用make_shared能够避免两次内存分配,构造时更安全。

std::weak_ptr

std::weak_ptr是一种不拥有对象所有权的智能指针,用于观察但不影响对象的生命周期。主要用于解决shared_ptr之间的循环引用问题。

我们将一个weak_ptr绑定到一个shared_ptr上时,不会改变shared_ptr的引用计数,weak_ptr就相当于一个观察者;并且当weak_ptr指定到一个对象时,那个对象依然会被释放掉,所以它具有弱共享对象的特点。

int main()
{
   auto s_ptr1 = std::make_shared<int>(5);// 创建一个shared_ptr
   std::weak_ptr<intw_ptr(s_ptr1);// 用s1构造一个weak_ptr

   std::cout << "s1 use_cout is: " << s_ptr1.use_count() << std::endl;
    
   return 0;
}

程序输出:

s1 use_cout is1

上述代码中因为w不会改变s1的引用计数,所以引用计数为1。

weak_ptr不仅提供了reset(),use_count()等方法,还提供了expired()方法。该方法在use_count等于0时返回true,否则返回false。所以我们可以使用expired()方法来判断weak_ptr的内置指针是否被释放:

std::weak_ptr<intcreate_weakptr(int num)
{
   std::shared_ptr<int> s_ptr = std::make_shared<int>(num);
   return std::weak_ptr<int>(s_ptr);
}

int main()
{
   std::weak_ptr<int> w_ptr = create_weakptr(5);

   if (w_ptr.expired())
      std::cout << "The w_ptr constructed by s_ptr has been deleted." << std::endl;
   else
      std::cout << "s_ptr has not been deleted." << std::endl;
 
   return 0;
}

最后的输出结果为“The w_ptr constructed by s_ptr has been deleted.”。因为create_weakptr函数返回的是一个局部变量,随着create_weakptr函数的结束而被释放,所以expired()为true。

我们使用weak_ptr时,因为其本身不控制对象的生命周期,可以通过lock()方法返回一个shared_ptr,这样就能安全的访问对象了。如果对象已经被释放,lock方法会返回一个空的shared_ptr。

int main()
{
      std::weak_ptr<int> w_ptr;

      {
          auto s_ptr1 = std::make_shared<int>(42);
          w_ptr = s_ptr1;                  

          if (auto s_ptr2 = w_ptr.lock())// 对象存在,返回有效的 shared_ptr
          {    
              std::cout << "s_ptr2 value: " << *s_ptr2 << std::endl;
              std::cout << "s_ptr2 use_count is: " << s_ptr2.use_count() << std::endl;
          }
          else 
          {
              std::cout << "object has been deleted.\n";
          }
      } // s1 离开作用域,对象被释放
      std::cout << "s1 goes out of scope." << std::endl;

      if (auto s_ptr2 = w_ptr.lock()) // 对象已释放,返回空shared_ptr
      {         
          std::cout << "s_ptr2 value: " << *s_ptr2 << std::endl;
      }
      else 
      {
          std::cout << "object has been deleted." << std::endl;
      }

   return 0;
}

程序输出:

s_ptr2 value42
s_ptr2 use_count is2
s1 goes out of scope.
object has been deleted.

在这段代码中,w_ptr是指向s1所管理对象的弱引用,我们通过w_ptr.lock()可以将其提升为shared_ptr,在s1未被释放时,lock函数返回的shared_ptr可以正常访问对象;当s1被释放后,lock函数返回的shared_ptr为空。

循环引用问题

shared_ptr通过引用计数来管理对象的生命周期:每当一个新的shared_ptr指向对象时,引用计数加1;每当一个shared_ptr被销毁或重置时,引用计数减1;当引用计数变为0时,对象被自动删除。
循环引用发生的情况是:对象 A 持有一个指向对象 B 的 shared_ptr,同时对象 B 也持有一个指向对象 A 的 shared_ptr(或者通过更多对象形成环状结构)。此时:

  • A 的引用计数因为 B 持有它而至少为1。
  • B 的引用计数因为 A 持有它而至少为1。
  • 即使所有外部 shared_ptr 都已释放,A 和 B 仍然互相持有,引用计数无法降为0,它们的析构函数永远不会被调用,导致内存泄漏。

代码示例:

#include <iostream>
#include <memory>

class B; // 声明
class A
{
public:
   std::shared_ptr<B> ptrB;
   A() { std::cout << "A Construct" << std::endl; }
   ~A() { std::cout << "A Destruct" << std::endl; }
};

class B
{
public:
   std::shared_ptr<A> ptrA;
   B() { std::cout << "B Construct" << std::endl; }
   ~B() { std::cout << "B Destruct" << std::endl; }
};

int main()
{
   {
      std::shared_ptr<A> a = std::make_shared<A>();
      std::shared_ptr<B> b = std::make_shared<B>();
        
      a->ptrB = b;
      b->ptrA = a;
        
      std::cout << "a use_count is: " << a.use_count() << std::endl;
      std::cout << "b use_count is: " << b.use_count() << std::endl;
   }
   std::cout << "main exit" << std::endl;

   return 0;
}

程序输出:

A Construct
B Construct
a use_count is2
b use_count is2
main exit

在上述代码中,当a和b离开作用域后,A和B的析构函数没有被调用。这是因为A和B相互引用,形成了循环引用,导致a和b的引用计数永远不会为 0,对象无法被释放。

我们可以把A和B其中一方改为weak_ptr,就可以打破循环引用,使对象能够正常释放 。

#include <iostream>
#include <memory>

class B; // 声明
class A
{
public:
   std::shared_ptr<B> ptrB;
   A() { std::cout << "A Construct" << std::endl; }
   ~A() { std::cout << "A Destruct" << std::endl; }
};

class B
{
public:
   std::weak_ptr<A> ptrA;
   B() { std::cout << "B Construct" << std::endl; }
   ~B() { std::cout << "B Destruct" << std::endl; }
};

int main()
{
   {
      std::shared_ptr<A> a = std::make_shared<A>();
      std::shared_ptr<B> b = std::make_shared<B>();
      a->ptrB = b;
      b->ptrA = a;
      std::cout << "a use_count is: " << a.use_count() << std::endl;
      std::cout << "b use_count is: " << b.use_count() << std::endl;
   }
   std::cout << "main exit" << std::endl;

   return 0;
}

程序输出:

A Construct
B Construct
a use_count is1
b use_count is2
A Destruct
B Destruct
main exit

我们可以看到当a和b离开作用域后,析构函数正常被调用,这是因为B使用weak_ptr指向A,不会增加引用计数,所有当a和b离开作用域,引用计数为0,资源被释放。

自定义删除器

有时,默认的 delete 操作不适用于所有资源管理场景。此时,可以使用自定义删除器来指定资源释放的方式。例如,管理文件句柄、网络资源或自定义清理逻辑。

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <memory>
#include <cstdio>

void fileCloser(FILE* file)
{
   if (file)
   {
    std::cout << "Closing file." << std::endl;
    fclose(file);
   }
}

int main() {
   std::shared_ptr<FILE> file_ptr(fopen("test.txt""w"), fileCloser);
   if (file_ptr)
   {
      std::cout << "File opened successfully." << std::endl;
      fprintf(file_ptr.get(), "Hello, World!\n"); // 使用file_ptr进行文件操作
   }

   return 0;
}

当shared_ptr被销毁会,会调用fileCloser关闭文件。使用filePtr.get()获取原生FILE*指针进行文件操作。

总结

std::unique_ptr

特性:

  • 独占所有权:同一时刻只有一个 unique_ptr 可以指向给定的对象,不允许拷贝,只允许移动。
  • 轻量级:大小通常与原始指针相当(除非使用自定义删除器),无额外性能开销。
  • 自动释放:当 unique_ptr 被销毁(如离开作用域)或通过 reset() 释放时,所管理的对象自动被删除。

适用场景:

  • 需要独占资源的场景(如工厂函数返回对象、容器元素等)。
  • 作为类的成员变量,表示独有所有权。
  • 避免裸指针带来的资源泄漏风险。

std::shared_ptr

特性:

  • 共享所有权:多个 shared_ptr 可以指向同一个对象,内部使用引用计数控制生命周期。最后一个指向对象的 shared_ptr 被销毁时,对象被删除。
  • 引用计数线程安全:引用计数的增减是原子操作,但所管理对象的线程安全性需自己保证。
  • 额外开销:包含指向控制块的指针,控制块存储引用计数、弱计数、删除器等,内存占用较大(约两个原始指针大小),且原子操作有一定性能成本。
  • 支持自定义删除器:可用于管理非 new 分配的资源(如文件句柄)。

适用场景:

  • 多个对象需要共享同一资源(如多个容器共享数据、图结构等)。
  • 复杂的生命周期管理,无法明确唯一的拥有者。
  • 与 weak_ptr 配合解决循环引用。

std::weak_ptr

特性:

  • 弱引用:指向由 shared_ptr 管理的对象,但不增加引用计数,因此不影响对象的生命周期。
  • 必须与 shared_ptr 配合使用:通过 shared_ptr 构造或赋值。
  • 解决循环引用:在可能形成环状依赖的结构中,将其中一个方向改为 weak_ptr 可打破循环,避免内存泄漏。
  • 线程安全:lock() 方法原子性地尝试获取一个 shared_ptr,安全地检查对象是否存活。

适用场景:

  • 打破 shared_ptr 循环引用。
  • 缓存或观察者模式:需要跟踪对象但不想延长其生命周期。
  • 延迟资源获取:需要时通过 lock() 临时获得所有权。