🐧 第五章 内存管理
📄 一句话概括

一个进程的地址空间不是一个整块,是分段的:代码、只读数据、已初始化数据、堆、匿名映射、栈、内核。堆往上长、栈往下长,中间是巨大的空洞。

📝 本节和前面几节的关系

5.5 讲完了页表这个机制,这一节换个角度:从外面看一个进程到底占了哪些段。这是排查内存问题最实用的一个视角。

一、先看现象:地址是散着的

在同一个进程里连续创建五个小对象,打印它们的地址。 结果地址横跨好几 MB,完全不是紧挨着的。 原因:分配器把不同大小的对象放在不同的池子里,池子之间隔着几 MB 的记账空间。 所以【对象的地址】这件事,程序员基本不该假设任何东西——除了【同一个对象地址不变】。

二、Linux 上的六段

打开 /proc//maps,会看到一长串【起始地址-结束地址 权限 用途】。 从上往下大致是:可执行文件(代码)→ 只读数据 → 已初始化数据 → 堆 → 匿名映射 → 栈 → 内核。 权限那四位是 rwxp 的组合:r 读、w 写、x 执行、p 私有(c 共享)。 注意内核那一段地址在所有进程里都一样——因为它是共享的同一份。 还有一段常被忽略:vDSO 与 vvar——内核把几个函数直接映射给用户态调用,省掉一次过门。

三、堆往上长、栈往下长

这是为了共用中间那片空白:两边对着长,谁也别提前撞上谁。 中间那片巨大的空洞不占物理内存——因为对应的页表项根本没建。 所以【VSZ 几十 GB】完全可以只有【RSS 几十 MB】。 栈往下长还有一个好处:递归深了自动就撞到保护区,触发栈溢出报错,而不是悄悄改掉别的数据。

四、这个视角有什么用

查内存涨:看是哪一段在涨——堆在涨是程序分配多了,匿名映射在涨可能是线程栈或 mmap。 查段错误:崩在哪一段,就知道是访问代码、数据还是栈。 查共享:权限位是 c(共享)的段,是多个进程共用同一份物理内存。 一句话:maps 是进程内存的【户口本】。

五、动手 Demo:看看一个进程的地址空间长什么样

这一节先在沙箱里打印几个对象的地址(看它们散得多开),再把 Linux 上 /proc//maps 的六段逐条列清楚。

objs = [bytearray(16) for _ in range(5)]
for i, o in enumerate(objs, 1):
    print(i, hex(id(o)))

addrs = [id(o) for o in objs]
print("跨度(MB)", (max(addrs) - min(addrs)) / 1024 ** 2)

实跑输出:

--------------------------------------------------------------------
看看一个进程的地址空间长什么样
--------------------------------------------------------------------
① 同一个程序里的对象,地址是散着的

  第几个     地址(十六进制)     与上一个差多少
  -----------------------------------------------
  #1         0x22256B03130        —
  #2         0x22256B031B0        128
  #3         0x222549E0DB0        34743296
  #4         0x222549E36F0        10560
  #5         0x22254ACBD70        951936

  **五个 16 字节的对象,地址横跨了 33.13 MB**——完全不是紧挨着的。
  **它们被分在不同的内存块里,中间的空隙是分配器自己留的记账空间**
  (和 5.9、5.10 讲的是同一件事)。

② Linux 上对应的是 /proc/<pid>/maps

  地址段           权限     用途
  ---------------------------------------------------------
  低地址一小段     r--p     可执行文件本身(代码)
  接着一段         r--p     只读数据、字符串常量
  再一段           rw-p     已初始化的全局变量
  再一段           rw-p     堆(malloc 从这里要)
  一长条           rw-p     匿名映射(大块 malloc 走这里)
  很高的一段       rw-p     栈(往下长)
  最高处           r-xp     内核的代码(所有进程都一样)

  **注意堆往上长、栈往下长,中间留出巨大的空洞**——
  **空洞不占物理内存,因为对应的页表项根本没建。**

③ 自己动手看一眼

  📋 **Linux**:`cat /proc/self/maps` 或 `pmap -x <pid>`。
  📋 **筛选只看堆和栈**:`grep '\[heap\]\|\[stack\]' /proc/self/maps`。
  **Windows 上对应的是 VMMap(微软官方工具)**,或 PowerShell 的
  `Get-Process -Id $PID | Select-Object -ExpandProperty Modules`。

五个 16 字节的对象,地址横跨了好几 MB。 这不是【内存浪费】,是分配器按大小分池的必然结果——同一尺寸的对象才会被放在一起。 所以不要写【假设某两个变量地址相邻】的代码,任何语言都不要。 Windows 上等价的工具是 VMMap,Linux 上是 /proc/maps 与 pmap;本节的六段表就是 pmap 的读法。

📋 Linux 上一条命令看清分段:pmap -x <pid>——每一段的起始、大小、RSS、脏页数都列出来。RSS 那一列加起来,就是这个进程真占的物理内存。

30 秒实验

  1. cat /proc/self/maps,数一数分了多少段。
  2. pmap -x $$ | tail -3,看最后那个 total 的 RSS 是多少。
  3. grep stack /proc/self/maps,看栈的范围有多大(一般 8 MB)。

常见误区

  1. 【一个进程的内存是一整块】。它是分段的,段与段之间隔着巨大的空洞。
  2. 【相邻变量的地址相邻】。分配器按大小池分配,相邻的声明完全可能差出几 MB。
  3. 【地址空间大就是占内存多】。空洞不占物理页,要看 RSS 不是 VSZ。
自测三问(点开对答案) 1. 一个进程的地址空间大致分哪几段? —— **代码、只读数据、已初始化数据、堆、匿名映射、栈、内核**。**堆往上长、栈往下长,中间留空洞。** 2. 为什么相邻声明的两个对象地址可能差很远? —— **分配器按【大小分类】分池子**,同类才放一起,**池子之间隔着记账空间。** 3. maps 里的权限位 `rwxp` 分别是什么意思? —— **读、写、执行、私有**(c 表示共享)。**看权限位就能知道这一段是代码、数据还是共享内存。**

相关笔记