当前位置: 首页 > article >正文

C++常见的内存错误和解决策略

目录

1.未初始化指针 (Uninitialized Pointer)

2.内存分配未成功却使用了它

3.野指针 (Dangling Pointer)

4.内存泄漏 (Memory Leak)

5.重复释放内存 (Double Free)

6.内存越界访问 (Buffer Overflow)

7.错误的数组删除方式 (Mismatched Delete)

8.栈内存溢出 (Stack Overflow)

9.delete (void*)

10.Virtual destructor

11. 对象循环引用


1.未初始化指针 (Uninitialized Pointer)

  • 错误描述:内存分配成功后,未进行初始化就引用,可能导致错误的数据引用。
  • 解决策略:无论用何种方式创建数组或动态内存,都应为其赋初值,防止将未被初始化的内存作为右值使用。

常见的示例代码如下:

int* p;
*p = 10; //可能会导致内存访问错误

解决方案:在使用指针前,进行初始化。

int* p = nullptr;
p = new int;
*p = 10;

2.内存分配未成功却使用了它

  • 错误描述:在内存分配失败的情况下,程序仍然尝试使用未成功分配的内存。
  • 解决策略:在使用内存之前,应检查指针是否为NULL。如果指针是函数的参数,那么在函数的入口处用assert(p!=NULL)(头文件是assert.h)进行检查。如果是用mallocnew来申请内存,应该用if(p==NULL)进行防错处理。

C++之assert惯用法_c++ assert-CSDN博客

3.野指针 (Dangling Pointer)

  • 错误描述:内存释放后,程序仍然尝试使用已释放的内存,可能导致程序崩溃或不稳定。

  • 解决策略

    • 重新设计数据结构,从根本上解决对象管理的混乱局面。
    • 注意函数的return语句,不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
    • 使用freedelete释放了内存后,将指针设置为NULL,防止产生“野指针”。
    • 使用智能指针可以避免野指针问题,因为它们会在对象超出作用域时自动释放资源。

4.内存泄漏 (Memory Leak)

问题: 动态分配的内存在不需要时没有被正确释放,导致可用内存越来越少。

解决策略:

  • 智能指针: 使用 C++ 的智能指针(如 std::unique_ptr 和 std::shared_ptr)来管理动态内存。它们可以在超出作用域时自动释放内存。

  • 确保delete匹配new: 每个通过new分配的内存,都要在适当的时机调用delete释放。

  • 工具检查: 使用内存检测工具如 Valgrind 或 AddressSanitizer 来检测内存泄漏。

C++惯用法之RAII思想: 资源管理_raii 思想-CSDN博客

下面看个例子:

void MemoryLeakFunction()
{
  XXX_Class * pObj = new XXX_Class();
  pObj->DoSomething();
  return; 
}

下面这个场景,就是析构函数中并没有释放成员所指向的内存。这个我们就要注意了,一般当你构建一个类的时候,写析构函数一定要切记释放类成员关联的资源。

class MemoryLeakClass
{
public:
  MemoryLeakClass() 
  { 
    m_pObj = new XXX_ResourceClass;
  }
  void DoSomething()
  {
    m_pObj->DoSomething();
  }
  ~MemoryLeakClass()
  {
    ;
  }
private:
  XXX_ResourceClass* m_pObj;
};

boost或者C++ 11后,通过智能指针去进行包裹这个原始指针,这是一种RAII的思想, 在out of scope的时候,释放自己所包裹的原始指针指向的资源。将上述例子用unique_ptr改写一下。

void MemoryLeakFunction()
{
  std::unique_ptr<XXX_Class> pObj = make_unique<XXX_Class>();
  pObj->DoSomething();
  return; 
}

5.重复释放内存 (Double Free)

问题: 动态分配的内存被释放多次,这可能会导致程序崩溃或未定义行为。

解决策略:

  • 将指针设置为 nullptr: 在释放指针之后,将其设置为nullptr,以避免重复释放。

  • 检查指针状态: 释放内存前,检查指针是否为nullptr

int* p = new int(10);
delete p;
p = nullptr;  // 避免重复释放

6.内存越界访问 (Buffer Overflow)

问题: 动态分配的内存被超范围使用,可能导致数据损坏、程序崩溃等问题。

解决策略:

  • 谨慎使用指针和数组: 确保访问的内存区域是合法的。

  • 边界检查: 在处理动态数组时,始终执行边界检查。

  • 使用标准库容器: 使用如 std::vectorstd::string 等标准库容器,它们会自动管理边界检查。

7.错误的数组删除方式 (Mismatched Delete)

问题: 使用new[]分配的数组使用delete而不是delete[]释放,或反之。

解决策略:

  • 确保匹配使用new/deletenew[]/delete[]:数组需要使用delete[]来释放。

  • 使用 C++ 的智能指针(如 std::unique_ptr 和 std::shared_ptr)来管理动态数组内存

  • 手动编写获取内存的类,自动释放内存,如:

C++简单缓冲区类设计_c++ 缓存区设计-CSDN博客

8.栈内存溢出 (Stack Overflow)

问题: 过度使用栈内存,通常是由于递归调用过深或栈上分配的大型对象。

解决策略:

  • 减少递归深度: 优化递归算法,避免过深的递归调用。

  • 将大对象放在堆上: 如果对象太大,使用new将其分配到堆上而非栈上。

9.delete (void*)

因为C++的灵活性,有时候会将一个对象指针转换为void *,隐藏其类型。这种情况SDK比较常用,实际上返回的并不是SDK用的实际类型,而是一个没有类型的地址,当然有时候我们会为其亲切的取一个名字,比如叫做XXX_HANDLE

那么继续用上述为例MemoryLeakClass, SDK假设提供了下面三个接口:

  1. InitObj创建一个对象,并且返回一个PROGRAMER_HANDLE(即void *),对应用程序屏蔽其实际类型
  2. DoSomething 提供了一个功能去做一些事情,输入的参数,即为通过InitObj申请的对象
  3. 应用程序使用完毕后,一般需要释放SDK申请的对象,提供了FreeObj
typedef void * PROGRAMER_HANDLE;

PROGRAMER_HANDLE InitObj()
{
  MemoryLeakClass* pObj = new MemoryLeakClass();
  return (PROGRAMER_HANDLE)pObj;
}

void DoSomething(PROGRAMER_HANDLE pHandle)
{
  ((MemoryLeakClass*)pHandle)->DoSomething();
}

void FreeObj(void *pObj)
{
  delete pObj;
}

看到这里,也许有读者已经发现问题所在了。上述代码在调用FreeObj的时候,delete看到的是一个void *, 只会释放对象所占用的内存,但是并不会调用对象的析构函数,那么对象内部的m_pStr所指向的内存并没有被释放,从而会导致内存泄露。修改也是自然比较简单的:

void FreeObj(void *pObj)
{
  delete ((MemoryLeakClass*)pObj);
}

那么一般来说,最好由相对资深的程序员去进行SDK的开发,无论从设计和实现上面,都尽量避免了各种让人泪流满满的坑。

10.Virtual destructor

C++析构函数为什么要为虚函数?_析构函数虚函数-CSDN博客

现在大家来看看这个很容易犯错的场景, 一个很常用的多态场景。那么在调用delete pObj;会出现内存泄露吗?

class Father
{
public:
  virtual void DoSomething()
{
    std::cout << "Father DoSomething()" << std::endl;
  }
};

class Child : public Father
{
public:
  Child()
  {
    std::cout << "Child()" << std::endl;
    m_pStr = new char[100];
  }

  ~Child()
  {
    std::cout << "~Child()" << std::endl;
    delete[] m_pStr;
  }

  void DoSomething()
{
    std::cout << "Child DoSomething()" << std::endl;
  }
protected:
  char* m_pStr;
};

void MemoryLeakVirualDestructor()
{
  Father * pObj = new Child;
  pObj->DoSomething();
  delete pObj;
}

会的,因为Father没有设置Virtual 析构函数,那么在调用delete pObj;的时候会直接调用Father的析构函数,而不会调用Child的析构函数,这就导致了Child中的m_pStr所指向的内存,并没有被释放,从而导致了内存泄露。

并不是绝对,当有这种使用场景的时候,最好是设置基类的析构函数为虚析构函数。修改如下:

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

class Child : public Father
{
public:
  Child()
  {
    std::cout << "Child()" << std::endl;
    m_pStr = new char[100];
  }

  virtual ~Child()
  {
    std::cout << "~Child()" << std::endl;
    delete[] m_pStr;
  }

  void DoSomething()
{
    std::cout << "Child DoSomething()" << std::endl;
  }
protected:
  char* m_pStr;
};

11. 对象循环引用

看下面例子,既然为了防止内存泄露,于是使用了智能指针shared_ptr;并且这个例子就是创建了一个双向链表,为了简单演示,只有两个节点作为演示,创建了链表后,对链表进行遍历。
那么这个例子会导致内存泄露吗?

struct Node
{
  Node(int iVal)
  {
    m_iVal = iVal;
  }
  ~Node()
  {
    std::cout << "~Node(): " << "Node Value: " << m_iVal << std::endl;
  }
  void PrintNode()
{
    std::cout << "Node Value: " << m_iVal << std::endl;
  }

  std::shared_ptr<Node> m_pPreNode;
  std::shared_ptr<Node> m_pNextNode;
  int m_iVal;
};

void MemoryLeakLoopReference()
{
  std::shared_ptr<Node> pFirstNode = std::make_shared<Node>(100);
  std::shared_ptr<Node> pSecondNode = std::make_shared<Node>(200);
  pFirstNode->m_pNextNode = pSecondNode;
  pSecondNode->m_pPreNode = pFirstNode;

  //Iterate nodes
  auto pNode = pFirstNode;
  while (pNode)
  {
    pNode->PrintNode();
    pNode = pNode->m_pNextNode;
  }
}

先来看看下图,是链表创建完成后的示意图。有点晕乎了,怎么一个双向链表画的这么复杂,黄色背景的均为智能指针或者智能指针的组成部分。其实根据双向链表的简单性和下图的复杂性,可以想到,智能指针的引入虽然提高了安全性,但是损失的是性能。所以往往安全性和性能是需要互相权衡的。 我们继续往下看,哪里内存泄露了呢?

如果函数退出,那么m_pFirstNodem_pNextNode作为栈上局部变量,智能指针本身调用自己的析构函数,给引用的对象引用计数减去1(shared_ptr本质采用引用计数,当引用计数为0的时候,才会删除对象)。此时如下图所示,可以看到智能指针的引用计数仍然为1, 这也就导致了这两个节点的实际内存,并没有被释放掉, 从而导致内存泄露。

你可以在函数返回前手动调用pFirstNode->m_pNextNode.reset();强制让引用计数减去1, 打破这个循环引用。
还是之前那句话,如果通过手动去控制难免会出现遗漏的情况, C++提供了weak_ptr

struct Node
{
  Node(int iVal)
  {
    m_iVal = iVal;
  }
  ~Node()
  {
    std::cout << "~Node(): " << "Node Value: " << m_iVal << std::endl;
  }
  void PrintNode()
{
    std::cout << "Node Value: " << m_iVal << std::endl;
  }

  std::shared_ptr<Node> m_pPreNode;
  std::weak_ptr<Node>    m_pNextNode;
  int m_iVal;
};

void MemoryLeakLoopRefference()
{
  std::shared_ptr<Node> pFirstNode = std::make_shared<Node>(100);
  std::shared_ptr<Node> pSecondNode = std::make_shared<Node>(200);
  pFirstNode->m_pNextNode = pSecondNode;
  pSecondNode->m_pPreNode = pFirstNode;

  //Iterate nodes
  auto pNode = pFirstNode;
  while (pNode)
  {
    pNode->PrintNode();    
    pNode = pNode->m_pNextNode.lock();
  }
}

看看使用了weak_ptr之后的链表结构如下图所示,weak_ptr只是对管理的对象做了一个弱引用,其并不会实际支配对象的释放与否,对象在引用计数为0的时候就进行了释放,而无需关心weak_ptrweak计数。注意shared_ptr本身也会对weak计数加1.

那么在函数退出后,当pSecondNode调用析构函数的时候,对象的引用计数减一,引用计数为0,释放第二个Node,在释放第二个Node的过程中又调用了m_pPreNode的析构函数,第一个Node对象的引用计数减1,再加上pFirstNode析构函数对第一个Node对象的引用计数也减去1,那么第一个Node对象的引用计数也为0,第一个Node对象也进行了释放。

如果将上述代码改为双向循环链表,去除那个循环遍历Node的代码,那么最后Node的内存会被释放吗?这个问题留给读者。

推荐阅读

C++智能指针的自定义销毁器(销毁策略)_c++指针销毁-CSDN博客


http://www.kler.cn/news/339443.html

相关文章:

  • 单元测试的定义
  • 二分图的判定-染色法
  • 了解Java面试
  • 【leetcode】238.除自身以外数组的乘积
  • 关于银联支付交易状态码的一些分析
  • 宠物咖啡馆业务流程:SpringBoot框架的高效实现
  • wildcard使用教程,解决绝大多数普通人的海外支付难题
  • firfly-rockchip sdk 环境搭建-Ubuntu24.04
  • 光伏电站线缆怎么选型
  • jmeter学习(4)提取器
  • YOLOv8改进线性注意力模块 ICCV2023 FLatten Transformer
  • 五十一、架构设计经验与技巧(实践经验)
  • windows C++-创建图像处理的异步消息(三)
  • Linux 基本系统命令及其使用详解手册(九)
  • yum 源配置
  • 后端路由解析:不同请求类型与数据定位
  • 前端vue-配置基地址并发送请求
  • Android开发:日志功能备忘
  • 回到原点再出发
  • 详解 Pandas 的 melt 函数