进程的地址空间:那 100 多 TB 地址是给谁的

进程的地址空间:那 100 多 TB 地址是给谁的
隔壁老康难度 ★★★★☆ · 前置:/proc 里那些文件是谁写的 | 一句话结论:
VSZ是虚拟地址空间的长度,RSS才是真正占用的物理页。页只有在第一次写入时才分配(缺页异常),所以mmap一块 1GB 只涨一页;线程栈是mmap出来的固定 8MB 匿名段。
二十个 G,和一台八个 G 的机器
那天是给一台内存告警的机器做排查,监控面板上内存曲线顶到了 90%,我登上去敲的第一条命令本来只是想看看谁在吃内存,回车之后屏幕上出来三行,最上面那行的第二列是 20384512,单位 KiB,我拿计算器一除,二十个 G,而我的键盘还没敲第二条命令,脑子里已经开始盘算是哪个服务漏了,是不是得连夜回滚,那十分钟里我甚至在想该给谁打电话,因为机器上跑的是我这周刚接手的服务。
1 | ps -eo pid,vsz,rss,comm --sort=-vsz | head -5 |
1 | PID VSZ RSS COMMAND |
java 那个进程 VSZ 20GB,而那台机器一共只有 8GB 物理内存。
第一次看到这个数字的人会以为 ps 坏了。
我当时的反应也是这样,于是又去对了一遍 free -h,想看看到底是谁把这台机器吃掉了,结果拿到的是另一组同样让人发毛的数字,free 那一列只剩 512Mi,我当时就想,得,这机器怕是下一秒就要死了,赶紧把监控曲线往前拉了几分钟,想看看是哪一秒开始掉下去的,结果那条曲线平得像尺子画的,一整天就长这样,倒是这条 90% 的告警本身有点可疑。
1 | total used free shared buff/cache available |
没有任何进程在吃 20GB。
这台机器连 8GB 都还没用满,四行输出里 used 只有 4.1Gi,真正的大头叫 buff/cache,这两个词后面还要单独讲,但它跟那个二十个 G 一点关系都没有,我当时的第一反应是 ps 算错了,还专门去对了一遍 /proc/PID/statm,它没算错,它算的是一个跟物理内存根本不是一回事的量,一个说的是这张地址空间表格有多长,另一个说的是表格里有多少格真的落在了内存条上,这两件事在进程刚起来的时候能差两个数量级。
(后来我才知道,那个 20GB 是 JVM 启动时按堆上限预留的号,跟机器装了多少内存没有半点关系,我当时吓得手都凉了。)
从这里开始,要面对的东西大都不在屏幕上。
也没法用 ls 直接验证,页、缓存、回收全都看不见,只能靠间接证据一点点推,这是整条路线里最需要耐心的一段。
领号和花钱是两件事
地址空间(address space)是操作系统发给每个进程的一张许可证,上面写着你有这么多地址可以用,但这张许可证只保证号码有效,不保证号码背后有货,所以一个进程完全可以在只有 8GB 内存的机器上领到 128TB 的号,整个过程内核眼睛都不眨一下,因为它压根没打算真的给你 128TB 的内存条,它只是把一张空表格的边界画在那个位置,表格里的内容是后来按需填的,填一行算一行,没填的行一直空着也没人管,你去翻 /proc/meminfo 也找不到任何跟这个数字有关的东西,这种感觉跟后来看 cgroup 的 memory.max 有点像,一个数字摆在那儿,意思却不是你想的那个意思,得先知道这个数字是谁记的、记的是什么,才谈得上拿它判断问题,不然只会被自己脑补出来的结论带着跑,还跑得挺快。
关键在于它领的是号,不是货。
| 名字 | 是什么 | 单位 | 会涨的原因 |
|---|---|---|---|
VIRT / VSZ |
虚拟地址空间大小,进程有多少号 | KiB | 映射了新区域(mmap、线程栈、堆预分配) |
RES / RSS |
常驻内存,真正在物理内存里的页 | KiB | 真正读写过的匿名页 + 文件页 |
SHR |
RSS 里可共享的部分(libc、共享库、共享内存) | KiB | 加载共享库、shm |
SWAP |
被换到 swap 的匿名页 | KiB | 内存压力 |
一句话记法,VIRT 是支票上写的数字,RSS 是账户里真有的余额,支票可以开得比余额大很多,只要没人在同一天来兑现。
java -Xmx4g 在启动时就把这 4GB 的堆映射出来了,这一步叫 reserve,但只有真正写到的那部分才对应物理页,这一步叫 commit,所以你会在 ps 里看到 VIRT 里有 4GB、RSS 里可能只有 200MB,中间那 3.8GB 全是还没兑现的号码,这 3.8GB 平时不占任何东西,只在页表里留下一行登记,代价就是一行,不是 3.8GB 的内存,这也是为什么 JVM 的堆上限可以开到比物理内存还大,只要它别真的全写满就行,而它能开这么大,正是因为内核默认允许你先开空头支票。
从 0 排到 0x7fffffffffff
x86-64 上用户的虚拟地址范围是 0x0 到 0x00007fffffffffff,一共低 128TB,内核占高位的 0xffff800000000000 往上,同一张页表里两半靠第 47 位区分,这种叫 canonical 地址,中间那一段是荒的,任何访问都会立刻 SIGSEGV,我第一次读到那个 47 位的规则时觉得很别扭,为什么要留一条谁也不能碰的沟,后来才想明白这是为了让硬件做地址检查时只看一个位,省掉一次比较,一个位的代价换掉一次比较,这笔账在 CPU 里很划算,而那条沟的存在也顺手让野指针立刻暴露。
从低到高排下来是这样。
graph TD A["0x0000000000400000 附近<br/>ELF 代码段 .text(r-xp)"] --> B["+ 数据段 .data / .bss(rw-p)"] B --> C["堆 heap(rw-p)<br/>brk 往上长 ↗"] C --> D["mmap 区(rw-p / r-xp)<br/>共享库、大块 malloc、线程栈"] D --> E["↙ 栈 stack(rw-p)<br/>8MB 上限,往下长"] E --> F["0x00007fffffffffff<br/>(之后是荒区,访问必崩)"] F --> G["0xffff800000000000 以上<br/>内核空间,用户态访问不到"]
栈在上面、堆在下面,两个相向生长,这样中间留出来的空洞最大,堆和栈都能自由扩展,如果反过来让栈在堆上面往上长,两者争夺同一块中间空间,冲突概率大增,而且这两个方向不是随便定的,堆之所以往上,是因为它得从 ELF 文件背面接着长出来,栈之所以往下,是因为它是从地址空间最高处往回收,两头各占一头,中间那一大片就永远是空的,谁要都不冲突,这套排布从 32 位时代一直用到今天,改的只是位数,格局没动过。我当初背这个方向背了很久,总在纸上把栈画到最下面,因为直觉里栈该从零开始,后来才明白栈是从最高处往下倒着长,跟函数调用一层层压进去正好对得上。
maps 里那六列
/proc/PID/maps 就是这张图的文字版,随便挑一个进程打印出来,长这样。
1 | cat /proc/self/maps |
1 | 55d4c2a1e000-55d4c2a1f000 r--p 00000000 08:02 1180482 /usr/bin/cat |
每行六列,从左到右依次是地址范围、权限、偏移、设备、inode、路径,地址范围左闭右开,权限那五位是读写执行加上一个 p 或者 s,p 表示私有映射、s 表示共享映射,偏移是在被映射文件里的位置,设备那一列写 00:00 就是匿名,inode 写 0 也是匿名,路径一列除了真实文件还会出现 [heap]、[stack]、[vdso] 这类方括号名字,那是内核给特殊映射取的标签,不是文件。
| 列 | 例子 | 意思 |
|---|---|---|
| 地址范围 | 55d4c2a1e000-55d4c2a1f000 |
这一段的起止,左闭右开 |
| 权限 | r-xp |
读/写/执行/私有(p)或共享(s) |
| 偏移 | 00001000 |
在映射的文件里的偏移;匿名映射是 00000000 |
| 设备 | 08:02 |
主:次设备号,00:00 等于匿名 |
| inode | 1180482 |
文件 inode,0 等于匿名 |
| 路径 | /usr/bin/cat |
或 [heap] [stack] [vdso] 这些特殊名字 |
有三个细节值得留意。
cat 自己占了五行,这是同一个 ELF 文件里不同的段,代码只读可执行、只读数据、写完变私有,第一行权限是 r--p 并不奇怪,ELF 文件头需要被读到,但不需要被执行,这五行合起来才是完整的 cat,少一行都跑不起来,你要是把其中某一段的权限改错了,cat 连启动都做不到。
7f2b3c021000-7f2b40000000 ---p 这一段是我在样机上最看不懂的,权限是 ---p,三个权限全无,只保留地址范围,中间四十多 MB 一个字节都不能碰,看着像内存泄漏出来的空洞,其实是 glibc 非主 arena 的堆保留区(arena 是 glibc 自己圈的一块地,线程还没讲,这里只要知道它是给多线程 malloc 准备的、当前一个字节都没在用),进程一多线程 malloc 就会给新线程开新的 arena,每次 mmap 一大块先标成 PROT_NONE,64 位上是 64MB(HEAP_MAX_SIZE = 2 × DEFAULT_MMAP_THRESHOLD_MAX),用到哪块再 mprotect 出可写段,所以它跟线程栈一点关系都没有,这段本来就是单线程的 cat,它根本没有线程栈,我第一次把这一行当成泄漏,是因为默认了标出来的一定在用,后来才想明白 maps 只负责告诉你这段地址被登记过,用没用是 RSS 的事,两个文件两套账,混着看只会自己吓自己。
(我第一次看到 ---p 的时候真去找过是谁在吃这块内存,翻了两小时才明白它压根没吃,那四十几 MB 只是被人圈起来了。)
[vdso] 和 [vvar] 是内核映射给用户态的小段代码和数据,gettimeofday 在某些情况下不需要真正陷入内核,直接读 [vvar] 里的数据就行,这是 glibc 性能的关键之一,[vsyscall] 在 0xffffffffff600000,是给古董程序用的兼容页,现代程序不走它。
一次访问要读四次内存
进程手里只有虚拟地址,内存条上存的是物理地址,中间隔着一次查表,x86-64 上这次查表要走四级,每一级都住在内存里的某一页上,所以严格来说不是查一次表而是查四次表,这个事实解释了很多性能问题,也解释了 TLB 这种缓存为什么存在,一个地址要落到内存条上,得先走完一条四层的链,每一层都可能要访问一次内存,链走完才知道数据到底在哪一页。
graph TD A["虚拟地址 48 位<br/>[47:39][38:30][29:21][20:12][11:0]"] --> B["PGD<br/>CR3 指向"] B --> C["PUD"] C --> D["PMD"] D --> E["PTE"] E --> F["物理页帧号<br/>+ 低 12 位偏移"]
每级 9 位做下标,最后 12 位是页内偏移,12 位就是 4KB 页大小,9 位等于 512 个表项,每项 8 字节,正好一页 4KB,这不是巧合,是设计,因为只有这样一张页表才能正好装进一页,不用跨页存,也不用为最后几个表项单独留一段。
四级合计能寻址 48 位,也就是 256TB,其中用户一半、内核一半,各 128TB,这就是 VSZ 能到 20GB 的原因,那个数字是页表能表达的范围,跟物理内存无关。
而且这张表是稀疏的,你 mmap 了 4GB,页表里只登记了这一段属于你,中间那些 PTE 是空的,真正访问到某个地址时才触发缺页异常,内核才去分配物理页、填 PTE,这叫按需分页(demand paging),所以映射了和占了之间隔着一整个缺页处理路径。
所以 malloc(1GB) 之后 RSS 不涨,因为一个字节都还没写。
那内核凭什么敢给?
malloc 一个 G 立刻成功,而机器上可能只剩 200MB 空闲,这件事在水位线之外还有一层,叫内核的超额承诺(overcommit),它的意思是内核在发号的时候不做严格兑现准备,它赌的是大部分进程不会把领到的号全部用掉,这个赌注在服务器上几乎总是赢的,因为一个进程的 RSS 通常只有 VIRT 的百分之几,但你总能在 java 进程上看到赌得比较大的那一类,堆上限一开就是四个 G。
1 | cat /proc/sys/vm/overcommit_memory |
三个值,每个值对应一套完全不同的记账方式。
| 值 | 策略 | 后果 |
|---|---|---|
0 |
启发式,默认 | 明显离谱的申请会被拒,一般申请都给 |
1 |
永远答应 | 申请一律成功,OOM 时再死 |
2 |
严格记账 | 申请总量不能超过 CommitLimit,超了 malloc 直接失败 |
默认是 0,所以 malloc 返回非 NULL 只代表内核答应了,不代表真有一块物理内存等着你,真正分配物理页发生在第一次写入,这也是为什么线上那些内存溢出经常表现为写的时候进程突然没了,而不是申请的时候报错,报错太早了,早到你还以为一切正常。
顺着这条还能解释一个常见误解,malloc 失败不一定是内存不够,可能是 overcommit_memory=2 下的记账失败,反过来 malloc 成功也压根不能证明内存够用。
地址每次都不一样
最后一个和 VSZ 有关的常识,同一个程序连跑两次,堆和栈的地址会不一样。
第一次碰到的时候我以为是自己敲错了命令。(后来才知道这种误会几乎是每个新人都会踩一次的,因为把两个都叫内存的东西混成一个了,而 free 的排版恰好又把最容易误会的那一列放在最左边。)
1 | for i in 1 2 3; do cat /proc/self/maps | grep '\[stack\]'; done |
三次输出三个不同的地址,内核在 execve 时随机化了栈顶、mmap 基址、堆起点和 vDSO 的位置,把 /proc/sys/kernel/randomize_va_space 设成 0 之后三次就一样了,这属于调试时才做的事,生产环境留着,它的意义在于抬高一次缓冲区溢出攻击的成本,如果攻击者不知道栈在哪,就得猜地址,猜错是段错误而不是拿 shell。
(写实验时我第一件事就是把它关掉,不然每次跑出来的地址都不一样,对着日志比对特别费劲。)
它不是万能的,信息泄露一类漏洞能绕过它,但成本极低,所以默认开着。
TLB 和 2MB 页
四级页表意味着访问一个此前没碰过的地址,最坏要额外读 4 次内存,因为每一级页表项各自可能 cache miss,如果每次都这样,程序当然慢得离谱,可实际上我们写的程序跑得并不慢,因为多出来的这四次读绝大多数时候根本不发生,CPU 里有个小缓存叫 TLB(Translation Lookaside Buffer)挡在前面,通常 64 到 1536 项,专门缓存虚拟页到物理页的翻译结果,命中就零成本,这才是绝大多数访问的实际情形。我第一次认真看这条链是在一台 QPS 怎么都上不去的机器上,perf 里 dTLB 那几项占比高得离谱,当时还怀疑是不是工具统计错了,毕竟这台机器不是新机器,跑的还是同一份代码,后来把工作集砍小、让它装得下页表项,指标立刻就降下来了,那一次我才真正明白所谓的四级不是课本上的一张图,而是每次访问都要付或者白付的一笔账,付与不付的差别就摆在同一张表里,谁也没法绕过。
TLB 很小,所以有了大页。
2MB 页让 PMD 直接指向物理页,少一级,1GB 页让 PUD 直接指,一个 2MB 页只需要一条 TLB 表项,覆盖同样范围用 4KB 页要 512 条,/proc/meminfo 里的 AnonHugePages 是内核透明大页(THP)的用量统计,由内核的 khugepaged 和缺页路径维护,跟 glibc 没有关系。
栈不是一开始就有 8MB
1 | ulimit -s |
1 | 8192 |
8MB 是内核的限制,不是硬件的。
栈溢出的那一刻长这样。
1 | /* 编译:gcc -O0 -fno-optimize-sibling-calls boom.c -o boom */ |
每次递归吃 1MB,第 9 次就崩,编译时必须 -O0 且关掉尾调用优化,否则编译器会把它变成循环,永远不栈溢出,崩的时候是 SIGSEGV,dmesg 里有 segfault at ... error 6。
但栈不是一开始就有 8MB 的,它从一小段开始,每次访问到边界外,内核检查这是不是栈的正常增长方向,是就扩一页,这个函数叫 expand_downwards,所以栈是用多少长多少,你在 maps 里看到的 [stack] 那一段永远只有实际用到的那么大,而不是 8MB,只有超出 8MB 上限、或者越过了 guard gap(栈和 mmap 区之间的空隙),内核才判定为非法访问,发 SIGSEGV。
(这台编译机当时就是这么配的,线程池一开大直接顶到 OOM,查了半天才发现是被栈吃掉的。)
这也是为什么某些程序会 ulimit -s unlimited,递归深的编译器(老版本 gcc)、数值计算,栈不够用,代价是之后每个线程的栈也按这个来,线程数一多物理内存就吃紧。
线程栈后面会单独讲,这里先看一眼,因为它解释了 maps 里那些奇怪的匿名段,Linux 的线程是 clone(CLONE_VM | CLONE_FS | ...) 出来的,共享同一个地址空间,所以每个线程的栈只能 mmap 一块出来,大小固定,默认 8MB,pthread_attr_setstacksize 可改,在 maps 里它们是那些没有路径、权限 rw-p、大小正好 8MB(0x800000)的段,每个线程栈一侧还有单独一页 ---p 的 guard page,默认 guard size 就等于系统页大小 4KB,pthread_attr_setguardsize 可以调大,这一页的存在是为了让栈踩出去的时候立刻撞墙,而不是悄悄踩进隔壁线程的数据里。
把自己的地址空间打出来
从自己进程的地址空间开始。
1 | cat /proc/self/maps | head -3 |
上面那条命令看到的是 cat 自己的地址空间,因为 /proc/self 指向执行 cat 的那个进程,这个坑我在讲 /proc 那篇里踩过一次,这里又踩了一次。
想稳定地看,得用一个长命进程。
1 | sleep 600 & |
1 | grep Vm /proc/$SPID/status |
1 | VmPeak: 9124 kB |
VmSize 是 VIRT,VmRSS 是 RSS,差了一个数量级,而 VmPeak 和 VmHWM 是历史最高值,排查内存泄漏时看 VmHWM 比看 VmRSS 有用,因为 VmRSS 会随着回收掉下来,VmHWM 只涨不跌,一个是当前水位,一个是历史最高水位,看泄漏永远看后者。
验证按需分页,先记下 RSS,然后 malloc 一大块但不写。
1 | python3 -c " |
1 | before: VmRSS: 9120 kB |
1GB 的映射只让 RSS 涨了 4KB,一页,剩下的都还只是页表里的空位。
再看栈的实际范围。
1 | grep stack /proc/$$/maps |
1 | 7ffd3b7e9000-7ffd3b80a000 rw-p 00000000 00:00 0 [stack] |
0x7ffd3b80a000 - 0x7ffd3b7e9000 等于 0x21000,也就是 135168 字节,132KB,这就是当前实际用到的栈大小,不是 8MB。
最后看一眼大页。
1 | grep -i huge /proc/meminfo |
1 | AnonHugePages: 20480 kB |
AnonHugePages 非零说明 THP 在工作,匿名页被合并成 2MB 大页,TLB 压力小很多。
这四笔账不该混着算
VIRT 大的进程占内存多是新手里最顽固的一条误会,两者完全没关系,VIRT 是页表能表达的范围,RSS 才是物理内存,java 的 VIRT 20GB 是地址空间预留,实际占多少看 RES,malloc 之后就占了内存也是同一个误会,不写不占,glibc 的 malloc 对大块直接 mmap 一块,默认阈值是 128KB(M_MMAP_THRESHOLD),但不碰它就不分配物理页,这条路径和跨进程共享在 mmap 与共享内存 那篇里讲透了。
还有一条是 free 里的 free 少说明内存不够,其实要看 available,free 是完全没被用过的,available 是算上能立刻回收的缓存,一个健康的 Linux 服务器 free 接近 0 才是正常的,buff/cache 那三 G 不是被程序占着,是内核拿来当缓冲了,紧张的时候随时能还回去。
栈也不是从 0x0 开始往上涨的,栈在高地址往下长,/proc/PID/maps 里 [stack] 那一段就是证据,RSS 涨了同样不等于内存泄漏,可能是缓存涨了,可能是刚触发缺页,要看 VmHWM 这个历史峰值和趋势,而不是单次快照。
进程之间的地址空间也不是连续的一整块,每个进程独立一张页表,物理页可以散落在任何地方,同一个虚拟地址在两个进程里通常映射到不同物理页,除非是共享内存或者 fork 出来的 COW 页,64 位也不等于 64 位地址,目前 x86-64 只用了 48 位,也就是四级页表,部分新 CPU 支持 57 位,也就是五级,LA57,VSZ 里那些天文数字是能表达的,不是能用的。
栈溢出同样不像 C 教材写的那样会覆盖别的变量,现代系统上通常是 SIGSEGV 直接崩,因为 guard page 拦住了,覆盖有用数据那种情况只发生在同一次函数调用内部的 buffer 溢出。
三个数字,三种含义
VIRT 是地址空间大小,RSS 是物理内存占用,每个进程有独立的 48 位用户地址空间,从低到高是代码段、数据段、堆、mmap 区、栈,虚拟地址通过四级页表翻译成物理页,9+9+9+9+12 位,TLB 缓存翻译结果,物理页是按需分配的,mmap 不等于占内存,写到才分配,栈默认 8MB,向下生长,越界有 guard page 兜着。
排查内存泄漏看 VmHWM,不看 VmRSS。
这三篇都在处理看不见的东西,接下来是最反直觉的一个,你读的每一个字节可能根本没碰磁盘,read 返回的那一刻数据是从内存副本里拿的,这个副本叫 page cache,它解释了为什么 free 少不一定是坏事,也解释了为什么机器重启后第一次启动特别慢。











