如果你也是被那条 native memory allocation (malloc) failed to allocate 2046256 bytes for chunk 的报错一路搜到这里的,那咱俩算同病相怜。去年我排查一个搜索集群节点故障,日志里反复出现这个错误,进程没崩,但内存就是“申请不到”。当时我手头只剩《CSAPP》里那点malloc概念,根本定位不了问题。后来硬着头皮把glibc的ptmalloc实现翻了一遍,才算搞明白malloc到底是怎么在用户态和内核态之间当“中间商”的。
这篇不打算讲malloc怎么用,讲的是它底层那套把戏:brk/mmap、chunk、bin、arena、tcache,以及为什么你free掉一块内存,它却不一定会还给操作系统。适合三类人看:写C/C++但总觉得内存行为是“玄学”的朋友,排查线上native内存分配失败、虚拟内存暴涨的人,以及准备面试时被“malloc底层实现”和“hashmap底层实现”连环追问的同学。看完你至少能回答一个问题:malloc(1)为什么会让进程多出132KB的虚拟内存。
1. malloc(1)背后的132KB:malloc是个批发商,不是零售店
1.1 一次malloc不等于一次系统调用
很多人的直觉是:每次调用malloc,操作系统就给我一块内存。这个直觉在早期实现里勉强成立,在现代glibc里是错的。malloc为了性能,会提前向内核“批发”一大块内存,然后自己在用户态做“零售”。
你用strace跟踪一个最简单的单线程程序,第一次调用malloc(1)时,大概率会看到类似这样的系统调用:
code复制brk(NULL) = 0x55f0a1f00000
brk(0x55f0a1f21000) = 0x55f0a1f21000
第一次brk就把堆顶指针推高了约132KB(0x21000 - 0x20000 = 0x1000加上初始段,不同平台略有差异)。这132KB就是glibc给主线程arena准备的“启动资金”。第二次malloc(1)时你会惊讶地发现:没有任何brk或mmap系统调用,malloc只是在这132KB里切了一小块出来返回。
这个设计的本质是:系统调用很贵,用户态malloc不能每次请求都触发一次内核态切换。一次系统调用摊分到几百次小分配里,成本就低很多。这也是后面理解chunk、bin、arena的基础——malloc管理的是用户态内存池,不是一个简单的“按需向内核要”的接口。
1.2 两种批发方式:brk和mmap
glibc向内核要内存只有两条路:brk和mmap。
brk操作的是“堆顶指针”(program break),把指针往高地址推,进程的堆就变大。它的特点是地址连续、适合小块分配、早期实现里释放时只能从堆顶往回缩,所以灵活度差。mmap则是匿名内存映射,可以在进程虚拟地址空间的任意位置映射一块内存,分配大块时更灵活,释放时直接munmap把整块还给内核。
这两条路的分工有个关键阈值:MMAP_THRESHOLD,默认128KB。请求小于128KB时,malloc优先用brk从主堆里切;请求大于等于128KB时,直接用mmap单独映射一块,不走堆。这个阈值不是死的,glibc会动态调整,但默认逻辑你可以当它是分水岭。
为什么大块要走mmap?设想一个场景:你malloc了1GB,用完free掉。如果这1GB是从brk堆顶切的,free之后堆顶只能缩到这块内存的下边界,但如果中间还夹杂着别的小块没释放,这1GB就永远卡在堆里,堆顶回不去,内存被白白占着。用mmap就没这问题,映射独立,释放即归还。
1.3 malloc成功不等于有物理内存
这一步是最多人踩坑的地方。malloc返回了一个非NULL指针,你立刻写入10MB数据,程序直接被杀,日志里只有OOM或Killed。这时候你可能会骂:“我不是malloc成功了吗?”
原因在于Linux的内存分配是“懒加载”的。malloc只是通过brk或mmap在进程的虚拟地址空间里划分了一块区域,内核为它建立了页表映射,但并不实际分配物理页。真正往这块内存写数据时,CPU缺页中断触发,内核才在物理内存里找空闲页填上。如果此时物理内存不够,要么内核回收别的页,要么触发OOM Killer杀进程。
类比一下:malloc像批发商跟你说“仓库有位置,你先登记”,你说“好,我要放货”,等货车真开到仓库门口,才发现仓库管理员说“货堆满了,你放不下”。这就是为什么`m
