【编译基础】内存资源的使用详解

文章详细阐述了C++中的内存管理,包括new关键字的三种用法,delete关键字的内存释放,malloc和free的内存分配与释放,以及内存泄漏的原因和防止方法。此外,还介绍了内存池的概念、使用和原理,以及STL中的内存池实现策略。
摘要由CSDN通过智能技术生成

内存的使用,一文不太够

在这里插入图片描述



😊点此到文末惊喜↩︎

内存管理

0. 概述

  1. 常见内存使用错误(debug注意事项)
    • 内存分配未成功却使用。内存分配不一定总是成功的,使用需要进行防御性编程if(p != NULL)
    • 内存分配成功却未初始化就使用。内存使用前要初始化,否则指针访问行为不可控,
    • 内存访问越界。读越界,若读的内存地址无效,则程序可能崩溃。写越界可能出现shellcode攻击
    • 释放内存却继续使用
      • 函数return局部变量
      • 使用free或delete释放了内存后,没有将指针设置为NULL,导致产生“悬空指针”

1.1 new关键字

  1. 作用:在C++中用于动态分配内存,可通过重载operator new函数改变new关键字的行为
  2. 三种用法
    • plain new
      • 最朴素的new
      • 进行内存的分配、转型和初始化,在分配失败时抛出异常并返回bad_alloc
    • nothrow new
      • 在空间分配失败的情况下是不抛出异常,而是返回NULL
      • 常用于服务器端内存不足时,不抛出异常而使程序终止,而是等待内存分配
    • placement new:
      • 原理:通过重载operator new,调用构造函数初始化指定内存空间,从而实现对已分配内存的复用。
      • 优点:复用已分配的内存,降低分配释放内存开销并减少内存碎片问题
class A {
        int m_v;
public:
        A() {}
        A(int v) : m_v(v)  {}
        A(double v) : m_v(ceil(v)) {}
};
// plain new
	A* p1 = new A;           //非必要情况不会调用合成的构造函数
	A* p2 = new A();         //必然调用构造函数,如果没有,调用合成构造函数
	A* p3 = new A(3);        //调用int参数构造
	A* p4 = new A(3.1);      //调用double类型构造
	A* p5 = new A[2];        //分配2个元素数组,非必要不初始化
	A* p6 = new A[2]();      //分配2个元素,初始化
	A* p7 = new A[2](3);     //非法调用,动态分配数组不能指定带参数的构造函数
	
	delete p1;
	delete p2;
	delete p3;
	delete p4;
	delete [] p5;
	delete [] p6;
// nothrow new
	p1 = new(nothrow) A;
	if(p1 == nullptr)
	   		···;//等待并重试
// placement new
	// 1. 申请内存
	void* pbuffer = (void*) new char[sizeof(CTest)];
	// 2. 在指定的内存空间上构造对象
	CTest* ptest = new (pbuffer) CTest();
	// 3. 使用对象 
	ptest->doing();
	// 4. 显式调用析构函数 
	ptest->~CTest();
	// 5. 释放原始的内存空间 
	delete[] pbuffer;
	// 6.重复234进行循环使用
  1. 原理
    • new本质:调用operator new分配内存,将该内存进行转型后赋值给指针,然后调用构造函数进行内存的初始化,如果失败返回bad_alloc
    • operator new本质:尝试使用malloc进行分配内存,同时进行异常处理,可以重载
    // ### 调用new
    Complex *pc = new Complex(1, 2);
    // ### new的原理解释(背诵)
    Complex *pc;
    try{
        // 1.内存分配,调用operator new
        void *mem = operator new(sizeof(Complex));
        // 2.内存转型,将分配的内存转化成相应的指针类型
        pc = static_cast<Complex*>(mem);
        // 3.内存初始化,调用构造函数初始化内存
        pc->Complex::Complex(1, 2);
        // 4.内存分配失败返回bad_alloc
    }catch(std::bad_alloc){
        // 若allocator失败就不执行constructor
    }
    
    // ### operator new的源码概括性解释
    // 第二参数保证函数不抛出异常
    void *operator new(size_t size, const std::nothrow_t &){
        void *p;
        // 如果内存耗尽导致分配失败 (实质调用malloc)
        while((p=malloc(size)) == 0){
            _TRY_BEGIN
                if(_callnewh(size) == 0)// 调用自定义函数进行处理
                    break;
            _CATCH(std::bad_alloc)
                return 0;
            _CATCH_END
        }
        return p;
    }
    

1.2 delete关键字

  1. 作用:
    • 释放new分配的动态内存:delete需要与new配对使用,其参数可以是指向一块内存首地址或空指针(nullptr)。不能对同一块内存多次delete,但是可以对空指针多次delete。
    • 阻止编译器合成默认函数:C++11以上,如果没有为类编写构造函数、析构函数、拷贝构造函数、移动构造函数,以及拷贝赋值运算符、移动赋值运算符,编译器可能会为类合成默认的函数版本。显式使用delete,可以阻止编译器合成对应函数。
  2. 本质:先调用类对象的析构函数,后调用operator delete函数进行内存释放。operator delete函数本质是调用free函数
    // delete的编译解释,如delete pc;
    	// 1.析构调用对象
    pc->~Complex();
    	// 2.后释放对象
    operator delete(pc);
    
    //operator delete源码
    void __cdecl operator delete(void *p)_THROW0(){
        free(p);
    }
    class MyObj {
    public:
    	// 阻止编译器合成构造函数,会导致类无法实例化
      	MyObj() = delete; 
      	// 阻止编译器合成拷贝构造函数,会导致类无法拷贝构造
      	MyObj& MyObj(const MyObj &) = delete;
      	// 阻止合成赋值运算符,会导致类无法使用赋值运算
      	MyObj& operator=(const MyObj &) = delete; 
      ...
    }
    
  3. 不能对同一内存地址进行多次delete的原因
    • 第一次delete:只是逻辑上释放p指向的内存(将内存池中该部分内存使用状态置为空闲)
    • 第二次delete:仍然可以通过该内存地址进行逻辑置为空闲,再通过该悬空指针delete会导致内存错误
    • 一旦我们释放了一个内存空间,必须保证不再通过任何其他残留的指针或变量能够访问到它
    // delete的使用
    int *p = new int(3);
    delete p;
    p = nullptr;// 重点
    
  4. new和delete的对称性
    • new分配的空间使用delete释放,new[] 使用 delete[]
    • 如果重载了operator new,要注意delete的使用,避免内存泄漏
  5. new和delete的组合使用
    // 动态申请/释放一个int类型的空间
    int* ptr1 = new int;
    delete ptr1;
    // 动态申请/释放一个int类型的空间并初始化为10
    int* ptr2 = new int(10);
    delete ptr2;
    // 动态申请/释放10个int类型的空间
    int* ptr3 = new int[10];
    delete[] ptr3;
    
  6. delete是如何获知需要释放的内存(数组)大小的?
    • 动态申请的内存块首部有cookie记录该块内存大小

2.1 malloc关键字

  1. 作用:申请一块连续的指定大小的内存块区域,以void*类型返回分配的起始地址
  2. 类型
    • malloc函数:从堆上申请内存空间,尽量使用memset初始化
    • calloc函数:从堆上申请内存空间并初始化为0
    • realloc函数:对已经存在的内存空间进行调整,如果更大会进行内存空间的延申,如果无法延申会申请新空间并拷贝和释放旧空间。如果更小会将原空间缩小。
  3. 内存空间的释放
    • 使用free函数
    • 使用指针参数为NULL的realloc函数
  4. 原理
    • 初始化内存块双链表
      • 将堆空间在逻辑上分割为空闲内存块,并且双向链表进行管理,利用系统调用完成对内存的申请。
      • 首部:链表内存块由mem_control_block+有效内存块组成,内存控制块主要包含pre指针、next指针、有效内存块大小、Used标志(是否使用)
      • 数据:有效内存块是实际承载数据的,malloc返回的是有效内存块的首地址。
    • 查找空闲区块
      • 首次适用算法First fit:遍历链表,使用第一个数据区大小大于要求size的块作为此次分配的块,具有更好的运行效率
      • 最佳适应算法Best fit:遍历链表,使用数据区大小大于size且差值最小的块作为此次分配的块 ,较高的内存使用率
      • 调用系统调用指令mmap或sbrk向系统申请新的虚拟内存满足用户需要,并将其加入链表管理。
    • 分解空闲区块
      • 将查找到的空闲内存块拆分,一部分设置used为已占用状态并返回该有效内存块首地址给程序使用,另一部分继续作为空闲块
    // 首次适用查找算法的malloc实现
    int has_initialized = 0;     // 初始化标志
    void *managed_memory_start;  // 指向堆底(内存块起始位置)
    void *last_valid_address;    // 指向堆顶
    
    void malloc_init() {
      // 这里不向操作系统申请堆空间,只是为了获取堆的起始地址
      last_valid_address = sbrk(0);
      managed_memory_start = last_valid_address;
      has_initialized = 1;
    }
    
    void *malloc(long numbytes) {
      void *current_location;  // 当前访问的内存位置
      struct mem_control_block *current_location_mcb;  // 只是作了一个强制类型转换
      void *memory_location;  // 这是要返回的内存位置。初始时设为
                              // 0,表示没有找到合适的位置
      if (!has_initialized) {
        malloc_init();
      }
      // 要查找的内存必须包含内存控制块,所以需要调整 numbytes 的大小
      numbytes = numbytes + sizeof(struct mem_control_block);
      // 初始时设为 0,表示没有找到合适的位置
      memory_location = 0;
      /* Begin searching at the start of managed memory */
      // 从被管理内存的起始位置开始搜索
      // managed_memory_start 是在 malloc_init 中通过 sbrk() 函数设置的
      current_location = managed_memory_start;
      while (current_location != last_valid_address) {
        // current_location 是一个 void 指针,用来计算地址;
        // current_location_mcb 是一个具体的结构体类型
        // 这两个实际上是一个含义
        current_location_mcb = (struct mem_control_block *)current_location;
        if (current_location_mcb->is_available) {
          if (current_location_mcb->size >= numbytes) {
            // 找到一个可用、大小适合的内存块
            current_location_mcb->is_available = 0;  // 设为不可用
            memory_location = current_location;      // 设置内存地址
            break;
          }
        }
        // 否则,当前内存块不可用或过小,移动到下一个内存块
        current_location = current_location + current_location_mcb->size;
      }
      // 循环结束,没有找到合适的位置,需要向操作系统申请更多内存
      if (!memory_location) {
        // 扩展堆
        sbrk(numbytes);
        // 新的内存的起始位置就是 last_valid_address 的旧值
        memory_location = last_valid_address;
        // 将 last_valid_address 后移 numbytes,移动到整个内存的最右边界
        last_valid_address = last_valid_address + numbytes;
        // 初始化内存控制块 mem_control_block
        current_location_mcb = memory_location;
        current_location_mcb->is_available = 0;
        current_location_mcb->size = numbytes;
      }
      // 最终,memory_location 保存了大小为 numbyte的内存空间,
      // 并且在空间的开始处包含了一个内存控制块,记录了元信息
      // 内存控制块对于用户而言应该是透明的,因此返回指针前,跳过内存分配块
      memory_location = memory_location + sizeof(struct mem_control_block);
      // 返回内存块的指针
      return memory_location;
    }
    
    
    //对应的free实现:
    void free(void *ptr) {  // ptr 是要回收的空间
      struct mem_control_block *free;
      free = ptr - sizeof(struct mem_control_block); // 找到该内存块的控制信息的地址
      free->is_available = 1;  // 该空间置为可用
      return;
    }
    
  5. malloc数据结构实现方式发展(增加伙伴分配和内存链表分配算法)
    • 全链表
      • 数据结构&算法:使用双向链表管理所有内存块,使用首次适应查找算法查找合适空闲内存块进行分割分配
      • 问题:易产生内存碎片,每次遍历要从头开始
    • 空闲链表
      • 数据结构&算法:维护一个只包含未分配内存块的空闲块链表,使用首次适应查找算法查找合适空闲内存块进行分割分配,剩余空间还是存在空闲链表中
      • 问题:无法使用内存紧凑,因为可能改变之前malloc返回的地址
    • 多空闲链表(目录思维)
      • 特点:维护多个大小不同的空闲链表,一般是2的指数递增。先选择合适的空闲链表,后进行遍历。
    • tcmalloc
      • 来源:tcmalloc 是 Google 开发的内存分配器,全称 Thread-Caching Malloc,即线程缓存的 malloc
      • 原理:利用池化思想管理内存分配。对于每个线程,都有自己的私有缓存池,内部包含若干个不同大小的内存块。对于一些小容量的内存申请,可以使用线程的私有缓存;私有缓存不足或大容量内存申请时再从全局缓存中进行申请。
      • 优点:在线程从自己内存池中申请内存不需要加锁,因此在多线程的情况可以大大提高分配效率。
  6. 其他相关
    • 编译映射:现在的 malloc() 往往采用多种方式复合而成,不同大小的内存块往往采用不同的措施,以保证内存分配的安全和效率。
    • 缓冲池思想:先通过sbrk函数扩展堆,将这部分空闲内存空间作为缓冲池,然后通过 malloc / free 管理缓冲池中的内存,能够避免频繁的系统调用,提高程序性能。
    • 使用双链表而不是单链表的原因:进行内存碎片整理时,可以更快的合并相邻的碎片
    • 堆的控制:malloc和free是通过系统调用brk实现的,sbrk也是基于brk实现的。通过改变堆顶指针而实现堆的容量控制
  7. 大内存块的分配
    • mmap() 系统调用可以在进程的虚拟地址空间中分配一块连续的内存区域,并返回该内存区域的首地址。
    • mmap() 分配的内存在 malloc() 管理的内存池中不可见,因此不能使用 free() 函数来释放,必须使用 munmap() 函数来释放。
    • 由于 mmap() 函数需要进行系统调用,因此在频繁调用时可能会带来一定的性能损失,需要谨慎使用。

2.2 free关键字

  1. 作用:释放指针指向的内存,即将对应内存块控制信息中的Used标识置0
  2. 注意点
    • 避免野指针:释放完成后,将指针置nullptr
    • free只能对自己管理内存池进行逻辑释放

内存泄漏

  1. 定义:动态申请的内存空间用完未释放
  2. 常见原因
    • 唯一指针改变指向:动态分配内存的未释放而唯一指向的指针被重新赋值
    • 唯一指针被释放:释放动态申请的结构体,而未释放其含有动态分配指针的子元素
    • 返回的动态内存没指针接收:函数返回动态分配内存而未处理
    • 僵尸进程占用资源无法释放
  3. 解决方式
    • 预防:针对上述三个错误,进行校验核对。使用智能指针
    • 检测:内存泄露检测工具valgrind、BoundsChecker
    • 解除:抛出异常给上层处理
  4. strcpy函数和strncpy函数
    • 函数原型
      char* strcpy(char* strDest, const char* strSrc)
      char *strncpy(char *dest, const char *src, size_t n)
      
    • strcpy函数: 如果参数 dest 所指的内存空间不够大,可能会造成缓冲溢出(buffer Overflow)的错误情况,在编写程序时请特别留意,或者用strncpy()来取代。
    • strncpy函数:用来复制源字符串的前n个字符,src 和 dest 所指的内存区域不能重叠,且 dest 必须有足够的空间放置n个字符。

垃圾回收机制

  1. 定义:程序员仅内存的申请和使用,而由内存管理器负责释放不再使用的内存空间
  2. 缺点及解决方式
    • 统一的垃圾回收机制,为适应不同环境而导致程序性能降低
    • 会减弱C++对底层的控制
    • 可以使用智能指针进行解决

STL的内存池Memory Pool

1.什么是内存池

  1. 池化技术
    • 原理:将程序中经常使用的一个核心资源预先申请出来,放到一个池里,提高资源的利用率,减少系统调用的使用
    • 示例:内存池、线程池
  2. 碎片问题
    • 原因:长时间的申请和释放内存,可能造成大量的内存碎片,从而降低性能。
    • 解决:预申请一定数量的内存块作为内存池,程序的申请和释放内存通过内存池实现。内存池不够时申请更大的内存,减少内存碎片。

2.如何使用内存池

  1. SGI版本的STL内存池实现原理

    • SGI使用双层级配置器
      • 第一层级仅是对malloc和free的简单封装,用于程序申请大于128B的内存时调用,通过系统调用sbrk动态修改进程的堆段段顶指针
      • 第二层级内置一个轻量级的内存池,当程序申请小于128B的内存时会被调用
        在这里插入图片描述
    • 第二级配置器是为了解决小区块申请释放导致的内存碎片化问题
      • SGI默认最大的小块内存大小为128bytes,并维护了16 个空闲链表(free list),每个list 分别维护大小为 8, 16, 24, …, 128bytes 的内存块(均为8的整数倍),
        • 如果有足够的小区块,则调整链表,返回第一个node, 链表头改为第二个 node。其中如果用户申请的空间大小不足8B的倍数,则向上取整。
        • 如果没有可用的区块,计算内存池容量,尽最大能力交付,不足向系统申请。释放内存时, 如果大于128bytes, 则直接 free, 否则加入相应的自由链表中而不是直接返还给操作系统。
      • 如果整个堆的空间都不够了,就会在原先已经分配区块中寻找能满足当前需求的区块数量,能满足就返回,不能满足就向客户端报bad_alloc异常
        在这里插入图片描述
    • freelist中的联合体既可以表示下一个空闲数据块(存在于空闲链表中)的地址,也可以表示已经被用户使用的数据块(不存在空闲链表中)的地址。
      union obj
      {
           union obj * free_list_link;     //下一个节点的指针
           char client_data[1];              //内存首地址
      }
      

    在这里插入图片描述

  2. 高性能的tcmalloc内存池

    • https://www.zhihu.com/search?type=content&q=tcmalloc%E5%8E%9F%E7%90%86

3.内存池的原理

  1. 内核的分页机制
    • 进程的地址空间都是虚拟地址
    • 虚拟地址的范围取决于地址总线的宽度,eg:32 位下,虚拟地址空间为4GB
    • 每个进程拥有各自完整的虚拟地址空间,eg:32 位下,每个进程都拥有4GB
  2. 物理内存池分为两部分
    • 内核物理内存池:物理内存只给操作系统使用,用户程序无权访问
    • 用户物理内存池:当用户进程消耗尽时,不再向内核内存池申请
      在这里插入图片描述

少年,我观你骨骼清奇,颖悟绝伦,必成人中龙凤。
秘籍(点击图中书籍)·有缘·赠予你


🚩点此跳转到首行↩︎

参考博客

  1. C++ new关键字详解
  2. 【C++】内存管理之new和delete
  3. C++中placement new操作符
  4. C++> delete关键字初探
  5. 百度百科malloc
  6. 内存泄漏
  7. 一文看懂内存池原理及创建(C++实现)
  8. C++STL内存池
  9. 不能多次delete的原因
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

逆羽飘扬

如果有用,请支持一下。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值