一、引言
内存对象的分配与释放一直是后端开发人员代码设计中需要考虑的问题,考虑不周极易造成内存泄漏、内存访问越界等问题。在发生内存异常后,开发人员往往花费大量时间排查用户管理层代码,而忽视了C运行时,库层和操作系统层本身的实现也可能会带来内存问题。本文先以一次线上内存事故引出问题,再逐步介绍 glibc 库的内存布局设计、内存分配、释放逻辑,最后给出相应的解决方案。
二、内存告警事件
在一次线上运维过程中发现服务出现内存告警。
【监控系统-自定义监控-告警-持续告警】
检测规则: xxx内存使用率监测:一般异常(>4096)集群id:xxx集群名称: xxxxxx异常对象(当前值): xx.xx.xx.xx-xxxxxxx(11335)开始时间: 2023-08-10 17:10:30告警时间: 2023-08-10 18:20:32持续时间: 1h10m2s异常比例: 2.1918 (8/365)异常级别: 一般备注:-
随即查看服务相关监控,判断是业务流量激增带来的内存短时间增高,或是发生了内存泄漏。
通过查看 OPS 和服务自身统计的内存监控,发现在告警时间内存在业务流量突增现象,但是内存已经下降到正常值了。然而告警持续到了18:20依然没有恢复,跟监控表现不符,登录机器后发现实例的内存并没有恢复,随即怀疑用户层发生内存泄漏。
经过分析,由于内存统计代码每次调用 new、delete 之后才会对统计值进行增减,而监控中服务统计内存已经下降,说明已经正常调用 delete 进行内存释放,而操作系统层面发现内存依然居高不下,怀疑使用的c运行库 glibc 存在内存释放问题。
三、glibc 内存管理机制
glibc 全称为 GUN C Library,是一个开源的标准C库,其对操作系统相关调用进行了封装,提供包括数学、字符串、文件 I/O、内存管理、多线程等方面标准函数和系统调用接口供用户使用。
以 Linux 内核 v2.6.7 之后的32位模式下的虚拟内存布局方式为例:
其中 Heap 和 Mmap 区域是可以提供给用户程序使用的虚拟内存空间。
Heap 操作
操作系统提供了 brk() 函数,c运行时库提供了 sbrk() 函数从 Heap 中申请内存,函数声明如下:
int brk(void *addr);void *sbrk(intptr_t increment);
Mmap 操作
在 Linux 中提供了 mmap() 和 munmap() 函数操作虚拟内存空间,函数声明如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);int munmap(void *addr, size_t length);
其中 mmap 能够将文件或者其他对象映射进内存,munmap 能够删除特定地址区域的内存映射。
开源社区公开了很多现成的内存分配器,包括 dlmalloc、ptmalloc、jemalloc、tcmalloc......,由于 glibc 用的是 ptmalloc 所以本文只对该内存分配器进行介绍。
3.3.1 Arena(分配区)
堆管理结构如下所示:
struct malloc_state { mutex_t mutex;/* Serialize access. */ int flags;/* Flags (formerly in max_fast). */ #if THREAD_STATS /* Statistics for locking. Only used if THREAD_STATS is defined. */ long stat_lock_direct, stat_lock_loop, stat_lock_wait; #endif mfastbinptr fastbins[NFASTBINS];/* Fastbins */ mchunkptr top; mchunkptr last_remainder; mchunkptr bins[NBINS * 2]; unsigned int binmap[BINMAPSIZE];/* Bitmap of bins */ struct malloc_state *next;/* Linked list */ INTERNAL_SIZE_T system_mem; INTERNAL_SIZE_T max_system_mem; };
ptmalloc 对进程内存是通过一个个的分配区进行管理的,而分配区分为主分配区(arena)和非主分配区(narena),两者区别在于主分配区中可以使用 sbrk 和 mmap 向操作系统申请内存,而非主分配区只能通过 mmap 申请内存。
对于一个进程,只有一个主分配区和若干个非主分配区,主分配区只能由第一个线程来创建持有,其和非主分配区由环形链表的形式相互连接,整个分配区中通过变量互斥锁支持多线程访问。
当一个线程调用 malloc 申请内存时,该线程先查看线程私有变量中是否已经存在一个分配区。如果存在,则对该分配区加锁,加锁成功的话就用该分配区进行内存分配;失败的话则搜索环形链表找一个未加锁的分配区。如果所有分配区都已经加锁,那么 malloc 会开辟一个新的分配区加入环形链表并加锁,用它来分配内存。释放操作同样需要获得锁才能进行。
3.3.2chunk
ptmalloc 通过 malloc_chunk 来管理内存,定义如下:
struct malloc_chunk {INTERNAL_SIZE_Tprev_size;/* Size of previous chunk (if free).*/INTERNAL_SIZE_Tsize;/* Size in bytes, including overhead. */struct malloc_chunk* fd;/* double links -- used only if free. */struct malloc_chunk* bk;/* Only used for large blocks: pointer to next larger size.*/struct malloc_chunk* fd_nextsize;/* double links -- used only if free. */struct malloc_chunk* bk_nextsize;};
使用该数据结构能够更快的在链表中查找到空闲 chunk 并分配。
3.3.3空闲链表(bins)
在 ptmalloc 中,会将大小相似的 chunk 链接起来,叫做空闲链表(bins),总共有128个 bin 供 ptmalloc 使用。用户调用 free 函数释放内存的时候,ptmalloc 并不会立即将其归还操作系统,而是将其放入 bins 中,这样下次再调用 malloc 函数申请内存的时候,就会从 bins 中取出一块返回,这样就避免了频繁调用系统调用函数,从而降低内存分配的开销。
在 ptmalloc 中,bin主要分为以下四种:
其中根据 bin 的分类,可以分为 fast bin 和 bins,而 bins 又可以分为 unsorted bin、small bin 以及 large bin 。
程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而, malloc 中在分配过程中引入了 fast bins 。
fast bin 总共有10个,本质上就是10个单链表,每个 fast bin 中所包含的 chunk size 以8字节逐渐递增,即如果第一个 fast bin 中 chunk size 均为16个字节,第二个 fast bin 的 chunk size 为24字节,以此类推,最后一个 fast bin 的 chunk size 为80字节。值得注意的是 fast bin 中 chunk 释放并不会与相邻的空闲 chunk 合并,这是由于 fast bin 设计的初衷就是小内存的快速分配和释放,因此系统将属于 fast bin 的 chunk 的P(未使用标志位)总是设置为1,这样即使当 fast bin 中有某个 chunk 同一个 free chunk 相邻的时候,系统也不会进行自动合并操作。
malloc 操作:
在 malloc 申请内存的时候,如果申请的内存大小范围在fast bin 以内,则先在 fast bin 中进行查找,如果 fast bin 中存在空闲 chunk 则返回。否则依次从 small bin、unsorted bin、large bin 中进行查找。
free 操作:
先通过 chunksize 函数根据传入的地址指针获取该指针对应的 chunk 的大小;然后根据这个 chunk 大小获取该 chunk 所属的 fast bin,然后再将此 chunk 添加到该 fast bin 的链尾。
unsorted bin
是 bins 的缓冲区,顾名思义,unsorted bin 中的 chunk 无序,这种设计能够让 glibc 的 malloc 机制有第二次机会重新利用最近释放的 chunk 从而加快内存分配的时间。
与 fast bin 不同,unsorted bin 采用的是 FIFO 的方式。
malloc 操作:
当需要的内存大小大于 fast bin 的最大大小,则先在 unsorted 中寻找,如果找到了合适的 chunk 则直接返回,否则继续在 small bin 和l arge bin中搜索。
free 操作:
当释放的内存大小大于fast bin的最大大小,则将释放的 chunk 写入 unsorted bin。
大小小于512字节的 chunk 被称为 small chunk,而保存 small chunks 的 bin 被称为 small bin。62个 small bin 中,每个相邻的的 small bin 之间相差8字节,同一个 small bin 中的 chunk 拥有相同大小。
small bin 指向的是包含空闲区块的双向循环链表。内存分配和释放逻辑如下:
malloc 操作:
当需要的内存不存在于 fast bin 和 unsorted bin 中,并且大小小于512字节,则在 small bin 中进行查找,如果找到了合适的 chunk 则直接返回。
free 操作:
free 一个 chunk 时会检查该 chunk 相邻的 chunk 是否空闲,如果空闲则需要先合并,然后将合并的 chunk 先从所属的链表中删除然后合并成一个新的 chunk,新的 chunk 会被添加在 unsorted bin 链表的前端。
大小大于等于512字节的 chunk 被称为 large chunk,而保存 large chunks 的 bin 被称为 large bin。large bins 中每一个 bin 分别包含了一个给定范围内的 chunk,其中的 chunk 按大小递减排序,大小相同则按照最近使用时间排列。63 large bin 中的每一个都与 small bin 的操作方式大致相同,但不是存储固定大小的块,而是存储大小范围内的块。每个 large bin 的大小范围都设计为不与 small bin 的块大小或其他large bin 的范围重叠。
malloc 操作:
首先确定用户请求的大小属于哪一个 large bin,然后判断该 large bin 中最大的 chunk 的 size 是否大于用户请求的 size。如果大于,就从尾开始遍历该 large bin,找到第一个 size 相等或接近的 chunk,分配给用户。如果该 chunk 大于用户请求的 size 的话,就将该 chunk 拆分为两个 chunk:前者返回给用户,且 size 等同于用户请求的 size;剩余的部分做为一个新的 chunk 添加到 unsorted bin 中。
free 操作:
large bin 的 fee 操作与 small bin 一致,此处不再赘述。
3.3.4 特殊chunk
top chunk 是堆最上面的一段空间,它不属于任何 bin,当所有的 bin 都无法满足分配要求时,就要从这块区域里来分配,分配的空间返回给用户,剩余部分形成新的 top chunk,如果 top chunk 的空间也不满足用户的请求,就要使用 brk 或者 mmap 来向系统申请更多的堆空间(主分配区使用 brk、sbrk,非主分配区使用 mmap)。
mmaped chunk
当分配的内存非常大(大于分配阀值,默认128K)的时候需要被 mmap 映射,则会放到 mmaped chunk 上,释放 mmaped chunk 上的内存的时候会将内存直接交还给操作系统。(chunk 中的M标志位置1)
last remainder chunk
如果用户申请的 size 属于 small bin 的,但是又不能精确匹配的情况下,这时候采用最佳匹配(比如申请128字节,但是对应的bin是空,只有256字节的 bin 非空,这时候就要从256字节的 bin 上分配),这样会 split chunk 成两部分,一部分返给用户,另一部分形成 last remainder chunk,插入到 unsorted bin 中。
3.3.5hunk 的合并与切分
合并
当 chunk 释放时,如果前后两个相邻的 chunk 均空闲,则会与前后两个相邻 chunk 合并,随后将合并结果放入 unsorted bin 中。
切分
当需要分配的内存小于待分配的 chunk 块,则会将待分配 chunk 块切割成两个 chunk 块,其中一个 chunk 块大小等同于用户需要分配内存的大小。需要注意的是分裂后的两个 chunk 必须均大于 chunk 的最小大小,否则不会进行拆分。
内存分配流程可以分为三步:
第一步:根据用户请求大小转换为实际需要分配 chunk 空间的大小;
第二步:在 bins 中搜索还没有归还给操作系统的 chunk 块,具体流程如下图所示。
第三步:如果 top chunk 依然无法满足分配请求,通过 sbrk 或 mmap 增加 top chunk 的大小并分配内存给用户。
3.5 内存释放
3.6 内存碎片
按照 glibc 的内存分配策略,我们考虑下如下场景:
1.假设 brk 起始地址为512k
2.malloc 40k 内存,即 chunk A,brk = 512k + 40k = 552k
3.malloc 50k 内存,即 chunk B,brk = 552k + 50k = 602k
4.malloc 60k 内存,即 chunk C,brk = 602k + 60k = 662k
5.free chunk A。
此时 chunk A 为空闲块,但是如果 chunk C 和 chunk B 一直不释放无法直接通过移动brk指针来释放 chunk A 的内存,必须等待 chunk B 和 chunk C 释放才能和 top chunk 合并并将内存归还给操作系统。
四、问题分析与解决
通过前面的内存分配器运行原理能够很容易得出原因,由于程序中连续调用 free/delete 释放内存仅仅只是将内存写入内存分配器的 bins 中,并没有将其归还给操作系统,所以会出现疑似内存未回收的情况。并且如果每次 delete 的内存都不与 top chunk 相邻,会导致 chunk 块长时间留在空闲链表中无法合并到 top chunk,从而出现内存无法释放给操作系统的现象。
4.1 优化办法
4.2 效果对比测试
为了验证优化后的内存使用效果,编写测试代码,模拟线上 pipline 模式下的3000万次连续请求,对比请求过程中的内存峰值、连接断开后的内存使用状况:
glibc内存分配器
内存峰值
连接断开后内存占用
jemalloc内存分配器
内存峰值
连接断开后内存占用
根据测试结果,jemalloc 相较于 glibc 释放空闲内存速度快12%。
本网站的文章部分内容可能来源于网络和网友发布,仅供大家学习与参考,如有侵权,请联系站长进行删除处理,不代表本网站立场,转载者并注明出处:https://jmbhsh.com/baihuokuaixun/34891.html