做后端和系统底层开发的朋友,几乎都遇到过这种场景:业务进程明明只占几个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与vmalloc
3.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_memory=2,让内核按物理内存+swap严格核算后再决定是否允许申请,同时给关键进程设置oom_score_adj,尽量降低被杀概率。理解这层机制后,看到OOM日志就不会再把锅甩给业务代码了。
6. 常见问题速查与三条避坑心得
6.1 高频问题速查表
| 现象 | 关注点 | 排查入口 |
|---|---|---|
| 进程RSS不高,但free显示内存被吃光 | page cache / 内核slab | slabtop、/proc/meminfo的Slab字段 |
| 应用内存稳定上涨,重启进程后恢复 | 用户态内存泄漏 | smaps_rollup、配合多次采样对比私有匿名页 |
| 内核对象数量持续增长 | 内核态内存泄漏 | /proc/slabinfo、kmemleak |
| 频繁swap且性能抖动 | 物理内存不足或回收策略激进 | vmstat的si/so、pgmajfault |
| malloc成功但一写入就被杀 | 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内存这两套体系虽然复杂,但它们的分工逻辑很清晰:应用层负责高效使用,内核层负责安全与公平。把机制搞明白,排查问题就不再靠猜了。