搞 Linux 的人,基本都躲不开一个问题:一个进程到底占了多少内存。top 里的 RES、VIRT,/proc 下一堆字段,加不加共享库,算不算 swap,口径本来就多,同一个进程在不同工具里显示的数值还不一样。我见过有同事拿着 top 里的 VIRT 数字说“这个进程吃了 20G 内存”,实际上物理内存才用了 2G 多,这就是把虚拟内存和驻留内存混为一谈了。借着梳理 Linux 进程内存这条线,我从虚拟地址空间讲到物理页分配,再聊到统计口径和实际排查,很多浮在表面的困惑其实背后是同一件事:进程面对的是一个虚拟的世界,物理内存只是背后按需供应的资源。这篇文章适合所有想真正读懂内存数据、定位内存问题的开发者,也适合在容器和云环境里跑服务的运维朋友。
1. 先搞清楚进程是怎么“看”内存的
多数人第一次接触虚拟内存时,最难扭转的观念是:进程拿到的地址,并不是物理内存里的地址。
1.1 虚拟地址空间就是进程的“地图”
32 位年代的教科书喜欢画一张经典图:低地址是代码段,依次是数据段、BSS、堆、映射区、栈,最高处留给内核。64 位时代布局没有本质变化,但用户空间的地图大得多了。在 x86-64 Linux 上,每个进程都有大约 128TB 的用户态虚拟地址空间,内核态还有自己的映射区域。普通程序根本用不完,所以“虚拟地址空间大小”这个数字对排错帮助有限。
这张“地图”上大致有这些区域:
- 代码段(.text):存放机器指令,只读,可以直接从可执行文件映射进来。
- 数据段(.data):已初始化的全局变量和静态变量。
- BSS 段:未初始化的全局变量,加载时不会真的在文件里占空间,只是虚拟地址上给你预留了位置,访问时内核会把页清零之后交给你。
- 堆(heap):向上增长,主要给 malloc 的小块分配用,由 brk/sbrk 机制维护。
- 内存映射区(mmap region):共享库、大块 malloc、文件映射、线程栈,都落在这片区域。
- 栈(stack):向下增长,函数调用、局部变量、线程栈的默认位置。
为什么要特意画这张地图?因为排查内存问题时,你会经常看到某个进程的虚拟空间里出现一堆地址相近的 0x7f... 开头映射,那基本都是共享库或者 mmap 出来的区域。见过一次cat /proc/<pid>/maps之后,再看工具连篇的输出心里就不慌了。
这里有个常被忽略的细节:线程的栈也是映射出来的。创建线程时,内核会给它分配一块独立的栈,默认大小通常 8MB,但这个 8MB 是虚拟内存,只有实际触碰到的页才产生物理内存消耗。所以一个进程开到几千个线程,RSS 不一定暴涨,但 VSZ 会很明显涨上去。
1.2 从虚拟地址到物理页:页表在中间做翻译
虚拟地址不能直接用,CPU 拿到的每条指令里的地址都要经过页表翻译,变成物理地址才能访问内存。内核为每个进程维护一份页表,把虚拟页面映射到物理页帧。在 x86-64 上,多级页表让这个翻译过程像查目录:PGD、PUD、PMD、PTE,一层层索引下来,最终找到物理页。
页的默认大小是 4KB,可以用getconf PAGESIZE确认。TLB 是 CPU 里的缓存,专门缓存最近的地址翻译结果;如果命中率低,翻译开销会拖慢程序,这也是“大页”(HugeTLB、THP)存在的意义:同样多的 TLB 条目,能覆盖的内存大得多。
翻译失败就会触发缺页异常(page fault)。缺页分两种:
- minor fault(次要缺页):页表项还不存在,但内存不缺,分配一个物理页并填好映射就行,很快。
- major fault(主要缺页):页面需要从磁盘加载,比如从文件映射的页或 swap 换出的页,涉及磁盘 IO,开销很大,程序很可能卡顿。
判断一个进程的访问模式,最直接的就是看缺页次数:
ps -o pid,minflt,majflt,cmd -p <pid>如果 majflt 明显偏大,说明这个进程频繁访问还没加载到内存里的文件或 swap 页,运行慢的根源往往就在这里,而不是 CPU 算得慢。
2. 懒加载与写时复制:Linux 省物理内存的两板斧
如果你 malloc 了 1GB 内存,然后进程立刻消失,没写入任何数据,这 1GB 物理内存其实几乎没被消耗。这就是懒分配。
2.1 申请不等于占有:缺页异常驱动的惰性分配
用户态malloc(1GB)成功,不代表内核立刻腾出 1GB 物理内存给你。对于小块内存,glibc 会通过 brk 把堆顶抬高,改动进程的虚拟地址空间;对于大块内存,malloc 会走 mmap 建立匿名映射。这两步都只是在“地图”上画了一块地,还没有真正把物理页分配好。
真正分配物理页发生在第一次写入(有的场景是读取)这块内存时:CPU 访问虚拟地址,页表找不到物理页,抛缺页异常,内核才从伙伴系统取一个页面,清空,建立映射,然后返回用户态继续执行。你可以把这种机制理解为“按需付费”:申请多大都行,但用多少才付多少。
这一点对理解 overcommit 至关重要。Linux 默认允许一定程度的内存超卖,因为很多进程申请了内存但永远用不满。代价是万一大家都突然把承诺的内存全部写入,物理内存就不够了,内核只能启动 OOM Killer 挑一个进程杀掉。所以:
提示:程序里 malloc 返回的非 NULL 只表示虚拟地址空间申请成功,不代表物理内存够用。宁可多处理 OOM,也不能盲目依赖 malloc 的成功返回值。
实测时可以这样验证:写一个小程序 malloc 1GB,不写内存,看 RSS 几乎不变;再循环 memset,RSS 立刻涨上去。用pidstat -r 1 -p <pid>能实时看到 minflt/s 在第一个写入循环里飙升,每个新增的写入页都对应一次缺页。
2.2 fork 之后的内存:写时复制(COW)
fork 是 Linux 进程创建的经典方式。老说法是“fork 要复制父进程的所有内存”,今天的实现早就不这么干了:fork 的时候只复制页表,并把所有可以共享的页标记为只读,父子进程都指向同一个物理页。之后任何一方想写这个页,CPU 都会触发写保护异常,内核才复制一个物理页给写入方,并把两边的映射设置为可写——这就是写时复制(Copy-On-Write)。
这个机制让 fork 一个一两 GB 内存的进程通常非常快,因为大多数页只是借了个地址映射,没有发生数据复制。exec 是另一回事:exec 会丢弃新进程大部分旧映射,重新从可执行文件加载代码段和数据段,那些复制出来的 COW 页也随之释放。
COW 带来的统计问题是:fork 之后,父进程和子进程在短时间内 RSS 会显示两倍,因为它俩各自认为自己在用那么多内存,但物理页实际上只有一份。工具不知道你有 fork,只能按页表统计。理解这一点,就不会在监控图上看到一个进程 fork 出子进程后总内存瞬间翻倍而恐慌,真正的物理数据其实在子进程逐页写入后才会逐步翻倍。
2.3 mmap:把内存和文件接到同一条通道
mmap 是另一个核心设施。它可以把文件的一部分区域直接映射进进程的虚拟地址空间,之后读写这段内存就像读写文件,缺页时内核用 page cache 承载文件内容。好处很明显:
- 同一文件可以被多个进程共享,共享库就是这么加载的,物理内存只放一份。
- 文件内容的访问和普通内存访问统一,省去了 read/write 的系统调用和数据拷贝。
- MAP_PRIVATE 的映射是 COW 的,改内存不影响文件;MAP_SHARED 的映射会通过后台回写或 msync 同步到文件。
匿名映射 MAP_ANONYMOUS 则是 malloc 大块内存的幕后功臣。另外,像 tmpfs、共享内存(shm)本质也走这一套映射机制。排查内存时,在 /proc/ /smaps 里看到的 [anon] 一般是匿名页,带文件路径的则是文件映射页。分清这两种,很多内存问题就有了方向。
3. 别只看 RSS:进程内存的统计口径
看到进程占多少内存,不同工具给出的数字经常对不上。这不是工具坏了,而是统计口径不一样。
3.1 VSZ、RSS、PSS、USS 分别代表什么
我把主流口径列一张表,记起来很方便:
| 指标 | 全称 | 含义 | 典型工具 |
|---|---|---|---|
| VSZ | Virtual Set Size | 虚拟地址空间大小,包含所有已映射但可能从未访问的区域 | top 的 VIRT、ps 的 VSZ |
| RSS | Resident Set Size | 驻留物理内存的页总数,包含共享页 | top 的 RES、ps 的 RSS |
| PSS | Proportional Set Size | 比例集大小,共享页按引用进程数均摊 | smem |
| USS | Unique Set Size | 独有集大小,只统计自己独占的物理页 | smem |
RSS 的坑在于共享部分。一个 libc.so 被几百个进程加载,按 RSS 算,每个进程都把这 100MB“算到自己头上”,几百个进程加起来远超实际物理内存。PSS 解决了这个问题:如果一个 100MB 的页被两个进程共享,每个进程记 50MB。USS 则更“苛刻”,只算独享的部分,非常适合衡量一个进程的“净增量”。
实践里我一般这么用:
# 按 PSS 排序看全系统进程真实内存占用 smem -t -p -s rss | sort -k 7 -nRSS 只是参考,别拿它做容量规划的基准。容器的 limit、宿主机告警阈值、进程内存配额,都应该优先考虑 PSS 口径。
3.2 从 /proc/ /status 读出真实账本
Linux 把进程内存的明细放在 procfs 里,读取不需要额外权限,这也是我最常用的观察手段:
cat /proc/<pid>/status重点关注这几个字段:
- VmSize:当前虚拟地址空间总大小,对应 VSZ。
- VmRSS:当前驻留物理内存,对应 RSS。
- VmData:数据段加堆的大小,malloc 堆的增长主要看它。
- VmExe 和 VmLib:可执行代码和共享库占用的部分。
- VmSwap:进程有多少页被换出到 swap,非零说明进程正在受 swap 拖累。
如果进程的堆内存一直在涨,VmData 往往是最先跳动的指标。而 /proc/ /smaps 更进一步,把每个映射单独列出来,包含 Size、Rss、Pss、Swap 等字段。比如想快速知道这个进程的 PSS 总量,可以用一行 awk:
# 统计该进程 PSS 总大小,输出单位 MB awk '/^Pss:/ {pss += $2} END {printf "PSS total: %.2f MB\n", pss/1024}' /proc/<pid>/smaps看多了之后你会发现,很多“内存暴涨”并不是一直分配新内存,而是某个文件映射(比如日志文件 mmap、JVM 的 .so)被大量读进 page cache。这时候区分进程私有页和可回收文件页就很重要,它可以避免半夜被监控误报叫醒。
4. malloc 之后内存去哪了:用户态与内核态的分配器
进程里写的 malloc/free 并不是直接找内核要页。中间隔着好几层分配器。
4.1 glibc 的 malloc:arena、chunk 与阈值
几乎每个 Linux C/C++ 程序都用 glibc 的 ptmalloc。它把内存分成 chunk 来管理,每个 chunk 带一个 16 字节的头部,用于记录大小、使用状态。大块逻辑如下:
- 小块分配优先从线程本地缓存(tcache)和 bins 里找空闲 chunk,速度快。
- 主线程的主分配区通过 brk/sbrk 维护堆顶,辅助分配区(arena)通常用 mmap 创建。
- 分配超过默认阈值(128KB)的大块内存,直接 mmap 从内核拿一个独立映射,释放时也直接还给内核。阈值可用 mallopt(M_MMAP_THRESHOLD) 调整。
这解释了三个常见的“诡异现象”。
第一,程序释放了大量内存,RSS 却不见下降。因为释放的 chunk 可能进入了 arena 的空闲列表,供后续分配复用,并没有归还内核。可以先看看malloc_info()的统计,再判断是不是泄漏,别急着下结论。
第二,程序大量分配小块又释放,堆可能碎成很小的片段,最大连续空闲块不够用,系统被迫不断 brk 抬高堆顶。表现为 VmData 涨、/proc 里堆区域不断扩大。很多长驻进程内存缓慢上升,不一定是泄漏,而是碎片加内存池策略。
第三,多线程程序 malloc 竞争激烈时,性能掉得厉害。因为默认会有多个 arena 来分担锁竞争,但 arena 越多,内存浪费也越多。所以“线程多了内存涨了”有时不是业务状态变大,而是分配器的正常开销。
4.2 内核怎么给进程“发”物理页
用户态分配器最终仍要向内核申请页。内核的物理内存管理核心是伙伴系统(buddy allocator):物理页按 2 的幂次分成多个空闲列表,分配时从合适的阶次拆页。它保证了连续物理页的可靠分配,尽量减少外部碎片。
对于内核自身要频繁创建和销毁的“小对象”,比如文件描述符、inode、task_struct,每次 open/close 都去伙伴系统凑个物理页太浪费。内核用 slab/slub 分配器维护对象池,重复利用已释放对象的内存。这块内存计入系统内核占用(/proc/meminfo 里的 Slab),不直接算到用户进程头上。
另外,透明大页(THP)打开之后,内核会在缺页时尝试把物理页凑成 2MB 大小,减少页表压力,但也可能让 RSS 出现“不符合预期的突增”,因为分配粒度变大了。对延时敏感的服务,有时候需要关掉 THP,具体取舍取决于业务。
5. 进程内存问题的定位与实战排查
前面的机制铺垫完了,下面聊点实际打架的招式。
5.1 内存泄漏:从现象到工具链
判断一个进程有没有泄漏,第一原则是看趋势,不是看瞬时值。单独一个时间点的 RSS 说明不了问题,连续观察才靠谱:
while true; do grep -E 'VmRSS|VmData' /proc/<pid>/status; sleep 5; done如果 VmRSS 或 VmData 单调上涨,重启后才降下来,多半有泄漏或无限缓存。接下来分两步排查:
- 如果是 C/C++,优先上 valgrind memcheck,它慢但是准:
valgrind --tool=memcheck --leak-check=full ./your_app。线上跑不了 valgrind 就退而求其次,用 AddressSanitizer 重新编译关键模块:gcc -fsanitize=address -g ...,它能在崩溃点或泄漏点直接给出栈。 - 如果是 Java/Python/Go 这类带运行时语言,先看运行时自己暴露的指标。Java 用 NMT 和 heap dump,Go 用 pprof,Python 用 tracemalloc。别在堆栈工具没有结果之前就开始“猜”。
还有一个被低估的排查入口:/proc/ /smaps 里 Pss 最大的几个映射。如果全是 [heap] 且持续增大,看用户态堆;如果是某个匿名映射在涨,大概率是共享内存或线程相关的对象;如果是文件路径映射在涨,去看是不是缓存没清理。这一招能帮你把锅分清楚。
5.2 OOM Killer 的裁决逻辑与保护策略
物理内存真的不够时,Linux 会启动 OOM Killer。它的裁决依据是 /proc/ /oom_score,分数越高越可能被杀。分数主要跟进程的 RSS、swap 占用以及 oom_score_adj 有关。可通过调整 oom_score_adj 影响生死:
# 把关键进程的保护级别调低,不容易被杀,范围 -1000 到 1000 echo -500 > /proc/<pid>/oom_score_adj但别盲目设置 -1000,那等于告诉内核“永远不要杀我”。如果它把内存吃完,整个系统可能直接卡死,连 OOM Killer 都跑不动,更麻烦。生产数据库、关键中间件建议调到 -200 到 -500 之间,留点余地就好。
OOM 发生时系统日志会留下痕迹:
dmesg | grep -i "out of memory" | tail -20日志里能看到被选中的进程名和当时的内存快照。如果你的服务日志里没有,但系统经常卡、核心服务被莫名杀掉,多半就是内核在杀“邻居”——这就是为什么容器环境里的进程内存超限经常表现为“被 cgroup 杀”,而不是整机 OOM。
顺带一提,/proc/sys/vm/overcommit_memory的默认值 0 是启发式超卖;1 表示永远允许超卖;2 是严格模式,超过 CommitLimit 的申请直接失败。生产环境建议保持 0,除非你对稳定性要求非常极致且业务内存固定,很少需要调到 2。
5.3 cgroup 内存限制与容器场景
现代容器和 systemd 服务普遍基于 cgroup 做资源隔离。cgroup v2 下,/sys/fs/cgroup 目录里有 memory.max、memory.current、memory.peak 等文件。当 cgroup 内所有进程总和超过 memory.max,内核会优先回收可回收页,仍不足就触发 OOM,杀掉 cgroup 里得分最高的进程。
怎么确认容器被杀:
cat /sys/fs/cgroup/memory.events里面 oom_kill 计数不为 0,说明该 cgroup 触发过 OOM。配合dmesg里的内存快照,能很快锁定是哪类进程超限。
cgroup 场景最常见的坑是“进程 RSS 没超,但 memory.current 超了”。因为 cgroup 统计的是该组内所有进程的匿名页加 page cache 加内核对象,比如日志文件缓存、临时文件写入,都会算进去。如果你看到 cgroup 内存使用很高,但业务进程 RSS 正常,先查 page cache:cat /sys/fs/cgroup/memory.stat里的 file 字段占比。必要时调整 memory.swap.max 或者加大 limit,而不是急着给进程加内存。
Java 服务在容器里要额外照顾:很多版本 JVM 默认最大堆按宿主机物理内存的四分之一算,宿主机 128G,容器只给 2G,堆可能分配几十 G 虚拟内存,超限后直接被杀的几率极高。建议显式设置-XX:MaxRAMPercentage=75.0或者直接-Xmx。
写在最后的经验
整套东西梳理下来,我个人的体会是:看 Linux 进程内存,心里要始终绷着两根弦——第一根是虚拟和物理的差别,几乎所有“内存数字异常”都源于混淆了这两层;第二根是共享与独享的差别,多进程共享页导致 RSS 虚高,必须用 PSS 观察实际物理压力。排查时也尽量走证据链:先看 RSS 趋势,再看 smaps 明细,最后翻内核日志,比“拍脑袋调参数”靠谱得多。
最后再分享一个小技巧:想在不停机的情况下快速确认一个进程是否在持续“新写入”内存,可以连续两次读取 /proc/ /status 里的 VmRSS,并配合pidstat -r 1 -p <pid>观察 minor fault 速率。minor fault 一直高说明进程在持续触碰新页,这通常对应大块内存的初始化或缓存填充,而不是正常的存量复用。用这个信号结合业务请求量,很快就能定位到哪个模块在吃内存。现在每次遇到“进程内存到底多少”的争论,我都会先问一句:你用的是 RSS 还是 PSS,是虚拟的还是物理的。把这个问题答完,一半的争论已经结束了。