3、Linux内存管理深入:进程地址空间布局、内核空间与用户空间、内存分配器(SLUB/SLAB)、OOM Killer机制

好,咱们继续往下走。上一章聊了虚拟内存和物理内存的映射关系,这一章我们深入一点,看看Linux到底是怎么管理这些内存的。说实话,这部分内容我当年啃了很久才真正搞明白。你想想看,一个进程跑起来,它的内存到底长什么样?内核和用户程序怎么分地盘?申请内存时底层又发生了什么?

嗯,今天我们就一个一个拆开来讲。

3.1 进程地址空间布局:你的程序在内存里长啥样

每个进程启动后,Linux都会给它画一张「内存地图」。这张地图就是进程地址空间。我习惯把它想象成一栋楼,从低地址到高地址,每一层住着不同的「住户」。

典型的32位Linux进程地址空间布局是这样的:

高地址 0xFFFFFFFF
+------------------+
| 内核空间          |  (1GB,用户不可见)
+------------------+ 0xC0000000 (TASK_SIZE)
| 栈 (stack)        |  (向下增长)
|   ↓               |
|                   |
|   ↑               |
| 堆 (heap)         |  (向上增长)
| 数据段 (BSS)      |
| 数据段 (Data)     |
| 代码段 (Text)     |
+------------------+
低地址 0x00000000

这里有个关键点:内核空间和用户空间的分界线。在32位系统上,通常是3GB+1GB的分法。用户空间占3GB(0x00000000 ~ 0xC0000000),内核空间占1GB。64位系统地址空间更大,但原理一样。

核心要点:每个进程都以为自己拥有完整的4GB(32位)地址空间,但实际上只有用户空间是「私有」的。内核空间是所有进程共享的。

我在项目中遇到过一个问题:一个服务进程莫名其妙地崩溃了,查了半天发现是栈溢出。为什么?因为栈和堆是相向而长的,一旦栈向下增长碰到了堆的区域,就会触发段错误。说白了,就是两个「住户」抢地盘了。

3.2 内核空间与用户空间:两个世界

为什么要把地址空间分成两部分?说白了,是为了安全。

用户程序不能直接访问内核空间的数据。你想想看,如果随便一个用户程序都能改内核的页表,那系统还怎么玩?所以CPU提供了特权级机制——x86上叫Ring 0(内核态)和Ring 3(用户态)。

用户程序想干点「特权事」,比如分配内存、读写文件,必须通过系统调用。这个过程叫「陷入内核」。我刚开始学的时候觉得这很麻烦,后来才明白,这是操作系统在用「最小权限原则」保护自己。

我的经验:调试内存问题时,我经常用 /proc/<pid>/maps 查看进程的内存布局。这个文件会列出每个内存区域的起始地址、权限、偏移量等信息。比如:

cat /proc/1234/maps
08048000-08049000 r-xp 00000000 08:01 12345    /usr/bin/hello
08049000-0804a000 r--p 00000000 08:01 12345    /usr/bin/hello
0804a000-0804b000 rw-p 00001000 08:01 12345    /usr/bin/hello
...
b7e00000-b7f00000 rw-p 00000000 00:00 0        [heap]
...
bfe00000-bfe27000 rw-p 00000000 00:00 0        [stack]

看到没?代码段是r-xp(可读可执行),数据段是rw-p(可读可写),堆和栈都是rw-p。权限清清楚楚。

3.3 内存分配器:SLUB vs SLAB

好,现在我们知道进程地址空间长啥样了。但内核自己也需要分配内存啊,比如创建一个进程结构体、分配一个网络缓冲区。这些对象通常很小,而且频繁创建销毁。如果直接用页分配器(一次分配4KB),那太浪费了。

所以内核搞了两个「批发转零售」的分配器:SLAB和SLUB。

3.3.1 SLAB分配器

SLAB是老牌分配器,从Linux 2.2就开始用了。它的思路很简单:把内存分成一个个「缓存」(cache),每个缓存专门管理一种固定大小的对象

比如,专门有一个cache管理task_struct(进程描述符),大小是固定的。当你创建一个进程时,直接从cache里拿一个现成的对象;销毁时,放回去。避免了频繁的页分配和释放。

SLAB有个问题:代码复杂,管理开销大。尤其是多核系统上,锁竞争很严重。

3.3.2 SLUB分配器

SLUB是后来者,从Linux 2.6开始引入,现在已经是默认分配器了。它简化了SLAB的设计,去掉了那些复杂的队列和着色机制。

我个人更喜欢SLUB,原因很简单:性能好,代码简洁。SLUB把每个CPU的本地缓存做得更轻量,减少了锁的争用。

你可以通过 /proc/slabinfo 查看当前系统的SLUB使用情况:

cat /proc/slabinfo
slabinfo - version: 2.1
# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> ...
task_struct         120    150   2112   15    8
mm_struct            45     60    896   18    4
vm_area_struct      300    400    192   21    2
...

看到task_struct那一行了吗?每个对象大小2112字节,每个slab里有15个对象,占用8个页面(32KB)。

避坑指南:我曾经遇到过一个问题:系统运行一段时间后,kmalloc-128这个cache占用了大量内存,怎么都释放不掉。后来发现是某个驱动模块在申请128字节内存后没有正确释放,导致内存泄漏。排查时,/proc/slabinfo帮了大忙——哪个cache的active_objs异常增长,问题就在哪。

3.4 OOM Killer机制:内存不够时,杀谁?

这是Linux内存管理里最「残酷」的机制。当系统内存耗尽,内核又无法通过回收页面(比如swap、page cache)获得足够内存时,就会启动OOM Killer。

OOM Killer会选择一个进程杀掉,释放它的内存。那问题来了:杀谁?

内核有一套评分机制,叫oom_score。评分越高,越容易被杀。评分考虑的因素包括:

  • 进程占用的内存大小(rss、swap)——占得越多,分越高
  • 进程的oom_score_adj——用户可以手动调整这个值,让某些进程「免死」
  • 进程的父进程——尽量杀子进程,不杀init进程
  • 进程的nice值——优先级低的进程更容易被杀

你可以查看每个进程的OOM评分:

cat /proc/<pid>/oom_score
cat /proc/<pid>/oom_score_adj

举个例子,假设你的MySQL进程占用了大量内存,而你又没设置oom_score_adj,那它很可能被OOM Killer选中。我见过一个生产事故:数据库服务器内存耗尽,OOM Killer把MySQL进程杀了,导致业务中断半小时。

我的建议:对于关键服务,一定要设置oom_score_adj。比如MySQL可以设置:

echo -1000 > /proc/<mysql_pid>/oom_score_adj

这样OOM Killer会尽量避开它。当然,更好的做法是做好内存监控和预警,别让系统走到OOM这一步。

3.5 小结

这一章我们聊了三个核心点:

  1. 进程地址空间:代码段、数据段、堆、栈、内核空间,各有各的地盘
  2. 用户态与内核态:通过系统调用切换,权限分明
  3. SLUB/SLAB和OOM Killer:内核怎么高效分配小对象,以及内存耗尽时的「最后手段」

说实话,这些内容看起来有点枯燥,但都是排查内存问题的基本功。下一章我们会聊更实战的东西——怎么用工具定位内存泄漏和性能瓶颈。到时候你会发现,今天讲的这些底层知识,全都能用上。

课后小练习:打开你的Linux机器,执行以下命令,看看你的系统里有哪些slab cache:

cat /proc/slabinfo | head -20

再看看某个进程的地址空间布局:

cat /proc/$$/maps | head -10

嗯,动手试试,比光看文章强多了。