ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java服务free()崩溃真凶:max_map_count耗尽导致的VMA触顶实战分析

Java服务free()崩溃真凶:max_map_count耗尽导致的VMA触顶实战分析 凌晨两点四十七分告警把我从梦里拽出来线上一个跑了二十三天的Java服务进程忽然没了。监控平台显示内存、CPU、磁盘都没异常OOM Killer也没动手唯一的产物是几个GB的core dump。等我把core拉进gdb栈顶那行字让我心里一沉——程序死在了glibc 2.11.3的free()里。说“心里一沉”是有原因的free()崩溃第一嫌疑人永远是double-free、堆越界这类经典内存错误这类问题通常要把应用代码翻个底朝天。但那次调用栈有个细节不对劲——_int_free的arena参数av0x0一个空指针。沿着这个空指针一路追下去最终锁定的根因不是谁多释放了一次指针而是/proc/sys/vm/max_map_count这个内核参数被耗尽。这篇文章把这次的完整追踪过程和最终解决方案整理出来给同样跑在RHEL 6/CentOS 6世代、还在用老glibc的多线程服务做个参考。下面所有命令、分析方法和配置都是可以直接拿去用的。1. 现场还原一个“病在free()、根在mmap”的崩溃套路1.1 第一眼core dump里的空指针先把当时的崩溃栈简化一下给你看(gdb) bt #0 _int_free (av0x0, p0x7f2bbe2e8b40, have_lock0) at malloc.c:5902 #1 0x0000003b4e67a123 in __libc_free (mem0x7f2bbe2e8b50) at malloc.c:3738 #2 0x00007f2bb7e4cb58 in Java_com_example_NativeBuffer_release (env0x7f2bb7e6d800, ...) #3 ...最扎眼的就是av0x0。理解这一行的关键是知道glibc的malloc在设计上把进程内存分成若干“arena”分配区每个arena是一个互斥保护的内存池。多线程程序里不同线程在不同arena上分配释放互不阻塞。而free()在回收非mmap内存块时必须先找到这个块所属的arena把它作为第一个参数传进_int_free。如果这个arena指针是NULL_int_free内部无论是读av-max_fast还是访问快速链表数组都会直接踩到空地址上进程立刻SIGSEGV。所以看到av0x0第一反应不应该是“这代码是不是double-free了”而应该是“这个chunk对应的arena信息为什么是空的”。1.2 这个崩溃位置的欺骗性free()崩溃在排障时特别容易把人带偏。因为它是最常见的内存使用错误爆发点经验不足时很容易陷入“查业务代码、开AddressSanitizer、翻最近上线变更”的循环里。我们一开始也查了将近两个小时把NativeBuffer相关的JNI代码、最近一个月的内存补丁全过了一遍没有任何发现。把方向掰回来的是两件事第一av0x0这个信号本身。如果是一般的堆元数据被写坏_int_free收到的av大概率还是一个假的但非零的地址或者p指针本身非法。而这里p看起来是正常堆内地址av却是NULL指向的是arena管理层面的异常不是用户数据越界。第二gdb里执行info proc mappings居然刷屏刷了好一阵子。/proc里暴露的地址空间映射数量明显不正常。这才把一个本该在“应用层”解决的问题拉回到了操作系统和运行时layer。1.3 排查方向被掰回来的瞬间真正让我确认方向的那一步是在core里数了一下映射数量。gdb中可以直接执行(gdb) shell wc -l /proc/pid/maps虽然进程死了之后/proc/pid/maps读不到但core里的info proc mappings给了同样信息。数千行的映射列表摆在那里再结合服务运行了23天、线程数量一直在缓慢增长的事实心里的判断变成了“大概率是VMA数量触顶了。”所谓VMA就是Virtual Memory Area进程地址空间里一段连续的、属性一致的虚拟内存区域。每一段mmap、每一块线程栈、每一个动态库的代码段/数据段都是至少一个VMA。Linux对单个进程能拥有的VMA数量有硬上限就是/proc/sys/vm/max_map_count。2. max_map_count与glibc 2.11.3故障机制拆解2.1 max_map_count到底管的是什么max_map_count是内核里对单进程VMA数量的硬限制。你可以把它理解成“这个进程名下能登记的地契册数”。每当你mmap一段新的、和已有段不连续的虚拟地址区间内核就要为它新建一个vm_area_struct节点挂到进程的地址空间树里。这个节点就是“一张地契”。默认值通常是65530配置在/proc/sys/vm/max_map_count也可以用sysctl vm.max_map_count查看。对绝大多数程序来说这个数字一辈子也触不到。但以下情况会让VMA数量缓慢而坚定地增长多线程程序NPTL线程的每个线程栈都是一个独立的匿名映射一个线程就贡献一个VMAJava的JIT编译产物CodeCache会按照护缩策略持续申请新的可执行内存映射Native内存分配glibc对超过M_MMAP_THRESHOLD默认128KB的malloc请求会直接走mmap每次mmap只要地址区间不连续就会新增VMAJNI库加载每加载一个.so通常产生3到5个映射段Java的DirectByteBuffer、文件映射FileChannel.map等也是VMA大户。一旦VMA数量顶到上限内核do_mmap会返回-ENOMEM也就是mmap失败。注意这里跟系统物理内存无关是“地契册数”满了哪怕内存还富余也映射不了新区域。2.2 glibc 2.11.3的arena机制与VMA消耗大户glibc 2.11.3是RHEL 6初期那一代发行版自带的libc版本距今已经十几年。它的malloc多线程模型是这样的主线程用main_arena基于brk堆扩张其他线程需要新arena时通过mmap分配一段匿名内存作为堆并在这个堆上创建arena每个arena有独立的mutex线程分配时先尝试锁定当前线程缓存的arena锁不上就找下一个实在找不到就新建一个。这个机制的后果是线程争抢越激烈arena被创建得越多。2.10之后引入了MALLOC_ARENA_MAX环境变量来限制arena数量默认值是“CPU核数×8”。一台16核机器默认最多允许128个arena每个arena初始mmap一段不小的堆空间这本身已经是不小的VMA开销。更麻烦的还在后面。2.11.3的malloc还没有tcache线程局部缓存是2.26才引入的所有小块分配都必须走arena的锁和bin。一旦线上服务的线程数上百、NIO和JNI的native指针满天飞malloc的内部锁竞争就会居高不下。此时分配器为了降低竞争会频繁尝试创建新arena。新arena要mmapmmap要新VMA而VMA数量又被max_map_count压着。同时Java服务本身也在大量产生VMA。线程池里每个线程一张栈映射JIT编译器持续为热点方法生成代码并mmap新的可执行区域DirectByteBuffer每次向os::commit_memory底层也可能调mmap。这些来自不同层面的“地契需求”汇在一起让一个长尾服务的VMA数量缓慢爬升最终在某次并发峰值期间撞穿65530这个天花板。2.3 为什么crash偏偏落在free()而不是mmap()很多人会问既然上限耗尽直接在mmap()那个位置失败不就行了为什么会跑到free()里崩事实是mmap()确实先失败了。但这只是“上游事故”进程不一定立刻死。真正的死亡原因取决于失败后各个层面的处理方式第一层glibc malloc。某个线程的malloc需要新arena内部调用_int_new_arena后new_heap的mmap失败返回NULL。在2.11.3的代码路径里这个NULL有概率被传递到与线程上下文相关的arena缓存位置。也就是说malloc这次虽然返回NULL但某些内部状态是“半更新”的没有像教科书里那样干净地回滚。后续线程再调用free()去访问这个已经变成NULL的arena指针时_int_free(av0x0)就直接炸了。第二层应用代码。Java的JNI本地代码如果从malloc拿到NULL后没有检查直接memcpy或直接使用这个指针就会写进地址0附近造成堆元数据被破坏。这种破坏往往不会当场暴露而是等到某个无关线程在free()里合并相邻chunk、遍历bin时才踩中用户数据和chunk头部被污染的地方表现出一种“随机地址段的随机crash”。我们那次core里av0x0这个直接原因更贴近第一层说明是arena管理状态在mmap失败后出了问题。但不管哪一层它们的共同上游都一样/proc/sys/vm/max_map_count耗尽导致mmap返回ENOMEM。3. 从core dump到内核参数完整排查链路3.1 gdb里确认arena指针的来源拿到core后我习惯先把栈打全然后是frame 0再打印出一堆关键变量(gdb) frame 0 (gdb) info args av (mstate) 0x0 p (mchunkptr) 0x7f2bbe2e8b40 have_lock 0 (gdb) p/x p-size $1 0x405f1p-size这个值看着是合法的chunk大小约263KB也就是说free()传入的用户指针本身不是野指针。问题就出在av上。接着往上一层frame看__libc_free是怎么找arena的(gdb) frame 1 (gdb) info locals ar_ptr (mstate) 0x0ar_ptr也是NULL。到这里可以断定chunk本身合法但glibc根据这个chunk所在的堆heap_info去取arena指针时取到了一个NULL。这说明创建这段堆的arena时内部状态没建立完整或者建立过程中被中断了。这一步最大的价值是把“疑似堆越界”的假说排除掉把怀疑范围缩小到glibc自身的arena管理上。3.2 /proc/PID/maps直接数一下“地契”进程还活着的时候排查这类问题根本不需要看core一条命令就可以定位$ sysctl vm.max_map_count vm.max_map_count 65530 $ wc -l /proc/PID/maps 65530 /proc/PID/maps当maps文件行数和max_map_count相等的时候说明这个进程已经一颗钉子都不剩了下一次任何mmap都会失败。不要小看这行数它就是决定进程生死的地契总数。再看看到底是哪些映射在吃数量$ awk {print $NF} /proc/PID/maps | sort | uniq -c | sort -rn | head -20对Java服务输出多半长这样2466 [anon] 1532 /usr/lib/jvm/java-.../jre/lib/amd64/server/libjvm.so 872 [stack:TID1] 821 [stack:TID2] ... 431 /usr/lib/jvm/java-.../jre/lib/amd64/libzip.so[stack:TID]这类就是线程栈数量等于活跃线程数[anon]里混着malloc的arena堆、JIT的CodeCache、DirectByteBuffer等匿名映射。如果再细分匿名映射的可写可执行属性还能看出哪些区域是JIT代码。我当时还对比了崩溃前一周的监控采样VMA数量从大约4万缓慢涨到6万5最后一天直接顶在65530平了。这个曲线一画出来根因基本就锁死了。3.3 用最小复现实验确认因果光看生产环境还不够我把因果链拉回到实验室验证了一遍。找了一台空闲测试机人为把上限调低让崩溃过程在几分钟内快速上演sysctl -w vm.max_map_count1024再跑一个故意制造大量VMA的多线程C程序#include stdio.h #include stdlib.h #include string.h #include pthread.h #include unistd.h static void *worker(void *arg) { char *p malloc(4 * 1024 * 1024); if (p) memset(p, 0, 4 * 1024 * 1024); pause(); return NULL; } int main(void) { for (int i 0; i 500; i) { pthread_t tid; pthread_create(tid, NULL, worker, NULL); } pause(); return 0; }每个线程一个栈映射加一块4MB匿名映射再把进程本身几十个映射算进去500个线程很快就能把1024这个上限撞穿。接着在另一个终端里持续观察watch -n 0.5 wc -l /proc/$(pgrep -f vma_test)/maps当数字顶在1024不再动时进程随后会出现malloc失败、线程创建失败在更高并发下则会复现free()或mmap路径上的crash。实验里崩溃的具体点位每次不一定完全一致这和线程调度、哪一次mmap先触顶有关但上游原因每次都是同一个max_map_count耗尽。实验做完不要忘记恢复参数sysctl -w vm.max_map_count655304. 解决方案调整、降耗、监控三步走4.1 内核参数调整治标的“扩容”线上第一要务是止损把进程先拉起来。最简单有效的就是调大vm.max_map_count# 临时生效 sysctl -w vm.max_map_count262144 # 永久生效 echo vm.max_map_count 262144 /etc/sysctl.conf sysctl -p为什么是262144这是Elasticsearch官方要求的生产值也是社区里验证过比较稳妥的档位等于默认值的4倍。极端场景下再往上调到1048576也不是不行但VMA数量过大会让内核的地址空间管理和缺页路径开销小幅上升所以建议按需设定不要一上来就拉满。选值时可以按“当前峰值×2再留30%余量”来算比如服务峰值VMA在6万左右那262144就非常宽裕。有一点必须说清楚这个参数是全局sysctl不是ulimit没法按进程单独给额度。调大之后所有进程共享更高的上限对系统整体稳定性几乎没有负面影响可以放心在公司内部统一交付。改完参数、重启服务之后建议继续观察至少一周。如果VMA数量仍然快速爬升并再次逼近新上限那就说明光扩容不够必须做应用层降耗。4.2 应用层减少VMA消耗治本的关键扩容只是给了更多空间不改掉“VMA持续增长”的毛病下次只是换个时间爆炸。对Java服务按贡献度从大到小排序可以逐项控制线程数量与线程栈每个线程栈都是一个独立VMA。先检查线程池配置确认是否存在无界线程池再评估-Xss能不能从默认的512K/1M降下来比如改成-Xss256k。线程这个因素在VMA账单里往往是大头。JIT CodeCache-XX:ReservedCodeCacheSize如果设得太大JIT会持续mmap可执行区域。压到合理范围比如128M或256M并开启-XX:UseCodeCacheFlushing让JIT在CodeCache满时自动清理。DirectByteBuffer用-XX:MaxDirectMemorySize限制堆外Direct Memory防止NIO这种“看不见的堆”无限膨胀。JNI/Native库排查是否有反复加载卸载.so的代码或者是通过Unsafe/自定义JNI频繁mmap的组件。对不用Java的C/C服务glibc这边有两个很好用的环境变量/接口export MALLOC_ARENA_MAX2这个变量控制线程arena数量的上限。默认值是核数×8在核心很多的机器上会让malloc为降低锁竞争而疯狂创建arena每个arena都占VMA。压到2或4多线程服务的VMA数量能肉眼可见地下降代价是某些高并发malloc场景下锁竞争略升。对绝大多数I/O型服务来说2到4完全够用实测是值得的。另一个是抬升mmap阈值让中小块分配尽量走堆而不是频繁创建新映射#include malloc.h mallopt(M_MMAP_THRESHOLD, 256 * 1024);把阈值从默认128KB抬到256KB后128KB到256KB之间的分配不再走mmap也就不会为每次分配新建VMA。代价是堆会更容易膨胀、释放时不一定立刻归还系统需要配合监控RSS确认没有异常增长。4.3 上线前先装好“预警器”这类问题最大的特点是慢积累、突然死。VMA数量不会一夜之间爆炸而是以每天几百、几千的速度爬坡。只要把这条曲线监控起来就能在触顶前半个月发现问题。最简单的监测脚本加到crontab里每分钟跑一次#!/bin/bash # /usr/local/bin/check_vma_count.sh CEILING$(sysctl -n vm.max_map_count) for pid in $(pgrep -f ^/usr/bin/java); do COUNT$(awk END{print NR} /proc/$pid/maps) THRESHOLD$((CEILING * 90 / 100)) if [ $COUNT -gt $THRESHOLD ]; then echo $(date %F %T) WARN pid$pid vma$COUNT ceiling$CEILING /var/log/vma_monitor.log # 发告警通知值班/运维 fi donepgrep -f的模式串按自己服务的关键字替换。更规范的做法是用监控agent直接抓取每个进程的maps行数上报时序库配一个“VMA使用率达到90%”的告警规则。这个指标在国外社区里通常叫process_vma_count很多开源exporter已经支持不需要自己造轮子。有了这条监控曲线以后再遇到类似crash排查时间能从几小时压到几分钟。5. 这类crash的可复用排查清单与踩坑体会5.1 先查这个一条命令排除max_map_count如果你下次值班遇到进程在free()、malloc()、mmap()、pthread_create任何一个位置莫名crash我建议按这个顺序快速过一遍排查步骤命令说明看当前上限sysctl vm.max_map_count确认当前案例的“天花板”数进程实际VMAawk END{print NR} /proc/PID/maps行数逼近上限即高度可疑看映射构成awk {print $NF} /proc/PID/maps | sort | uniq -c | sort -rn | head找出是谁在吃VMA看崩溃点gdb里btframe 0打印参数特别关注av/ar_ptr是否NULL查系统日志dmesg -T | tail有些内核版本会打印mmap失败或资源不足的线索这个清单的价值在于整套检查不超过5分钟而且完全无损。即使最终不是max_map_count问题你也能快速排除一个高危因素把精力放回堆越界、double-free这些应用层嫌疑上。5.2 这次踩坑换来的几条经验第一free()崩溃不等于double-free。看到_int_free的栈应该先看第一个参数av和第二个参数p别急着怀疑业务代码。av0x0的语义和“用户野指针导致堆损坏”完全不同前者指向glibc arena管理层的异常后者指向应用内存越界。第二老glibc的失败处理没有想象中健壮。glibc 2.11.3那个年代的malloc在_int_new_arena的mmap失败路径上并没有像新版本那样做那么多防御性检查。生产环境如果还跑着RHEL 6世代能升级尽量升级到内核和glibc更新的版本如果暂时升不了就要用sysctl和应用层配置把这个场景规避掉。现代glibc在VMA耗尽时通常会干净地返回NULL不那么容易把内部arena状态搅浑。第三Java服务的VMA消耗会随运行时长单调增长的特性甚至可以作为健康指标。我们后来在新版本服务里加了个定时任务每天记录/proc/self/maps行数跟线程数、CodeCache使用率放在同一张报表里。哪一天这个曲线突然抬头往往意味着哪个第三方JNI库或者线程池配置出问题了。最后再分享一个小技巧遇到这类“跑了很久才崩”的问题不要只盯着core dump的最后一帧记得把/proc/PID/maps的历史采样、sysctl的当前值、服务器内核版本、glibc版本这四样东西一次性收集齐。这个案子如果一开始就有人拍下maps的行数根本不需要熬到后半夜做两个小时的堆内存考古。
返回列表