
1. 从一次内存告警说起为什么内核内存分配值得单独拎出来讲凌晨两点被监控告警叫醒日志里刷屏的是Out of memory: Killed process业务进程被内核的 OOM Killer 挑中干掉。这种场景做过 Linux 运维或者后端开发的人大概率都遇到过。很多人第一反应是内存不够加内存条但真正排查下去会发现问题往往出在对内核内存分配机制的理解偏差上——进程申请的虚拟内存和实际占用的物理内存是两回事内核自己也要吃掉一大块内存而malloc返回成功不代表真的拿到了物理页。Linux 内核内存分配这个话题看起来是内核开发者才需要关心的底层知识实际上它跟每一个在 Linux 上写代码、部署服务、调优系统的人都相关。你写的程序为什么在容器里跑着跑着就被杀了为什么free命令显示的 available 内存和实际能用的对不上为什么同样一份代码在物理机上稳如老狗进了容器就频繁触发内存回收这些问题的答案都藏在内核的内存分配机制里。这篇内容我打算把 Linux 内核内存分配这条链路从头到尾捋一遍覆盖物理内存管理、伙伴系统、slab 分配器、vmalloc、页表映射、内存回收与 OOM 判定这些核心环节。既讲清楚机制背后的设计逻辑也给出实际排查问题时能直接用的命令和参数。不管你是刚接触 Linux 底层的新手还是已经能看懂内核源码片段的老手应该都能从中找到对自己有用的部分。文中涉及的内核版本以 5.x 和 6.x 为主部分行为在更老的版本上会有差异我会在关键位置标注出来。2. 物理内存是怎么被内核管起来的从 memblock 到伙伴系统2.1 启动阶段的内存描述memblock 的过渡角色内核刚启动的时候伙伴系统还没建立起来这时候需要一个临时的内存管理器来记录哪些物理内存可用、哪些被占用。这个角色由memblock承担。BIOS 或者 UEFI 通过 e820 或者设备树把物理内存布局告诉内核内核把这些信息整理成 memblock 的 region 数组标记为memblock.memory可用物理内存和memblock.reserved已保留区域比如内核镜像本身、设备 MMIO 空间等。memblock 的设计非常朴素就是两个数组加线性扫描分配效率不高但胜在简单可靠启动早期够用。等到mm_init()阶段内核会把 memblock 里记录的可用内存移交给伙伴系统之后 memblock 的大部分功能就退居二线只在特定场景比如早期预留还会用到。这里有个容易踩的坑如果你在调试内核启动问题时看到memblock相关的日志比如memblock_reserve失败往往意味着物理内存布局和预期不符可能是 BIOS 设置、内核命令行参数如mem或者设备树里的 memory 节点写错了。我遇到过一台机器因为mem4G参数写死导致 8G 物理内存只识别了一半排查了半天才发现是启动参数的问题。2.2 伙伴系统物理页框的批发商伙伴系统Buddy System是内核管理物理页框的核心机制它把物理内存按 2 的幂次方划分成块最小单位是一个页框通常 4KB。所有空闲块按 order 挂在free_area数组里order 从 0 到MAX_ORDER-1通常是 10 或 11对应 4MB 或 8MB 的最大块。分配的时候如果当前 order 没有空闲块就往上找更大的 order把大块一分为二一半返回另一半挂到低一级的 order 链表里。释放的时候反过来如果相邻的伙伴块也空闲就合并成更大的块。这个分裂和合并的过程就是伙伴系统名字的由来。伙伴系统的核心优势是减少外部碎片。因为块的大小都是 2 的幂次方合并和分裂的规则很清晰不会出现那种总共还有 100MB 空闲但最大连续块只有 4KB的尴尬局面。但它也有代价内部碎片。你申请 5KB实际会给你 8KB 的块浪费 3KB。对于小内存分配这个浪费比例可能很高所以内核又搞了 slab 分配器来处理小对象。实际排查内存碎片问题时/proc/buddyinfo是最直接的入口cat /proc/buddyinfo # Node 0, zone Normal 1024 512 256 128 64 32 16 8 4 2 1每一列对应一个 order 的空闲块数量。如果低 order左边几列数量很多但高 order右边几列全是 0说明内存碎片化严重这时候申请大块连续内存比如大页、DMA 缓冲区就容易失败。解决办法通常是触发内存规整echo 1 /proc/sys/vm/compact_memory或者重启相关服务释放内存。2.3 内存 zone 的划分逻辑物理内存并不是铁板一块内核把它划分成不同的 zone每个 zone 有自己的用途和分配策略。常见的 zone 包括Zone 名称典型用途地址范围x86_64ZONE_DMA老式 ISA 设备 DMA0 - 16MBZONE_DMA3232 位设备 DMA0 - 4GBZONE_NORMAL内核常规使用4GB 以上ZONE_HIGHMEM32 位系统高端内存32 位特有ZONE_MOVABLE可迁移内存内存热插拔动态在 64 位系统上ZONE_HIGHMEM 基本不存在了因为虚拟地址空间足够大可以直接映射所有物理内存。ZONE_DMA 和 ZONE_DMA32 的存在是因为某些老设备只能访问低地址物理内存这个限制是硬件层面的软件绕不过去。分配内存时可以指定从哪个 zone 分配比如GFP_DMA标志就要求从 ZONE_DMA 分配。如果你在写驱动设备有 DMA 地址限制就必须用对应的 GFP 标志否则分配到的内存设备访问不了会出现数据错乱或者 DMA 失败。3. slab 分配器小对象分配的性能担当3.1 为什么需要 slab伙伴系统的粒度问题伙伴系统最小分配单位是一个页框4KB但内核里大量对象远小于 4KB比如task_struct、inode、dentry这些结构体小的几十字节大的也就一两 KB。如果每个小对象都走伙伴系统分配一个页框内存浪费会非常严重而且频繁分配释放页框的性能开销也扛不住。slab 分配器的思路是从伙伴系统批发大块内存然后自己切成固定大小的小块零售。每种内核对象对应一个 slab 缓存kmem_cache缓存里预分配好一批对象分配时直接从空闲链表拿释放时还回去不需要每次都和伙伴系统打交道。这个设计借鉴了 SunOS 的 slab 机制核心目标是对象复用。已经分配过的对象内存保持初始化状态下次分配时可以直接用省去了重复初始化的开销。对于频繁创建销毁的对象比如网络连接、文件描述符这个优化效果非常明显。3.2 slab、slub、slob三种实现的取舍内核里有三套 slab 实现编译时通过配置选择SLAB最早期的实现功能全但代码复杂元数据开销大现在主要用于兼容老架构。SLUB现代默认实现设计精简元数据少支持调试功能是 x86_64 和 ARM64 的默认选择。SLOB面向嵌入式小内存系统用最简单的链表管理内存开销最小但性能一般。查看当前系统用的是哪种可以看内核配置zcat /proc/config.gz | grep -E CONFIG_SLAB|CONFIG_SLUB|CONFIG_SLOB # 或者 grep -E slab|slub|slob /boot/config-$(uname -r)SLUB 相比 SLAB 最大的改进是去掉了复杂的队列管理每个 CPU 有自己的 per-cpu 缓存分配释放基本无锁。这在多核系统上性能提升很明显。我实测过同一台 32 核机器上SLUB 相比 SLAB 在高并发小对象分配场景下吞吐能高出 20% 以上。3.3 查看和分析 slab 使用情况/proc/slabinfo是观察 slab 使用情况的主要入口sudo cat /proc/slabinfo | head -20 # slabinfo - version: 2.1 # name active_objs num_objs objsize objperslab pagesperslab ... task_struct 512 640 9088 3 1 inode_cache 2048 2340 608 26 1 dentry 10240 12000 192 21 1关键字段解读active_objs当前活跃对象数num_objs总对象数活跃 空闲objsize单个对象大小objperslab每个 slab 能放多少对象pagesperslab每个 slab 占多少页如果某个缓存的num_objs远大于active_objs说明有大量空闲对象占着内存没释放。这种情况在内存紧张时可以通过echo 2 /proc/sys/vm/drop_caches触发 slab 回收注意这会清理 dentry 和 inode 缓存可能影响性能。排查内存泄漏时slab 是重点怀疑对象。如果发现某个缓存的 active_objs 持续增长不下降基本可以确定有内核对象泄漏。这时候需要结合kmemleak或者slabinfo的历史趋势来定位具体是哪个模块的问题。4. vmalloc 与 kmalloc内核分配接口的选择逻辑4.1 kmalloc物理连续的首选kmalloc是内核里最常用的分配接口它保证返回的内存在物理上连续。这个特性对 DMA 操作至关重要因为很多设备要求缓冲区物理连续。kmalloc 底层走的是 slab 分配器所以分配小对象时效率很高。kmalloc 的大小限制取决于架构和配置通常单次分配不能超过 4MBKMALLOC_MAX_SIZE。超过这个大小就得用其他接口。另外 kmalloc 有 GFP 标志参数控制分配行为void *kmalloc(size_t size, gfp_t flags);常用的 GFP 标志GFP_KERNEL常规分配可能睡眠不能在中断上下文用GFP_ATOMIC原子分配不会睡眠中断上下文可用但成功率低GFP_DMA从 DMA zone 分配GFP_NOWAIT不等待直接返回失败选错 GFP 标志是新手常犯的错误。在中断处理函数里用GFP_KERNEL会导致内核崩溃或者死锁因为中断上下文不允许睡眠。反过来在可以睡眠的场景用GFP_ATOMIC会降低分配成功率还可能触发不必要的内存回收压力。4.2 vmalloc虚拟连续但物理可以不连续vmalloc分配的是虚拟地址连续的内存物理页可以是离散的。它通过修改页表把不连续的物理页映射到连续的虚拟地址空间。这个特性适合分配大块内存因为物理内存经过长时间运行后很难找到大块连续区域而 vmalloc 不受这个限制。代价是性能vmalloc 需要建立页表映射分配和释放都有额外开销而且访问时 TLB 命中率可能不如 kmalloc。所以 vmalloc 一般用于分配较大的缓冲区比如模块加载、大数组不适合高频小对象分配。void *vmalloc(unsigned long size); void vfree(const void *addr);vmalloc 的地址空间是有限的在 32 位系统上尤其紧张通常只有 128MB 左右64 位系统好很多但也有上限。如果 vmalloc 空间耗尽会看到vmalloc: allocation failure的报错。查看 vmalloc 使用情况cat /proc/vmallocinfo | head # 0xffffc90000000000-0xffffc90000008000 32768 load_module0x...4.3 选择策略一张表说清楚需求场景推荐接口原因小对象 4KBkmallocslab 缓存性能好中等对象4KB - 4MBkmalloc物理连续DMA 友好大块内存 4MBvmalloc不受物理碎片限制DMA 缓冲区kmalloc GFP_DMA物理连续且地址受限高频分配释放kmem_cache专用缓存复用对象中断上下文kmalloc GFP_ATOMIC不能睡眠实际写代码时优先用 kmalloc只有确实需要大块内存且不要求物理连续时才考虑 vmalloc。如果某个对象频繁创建销毁应该建专用的 kmem_cache而不是反复 kmalloc/kfree。5. 内存回收与 OOM当内存不够时内核做了什么5.1 页面回收的三条路径内存不够时内核会尝试回收一些页面来腾出空间。回收的对象主要是文件页page cache和匿名页进程堆栈数据。回收路径有三条直接回收分配内存时发现空闲不足当前进程自己触发回收会阻塞分配。kswapd 后台回收内核线程周期性检查在内存降到低水位时异步回收。内存规整整理碎片把分散的空闲页合并成大块不释放页面但改善连续性。文件页回收比较简单如果页面干净没被修改过直接丢弃下次读文件时重新加载如果脏页就先写回磁盘再丢弃。匿名页回收麻烦得多因为没地方备份只能写到 swap 分区。如果没有 swap 或者 swap 也满了匿名页就回收不了。5.2 水位线与分配行为每个 zone 有三个水位线min、low、high。空闲页高于 high 时正常分配降到 low 以下唤醒 kswapd 回收降到 min 以下触发直接回收如果还不行就 OOM。cat /proc/zoneinfo | grep -A5 Node 0, zone Normal # pages free 12345 # min 1024 # low 1280 # high 1536调整水位线可以影响回收的激进程度。比如vm.min_free_kbytes控制 min 水位调大它会让内核更早开始回收避免突然 OOM但代价是可用内存变少。这个参数在内存紧张的容器环境里需要仔细调默认值往往偏小。5.3 OOM Killer 的挑选逻辑当回收也救不了的时候OOM Killer 出场挑一个进程杀掉。挑选依据是oom_score分数越高越容易被杀。分数计算考虑进程占用的内存量、运行时间、是否 root 等因素。占用内存越多、运行时间越短的进程分数越高。cat /proc/pid/oom_score # 当前分数 cat /proc/pid/oom_score_adj # 调整值-1000 到 1000可以通过oom_score_adj保护关键进程把它设成 -1000 就基本不会被杀但如果是内核 panic 级别的 OOM 还是保不住。生产环境里数据库、消息队列这类核心服务建议设置保护值。容器环境下的 OOM 更复杂因为 cgroup 有自己的内存限制和 OOM 判定。容器内看到的free是宿主机内存但实际可用受 cgroup limit 约束。排查容器 OOM 要看memory.max、memory.current这些 cgroup 文件而不是宿主机层面的free。6. 实战排查几个真实的内存问题定位过程6.1 案例一available 内存充足但分配失败有次线上服务报Cannot allocate memory但free -h显示 available 还有好几个 G。这种情况通常是碎片化或者zone 限制导致的。排查步骤# 1. 看各 zone 的空闲分布 cat /proc/buddyinfo # 2. 看是否某个 zone 特别紧张 cat /proc/zoneinfo | grep -E zone|free|min # 3. 看 slab 是否占用过多 sudo cat /proc/slabinfo | awk {print $1, $2*$4/1024KB} | sort -k2 -rn | head那次最后发现是 ZONE_DMA32 耗尽因为有个驱动一直在申请 DMA 内存没释放。available 内存都在 ZONE_NORMAL但 DMA 分配只能从低地址 zone 拿所以失败。解决办法是修复驱动的内存泄漏短期可以调整 DMA zone 大小需要重启。6.2 案例二slab 缓存持续增长监控发现某台机器内存使用率缓慢上升重启后恢复过几天又涨。free显示 buff/cache 占比很高但drop_caches之后没降多少说明不是普通的 page cache。# 对比两次 slabinfo找出增长最快的缓存 sudo cat /proc/slabinfo /tmp/slab1 sleep 3600 sudo cat /proc/slabinfo /tmp/slab2 diff (sort /tmp/slab1) (sort /tmp/slab2) | grep | sort -k3 -rn | head定位到是某个网络模块的缓存泄漏每个连接创建一个对象但异常路径下没释放。这种问题只能改代码临时缓解可以定期重启服务或者用echo 2 /proc/sys/vm/drop_caches强制回收但会影响性能治标不治本。6.3 案例三容器内 OOM 但宿主机内存充足容器里跑个 Java 服务堆设了 2G容器 limit 也是 2G但频繁被 OOM Kill。宿主机free看还有几十 G 空闲。问题在于 JVM 除了堆内存还有元空间、线程栈、直接内存等开销这些加起来可能超过 2G。容器 limit 是 cgroup 层面的硬限制超了就杀不管宿主机有多少空闲。解决办法容器 limit 留出余量比如堆 2G 的话 limit 设 3GJVM 参数加-XX:MaxMetaspaceSize、-XX:MaxDirectMemorySize限制非堆内存用-XX:UseContainerSupport让 JVM 感知容器限制新版本默认开启排查容器 OOM 要看 cgroup 的memory.eventscat /sys/fs/cgroup/memory/container-id/memory.events # oom 5 # oom_kill 3oom_kill次数就是被杀的次数配合memory.max和memory.current能看出是不是真的超限。7. 几个容易被忽略的调优参数和注意事项7.1 overcommit 策略不是所有 malloc 都真的给内存Linux 默认允许 overcommit也就是进程申请的虚拟内存可以超过物理内存总量。malloc返回成功只是拿到了虚拟地址真正访问时才分配物理页。这个机制提高了内存利用率但也埋了雷如果所有进程同时访问自己申请的内存物理内存不够就会触发 OOM。cat /proc/sys/vm/overcommit_memory # 0: 启发式 overcommit默认 # 1: 总是允许 overcommit # 2: 严格模式不允许超过 CommitLimitovercommit_memory2适合对内存确定性要求高的场景但可能导致大内存应用启动失败。overcommit_ratio控制 CommitLimit 的计算比例。生产环境一般保持默认的 0配合监控告警来兜底。7.2 透明大页性能提升还是延迟杀手透明大页THP自动把 4KB 页合并成 2MB 大页减少 TLB miss提升内存密集型应用性能。但它也可能导致内存分配延迟抖动因为合并大页需要整理碎片可能阻塞分配。cat /sys/kernel/mm/transparent_hugepage/enabled # [always] madvise never数据库类应用如 Redis、MongoDB通常建议设成madvise或者never避免 THP 带来的延迟毛刺。普通应用保持always问题不大。这个参数没有绝对优劣要看具体负载特征。7.3 内存 cgroup 的层级限制cgroup v2 的内存控制器支持层级限制子 cgroup 的总用量不能超过父 cgroup。配置时要注意memory.max和memory.high的区别max是硬限制超了触发 OOMhigh是软限制超了会限流但不会杀进程。# 设置软限制和硬限制 echo 2G /sys/fs/cgroup/mygroup/memory.high echo 3G /sys/fs/cgroup/mygroup/memory.max用memory.high做软限流比直接max更平滑给应用一个缓冲空间避免突然被杀。但要注意high设置过低会导致应用频繁被限流性能下降。7.4 内核内存泄漏的排查工具内核内存泄漏比用户态难查因为没法用 valgrind 这类工具。常用的手段kmemleak内核自带的泄漏检测需要编译时开启CONFIG_DEBUG_KMEMLEAK运行时通过/sys/kernel/debug/kmemleak查看报告。开销较大一般只在调试时开。slabinfo 趋势分析定期采样对比找出持续增长的缓存。ftrace/kmem tracepoint跟踪kmalloc、kfree事件分析配对情况。kmemleak 的使用echo scan /sys/kernel/debug/kmemleak cat /sys/kernel/debug/kmemleak它会列出疑似泄漏的地址和调用栈但会有误报需要结合代码分析确认。8. 写在最后一些个人体会内核内存分配这块内容光看文档和源码容易陷入细节出不来最好的学习方式是带着问题去查。比如你遇到一次 OOM顺着/proc/zoneinfo、/proc/buddyinfo、/proc/slabinfo这条线走一遍比干看伙伴系统原理理解得深得多。另外提醒一点不同内核版本的行为差异不小。比如 cgroup v1 和 v2 的内存管理逻辑完全不同5.x 和 6.x 在内存回收策略上也有调整。排查问题时第一件事是确认内核版本别拿老版本的经验套新版本。最后分享一个我常用的排查顺序先看free和/proc/meminfo确认整体情况再看/proc/buddyinfo判断碎片然后/proc/slabinfo查内核对象最后结合dmesg里的 OOM 日志定位具体进程。这个顺序覆盖了大部分内存问题的排查路径熟练之后基本能在几分钟内缩小范围。