动态内存开辟详解

在C/C++中,动态内存的开辟是不可缺少以及十分重要的。而在C语言中我们是调用了malloc库函数来动态申请内存,C++ 则是使用了new。接下来会对malloc的底层原理和Glibc的ptmalloc内存管理器进行解析动态内存开辟的过程。


首先说一个问题就是为何C/C++中要有动态内存开辟存在?
主要是因为我们在写程序过程中单单使用静态的内存分布,比如数组。是极度不灵活的。
因为我们都知道计算机的内存资源有限,如果我们使用的数据并不是要一直存在,当大量的静态数据堆积,那么计算机的内存资源就会出现不足。或者当程序在应对不同的数量时,我们前面编写程序的静态申请的空间很有可能出现不足,又要手动去修改(比如:之前我只要处理50个人的空间,现在我想要处理5000人)。所以我们针对上面的情况需要动态的去申请空间,当我们有的数据使用完毕的时候我们便可以直接释放掉那块内存。或者当需求不同可以随时增加和减少内存的申请。

在开始之前要了解一下linux下32位进程内存布局:
动态内存开辟详解_第1张图片

linux系统在装载elf格式文件时, 会调用loder把可执行文件按照上面所划分的段依次的装入到某以一地址空间,此地址根据CPU的位数来决定,像上图32位操作系统,则是从0x08048000开始装载。把.text,.data,.bss等装入,直到是把4G(2^32就是4G)的虚拟地址空间全部装入。也就是意味着每一个程序的执行在32位的Linux平台下都会伴随着一个4G的虚拟地址空间的产生。而每个段的属性介绍我在很久以前写过一篇博客大致讲解了一下:虚拟地址空间段属性讲解。

在这里插个题外话,就是每个进程的mmap区域默认每次执行的起始地址都不同(从上图的random stack offset也可以看出来),因为这样可以防止使用缓冲区溢出进行攻击。这里我们也可以验证一下。

  7 int main()
  8 {
  9     int i=0;
 10     cout<<&i<11     return 0;
 12 }

我们定义一个局部变量在栈上。然后去打印它的地址。
动态内存开辟详解_第2张图片
可以验证出来的确地址是不断在发生变化的。而这个4G虚拟地址空间中的堆和栈后面会专门写一篇博客介绍它们。


了解了这个4G虚拟地址空间的作用,以及各个段的属性和作用,我们来开始认识一下动态内存开辟的过程。

从4G的虚拟地址空间图,我们可以发现动态的区域的开辟,主要分两块,一个Heap区,一个mmap区。在详细讲解这两个区域之前我们先来看一个程序:

 4 int main()
  5 {
  6     void *p=malloc(4);     //我们想在堆上申请4个字节的动态内存空间。
  7     free(p);
  8     return 0;
  9 }

动态内存开辟详解_第3张图片

我们利用了strace 来跟踪了malloc程序查看了它的系统调用。发现我们在申请4字节的时候是brk()系统调用。

 4 int main()
  5 {
  6     void *p=malloc(128*1024);     //这次我们动态申请了128K的内存空间
  7     free(p);
  8     return 0;
  9 }

这里写图片描述
发现底层的系统调用变成了mmap()了。

在Linux中,对于heap区的操作提供的是brk()系统调用,C运行库又提供了sbrk()库函数。而对于与mmap区则提供的是mmap()和munmap()函数。在C中的malloc函数的底层主要就是使用了brk()和mmap()系统调用,它们的区别也从上面可以看出来,在<128K内存申请的时候,linux内核调用的是brk(),而>=128K的时候则是调用了mmap(). 两者申请的都是虚拟内存,并不是物理内存,只有当真正要访问的时候该内存的时候,就会产生缺页中断,然后陷入内核,操作系统根据资源情况分配物理内存,然后建立虚拟地址到物理地址的映射关系(地址映射后面也会专门写博客介绍)。


大致的malloc流程(下面还会再细致讲解):
有了前面知识的基础,我们现在可以大致看一下malloc申请内存的过程了。

在低于128K的内存申请时,我们可以看见4G虚拟地址空间Heap区有一个program break。这是linux维护了一个break指针用于应对malloc内存申请。我用图来简略的描述一下过程:
动态内存开辟详解_第4张图片

我们可以发现申请低于128K内存时,就是把break指针向上偏移了对应申请的内存大小位置。(这里要注意的是这是仅仅分配了虚拟内存,前面也讲了)。

动态内存开辟详解_第5张图片

当我们申请大于128K的内存时候,这时不在是推动break指针,而是调用mmap()在mmap区(也就是heap堆和栈之间的一块区域区分配虚拟内存)申请了一块200K的内存。

执行free(C)的时候,其对应的虚拟内存和物理内存都会被释放并且归还给了操作系统。那么继续看我们执行了free(A),发现break指针没有发生任何的移动,而且内存也没有释放和归还给操作系统,这是为什么呢? 这是因为操作系统为了内存申请的效率性,将小块内存都用类似内存池的管理方式管理起来了。并且因为只有一个break指针,当B没有释放掉的时候,A的内存是不会释放掉的,此时就产生了内存碎片那么留着这块内存又有什么用呢?当用户再一次申请一块内存是在刚刚释放20K的A的内存范围内的话,那么操作系统就会把刚刚释放的A的内存直接返回给用户,不必在申请新的资源和重新建立映射关系。

上面提到的内存碎片,其实就是由于用户动态申请内存,因为操作系统的内存分配算法的原因从而导致产生了一些不可用的空间内存块。内存碎片又分为了:内部内存碎片外部内存碎片两种。

内部内存碎片:当我们第一次去使用malloc去申请内存的时候,32位linux操作系统在内核空间会以4K页面为单位的内存返回给我们的用户空间,所以像前面我们申请B,30K内存空间的时候,其实内核返回给用户空间的内存是32K(4K的整数倍)。然后malloc再返回30K的内存空间给我们使用,那么剩余的2K就属于内部内存碎片。

外部内存碎片:就像是前面我们free掉A之后,因为A的前面还有B,并且只有一个break指针,A的内存并没有释放,这时产生的内存碎片就是外部内存碎片。

而linux操作系统到底是怎么管理这些内存,如何充分利用这些内存碎片以及分配效率问题?下面就将介绍Glibc默认的内存管理器Ptmalloc


Ptmalloc

” 学习ptmalloc是因为老师推荐的资料,当看到开头作者因为项目组在制作类似Nosql系统时,然后出现了内存暴增的情况,然后他们一步步排查,发现不是项目所致,而是glibc的内存管理方式所引起,然后作者特意刨析了ptmalloc的源码,然后做出了资料供大家学习,我觉得这种遇到问题刨根问底的精神应该是我们没一个程序员所应该拥有的(废话有点多^-^) “

前面也说了ptmalloc是Glibc默认的内存管理器,我们常用的malloc和free就是由ptmalloc内存管理器提供的基础内存分配函数。ptmalloc有点像我们自己写的内存池,当我们通过malloc或者free函数来申请和释放内存的时候,ptmalloc会将这些内存管理起来,并且通过一些策略来判断是否需要回收给操作系统。这样做的最大好处就是:让用户申请内存和释放内存的时候更加高效。

为了内存分配函数malloc的高效性,ptmalloc会预先向操作系统申请一块内存供用户使用,并且ptmalloc会将已经使用的和空闲的内存管理起来;当用户需要销毁内存free的时候,ptmalloc又会将回收的内存管理起来,根据实际情况是否回收给操作系统。


内存管理设计假设

ptmalloc在设计时折中了高效率,高空间利用率,高可用性等设计目标。所以有了下面一些设计上的假设条件:

  1. 具有长生命周期的大内存分配使用 mmap。
  2. 特别大的内存分配总是使用 mmap。
  3. 具有短生命周期的内存分配使用 brk,因为用 mmap 映射匿名页, 当发生缺页异常
    时, linux 内核为缺页分配一个新物理页,并将该物理页清 0, 一个 mmap 的内存块
    需要映射多个物理页,导致多次清 0 操作,很浪费系统资源,所以引入了 mmap
    分配阈值动态调整机制,保证在必要的情况下才使用 mmap 分配内存。
  4. 尽量只缓存临时使用的空闲小内存块,对大内存块或是长生命周期的大内存块在释
    放时都直接归还给操作系统。
  5. 对空闲的小内存块只会在 malloc 和 free 的时候进行合并, free 时空闲内存块可能
    放入 pool 中,不一定归还给操作系统。
  6. 收缩堆的条件是当前 free 的块大小加上前后能合并 chunk 的大小大于 64KB、,并且
    堆顶的大小达到阈值,才有可能收缩堆, 把堆最顶端的空闲内存返回给操作系统。
  7. 需要保持长期存储的程序不适合用 ptmalloc 来管理内存。
  8. 为了支持多线程,多个线程可以从同一个分配区(arena) 中分配内存, ptmalloc
    假设线程 A 释放掉一块内存后, 线程 B 会申请类似大小的内存,但是 A 释放的内
    存跟 B 需要的内存不一定完全相等,可能有一个小的误差, 就需要不停地对内存块
    作切割和合并,这个过程中可能产生内存碎片。

Main_arena(主分配区) 与 non_main_arena(非主分配区)

1-Glibc 的 malloc 可以支持多线程,增加了非主分配区支持, 主分配区与非主分配区用环形链表进行管理。

2-每个进程只有一个主分配区,但可能存在多个非主分配区。

3-主分配区可以访问进程的 heap 区域和 mmap 映射区域,也就是说主分配区可以使用 sbrk 和 mmap向操作系统申请虚拟内存。而非主分配区只能访问进程的 mmap 映射区域, 非主分配区每次使用 mmap()向操作系统“批发” HEAP_MAX_SIZE(32 位系统上默认为 1MB, 64 位系统默认为 64MB) 大小的虚拟内存。

4-如果主分配区的内存是通过 mmap()向系统分配的,当 free 该内存时,主分配区会直接调用 munmap()将该内存归还给系统。

5-当某一线程需要调用 malloc()分配内存空间时, 该线程先查看线程私有变量中是否已经
存在一个分配区,如果存在, 尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,
如果失败, 该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经
加锁,那么 malloc()会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁,
然后使用该分配区进行分配内存操作。在释放操作中,线程同样试图获得待释放内存块所在
分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的
互斥锁之后才可以进行释放操作。

从上面我们也可以看出来,ptmalloc在内存管理上面使用了大量的mutex(锁)操作,而频繁加锁解锁的操作会在多线程竞争情况下性能大大降低,而tcmalloc则有更好的方式来解决(后期博客会介绍到)。


chunk 的组织

ptmalloc通过chunk的数据结构来组织每个内存单元。当我们使用malloc分配得到一块内存的时候,这块内存就会通过chunk的形式被记录到glibc上并且管理起来。你可以把它想象成自己写内存池的时候的一个内存数据结构。

chunk的格式分为两种,一种分配中,一种是空闲中。

使用中的chunk
动态内存开辟详解_第6张图片
1.chunk 指针指向一个 chunk 的开始,图中的 mem 指针才是真正返回给用户的内存指针。

2.P代表前一个chunk块是否在被使用,P=0则空闲,这时第一个域prev_size(也就是前一个chunk块的大小)才有效。这样就可以通过它来找到前一个空闲chunk块的起始地址。P=1则代表前一个chunk已经被使用。无法访问。

3.M代表当前的chunk是从heap区分配而来(当其为0时),还是从mmap区映射而来(当其为1时)。

4.A代表当前chunk是属于主分配区还是非主分配区。

空闲的chunk
动态内存开辟详解_第7张图片

  1. 空闲的chunk会被放置到空闲的链表bins上。当用户申请内存malloc的时候,会先去查找空闲链表bins上是否有合适的内存。

  2. fp和bp分别指向前一个和后一个空闲链表上的chunk

  3. fp_nextsize和bp_nextsize分别指向前一个空闲chunk和后一个空闲chunk的大小,主要用于在空闲链表上快速查找合适大小的chunk。

  4. fp、bp、fp_nextsize、bp_nextsize的值都会存在原本的用户区域,这样就不需要专门为每个chunk准备单独的内存存储指针了(采用了空间复用的技术)。


空闲 chunk 容器

当用户使用free函数释放掉的内存,ptmalloc并不会马上交还给操作系统(这边很多时候我们明明执行了free函数,但是进程内存并没有回收就是这个原因),而是被ptmalloc本身的空闲链表bins管理起来了,这样当下次进程需要malloc一块内存的时候,ptmalloc就会从空闲的bins上寻找一块合适大小的内存块分配给用户使用。这样的好处可以避免频繁的系统调用,降低内存分配的开销.
动态内存开辟详解_第8张图片
1.ptmalloc将相似大小的 chunk 用双向链表链接起来,这样的一个链表被称为一个 bin。 Ptmalloc 一共维护了 128 个 bin,并使用一个数组来存储这些 bin。

2.通过上图这个bins的列表就能看出,当用户调用malloc的时候,能很快找到用户需要分配的内存大小是否在维护的bin上,如果在某一个bin上,就可以通过双向链表去查找合适的chunk内存块给用户使用。

3.Fast bins:fast bins是bins的高速缓冲区,大约有10个定长队列。当用户释放一块不大于max_fast(默认值64B)的chunk(一般小内存)的时候,会默认会被放到fast bins上。当用户下次需要申请内存的时候首先会到fast bins上寻找是否有合适的chunk,然后才会到bins上空闲的chunk。ptmalloc会遍历fast bin,看是否有合适的chunk需要合并到bins上。

4.unsorted bin:是bins的一个缓冲区。当用户释放的内存大于max_fast或者fast bins合并后的chunk都会进入unsorted bin上。当用户malloc的时候,先会到unsorted bin上查找是否有合适的bin,如果没有合适的bin,ptmalloc会将unsorted bin上的chunk放入bins上,然后到bins上查找合适的空闲chunk。

5 small binslarge bins:small bins和large bins是真正用来放置chunk双向链表的。每个bin之间相差8个字节,并且通过上面的这个列表,可以快速定位到合适大小的空闲chunk。前64个为small bins,定长;后64个为large bins,非定长。

6.Top chunk:并不是所有的chunk都会被放到bins上。top chunk相当于分配区的顶部空闲内存,当bins上都不能满足内存分配要求的时候,就会来top chunk上分配。

7.mmaped chunk:当分配的内存非常大(大于分配阀值,默认128K)的时候,需要被mmap映射,则会放到mmaped chunk上,当释放mmaped chunk上的内存的时候会直接交还给操作系统。


细致malloc流程

  1. 获取分配区的锁,防止多线程冲突。

  2. 计算出需要分配的内存的chunk实际大小。

  3. 判断chunk的大小,如果小于max_fast(64B),则取fast bins上去查询是否有适合的chunk,如果有则分配结束。

  4. chunk大小是否小于512B,如果是,则从small bins上去查找chunk,如果有合适的,则分配结束。

  5. 继续从 unsorted bins上查找。如果unsorted bins上只有一个chunk并且大于待分配的chunk,则进行切割,并且剩余的chunk继续扔回unsorted bins;如果unsorted bins上有大小和待分配chunk相等的,则返回,并从unsorted bins删除;如果unsorted bins中的某一chunk大小 属于small bins的范围,则放入small bins的头部;如果unsorted bins中的某一chunk大小 属于large bins的范围,则找到合适的位置放入。

  6. 从large bins中查找,找到链表头后,反向遍历此链表,直到找到第一个大小 大于待分配的chunk,然后进行切割,如果有余下的,则放入unsorted bin中去,分配则结束。

  7. 如果搜索fast bins和bins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了(top chunk相当于分配区的剩余内存空间)。判断top chunk大小是否满足所需chunk的大小,如果是,则从top chunk中分出一块来。

  8. 如果top chunk也不能满足需求,则需要扩大top chunk。主分区上,如果分配的内存小于分配阀值(默认128k),则直接使用brk()分配一块内存;如果分配的内存大于分配阀值,则需要mmap来分配;非主分区上,则直接使用mmap来分配一块内存。通过mmap分配的内存,就会放入mmap chunk上,mmap chunk上的内存会直接回收给操作系统。


free流程

  1. 获取分配区的锁,保证线程安全。

  2. 如果free的是空指针,则返回,什么都不做。

  3. 判断当前chunk是否是mmap映射区域映射的内存,如果是,则直接munmap()释放这块内存。前面的已使用chunk的数据结构中,我们可以看到有M来标识是否是mmap映射的内存。

  4. 判断chunk是否与top chunk相邻,如果相邻,则直接和top chunk合并(和top chunk相邻相当于和分配区中的空闲内存块相邻)。转到步骤8

  5. 如果chunk的大小大于max_fast(64b),则放入unsorted bin,并且检查是否有合并,有合并情况并且和top chunk相邻,则转到步骤8;没有合并情况则free。

  6. 如果chunk的大小小于 max_fast(64b),则直接放入fast bin,fast bin并没有改变chunk的状态。没有合并情况,则free;有合并情况,转到步骤7

  7. 在fast bin,如果当前chunk的下一个chunk也是空闲的,则将这两个chunk合并,放入unsorted bin上面。合并后的大小如果大于64KB,会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的空闲chunk进行合并,合并后的chunk会被放到unsorted bin中,fast bin会变为空。合并后的chunk和topchunk相邻,则会合并到topchunk中。转到步骤8

  8. 判断top chunk的大小是否大于mmap收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。free结束。


配置选项

Ptmalloc 主要提供以下几个配置选项用于调优,这些选项可以通过 mallopt()进行设置:

1. M_MXFAST:用于设置fast bins中保存的chunk块的最大大小,默认是64B,最大只能到80B。为何不能再大?因为在ptmalloc中我们的fast bins就是为了更加高效率的来应对用户申请小块内存的分配,如果值设置过大,那么会产生大量内存碎片,从而导致ptmalloc堆积大量空闲内存,无法归还给操作系统,导致内存暴增。

2.M_TRIM_THRESHOLD: 用于设置 mmap 收缩阈值,默认值为 128KB。只有在free的时候才会出现收缩,当free的空间大于128K,那么ptmalloc就会将多余的那一部分返回给操作系统(具体是主分配区,调用 malloc_trim()返回。非主分配区,调用 heap_trim()返回)。

3。M_TRIM_THRESHOLD:用于设置 mmap 分配阈值。默认值也是128K,当用户需要分配的内存大于 mmap分配阈值,ptmalloc的 malloc()函数其实相当于 mmap()的简单封装, free 函数相当于 munmap()的简单封装。相当于直接通过系统调用分配内存,回收的内存就直接返回给操作系统了。

4.M_MMAP_MAX:用于设置进程中用 mmap 分配的内存块的最大限制,默认值为 64K,因
为有些系统用 mmap 分配的内存块太多会导致系统的性能下降。


使用注意事项

为了避免Glibc内存暴增,需要注意:

  1. 后分配的内存先释放,因为ptmalloc收缩内存是从top chunk开始,如果与top chunk相邻的chunk不能释放,top chunk以下的chunk都无法释放。

  2. Ptmalloc不适合用于管理长生命周期的内存,特别是持续不定期分配和释放长生命周期的内存,这将导致ptmalloc内存暴增。

  3. 多线程分阶段执行的程序不适合用ptmalloc,这种程序的内存更适合用内存池管理

  4. 尽量减少程序的线程数量和避免频繁分配/释放内存。频繁分配,会导致锁的竞争,最终导致非主分配区增加,内存碎片增高,并且性能降低。

  5. 防止内存泄露,ptmalloc对内存泄露是相当敏感的,根据它的内存收缩机制,如果与top chunk相邻的那个chunk没有回收,将导致top chunk一下很多的空闲内存都无法返回给操作系统。

  6. 防止程序分配过多内存,或是由于Glibc内存暴增,导致系统内存耗尽,程序因OOM被系统杀掉。预估程序可以使用的最大物理内存大小,配置系统的/proc/sys/vm/overcommit_memory,/proc/sys/vm/overcommit_ratio,以及使用ulimt –v限制程序能使用虚拟内存空间大小,防止程序因OOM被杀掉。


linux的OOM机制

该机制会监控那些占用内存过大,尤其是瞬间很快消耗大量内存的进程,为了防止内存耗尽而内核会把该进程杀掉。

防止重要的系统进程触发(OOM)机制而被杀死:可以设置参数/proc/PID/oom_adj为-17,可临时关闭linux内核的OOM机制


借鉴博客:Linux c 开发 - 内存管理器ptmalloc
参考资料:淘宝网-ptmalloc

你可能感兴趣的:(程序员的基本素质)