
做后端和系统底层开发的朋友几乎都遇到过这种场景业务进程明明只占几个GB内存系统却频繁告警内存不足再用free一看内存确实被吃光了。你把应用代码翻了个底朝天怎么看都不像泄漏可问题就是反复出现。这时候如果只盯着应用层那点内存大概率找不到真凶——Linux的内存大头往往藏在看不见的内核内存里。今天我就把Linux内核内存和应用层内存这两套体系彻底拆开聊一遍从地址空间布局、分配机制、用户态与内核态的数据通道路径到实战排查手段一次讲透。1. 内核内存与应用层内存两套体系一个物理仓库1.1 每个进程眼里都有“一整套系统”在学Linux内存的时候最容易混淆的一点就是进程看到的“内存”和系统实际的物理内存根本不是一回事。我们平时说的内存实际上只有一条——物理内存条上的那几根颗粒。但每个运行中的进程都会在自己的视角里拥有一份完整的地址空间这份地址空间是内核给它“画”出来的称为虚拟地址空间。在32位时代一个进程的虚拟地址空间通常是一张4GB的“图纸”。其中从0到3GB归用户态程序使用剩下从3GB到4GB这一块也就是最高的1GB是内核态的专属区域。这也是常说的3:1划分。64位系统下布局更宽裕用户态和内核态的地址范围都被放大到T量级但核心思想没变进程的地址空间被明确切成了“应用层”和“内核层”两段。你在程序里打印的指针都是虚拟的根本没直接指向物理内存。这段划分不是随意定的而是有严格逻辑的。用户态的代码由应用程序控制必须有足够大的空间放堆、栈、代码段和数据段。内核态部分则是操作系统自己的地盘存放页表、内核栈、各种驱动和内核模块这些对普通进程是不可见的。进程就算想访问内核区域的地址CPU的权限检查也会直接拦下来报段错误或权限错误而不是给你一个内存值。1.2 隔离带来安全也带来性能成本为什么要把内存这样分开答案就两个字安全。如果应用程序能直接读写内核的内存那任何一次野指针错误都可能踩坏系统的核心数据结构一个普通程序的崩溃就会拖垮整台机器。把内存划分成用户态和内核态两个空间后代码也分成了两个特权级别用户态代码运行在低特权级Ring 3内核代码运行在高特权级Ring 0。低特权级的代码没有权限访问高特权级的地址空间这就从CPU层面把“应用闯祸”和“系统瘫痪”隔离开来。但隔离是要付出代价的。最直接的代价就是用户态和内核态之间拷贝数据需要“跨界”。比如一个普通的read()系统调用应用层要先准备一个缓冲区内核把磁盘数据读到一个内核空间的缓冲区里再拷贝到这个用户缓冲区中。这一来一回涉及到数据在物理内存中搬来搬去还不能出错。很多性能优化手段比如mmap、sendfile本质都是在想方设法减少这种跨界的拷贝次数。我刚开始接触内核时最容易犯的错误就是把物理内存和虚拟内存混为一谈认为进程申请了100MB系统物理内存就少了100MB。实际上完全不是这样虚拟内存的申请只是个“纸面记录”真正占物理内存的时机在后面。理解了这一点再去排查内存问题思路会清晰很多。提示当你在程序里打印指针试图判断“这个地址在不在内核区”时用户态看到的地址永远落在低地址段内核区地址你是直接看不到的。想确认内核模块的地址范围需要看/proc/modules或/proc/kallsyms。1.3 两套分配器管同一个仓库物理内存是唯一的但管理它的人有两个视角。应用层的视角是由C库函数比如malloc和内核的缺页机制共同完成的内核层的视角则是由页分配器、slab分配器这些内核组件完成的。它们两个其实是从同一个物理内存池里取货只不过一个面向用户进程一个面向内核自身。这就导致了一个很有意思的现象应用层申请的内存即使释放了物理页也不一定会立刻归还系统而是可能被内核留在缓存里内核模块申请的内存则完全不同一旦泄漏外部工具很难察觉。后面我会逐一拆开讲这两种内存的分配方式和回收条件。2. 应用层内存的完整链路从malloc到物理页2.1 malloc返回地址的那一刻物理页还没到位在Linux下写C程序的人几乎天天和malloc打交道。在大多数人的直觉里malloc(1MB)这个调用返回成功后我就拥有了1MB内存。这个直觉是错的。实际上malloc调用返回一个非空指针只代表地址空间里“预留”了一段区域这段区域在虚拟地址层面已经是你的了但物理内存还没真正分配。这是Linux所谓“延迟分配”策略的核心先记账后付款。进程申请内存时内核只是更新进程页表里对应的虚拟内存区域VMA把这些区域标记为“可写但尚未映射物理页”。真正分配物理页要等到进程第一次写入这些地址硬件产生缺页异常内核才会从伙伴系统里挑一个物理页映射过来然后重新执行出错的访问指令。这样设计的直接好处是快。如果一个程序malloc了1GB内存但实际只用100MB那物理内存也只消耗100MB不会为了“预留”浪费900MB。很多服务端程序启动即申请大量内存实际水位不高靠的正是这个机制。2.2 缺页异常才是真正的“交货”环节让我们把2.1的场景落到实际操作上看。假设某程序写了一个这样的循环申请一大块内存然后逐字节填充。第一次写每个页的时候CPU访问虚拟地址时发现页表里没有对应的物理页就会触发缺页异常。内核在异常处理里分配物理页、建立映射、刷新TLB再回到用户态继续执行。这个过程对应用程序而言几乎无感但在系统层面上它会体现在/proc/vmstat里的pgfault和pgmajfault两个计数上。缺页还分两种一种是只缺少页表项但物理内存还够快得很另一种是数据在swap分区或者页面缓存里可能需要从磁盘读回来慢得多。排查内存性能问题的时候如果发现系统频繁发生major fault那基本可以判断物理内存吃紧或swap配置激进这时候加内存或调优缓存回收策略往往比优化业务逻辑见效更快。我自己排查过一个Java服务的性能抖动业务量没怎么变但latency时不时飙高。查了半天最后发现在/proc/vmstat里pgmajfault隔几分钟涨一波配合dmesg看是磁盘IO导致的换页根因是另一台机器上的进程把物理内存吃得太狠把Java进程的匿名页挤出到swap了。这类问题如果只盯着业务代码根本发现不了。2.3 查看应用层内存VSZ、RSS与PSS的区别用ps或top查看进程内存几乎都会看到两列VSZ和RSS。VSZ是虚拟内存大小就是进程地址空间的总大小包括了malloc预留但没写入的部分。RSS是驻留内存大小即当前时刻实际占据物理内存的页面总数。RSS才是你真正关心的物理内存占用。但RSS有一个陷阱多进程共享的内存比如共享库、共享内存段会被每个进程重复统计。比如两个进程都使用libc.so同一份物理页被映射到两个进程的地址空间那么每个进程的RSS里都算一次。如果用RSS加总来判断总内存数值会虚高。Linux也提供了PSS按比例分担共享页。排查共享内存类型的服务时最好以PSS或smaps_rollup为准。实际操作里我最常用的是cat /proc/[pid]/smaps_rollup它直接列出该进程的RSS、PSS、私有内存和共享内存。比ps那个RSS要准确得多因为能看出私有的匿名页占多少文件映射页占多少。如果私有匿名页一直涨那就是业务内存在涨如果文件映射页在涨则可能是缓存或mmap文件的情况。3. 内核内存的分配生态伙伴系统、slab与vmalloc3.1 伙伴系统管大块slab管小对象与用户态不同内核态内存的分配逻辑更直接也更严格。内核自身管理物理页的顶层机制叫伙伴系统。伙伴系统把物理内存按2的幂次拆分成页块比如4KB、8KB、16KB……直到更大的阶。申请内存时按需拆分释放时再把相邻的空闲块合并回去。这样设计的目的是尽量减少外部碎片同时保证能够快速找到合适大小的连续物理内存。但伙伴系统分配粒度过大最小也是4KB的一个页。内核里有大量小对象的分配需求比如文件描述符、inode、dentry、task_struct这类几十到几百字节的结构。如果都按页分配浪费非常严重。于是内核在伙伴系统之上又实现了slab分配器——先向伙伴系统申请一组页面再切分成同类型的小对象按类型缓存。这就是kmalloc和kmem_cache的底层来源。3.2 不要混淆kmalloc、vmalloc与物理连续内核编程新手最容易踩坑的是区分kmalloc和vmalloc。kmalloc分配的内存是物理连续的适合做DMA等需要物理连续的场景但它的容量有限较大块分配容易失败。vmalloc分配的内存只有虚拟地址连续物理地址可以是零散的更适合大块分配比如为驱动模块建立缓冲区。但vmalloc也有代价它需要建立额外的页表项访问时TLB命中率可能变差。还有一个经常让人困惑的点内核内存默认不可换出。用户态的内存页可以被swap到磁盘、可以被回收但内核态的很多页面尤其是内核堆和内核栈是不允许换出的。一旦内核内存出现泄漏或增长你没有swap这一层缓冲问题会直接表现为系统可用内存越来越少直到OOM发生。这也是内核内存问题更危险的原因。3.3 内核内存泄漏为什么比应用层泄漏更难搞应用层内存泄漏通常可以靠重启进程来解决而且定位工具多。内核态内存泄漏就麻烦得多泄漏的内存在内核堆里普通进程的工具看不到free只能看到总数变少但不知道是谁拿走的。排查手段主要集中在/proc/meminfo、/proc/slabinfo和slabtop这些对象。恰好几年前处理过一个模拟项目X的问题服务器每跑两周可用内存就掉一截重启后恢复但过阵子又掉。排查进程列表用户态程序都很正常。后来用slabtop发现kmalloc-64对象数量持续增长配合/sys/kernel/slab/kmalloc-64/下的计数逐步缩小范围最终定位到某内核模块每次调用都分配对象却忘了释放。这类问题如果没用kmemleak这种内核自己带的检测工具纯靠业务侧排查几乎是黑盒。因此对内核开发场景内存泄漏的预防比排查更重要设计时就要想清楚对象的释放路径。4. 用户态与内核态的数据通道拷贝、切换与零拷贝4.1 read/write路径上的两次拷贝与一次切换跨内核与应用层的数据交换典型的入口就是read()和write()系统调用。以读文件为例数据流是这样的磁盘数据先被读到内核的page cache中然后系统调用进入内核态内核把page cache里的数据拷贝到用户空间的缓冲区最后返回到用户态。这中间至少有两次数据拷贝一次是磁盘到内核page cache另一次是内核page cache到用户缓冲区。这两次拷贝是“安全隔离”的直接代价。如果不拷贝而直接让用户程序引用内核内存应用层的野指针就可能破坏内核数据所以拷贝是必须的。但在大量数据传输的高性能场景比如网络代理或文件分发服务这种拷贝就成了瓶颈CPU大量浪费在内存搬运上。这时候就是用mmap和sendfile这类零拷贝技术的时候。4.2 mmap零拷贝的适用边界mmap可以把文件或匿名内存直接映射到进程地址空间让用户态代码直接读写映射区域省掉系统调用临时缓冲区的拷贝。它的典型优势是大文件处理、多进程共享内存场景。sendfile则更进一步可以直接把文件数据从内核页缓存发送到socket完全绕过用户态缓冲区。但零拷贝不是银弹。我见过不少团队把mmap当作万能优化手段结果反而埋了坑。mmap适合大块、低频变更的数据如果频繁小颗粒写入页表更新和缺页开销会吃掉优化收益。而且mmap映射的内存在进程挂掉时不会自动释放持续映射而不解除也会造成地址空间碎化。选择数据交换方式时要结合数据大小、写入频率、生命周期来权衡而不是看到“零拷贝”就无脑上。5. 实战排查一步步找出谁吃光了内存5.1 第一层free与/proc/meminfo定基调拿到一台内存告警的机器先别急着进应用先看free -m。这里有个关键常识free里的buff/cache是内核可以回收的不代表真正被“占用”了真正该看的是available。如果available已经很低那说明系统真的缺内存需要往下追。追的时候看/proc/meminfo。重点看几行MemTotal、MemFree、Slab、PageTables、VmallocUsed、KernelStack。如果Slab占得高用slabtop看是什么对象如果PageTables很大可能进程数量多或共享内存映射多如果KernelStack异常多数是线程数量暴涨。这一步定不了位但能快速把问题划分为“用户态占得多”还是“内核态占得多”。5.2 第二层区分用户态进程与内核态缓存如果判断用户态是主要消耗方用top按内存排序再对每个可疑进程看/proc/[pid]/status里的VmRSS和VmSwap配合smaps_rollup确认是堆还是映射导致的增长。如果判断内核态是主要消耗方就用/proc/buddyinfo看页块的碎片分布用slabtop看具体对象类型再看dmesg有没有报order分配失败。这一步能把范围从整机收敛到具体对象。值得单独提的是page cache。有些时候free显示used很高但实际的业务进程并不吃内存一大块都被page cache占了。page cache本身是好事内核可以按需回收但如果回收不及时又赶上应用申请大块内存就可能引发回收延迟和性能抖动。这种情况下调整/proc/sys/vm/下的一些回收阈值参数比如vfs_cache_pressure往往能改善体验。5.3 OOM Killer与overcommit的真相当系统实在拿不出内存时内核的OOM Killer会介入挑选一个进程杀掉。很多人困惑为什么我的进程内存还没到limit反而被杀原因是系统整机内存不足OOM Killer挑“分数高”的进程动手并不看某个进程单独占了多少。Linux默认允许overcommit即malloc申请大块虚拟内存时可能“超卖”——内核先答应申请等真正使用时才发现物理内存不够。这会造成一种典型现象进程malloc成功一访问就被OOM Killer杀掉。解决方案是设置vm.overcommit_memory2让内核按物理内存swap严格核算后再决定是否允许申请同时给关键进程设置oom_score_adj尽量降低被杀概率。理解这层机制后看到OOM日志就不会再把锅甩给业务代码了。6. 常见问题速查与三条避坑心得6.1 高频问题速查表现象关注点排查入口进程RSS不高但free显示内存被吃光page cache / 内核slabslabtop、/proc/meminfo的Slab字段应用内存稳定上涨重启进程后恢复用户态内存泄漏smaps_rollup、配合多次采样对比私有匿名页内核对象数量持续增长内核态内存泄漏/proc/slabinfo、kmemleak频繁swap且性能抖动物理内存不足或回收策略激进vmstat的si/so、pgmajfaultmalloc成功但一写入就被杀overcommit超卖vm.overcommit_memory、oom_score_adj多进程RSS加总超过物理内存共享内存重复统计用PSS或smaps_rollup重新统计6.2 三条可以抄作业的避坑心得第一不要在业务代码里用过量的malloc预分配。很多程序为了“保险”一启动就申请几GB虚拟内存平时根本不用。这种“纸上内存”虽然不占物理页但会影响内核的内存规划也让监控误判。真正需要大内存时按需分配按需释放比什么都靠谱。第二排查内存问题前先统一度量口径。团队里有人说“内存满了”看的是free的used有人看的是RSS还有人看的是容器监控里的cgroup值经常各说各话。建议把/proc/meminfo的available作为系统维度标准把smaps_rollup的PSS作为进程维度标准口径一致之后再排查效率能提高一倍。第三给关键进程提前设置oom_score_adj。我之前遇到过数据库进程在整机内存紧张时被OOM Killer优先选中的情况原因就是它的内存占用大分数自然高。提前给数据库进程设一个较低的oom_score_adj重要业务能多一层保命。类似的经验在容器化环境里同样适用只是要确保优雅退出逻辑提前做好别单纯靠内核来救场。最后再分享一个习惯我每次上线新内核模块或重大内存相关改动都会先在测试环境跑一轮压力脚本同时持续记录/proc/meminfo、/proc/slabinfo和进程RSS。内存问题的特点是“累积型”短时间不一定爆发但只要留有历史数据事后追溯定位就会容易得多。Linux内存这两套体系虽然复杂但它们的分工逻辑很清晰应用层负责高效使用内核层负责安全与公平。把机制搞明白排查问题就不再靠猜了。