崩溃根因排查:从Segmentation Fault到max_map_count耗尽)
半夜两点多值班群直接被刷穿了。线上一个高并发服务节点突然core dump日志最后几行只有孤零零的Allocation failed紧接着就是Segmentation Fault。把core拉起来一看崩溃点落在glibc的free()里GDB栈顶停在_int_free当时我的第一反应是堆被改坏了查了一圈才发现真正的元凶是/proc/sys/vm/max_map_count被耗尽。整个过程从core文件一路追到系统内核参数花了大半夜才把问题彻底定性。这篇文章把这个case完整复盘一遍为什么max_map_count耗尽会让free()背锅怎么定位以及最后是怎么根治的希望对还在老glibc版本上跑业务的同学有帮助。1. 事故现场与第一反应1.1 故障现象日志、崩溃现场和第一印象这个业务节点的特征是长连接多、线程池大、偶尔还会做一批大尺寸内存块的缓存操作。故障发生前几分钟监控显示线程数缓慢上涨内存占用没有明显异常但紧接着进程就异常退出退出码是139SIGSEGV。应用日志里没有留下堆栈只有一条来自内存分配调用的失败日志再往后就是进程消失。这是最磨人的故障类型没有明显OOM没有明显的死循环所有常规指标看起来都还算正常结果进程直接没了。第一印象是内存踩踏memory corruption因为free()崩溃在绝大多数情况下都和堆元数据被破坏有关。很多开发同学一看到free()崩了第一反应就是去找谁越界写了、谁double free了我们也是这个思路。于是把core文件用gdb加载先看崩溃栈(gdb) bt #0 0x00007f5b6c8f2c9b in _int_free (av0x7f5b6c9b6b40, p0x7f5b6a1e4c20, have_lock0) at malloc.c:4024 #1 0x00007f5b6c8f3b7a in __libc_free (mem0x7f5b6a1e4c30) at malloc.c:3637 #2 0x0000000000405f8a in release_buffer (buf0x7f5b6a1e4c30) at buffer.c:118 #3 0x0000000000407a31 in worker_thread (arg0x0) at worker.c:243 #4 0x00007f5b6c8e7d1c in start_thread (arg0x7f5b6c9f8700) at pthread_create.c:301 #5 0x00007f5b6c61734d in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:115从栈看确实是worker线程在工作完成后释放一块缓冲区时崩的。但如果只是普通free一个合法指针理论上不该崩。真正可疑的是malloc.c内部的_int_free它收到了一个看起来不合理的chunk指针。继续看寄存器(gdb) info registers rdi rsi rdx rdi 0x7f5b6a1e4c20 // 传入的chunk指针 rsi 0x7f5b6c9b6b40 // arena指针看起来正常 rdx 0x0再用p *p查看chunk头发现size字段的值非常奇怪既不在当前arena的堆范围内也跟相邻chunk对不上。顺着这个线索我们意识到不是简单越界写而是更上层的“内存分配失败”把状态搞乱了。1.2 第一轮排查从core文件里找真相崩溃点出来了接下来要做两件事一是确认free()拿到的这个指针是怎么来的二是搞清为什么malloc内部会认为它非法。先看release_buffer这个函数源码逻辑很简单调用free()释放一个之前由malloc()分配出来的缓冲区。也就是说这块内存理论上应该是malloc分配的合法对象。那问题是什么时候开始出错的用gdb在崩溃处往上翻看worker_thread里的执行路径发现这个线程在上一轮循环里做了几次大块内存分配分配结果没有判空就直接往缓冲区里写数据。顺着分配点查看发现其中一个分配调用返回了NULL。这里插一句在C程序里malloc返回NULL本身不致命致命的是你没检查就继续当有效指针用。但奇怪的是这个case里后续并没有立即崩溃而是在稍后某个free()上炸了。这说明内存管理内部状态已经被污染并且污染源正是某次分配失败后代码错误地Continue执行导致某个指针被覆盖成无效值。再具体一点代码里有类似这样的写法buf realloc(buf, new_size); if (buf NULL) { // 只打了日志没return } memcpy(buf, data, data_size);realloc失败时根据C标准原始指针依然有效但很多人不知道的是如果直接把返回值赋给原指针再判空原指针已经被覆盖成NULL旧内存就丢了。然后代码没终止继续往NULL地址memcpy自然Segmentation Fault。这个case更隐蔽的地方在于外层还有一个状态机第一处失败没有立刻触发崩溃而是等到后续某个对象状态错乱后在release阶段free了一个已经被污染的内存地址。gdb里看一下分配失败附近的内存记录发现确实有一段指针被置成0x7f5b6a1e4c30附近但那个地址并不在进程的合法heap映射里。综合判断崩溃的直接原因是“使用了一个由分配失败引发的悬空指针/损坏指针”而分配失败的根本原因指向了系统级限制max_map_count耗尽。2. 问题根因max_map_count耗尽背后的内存管理链条2.1 VMA、max_map_count和mmap的关系先把概念讲清楚。每个进程的虚拟地址空间不是一整块而是由若干段组成内核把这些段叫作Virtual Memory AreaVMA。每次调用mmap()、加载一个动态库、创建线程栈、映射一个共享内存内核都会给当前进程新建或者扩展一个VMA。VMA不是无限多的内核通过/proc/sys/vm/max_map_count限制一个进程最多能拥有的VMA数量默认值在多数发行版上是65530左右。可以这样类比把虚拟地址空间想成一套房子的房间划分VMA就是房间里的一堵堵墙。墙太少不便于细分墙太多则隔出了大量琐碎空间管理成本巨大。max_map_count就是限制这堵墙数量的上限。当进程需要新建一个映射但VMA数量已经达到上限时内核会拒绝这次mmap调用返回ENOMEM错误码是“Cannot allocate memory”。关键点在于这个错误码经常让人误以为是物理内存不够。实际上一台机器内存明明还剩很多但进程就是报“Cannot allocate memory”这时候就要高度怀疑max_map_count耗尽。查看当前进程VMA数量的方法很简单wc -l /proc/PID/maps cat /proc/PID/status | grep VmPeak sysctl vm.max_map_count如果wc -l /proc/PID/maps输出的行数已经接近或超过vm.max_map_count基本可以确认是这个限制导致mmap失败。2.2 为什么是glibc 2.11.3的free()崩了在CentOS 6 / RHEL 6这一类老系统上glibc版本通常是2.11.3或者2.12。它内部的malloc实现要分配比较大的内存块时不会一直从堆里切而是直接走mmap系统调用由内核给进程新增一块匿名内存映射。这个阈值旧版本里通常是128KB可以通过mallopt()调整。所以当业务在短时间内大量分配超过128KB的缓冲区或者频繁创建线程每个线程栈默认8MB也是通过mmap映射进程的VMA数量就会快速上涨。VMA数量一旦达到max_map_count后续的mmap调用就会失败malloc自然分配不出内存返回NULL。那为什么崩溃点在free()而不是malloc()或memcpy()因为malloc()返回NULL并不是所有场景都会立刻出事很多业务代码在malloc/realloc失败后没有正确处理有的做法是“先记下来稍后再处理”有的是“返回NULL后其他模块又把某个状态标记成成功”。等到真正释放内存时free()拿到的是被破坏的chunk头或非法指针glibc内部做chunk校验时直接触发SIGSEGV。旧版glibc在_free_int里的校验逻辑又相对简单面对一个非法地址没有现代版本那么强的防御能力所以表现为free()处崩溃。另外还有一个容易忽略的细节老glibc里malloc的arena管理策略对VMA压力很敏感。当进程线程很多、每个线程各自持有arena某些情况下malloc为了腾挪空间会尝试sbrk或mmap来扩展/收缩heapVMA耗尽会让这些内部操作连续失败最终堆元数据一致性也会受影响。虽然这不是日常崩溃的第一原因但在VMA打满的极端场景下它确实会让malloc内部状态变得比平时更脆弱。2.3 什么业务会撞上这个限制不是所有进程都会撞上max_map_count大部分普通C程序VMA数量可能只有几百。比较容易触顶的业务有几类第一类是线程数量巨大的服务尤其是用了线程池但没做上限约束的业务。每创建一个线程glibc pthread_create里会通过mmap给线程分配独立栈默认栈大小8MB也就是每多一个线程就多一个VMA。线程数涨到一两万VMA数量自然爆炸。第二类是重度使用文件映射的中间件比如搜索引擎、KV存储、消息队列它们为了性能会把索引文件或消息文件直接mmap到进程地址空间。文件数量一多每个文件可能还会被切成多个映射段VMA数量呈线性增长。第三类是大量使用大块内存缓存的应用。如果代码里频繁malloc超过128KB的大块内存而分配之后马上释放再分配短时间内的VMA峰值会非常高。虽然释放后会回收VMA但极端并发下瞬时达到max_map_count仍然会发生。第四类就是Java应用和JVM类进程。JVM自身会为堆、元空间、JIT代码缓存创建大量匿名映射再加上直接内存DirectBuffer的分配VMA数量很容易上千上万。如果一个宿主机上同时跑很多这类进程VMA上限的风险会被进一步放大。3. 问题复现与验证实验3.1 构造高VMA压力环境为了确认“max_map_count耗尽 - 分配失败 - free崩溃”这条因果链我在测试环境搭了一个最小化复现实验。先在机器上故意调低内核参数模拟极限情况sysctl -w vm.max_map_count300这个值远低于生产环境但足够让压力测试在一分钟内触顶。然后写一个简单的测试程序循环创建线程每个线程分配一块256KB的缓冲区写入数据后调用free释放同时每创建一个线程就把线程join掉确保短期线程数和VMA数量都有明显波动。核心代码大概长这样void *worker(void *arg) { char *buf (char *)malloc(256 * 1024); if (buf NULL) { // 故意不退出模拟业务代码继续运行 fprintf(stderr, malloc failed but continue\n); } else { snprintf(buf, 256 * 1024, hello-vma); } free(buf); return NULL; } int main() { for (int i 0; i 500; i) { pthread_t tid; pthread_create(tid, NULL, worker, NULL); pthread_join(tid, NULL); } return 0; }注意这段代码其实就是典型错误写法malloc失败后不return继续往下运行最后free(buf)。当buf是NULL时free(NULL)不会崩但我在真实业务里还有别的逻辑比如buf被转存到全局链表后续有另一个线程在释放时再取出来那样就会拿到被污染的值。为了模拟得更接近线上我改成了一个双线程版本主线程负责在分配失败时把buf地址塞进全局队列另一个消费者线程从队列里取地址并调用free。3.2 复现崩溃并抓取调用栈跑起来之后VMA数量很快冲到300附近。用另一台终端实时查看for i in $(seq 1 30); do count$(wc -l /proc/$(pgrep -f vma_test)/maps | awk {print $1}) echo $(date %H:%M:%S) vma$count sleep 1 done当计数到300时测试程序随即输出malloc failed but continue Segmentation Fault (core dumped)gdb加载core调用栈和线上事故几乎一致free()内部崩溃传入的指针是一个从未被mmap映射过的垃圾地址。用cat /proc/PID/status看Threads和VmPeak再对照/proc/PID/maps确认在崩溃前一刻VMA数量刚好打满max_map_count。说明这个链路是稳定可复现的不是偶发内存踩踏。3.3 内核态与用户态证据交叉验证复现完成后我从两个维度做交叉验证。用户态用strace抓系统调用命令如下strace -f -e tracemmap,munmap,mremap -o /tmp/vma_test.strace ./vma_test在strace输出里能看到一条关键记录mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) -1 ENOMEM (Cannot allocate memory)这条记录对应着malloc内部尝试分配256KB缓冲区时的mmap调用返回值ENOMEM。内核态用dmesg查是否有相关的内存管理告警但因为这是VMA数量限制不是真正的物理内存耗尽所以dmesg里通常没有OOM Killer记录这也是排查时容易绕远路的原因。最终确认用户态分配失败 内核态VMA打满两条证据互相印证根因明确。4. 解决方案与落地步骤4.1 短期止损调整vm.max_map_count最直接的止血办法是提高系统对单进程VMA数量的限制。生产环境根据业务实际情况建议不要拍脑袋改成6553000这种离谱值而是先看业务正常情况下的VMA基数再按峰值冗余估算。我当时是这样估的确认问题前线上进程VMA数量大约在5万左右而且随着业务增长有缓慢上升趋势。把当前5万乘以1.5倍再留出至少1到2倍的峰值缓冲所以临时先调到262144。具体命令sysctl -w vm.max_map_count262144 echo vm.max_map_count 262144 /etc/sysctl.conf sysctl -p调完之后要验证立即生效sysctl vm.max_map_count cat /proc/PID/maps | wc -l确认VMA数量已经远低于限制值故障不再复现。注意sysctl -w是临时生效重启失效必须同时写入/etc/sysctl.conf做持久化。调这个参数要有一个概念VMA不是免费的。每个VMA在内核里都对应一个vm_area_struct结构体VMA数量越多内核在缺页中断、查找映射、fork进程时扫描红黑树的成本越高。所以不要无脑调大够用就好同时要配合应用层治理。4.2 中期治理从资源使用上减少VMA单纯调大内核参数只能争取时间更健康的办法是控制应用自身对VMA的消耗。第一件能做的事是给线程池设上限不要让线程数量无限增长。线程栈是VMA消耗大户每个线程8MB栈对应一个VMA线程数从1000涨到10000VMA数量就会多出9000个。第二件能做的是减少大块内存的频繁mmap。上面讲过老glibc对超过128KB的malloc会直接走mmap路径。如果业务里类似“申请256KB/512KB缓冲区用完就释放”的模式非常普遍可以在程序启动时调用mallopt()调整阈值#include malloc.h mallopt(M_MMAP_THRESHOLD, 1024 * 1024); // 超过1MB才走mmap mallopt(M_MMAP_MAX, 100); // 限制mmap分配的最大块数把MMAP_THRESHOLD提高到1MB之后256KB和512KB的分配会尽量走堆内存而不是每次创建新VMA。这样能显著降低VMA峰值。但要注意提高阈值也会让堆内存碎片更严重必须根据业务分配大小和生命周期观察RSS避免换来物理内存暴涨。M_MMAP_MAX则更激进限制malloc通过mmap分配块的数量超过之后大块分配会失败生产环境要谨慎。第三件事是排查业务里是不是有大量通过mmap直接建立的映射没有及时munmap。可以用pmap -x PID检查映射数量和单块映射大小找出那些不合理的文件映射或匿名映射。4.3 长期优化要不要换掉glibc 2.11.3在生产环境还跑glibc 2.11.3的多半是CentOS 6/RHEL 6时代的存量系统。新版本glibc在malloc实现上做了大量改进包括更完善的chunk一致性检查、更鲁棒的arena管理、对分配失败的容错也更友好。从稳定性角度当然建议操作系统版本升级到CentOS 7及以上glibc 2.17甚至CentOS 8/兼容发行版glibc 2.28。但现实是很多核心业务短期内没法升级因为涉及JDK版本、第三方库、内核驱动、运维工具链等一系列兼容问题。如果暂时留在2.11.3长期优化要从几个方向同时做一是在应用层全面审查malloc/realloc调用杜绝“失败后不return”的写法二是针对分配大块内存的场景用完立即释放减少VMA攀峰三是建立VMA数量监控不要等到崩溃才发现。对实在改不动代码的存量进程也可以考虑用一个小的LD_PRELOAD库把malloc/realloc/free包一层在mmap失败时打日志或者主动abort让问题在分配失败点暴露而不是等到free()崩溃时再来查。这种方案成本不高但能把故障定位时间从小时级压缩到分钟级。5. 常见问题与避坑手册5.1 五个最容易踩的坑这类故障在排查时特别容易走弯路我把踩过的坑整理一下。第一个坑是看到“Cannot allocate memory”就去看物理内存。max_map_count耗尽时free -m可能显示内存充足swap也充足但mmap就是失败。这时候要立刻检查VMA数量而不是清缓存、缩堆、加机器。用cat /proc/PID/status里的VmLck和wc -l /proc/PID/maps是判断效率最高的方式。第二个坑是只改sysctl不持久化。线上服务器重启后sysctl -w设置的参数会全部还原VMA限制回到默认值事故复现。一定要同步写/etc/sysctl.conf并执行sysctl -p确认。第三个坑是不检查malloc/realloc的返回值。这是整个事故链的源头。即使max_map_count不耗尽任何分配器都有失败的可能代码里把返回值当成有效指针继续用早晚会出问题。前面提到的realloc覆盖原指针更是高危写法应该先把返回值存临时变量确认非null后再赋值给原指针。第四个坑是只追free()崩溃位置不追分配失败点。free()崩溃只是结果不是根因。应该用gdb在崩溃处向上看找到指针最初被赋值的位置然后顺着调用链查是否有mmap失败。最好在分配点也加上错误日志记录分配大小和失败类型后续排查会清晰很多。第五个坑是把vm.max_map_count调得过大。有些同学为了省事直接把参数改成1000万但VMA数量过大会拖慢内核查找和进程fork速度极端情况下反而引发新的性能问题。建议观察至少一周的业务峰值按峰值的2到3倍设定限制即可。5.2 快速诊断命令速查表目的命令判断依据查看系统VMA上限sysctl vm.max_map_count当前限制值查看进程VMA数量wc -l /proc/PID/maps是否接近或超过max_map_count查看进程线程数grep Threads /proc/PID/status线程是否异常上涨查看进程虚拟内存峰值grep VmPeak /proc/PID/status虚拟内存是否暴涨查看所有映射详情pmap -x PID定位大块匿名映射/文件映射跟踪mmap失败strace -f -e tracemmap,munmap,mremap -p PID是否有ENOMEM调试core崩溃点gdb 程序 corebt确认free/realloc/malloc栈位置实时监控VMA数量watch -n 1 wc -l /proc/PID/maps观察VMA增长趋势把这个表贴在值班文档里下次遇到类似告警基本五分钟能定位到方向。再补充一个小技巧在业务高峰期用slabtop看内核里vm_area_struct相关内存是不是异常增长以及在测试环境故意把max_map_count调小做混沌实验提前暴露那些对mmap成功率敏感的应用。老话说得好与其线上被坑不如测试环境把坑填平。这个case最终在sysctl和业务代码两边同时修复系统参数调到262144业务侧完成了所有malloc/realloc的返回值检查重构VMA数量监控也接进了告警平台。之后运行数月再也没有复现过。坦白讲glibc 2.11.3并不是一开始就有bug是它在极端资源限制下把应用层错误状态暴露了出来真正的教训只有一条分配失败必须被当成一等公民处理而不是靠侥幸蒙混过关。