首页 > 系统相关 >CPP内存管理

CPP内存管理

时间:2023-04-10 21:33:58浏览次数:53  
标签:malloc 调用 管理 mmap operator 内存 CPP new

从内核到C++应用

整个系统的不同层级有着不同的内存管理器。

  • linux内核: 伙伴系统(以页为单位进行管理)、slab分配器(定制化的内存管理器)。
  • malloc\free库函数:使用系统调用mmap、sbrk,以及bins管理多个空闲链表(内存池)。有合并空闲块的操作。有两种方式管理内存:
    • 如果分配内存大于mmap分配阈值(见下一节),直接使用mmap分配;如果释放的内存大于mmap分配阈值,直接使用mummap释放,直接返还给操作系统
    • 否则,通过内存池分配算法分配,且用户层free的内存块,不一定立刻返回给操作系统,而是缓存在内存池中以供后续的请求。
  • C++ stl分配器:16个链表,分别管理16种小于128 B的对象(内存池),没有合并空闲块操作,如果分配\释放的内存大于128B直接调用malloc\free交由库函数处理。

image-20221116215651343

image-20221116215657325

glibc 内存管理

参考资料:

glibc的mallocfree使用的ptmalloc实现,详见这篇文章的解析:Glibc内存管理 —— 华庭

首先,glibc的设计假设是这样的,我把我觉得比较重要的内容高亮了出来:

image-20230404210044038

“malloc如果分配的内存大于128KB,则使用mmap分配内存,否则使用sbrk分配内存”。正确吗?

这句话并不绝对正确,如上图所示,使用mmap分配的阈值是动态变化的,因此不一定分配128KB的malloc请求一定会使用mmap系统调用。请参考下图,假设malloc的初始mmap阈值为128KB,表示分配内存大于128KB时直接使用mmap而不用通过内存池机制;如果某一时刻释放了一个大于128KB的页,比如256KB,那么mmap阈值将被调整为256KB,表示分配内存大于256KB时才使用mmap,此时如果分配的内存大小为128KB,那么这个请求还是会走内存池机制,而且释放128Kb的内存块时也不会使用mumap直接将其返还给操作系统了。当然mmap阈值不能无限增加,见下图的描述。

image-20230404213030113

而且通过mmap分配的内存如果被释放了,那么这片地址就直接返回给操作系统了,如果再次引用这篇地址,则会引发segmentation fault

glibc内存池机制

除了使用mmap分配大内存块,glibc还有一种内存池机制,使用一种chunk的数据结构来管理堆内存。chunk结构中包括用户数据,以及为了维护内存池的一些其他信息(比如下一个chunk的指针、本chunk的大小等),在侯捷老师的课程中,这些除用户信息以外的管理信息被称作cookie,在glibc中以32位系统为例,每个chunk的cookie大小位16B。

空闲的chunk使用bins来管理。用户free掉的内存并不会马上归还给操作系统,而是缓存在各种bin中。ptmalloc会统一管理 heap 和 mmap 映射区间的空闲chunk,当用户需求来临时,它首先在bins中找到空闲的chunk返回给用户,如果找不到则使用系统调用再向操作系统申请内存。这样就避免了频繁的系统调用,较少分配开支。

bins有很多种:

  • fast bins, 存放大小小于等于64B的chunk, 这个bin是为了加快热点小内存块的分配
  • unsorted bins,可以看做是 bins 的一个缓冲区,增加它只是为了加快分配的速度
  • small bins,有62种规格,每一种规格相差8B,(16B - 576B),每个small bin管理的chunk大小都相同
  • large bins,有63个,每个bin都管理在一定范围内的内存块,也就是说每个large bin种的chunk大小不同,但是会按照大小从大到小排列。

image-20230404214933178

还有两种不属于任何bin的chunk:

  • mmaped chunk: 直接使用mmap分配的内存块,在释放时使用ummap直接返还给操作系统
  • top chunk: heap最高处的一块空闲chunk,它的大小是随着分配和回收不停变化的。

具体的分配和回收算法参考Glibc内存管理 —— 华庭 3.2.4和3.2.5节。

那么,通过内存池分配的内存会返还给操作系统吗?什么情况下会返还?

还是在上面的参考资料的3.2.5节的free流程中有详细解释:

image-20230404222041634

简单讲,就是在free时会判断释放块前后是否有空闲块,如果有则迭代地进行合并,如果最后合并到到的空闲块与topchunk相邻(topchunk是一定为空闲的),那么将它与topchunk合并,如果合并而得的topchunk大于mmap的收缩阈值,则归还topchunk中的一部分给操作系统。

从这里可以隐隐看出 Glibc内存管理 —— 华庭 这篇文章中提到的内存暴增问题的一个原因了:

  • 收缩内存(将内存归还给操作系统)是从topchunk开始的,如果topchunk相邻的chunk不是空闲的,那么topchunk以下的chunk都不能归还给操作系统,因此会堆积越来越多的空闲chunk而不返还给操作系统。

    image-20230404223143072

  • 一种缓解方法:绕过glibc的内存池系统,每次分配的内存都大于等于mmap分配阈值的最大值(在64为系统上是32MB),那么malloc将直接使用mmap分配内存,且free时一定会将内存返还给操作系统。这样的话malloc\free就是mmap和mumap系统调用的简单封装,可以考虑直接使用系统调用mmap和mumap进行内存分配管理。

C++ primitives

参考资料:

  • 侯捷老师的视频讲解

new delete表达式

image-20221116220736011

new表达式(new后不加其他任何参数)会被编译器转化为两个步骤

  • 调用operator new 分配内存,调用的函数版本为(operator new(size_t)),对象大小由编译器算出并自动传入
  • 然后调用对象的构造函数,在得到的内存上创建object

此外,operator new 可能抛出异常,需要用try catch处理异常。

而operator new 内部直接调用C运行库的 malloc, 当内存分配失败时,执行_callnewh, 这个函数主要释放一些内存,看看能不能空出一些来给新对象

如果operator new函数分配内存成功了,但是构造函数却抛出异常。这时候,就要取消第一个步骤中分配的内存,否则就会导致内存泄漏。

这个责任落在C++运行库上,由它调用相应的operator delete(什么叫相应的delete,见effective C++,条款52)取消已经分配的内存。

delete表达式会也被编译器转化为两个步骤

  • 调用对象的析构函数,调用的函数版本为(operator new(size_t))
  • 调用 operator delete

operator delete内部调用C运行库的 free 释放内存

image-20221116221103756

array new

使用了array new 之后就要使用array delete,否则就会只调用一次析构函数,这样有可能发生内存泄漏,仅限于class with pointer member 的对象,见下图示意。泄漏的内存不是array本身,而是array的元素所指向的内存。

下图的cookie大多是记录了array new 操作分配了多少个objext

image-20221117201830011

如果

int* p = new int[10] // array new
delete p // 但不 array delete

或者向上图那样的无指针成员的对象Complex,不会造成内存泄漏。

palcement new

operator new函数如果接收的参数除了一定会有的哪个size_t之外,如果还有其他参数,那么它就属于placement new(见重载oeprator new 一节)

placement new 等同于直接调用构造函数,不会像new 表达式那样先分配内存。

// 像这样使用placement new
char* buf = new char[sizeof(Complex)]; // 首先分配内存
Complex* p = new(buf)Compelx(1,1) // new后传入参数buf指针,表示在这个地址上构造Complex(1,1)

重载operator new

image-20221117205909352

new表达式内部调用 operator new 函数,侯捷老师说我们可以在两个地方重载operator new 函数来实现自己的内存分配函数。

一开始不理解什么较做“在两个地方”,后来查了些资料明白了这与C++的名称查找规则(Name lookup rule)有关。当C++编译器在class scope内碰到一个name后,首先在class内搜寻这个name,看看有没有它的declaration,如果没有再去全局域找找。

因此,如果我们在类内重载了operator new,那么全局的operator new根本不会被搜寻,更不用说执行了。

相比重载全局的operator new,重载class scope的做法更安全些。

下例摘自 cppreference,注意类内的operator new函数一定是static的,无论定义式加不加static关键词,编译器一律视类内的operator new函数为static

operator new 函数的参数一般是对象的大小,当我们写 new Obj() 时, Obj对象的大小会自动被当作operator new的第一个参数。

struct X
{
    static void* operator new(std::size_t count)
    {
        std::cout << "custom new for size " << count << '\n';
        return ::operator new(count); // 调用全局的operator new
    }
 
    static void* operator new[](std::size_t count)
    {
        std::cout << "custom new[] for size " << count << '\n';
        return ::operator new[](count);//// 调用全局的operator new
    }
};
 
int main()
{
    X* p1 = new X;
    delete p1;
    X* p2 = new X[10];
    delete[] p2;
}

也可以在重载函数中直接调用malloc,而不使用全局operator new,因为operator new只是分配了内存我们自己可以实现它。如果对应构造函数也不抛出异常,那么整个new表达式就不会抛出异常!(前面说过,new 表达式会转化为 1.operator new,分配内存 + 2.调用构造函数两部,其中全局operator new会有异常的抛出)

struct X
{
    static void* operator new(std::size_t count)
    {
        std::cout << "custom new for size " << count << '\n';
        return malloc(count);
    }
 
    static void* operator new[](std::size_t count)
    {
        std::cout << "custom new[] for size " << count << '\n';
        return malloc(count);
    }
};

我们可以重载多个operator new 版本:

image-20221121113240277

其中第一个版本就是new 表达式(new后不加任何其他参数)会自动转化成的版本

注意当在class内声明operator的各种函数时,可能会被继承增加复杂性

class Base {
public: 
    static void* operator new(size_t size) throw(std:: badalloc){
        // 一些针对Base Class的操作
    }
    ...
};
public Derived {
public:
    // 未申明operator new ,从分类继承operator new
}

这样当new一个Derived对象时,调用的是Baseclass中定义的operator new,二这可能带来很多不确定因素。

解决这样的做法是在Baseclass的operator new中加入判断 size是否等于Baseclass大小的逻辑

class Base {
public: 
    static void* operator new(size_t size) throw(std:: badalloc){
        if (size != sizeof(Base)) { // 如果创建的不是Basevlass对象
            return ::operator new(size); // 转发至全局的operator new 函数
        }
    }
    ...
};

这样当使用BaseClass定义的operator new 函数去分配一个Derived对象时,改用全局operator new 函数,这样可万无一失。

GNUC 2.9版本std::alloc

主要看SGI的实现,有两个空间配置器

  • _malloc_alloc_template<0>
  • __default_alloc_template<...>

用户可以选择单独使用第一个分配器,或者一起使用两个分配器。

当用户选择使用两个分配器时,编译器会分别将上述两个分配器typedef成 malloc_allocalloc, 容器的分配器默认使用alloc,即第二个分配器。

两个配置器的接口都有allocate() deallocate() reallocate(),这里主要聚焦于前两个接口。

第一个配置器(malloc_alloc)的allocate()从typedef的名字上可以看出,它只是简单调用malloc(), deallocate()也只是简单调用free(),唯一的特别之处是,这个配置器能够模拟C++ new 运算符的set_new_handler()以处理内存不足的情况。

而第二个配置器(alloc), 当内存小于128字节时则由自己管理这些内存块,会自己管理一个内存池当分配的内存大于128字节时,直接调用malloc::allocate()。如果系统空间不足,那么也调用malloc::allocate(),因为它有处理程序处理内存不足的情况。

为什么对于小于128bytes的内存块使用内存池来管理?

1.防止内存碎片

2.若使用malloc直接分配的内存,每块都带有一些cookie,若小内存偏分配次数多,那么cookie的占用空间相比于有用空间会很大,空间利用率不高。

alloc配置器如何管理内存池?这里只记个大概,细节看书。

alloc管理一个16个长度的数组,数组的每个元素都指向一个free_list, 每个free list都管理一种大小的空闲数据块。

内存块大小有8bytes 、16bytes、24bytes ... 128bytes。 因此一共要16个free list来进行空闲块的管理。

最初,alloc的内存池空无一物,当有请求来时,比如要申请8字节的空间,就调用malloc向系统申请空间大小为8 * 40,将其中的1块返回给用户,其中19块做切割处理后交给对应的free list管理,剩余的20块留给内存池的备用。当再有8字节申请时,则直接从这个free list中拨给用户空闲空间。当有16字节申请时,从内存池中的备用空间中找空闲内存,如果不够则再调用malloc重复刚刚的操作,但是在当前情景中,确实是存在备用内存的(刚刚分配8字节内存时剩余的20块)。

image-20221121201129114

容器使用该分配器分配内存,而不是直接用malloc向操作系统索要,这样会节省很多存放cookie的内存空间。

处理内存申请时,如果申请的内存块小于128bytes, alloc将从以下几方面递进式地申请内存

  • 首先看看对应freelist有无空闲空间
  • alloc的内存池的备用内存是否有空余块(end_free - start_free)
  • 如果没有,则使用malloc向系统申请内存
  • 如果malloc还失败了,那么再看看其他freelist是否还有没有划分出去的内存块
  • 最后,已然山穷水尽,调用malloc_alloc(第一个配置器)的allocate()看看它的 handler 处理程序能否空出一些内存

有几个问题值得探讨

  1. sgi版本的stl管理内存的方式乍一看和linux内核的伙伴系统很像,但是stl内存池根本不涉及连续内存块合并的操作,也就是说没有伙伴的概念
  2. 内存池管理的内存在程序运行期间,并没有被调用free()库函数,因为如果要free,则必须要传回紧挨着cookie之后的那根指针,但是这根指针在分配过程中早已丢失。所以也更谈不上这些内存会归还给操作系统了。

标签:malloc,调用,管理,mmap,operator,内存,CPP,new
From: https://www.cnblogs.com/HeyLUMouMou/p/17304377.html

相关文章

  • InnoDB引擎之内存与磁盘结构
     一、逻辑存储结构      1、表空间(Tablespace)表空间(Tablespace)是一个逻辑容器,在一个表空间中可以有一个或多个段,一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间、用户表空间、撤销表空间、临时表空间等。 ......
  • C++通讯录管理系统
    编辑器vs2019代码如下:#include<iostream>usingnamespacestd;#defineMAX1000//最大人数//联系人信息结构体structPerson{ stringm_name; //性别1男2女 intm_sex; intm_age; stringm_phone; stringm_addr;};//通讯录结构体structAddressbo......
  • 项目管理软件或平台应该具备的基本功能
    客户询盘管理:这个功能可以帮助用户记录和跟踪客户询盘信息,包括产品、数量、价格、交货时间、质量要求等,以及客户反馈和商务合同信息,从而更好地了解客户需求,为客户提供更好的服务。客户询盘管理:这个需求是为了记录客户的需求,以便跟进报价和商务合同等事宜。通过记录客户的......
  • JVM 堆内存大小查看
    JVM堆内存大小查看在默认不配置JVM堆内存大小的情况下,JVM根据默认值来配置当前内存大小,可通过如下命令进行查看:java-XX:+PrintFlagsFinal-version|grepHeapSize  上图表示启动的JVM默认最大堆内存约为2.9G,初始化大小为195MB。 ......
  • 最小化的项目管理流程
    最小化的项目管理流程RDMP敏捷项目管理的最小化流程涵盖了项目启动、执行和收尾的全流程工作。这种流程串联确保项目能够在可控范围内完成,大致流程如下:项目启动流程:由高层领导授权项目任务书启动,各参与方对项目有共识。项目执行:为不同的成员分配工作,监督、并管理问题和风险......
  • Dart内存泄漏示例及如何解决
    内存泄漏是指应用程序中的对象被分配了内存空间,但在不再需要这些对象时,它们仍然占用着内存空间而没有被垃圾回收。Dart语言使用自动垃圾回收器来管理内存,但如果代码存在一些常见的陷阱,可能会导致内存泄漏问题。下面是一些解决方案:及时释放资源:在使用完资源后,及时将其关闭或释放。例......
  • 制造企业如何解决数据分散和管理困难的问题,实现数字化转型?
     随着全球制造业市场竞争愈发激烈,数字化转型已成为制造业企业发展的必然趋势。然而,面对数字化转型中的技术难题、成本压力和安全挑战,众多企业依然步履维艰。华为云凭借对制造业企业转型需求和痛点的深刻理解,为企业提供多种场景化解决方案,帮助企业高效上云,快速实现数字化转型。 华......
  • 56、K8S-监控机制-Prometheus-配置解析、标签管理
    Kubernetes学习目录1、配置文件1.1、配置简介1.1.1、简介Prometheus可以通过命令行或者配置文件的方式对服务进行配置。一般情况下,命令行方式一般用于不可变的系统参数配置,例如存储位置、要保留在磁盘和内存中的数据量等;配置文件用于定义与数据动态获取相关的配置选项和文件......
  • 最小化项目管理流程,并解决当前遇到的问题
    最小化项目管理流程,并解决当前遇到的问题。实时更新成果清单和进度为了解决实时更新成果清单和进度的问题,建议您使用一些项目管理工具,如Trello、Asana、JIRA等。这些工具可以帮助您协同更新和跟踪成果清单,同时监测项目的进度和状态。您可以将任务分配给不同的团队成员,并实时跟......
  • 项目管理流程和角色安排
    这是一个很好的项目管理流程和角色安排。以下是一些建议:对于成果清单的实时更新,确实可以通过项目管理工具来实现。这些工具可以让团队成员在每个任务完成后报告进度,同时也可以让项目经理查看任务状态并更新进度。这些工具还可以自动生成各种报告,例如进度报告和问题报告,以帮助团......