崩溃:一次max_map_count排查实录)
凌晨两点被告警吵醒线上一个 Java 服务进程突然 core dump业务方反馈超时率飙升。拿到 core 文件用 gdb 一查崩溃栈停在free()附近调用方是我们自己封装的一个插件卸载逻辑。当时第一反应是“堆被写坏了”但压测环境怎么都复现不了后来才一步步追到真正的元凶/proc/sys/vm/max_map_count被耗尽。free() 只是压垮进程的最后一根稻草根因藏在 Linux 虚拟内存区域的分配机制和 glibc 2.11.3 的内存管理策略里。这篇内容我会完整复盘整个过程从现场现象、底层原理、排查命令到最终修复方案最后整理几个容易踩的坑。如果你是做服务端运维、C/C 开发或者中间件维护的遇到类似的“free 崩溃”“mmap 失败”“进程 maps 行数异常”等问题这篇文章应该能帮你省下好几个小时的排查时间。1. 事故现场free() 崩溃但堆并没有坏1.1 凌晨两点的一次 core dump当时监控显示进程的对外端口已经不再响应系统里留下了一个 core 文件。用 gdb 加载之后bt输出大概是这样的#0 0x00007f2a4c3d7a37 in free () from /lib64/libc.so.6 #1 0x00007f2a4c5b8f20 in PluginManager::UnloadPlugin(PluginHandle*) from libplugin_mgr.so #2 0x00007f2a4c5b92d0 in ReloadPlugin() from libplugin_mgr.so #3 0x00007f2a4c6b1120 in WorkerThread::Run() from libworker.so ...第一眼看到这个栈你会觉得莫名其妙。free()是 glibc 里最常用的函数正常情况下它只负责把指针指向的内存块归还给分配器。调用它崩了大概率是传给它的指针有问题或者堆元数据被破坏。当时团队里的同学第一反应也都是“是不是哪里缓冲区溢出把堆搞坏了”。但奇怪的是我用 AddressSanitizer 重编了整个服务压测了整整两天没有抓到任何越界读写用 valgrind 跑小流量回归也没有报告 invalid free甚至怀疑过是 ASLR 导致的偶发问题把随机化关掉后依旧不能稳定复现。问题的复现条件非常苛刻只在线上高峰期出现这已经超出了普通堆损坏的范畴。1.2 一个不经意的命令让排查方向翻转真正带来转机的是无意中执行的一条命令cat /proc/PID/maps | wc -l正常情况下一个 Java 服务的 maps 行数在几千行最多上万行。但当时的输出是65530看到这个数字我愣了一下——这不就是/proc/sys/vm/max_map_count的默认上限吗再一查sysctl vm.max_map_count输出确实是vm.max_map_count 65530。也就是说这个进程的虚拟内存区域VMA数量已经顶到了内核设定的天花板。在这之后任何新的mmap、mprotect、dlopen或者其他需要创建 VMA 的操作都会直接返回失败。我们的服务里恰好有热加载插件的逻辑插件加载失败之后没有做严格的空指针保护后续清理阶段调用free()时就把一个非法地址传了进去于是进程当场崩溃。到这里问题性质完全变了这不是一次简单的堆越界而是一个“VMA 耗尽引起的连锁反应”。2. max_map_count、mmap 和 glibc 2.11.3 的关系2.1 虚拟内存区域VMA到底是什么为了把这个问题讲清楚得先说说 VMA。Linux 内核在管理进程地址空间时会把一段一段连续且属性相同的虚拟内存区间称为一个 VMAVirtual Memory Area。你可以把它想象成一个书架上的格子每个格子记录了一段虚拟地址的起始位置、结束位置、读写权限、映射文件等等。进程每次调用mmap创建一个新的映射、每次mprotect修改某段内存权限、每次线程创建时分配线程栈、每次加载一个动态库、每次申请一块大内存超过 glibc 的 mmap 阈值都可能在进程的地址空间里新增一个 VMA。如果代码写得不好频繁地删除和重新映射小段内存还可能导致 VMA 数量碎片化增长。内核为什么限制这个数量因为每个 VMA 在描述结构上是一棵红黑树节点既占内核内存又会影响缺页、访问权限查找等路径的性能。max_map_count这个参数就是用来限制单个进程最多能拥有的 VMA 数量的默认值通常是 65530。对于普通应用来说这个数字绰绰有余但一旦碰到“大量线程 热加载插件 反复 mmap/munmap 不释放”这类场景几万块 VMA 很快就会被吃光。2.2 glibc 2.11.3 的内存分配策略我们线上这台机器是老系统glibc 版本还是 2.11.3。这个版本的malloc实现策略和现在的主流版本没有本质区别核心是两条路径小内存分配走brk也就是在数据段末尾扩展堆空间大内存分配默认超过 128KB直接走mmap分配一块独立的匿名映射释放时直接munmap。线程栈也是一个典型的mmap大户。每次调用pthread_createglibc/NPTL 都会通过mmap为线程分配一块栈空间默认 8MB 左右但不一定全部映射成实际物理内存。一个几千线程的进程光线程栈就会占掉几千个 VMA。更麻烦的是老版本 glibc 对分配失败后的内部处理不如新版本健壮。比如当mmap返回ENOMEM时malloc会返回 NULL但如果调用方没有检查返回值程序继续跑就会把 NULL 或者其他乱值当成有效指针到处传最后在某个free()或者memcpy上爆炸。glibc 2.11.3 本身没有像后续版本那样针对 VMA 耗尽场景加入比较完善的mmap失败恢复逻辑所以这种问题在老环境上更容易暴露。2.3 为什么 free() 会被牵连很多人在第一眼看到“free() 导致 crash”的时候都会去怀疑 glibc 的 free 实现是不是有 bug。但从实际排查结果看free() 基本上不会平白无故崩溃它崩溃只有几种可能传入的指针不是malloc系函数的返回值指针已经被释放过第二次释放触发 double free 检测指针指向的内存越界元数据被破坏指针指向的地址本身不属于当前进程的合法地址空间。我们的场景属于最后一种。插件热加载时某个.so文件需要通过dlopen加载进进程而dlopen内部要为新的代码段、数据段创建 VMA。在max_map_count已经打满的情况下dlopen返回 NULL但上层代码没有及时 return继续往后走把一个空的结构体指针或者一个已经被清理掉的插件句柄传给了free()最终触发了段错误。换句话说free()只是一个“报信者”真正出问题的是前面已经持续了很久的 VMA 泄漏。3. 一步步定位 root cause 的实操记录3.1 先确认 max_map_count 确实到顶如果你也遇到类似问题建议先按这个顺序做第一轮检查查看当前进程的 VMA 数量wc -l /proc/PID/maps注意maps文件里每一行对应一个 VMA所以行数可以直接作为 VMA 数量的近似值。查看系统限制sysctl vm.max_map_count查看进程状态里的内存峰值等指标grep VmPeak /proc/PID/status我们的现场数据是maps 行数 65530max_map_count 65530两者完全相等基本可以断定 VMA 打满。为了严谨还可以对比崩溃前几分钟的监控数据确认这不是 core dump 瞬间才出现的现象。3.2 找出是谁在疯狂制造 VMA确认 VMA 耗尽后下一步是找出这些 VMA 是从哪来的。我用了几条命令辅助判断# 按映射文件名统计 VMA 数量看看哪类映射最多 awk {print $6} /proc/PID/maps | sort | uniq -c | sort -rn | head -20输出里最显眼的是几千行/memfd:shared_buf (deleted)还有一些线程栈映射。这说明进程里有代码在频繁创建匿名共享内存memfd并且创建之后没有释放映射导致 memfd 类型 VMA 越积越多。再查一下线程数ls /proc/PID/task | wc -l显示差不多有三千多个线程。线程栈属于正常消耗合计三千多 VMA还算可控真正失控的是 memfd 映射的数量——线上高峰期能堆到五万多个。顺着 memfd 的线索查代码发现是一个数据同步模块为了做跨线程内存共享每次收到一批数据就memfd_createmmap一块缓冲区处理完只关了 fd没有调用munmap。在 Linux 里mmap出来的映射不会因为 fd 关闭而自动消失必须显式munmap这就成了 VMA 泄漏点。每来一批数据就漏一个 VMA时间一长必然打满。3.3 用 core dump 和 strace 还原崩溃链路光知道 memfd 泄漏还不够我还想确认崩溃那一刻到底发生了什么。借助 core 文件我在 gdb 里打印了崩溃时free()的参数(gdb) frame 1 (gdb) info args handle 0x7f2a4c600000这个地址一查属于一个已经被dlclose卸载的共享库地址范围。也就是说UnloadPlugin里传入的插件句柄已经是悬空指针了。再往前翻日志发现崩溃前一条加载插件的日志是“LoadPlugin failed: Cannot allocate memory”但没有对应的“Abort load”日志。综合判断就是dlopen因 VMA 耗尽返回 NULL - 插件对象没有成功创建 - 后续 Unload 流程把句柄当成有效指针 free - SIGSEGV。为了验证我还在另一台机器上对故障进程做了短时间 strace 采样strace -f -e tracemmap,munmap,mprotect -p PID -o /tmp/mmap_trace.log在日志里能看到大量mmap调用返回ENOMEM。到了这一步root cause 已经非常明确了。4. 解决方案先止血再根治4.1 临时策略提高 max_map_count面对线上故障第一步不是重构代码而是先让服务恢复。最直接的手段是提高内核参数上限sysctl -w vm.max_map_count262144同时写入/etc/sysctl.conf或/etc/sysctl.d/99-vm.conf防止重启丢失echo vm.max_map_count 262144 /etc/sysctl.conf sysctl -p调整之后服务重启恢复。那么应该调多大合适我的经验是结合业务预估假设一个进程的线程数量稳定在 5000每个线程栈加 TLS 平均占用 2 个 VMA那就是 1 万个 VMA再加上 JIT 代码缓存、共享库、堆空间预留 3 倍余量5 万左右基本够用。如果业务需要几万线程建议直接给到 26 万到 50 万这个档位。但不能无脑调太大。每个 VMA 在内核里对应一个vm_area_struct节点大概占用几百字节内存。100 万个 VMA 对应的内核内存开销可能是几百 MB虽然一般服务器能承受但 VMA 过多也会拖慢缺页异常和mmap路径的性能所以治标不能治本。4.2 代码修复释放映射 检查返回值临时调参只能争取时间真正的修复要落在代码层。针对这个案例改动是两部分第一共享内存模块在使用完缓冲后补上munmapvoid* buf mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); // ... 使用 buf ... // 收尾时除了 close(fd)必须把映射解除 munmap(buf, size); close(fd);第二所有可能返回失败的资源型函数必须检查返回值。包括mmap、dlopen、pthread_create、memfd_create等。拿插件加载举例void* handle dlopen(path, RTLD_NOW); if (!handle) { log_error(dlopen %s failed: %s, path, dlerror()); return -1; // 上层立即终止加载流程 }如果旧系统的版本太老不方便大改代码也可以先从策略上规避比如把插件热加载改成冷启动加载或者对加载失败做“失败熔断”避免错误句柄继续流传。4.3 监控和预防VMA 数量也要建指标这个坑最隐蔽的地方在于常规监控一般只盯 CPU、内存、磁盘、文件描述符很少有人盯 VMA 数量。但 VMA 数量其实和 fd 数量一样也是一个会“泄漏”的资源。我的建议是至少给核心服务加一个采集脚本周期性地记录 maps 行数、线程数和 fd 数#!/bin/bash PID$1 while true; do echo $(date %F %T) VMA$(wc -l /proc/$PID/maps) THREAD$(ls /proc/$PID/task | wc -l) FD$(ls /proc/$PID/fd | wc -l) /var/log/vma_monitor.log sleep 60 done然后把这些指标接入现有监控系统设置告警阈值比如 VMA 数量超过max_map_count的 70% 就告警。这样即使未来再出现类似的泄漏也能在故障发生前发现趋势。上线前压测也要把这项纳入验收在峰值流量的压测过程中观察 VMA 数量曲线如果它在持续上涨而不回落就说明存在 VMA 泄漏必须排查掉才能发布。5. 经验教训与几个常见误区5.1 max_map_count 耗尽的典型误判这个案例里最浪费时间的阶段就是一开始把方向锁定在“堆越界”上。我整理了一张排查对照表帮大家快速排除相似问题常见误判方向实际表现如何快速验证内存泄漏RSS 持续上涨回收后不下降观察/proc/PID/status里的 VmRSS堆被踩坏free/realloc 偶发崩溃Sanitizer 偶发捕获用 AddressSanitizer 压测复现glibc 版本 bug只在特定版本崩溃对比新版 glibc 是否同样崩溃VMA 耗尽mmap/dlopen 返回 ENOMEMmaps 行数接近上限wc -l /proc/PID/mapssysctl vm.max_map_count这两种“泄漏”有本质区别内存泄漏是物理内存页被占用VMA 泄漏是虚拟内存区域的数量被占用。即使物理内存占用很低只要 VMA 数量顶到上限进程依然无法创建新的映射。5.2 值得长期养成的排障习惯经历这次问题后我再排查线上故障会先做几个“低成本高收益”的动作拿到 core 文件之后先别急着深入反汇编先看一眼/proc/PID/maps或者 core 里的 map 信息确认进程的虚拟地址空间是否异常遇到free、malloc这类基础函数崩溃先确认参数是不是合法指针可以用info proc mappings在 gdb 里快速判断地址是否属于当前进程不要轻易怀疑 glibc 有 bug99% 的情况下是调用方自己的问题strace 是排查系统调用的瑞士军刀看到ENOMEM别忽略要把上下文日志一起看。还有一个更底层的小技巧如果怀疑 VMA 数量异常可以直接在/proc/PID/smaps_rollup里看整体映射统计比逐行解析 maps 快得多。某些环境下没有这个文件就只能退回到pmap -x PID | tail -1看汇总。5.3 对老版本 glibc 的一点兼容建议如果生产环境短期内无法升级 glibc建议在关键模块启动时主动检查并保留一部分 VMA 余量。比如在初始化阶段把当前 VMA 数量记录到一个全局变量里加载插件前检查“剩余 VMA 数量是否足够”不够就直接失败并打印明确日志而不是等mmap返回ENOMEM后再被动处理。另外老版本 glibc 创建的线程栈在退出时不一定立刻释放对应 VMA取决于线程缓存和栈缓存设置高频率创建短生命周期线程的应用要格外注意线程栈的堆积。可以用ulimit -s调小线程栈减少每个线程的地址空间占用但要注意不要影响业务代码的递归深度。说实话我很庆幸最后找到了真实原因。排查过程中我有几次差点准备去给 glibc 提 bug甚至想用 LD_PRELOAD 把 free 包一层来“规避”崩溃现在想想都是弯路。系统层面的问题有时候并不是代码写错了而是资源边界被突破后错误被随机放大到了任何一个看似无辜的函数上。VMA 数量这个指标以后在我负责的服务里一定会和 CPU、内存一样重要。