Linux 进程内存管理深度报告
> 主题: Linux 进程内存管理机制全解析
> 适用范围: Linux x86_64 系统,内核 4.x-6.x
> 研究时间: 2026-03-29
一句话版本
Linux 进程看到的是虚拟地址空间,物理内存按需分配(lazy allocation),通过 page fault 触发真正的内存映射。理解 VSZ/RSS/PSS/USS 的区别、OOM Killer 的打分逻辑、swap 与 overcommit 策略,是排查内存问题的核心知识。
目录
1. 虚拟地址空间布局
2. 内存指标详解
3. 页面机制
5. Swap 机制
6. 实用诊断命令
1. 虚拟地址空间布局
每个 Linux 进程运行在自己的 虚拟地址空间 中。在 64 位系统上,用户空间通常占据低 128TB(0x0000000000000000 ~ 0x00007FFFFFFFFFFF),内核空间占高地址。
经典布局(高地址 → 低地址)
┌─────────────────────────────────┐ 0x7FFF_FFFF_FFFF (128TB)
│ 内核空间 │ ← 用户态不可访问
├─────────────────────────────────┤
│ 栈 (Stack) │ ← 向下增长 ↓
│ ↓↓↓ │
│ │
│ (随机间隔 - ASLR) │
│ │
│ 内存映射区 (mmap) │ ← 共享库、mmap 文件
│ libpthread.so │
│ libc.so │
│ ld-linux.so │
│ │
│ (随机间隔 - ASLR) │
│ │
│ ↑↑↑ │
│ 堆 (Heap) │ ← 向上增长 ↑ (brk/sbrk)
├─────────────────────────────────┤
│ BSS 段 │ ← 未初始化的全局/静态变量
├─────────────────────────────────┤
│ 数据段 (Data) │ ← 已初始化的全局/静态变量
├─────────────────────────────────┤
│ 代码段 (Text) │ ← 可执行指令,只读
└─────────────────────────────────┘ 0x0000_0040_0000 (通常起始)
各段详解
| 段 | 权限 | 内容 | 增长方向 |
|---|---|---|---|
| **Text (代码段)** | r-x (只读+可执行) | 编译后的机器指令 | 固定大小 |
| **Data (数据段)** | rw- (可读写) | 已初始化的全局变量 `int x = 42;` | 固定大小 |
| **BSS** | rw- | 未初始化的全局变量 `int y;`,启动时清零 | 固定大小 |
| **Heap (堆)** | rw- | `malloc()`/`new` 动态分配 | 向上增长 ↑ |
| **mmap 区** | 各异 | 共享库、`mmap()` 映射的文件/匿名内存 | 向下增长 ↓ |
| **Stack (栈)** | rw- | 局部变量、函数调用帧、返回地址 | 向下增长 ↓ |
ASLR (地址空间布局随机化)
Linux 默认启用 ASLR(/proc/sys/kernel/randomize_va_space = 2),每次程序运行时随机化栈、mmap、堆的起始地址,防止缓冲区溢出攻击利用固定地址。
# 查看 ASLR 设置
cat /proc/sys/kernel/randomize_va_space
# 0 = 关闭, 1 = 随机化 mmap/stack, 2 = 全部随机化(默认)
# 对比两次运行的地址
cat /proc/self/maps | head -5
cat /proc/self/maps | head -5 # 地址不同!
大页内存分配的特殊路径
当 malloc() 请求的内存 超过 128KB(glibc 默认阈值 M_MMAP_THRESHOLD),不再用 brk() 扩展堆,而是直接调用 mmap() 分配匿名内存页。这意味着:
- 大块分配不在堆区,在 mmap 区
free()后能直接munmap()归还给操作系统- 小块分配在堆区,
free()后 glibc 缓存,不一定归还
2. 内存指标详解:VSZ/RSS/PSS/USS/Swap
这是最容易混淆的部分。一张图说清楚:
进程 A 进程 B
┌──────┐ ┌──────┐
VSZ → │ 300MB│ │ 250MB│ ← 虚拟地址空间总量
│ │ │ │
│ ┌────┤ ├────┐ │
RSS → │ │120M│ │100M│ │ ← 驻留物理内存
│ │ │ │ │ │
│ │ ┌──┤ ├──┐ │ │
│ │ │40│ 共享库libc │40│ │ │ ← 共享部分
│ │ └──┤ ├──┘ │ │
USS → │ │80MB│ │60MB│ │ ← 独占物理内存
│ └────┤ ├────┘ │
└──────┘ └──────┘
PSS(A) = 80 + 40/2 = 100MB (共享部分按进程数均分)
PSS(B) = 60 + 40/2 = 80MB
指标对比表
| 指标 | 全称 | 含义 | 是否计算共享 | 数据来源 | 适用场景 |
|---|---|---|---|---|---|
| **VSZ / VIRT** | Virtual Memory Size | 虚拟地址空间总大小 | 包含所有映射 | `ps`, `top` | 几乎没用 — 包含大量未实际使用的映射 |
| **RSS / RES** | Resident Set Size | 实际驻留在物理 RAM 中的内存 | 共享库被每个进程重复计算 | `ps`, `top` | 粗略判断 — 但会高估(共享库重复计) |
| **PSS** | Proportional Set Size | 按比例分摊共享内存 | 共享部分 ÷ 使用该共享的进程数 | `/proc/[pid]/smaps` | **最准确的单进程内存占用** |
| **USS** | Unique Set Size | 进程独占的物理内存 | 完全不计共享 | `/proc/[pid]/smaps` | 杀掉该进程能释放的内存量 |
| **Swap** | Swap Usage | 被换出到 swap 的内存大小 | — | `/proc/[pid]/status`, `smaps` | 判断进程是否受内存压力影响 |
实际意义
- VSZ 很大别紧张:一个 Java 进程 VSZ 可能 10GB+,但 RSS 只有 500MB。很多映射是预留但未使用的。
- RSS 会骗你:10 个进程都 link 了 libc(~2MB),RSS 会把这 2MB 算 10 次。全系统 RSS 之和 > 物理内存是正常的。
- PSS 是黄金指标:按比例分摊共享库,全系统 PSS 之和 ≈ 实际物理内存使用量。
- USS 告诉你真相:杀掉一个进程到底能释放多少内存?看 USS。
快速获取各指标
# VSZ 和 RSS (KB)
ps aux | head -1; ps aux | sort -k6 -rn | head -10
# PSS(需要 root 或 smaps_rollup)
sudo cat /proc/$(pgrep -f node)/smaps_rollup | grep Pss
# 一键获取 PSS/USS/RSS
sudo smem -t -k -s pss | head -20
# 单进程完整内存分布
sudo cat /proc/$(pgrep -f node)/smaps | head -60
3. 页面机制
Linux 内存管理的核心单位是 页(page),默认大小 4KB。
3.1 Page Fault(页面错误)
当进程访问一个虚拟地址,而该地址对应的物理页不在 RAM 中时,CPU 触发 page fault 异常,内核介入处理。
进程访问虚拟地址 0x7f3a...
│
▼
MMU 查页表 ──→ 映射存在且物理页在 RAM?
│ │
否 是 → 正常访问
│
▼
触发 Page Fault
│
├─── Minor Fault:页在内存中(页缓存/其他映射),只需更新页表
│
├─── Major Fault:页不在内存中,需要从磁盘读取
│
└─── Invalid Fault:非法访问 → SIGSEGV (Segmentation Fault)
Minor Page Fault(次要页面错误)
- 物理页已经在内存中(比如在 page cache 里),只需修改页表映射
- 非常快:~微秒级
- 场景:
- 首次访问 mmap() 映射的文件(数据已在 page cache)
- fork() 后子进程首次读取父进程的 COW 页
- Lazy allocation 首次写入
Major Page Fault(主要页面错误)
- 物理页不在内存中,需要从 磁盘(文件系统或 swap)读取
- 很慢:~毫秒级(SSD)到 ~十毫秒级(HDD)
- 场景:
- 访问 mmap 映射的文件但数据未缓存
- 访问已被 swap out 的页
- 程序首次加载代码页
# 查看进程的 page fault 统计
ps -o pid,min_flt,maj_flt,cmd -p $(pgrep -f node)
# 实时监控
perf stat -e page-faults,minor-faults,major-faults -p <PID> sleep 5
# /proc/[pid]/stat 第 10、12 字段
cat /proc/$(pgrep -f node)/stat | awk '{print "minor:", $10, "major:", $12}'
3.2 Copy-on-Write (COW)
fork() 是 Linux 创建进程的基本方式。如果 fork 时复制整个地址空间,对于一个 RSS 1GB 的进程来说代价太高。
COW 的解决方案:fork 时不复制物理页,父子共享同一份物理内存,把页表标记为只读。当任一方写入时,触发 page fault,内核才复制该页。
fork() 之前:
父进程 → 物理页 [A][B][C][D]
fork() 之后(COW):
父进程 ─┐
├──→ 物理页 [A][B][C][D] (共享,标记只读)
子进程 ─┘
子进程写入页 B:
父进程 ──→ [A][B ][C][D]
子进程 ──→ [A][B'][C][D] ← B 被复制为 B',子进程写 B'
实际影响:
fork()本身非常快(只复制页表,不复制数据)- Redis 的
BGSAVE用 fork+COW 做后台持久化,写入量小时几乎不增加内存 - 但如果 fork 后父进程大量写入,COW 会导致内存翻倍(每个脏页都要复制一份)
3.3 Lazy Allocation(延迟分配)
malloc(100MB) 不会立即分配 100MB 物理内存!
malloc(100MB)
│
▼
内核:好的,在虚拟地址空间标记 100MB 区域(VMA),但不分配物理页
│
▼
VSZ += 100MB, RSS 不变
│
▼
进程写入第 1 页 → page fault → 内核分配 1 个物理页 (4KB)
进程写入第 2 页 → page fault → 内核分配 1 个物理页 (4KB)
...
只有被实际触碰的页才分配物理内存
这就是为什么 VSZ 远大于 RSS。 进程可以"声称"使用大量内存,但操作系统只在实际需要时分配。这也是 Linux overcommit 策略的基础。
4. Linux 内存回收与 OOM Killer
4.1 Overcommit 策略
Linux 默认允许进程申请比物理内存更多的虚拟内存(因为 lazy allocation,申请不等于使用)。但如果所有进程同时使用,物理内存就不够了。
通过 /proc/sys/vm/overcommit_memory 控制:
| 值 | 策略 | 行为 | 适用场景 |
|---|---|---|---|
| **0** (默认) | Heuristic | 内核用启发式算法判断。"合理的"过量分配允许,太离谱的拒绝 | 大部分服务器 |
| **1** | Always | 永远允许分配,`malloc()` 永不返回 NULL | 科学计算、稀疏矩阵(大数组但只用一小部分) |
| **2** | Never | 严格模式。总 commit ≤ swap + RAM × overcommit_ratio | 数据库服务器、对 OOM 零容忍的场景 |
# 查看当前策略
cat /proc/sys/vm/overcommit_memory
# 查看 commit 限制和当前使用
cat /proc/meminfo | grep -i commit
# CommitLimit: 16384000 kB (overcommit=2 时的上限)
# Committed_AS: 8234567 kB (当前已承诺的虚拟内存)
overcommit_ratio(仅 overcommit_memory=2 时生效):
cat /proc/sys/vm/overcommit_ratio # 默认 50
# CommitLimit = Swap + RAM × (overcommit_ratio / 100)
# 例:8GB RAM + 2GB Swap, ratio=50 → CommitLimit = 2 + 8×0.5 = 6GB
4.2 OOM Killer 打分机制
当系统内存耗尽且无法回收时,内核启动 OOM Killer 杀进程以释放内存。
打分算法
每个进程有一个 oom_score(0-1000),分数越高越可能被杀:
基础分 = 进程 RSS / 系统总 RAM × 1000
调整因子:
+ 子进程的 RSS 也计入
+ root 进程得分减半(更不容易被杀)
+ oom_score_adj 手动调整(-1000 ~ 1000)
# 查看进程的 OOM 分数
cat /proc/$(pgrep -f node)/oom_score # 当前分数
cat /proc/$(pgrep -f node)/oom_score_adj # 手动调整值
# 保护关键进程(永不被 OOM Kill)
echo -1000 > /proc/$(pgrep -f sshd)/oom_score_adj
# 让某进程优先被杀
echo 1000 > /proc/$(pgrep -f cache-worker)/oom_score_adj
# 在 systemd 中设置
# [Service]
# OOMScoreAdjust=-500
OOM 日志分析
# 查看 OOM Kill 事件
dmesg | grep -i "out of memory"
journalctl -k | grep -i oom
# 典型输出:
# Out of memory: Killed process 12345 (node) total-vm:1234567kB,
# anon-rss:567890kB, file-rss:12345kB, shmem-rss:0kB,
# oom_score_adj:0
4.3 内核内存回收路径
在触发 OOM Killer 之前,内核会先尝试回收内存:
内存不足
│
▼
1. 回收 Page Cache(文件缓存)
│ ─ 干净页直接丢弃
│ ─ 脏页写回磁盘后丢弃
│
▼ 仍不够
2. 回收 Slab Cache(内核对象缓存)
│ ─ dentry cache, inode cache
│
▼ 仍不够
3. Swap Out(将匿名页写入 swap)
│ ─ 受 swappiness 控制
│
▼ 仍不够
4. 触发 OOM Killer
│ ─ 选分数最高的进程杀掉
└── 释放其内存
5. Swap 机制
5.1 Swap 是什么
Swap 是磁盘上的空间,充当物理 RAM 的"溢出区"。当物理内存紧张时,内核将不活跃的内存页写入 swap(swap out),需要时再读回来(swap in)。
常见误解:
- ❌ "有 swap 说明内存不够" → 不一定,内核主动将冷数据换出是正常行为
- ❌ "swap 越大越好" → swap 太大会让系统在 OOM 前长时间卡顿(swap thrashing)
- ❌ "SSD 不需要 swap" → SSD 上的 swap 仍然比 OOM Kill 好
- ✅ 少量 swap 使用正常;持续高 swap I/O(si/so in
vmstat)才是问题
5.2 swappiness 参数
控制内核在回收内存时倾向于回收 page cache 还是 swap 匿名页:
cat /proc/sys/vm/swappiness # 默认 60
# 范围 0-200(内核 5.8+,之前是 0-100)
# 0 = 尽量不 swap,优先回收 page cache(但极端情况仍会 swap)
# 60 = 默认,平衡策略
# 100 = page cache 和 swap 同等对待
# 200 = 激进 swap(cgroup v2 中可用)
推荐设置:
- 数据库服务器:
swappiness=10(数据库有自己的缓存,不希望被 swap) - 桌面系统:
swappiness=60(默认就好) - 小内存服务器:
swappiness=60或更高(充分利用 swap 避免 OOM)
# 临时修改
sudo sysctl vm.swappiness=10
# 永久修改
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.d/99-swap.conf
sudo sysctl -p /etc/sysctl.d/99-swap.conf
5.3 创建 Swap File
现代 Linux 推荐用 swap file 而不是 swap 分区(更灵活,随时可加减):
# 创建 4GB swap 文件
sudo fallocate -l 4G /swapfile
# 如果 fallocate 不支持(btrfs),用 dd:
# sudo dd if=/dev/zero of=/swapfile bs=1M count=4096
# 设置权限(必须 600,否则不安全)
sudo chmod 600 /swapfile
# 格式化为 swap
sudo mkswap /swapfile
# 启用
sudo swapon /swapfile
# 验证
swapon --show
free -h
# 开机自动挂载 — 加入 /etc/fstab
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# 调整 swap 大小(增加)
sudo swapoff /swapfile
sudo fallocate -l 8G /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# 完全移除 swap
sudo swapoff /swapfile
sudo rm /swapfile
# 并从 /etc/fstab 删除对应行
6. 实用诊断命令
6.1 ps — 进程内存概览
# 按 RSS 排序,显示前 15 个进程
ps aux --sort=-%mem | head -15
# 自定义格式
ps -eo pid,user,%mem,rss,vsz,comm --sort=-%mem | head -15
# 特定进程
ps -o pid,rss,vsz,%mem,cmd -p $(pgrep -f "node\|python\|java")
6.2 pmap — 进程内存映射
# 基本映射
pmap <PID>
# 详细模式(显示每个映射区的脏页、共享/私有等)
pmap -x <PID>
# 超详细模式
pmap -XX <PID>
# 实际用法:看 node 进程的内存映射
pmap -x $(pgrep -f node) | tail -5
# 最后一行是汇总:total VSZ RSS Dirty
输出示例:
Address Kbytes RSS Dirty Mode Mapping
0000555555554000 1024 512 0 r-x-- node ← 代码段
0000555555754000 16 16 16 rw--- node ← 数据段
00007f3a00000000 262144 131072 131072 rw--- [ anon ] ← V8 堆
00007f3a8c000000 16384 8192 0 r-x-- libc.so.6 ← 共享库
...
6.3 smaps — 最精确的内存分析
# 完整的内存映射详情
sudo cat /proc/<PID>/smaps
# 汇总版(内核 4.14+)
sudo cat /proc/<PID>/smaps_rollup
# 输出关键字段:
# Size: 映射大小(≈ VMA 大小)
# Rss: 驻留物理内存
# Pss: 按比例分摊的物理内存
# Private_Clean: 私有干净页(可直接丢弃)
# Private_Dirty: 私有脏页(USS 的主要组成)
# Shared_Clean: 共享干净页
# Shared_Dirty: 共享脏页
# Swap: 被换出的大小
# SwapPss: 按比例分摊的 swap
# 一键提取关键指标
sudo awk '/^Rss/{rss+=$2} /^Pss/{pss+=$2} /^Private_Dirty/{pd+=$2} /^Swap/{sw+=$2}
END{printf "RSS: %d MB\nPSS: %d MB\nUSS(Private_Dirty): %d MB\nSwap: %d MB\n",
rss/1024, pss/1024, pd/1024, sw/1024}' /proc/<PID>/smaps
6.4 /proc/meminfo — 系统级内存总览
cat /proc/meminfo
# 关键字段解释:
# MemTotal: 物理总内存
# MemFree: 完全空闲(未被任何用途使用)
# MemAvailable: 可用内存 = Free + 可回收的 Cache/Buffer
# Buffers: 块设备 I/O 缓冲
# Cached: Page Cache(文件内容缓存)
# SwapTotal: Swap 总大小
# SwapFree: Swap 空闲
# Dirty: 待写回磁盘的脏页
# Slab: 内核 slab 分配器使用
# SReclaimable: 可回收的 slab
# CommitLimit: overcommit=2 时的限制
# Committed_AS: 已承诺的虚拟内存总量
⚠️ 常见误区:
MemFree很低 ≠ 内存不够。Linux 会把空闲内存用作 page cache。- 看
MemAvailable!这才是"还能给应用用多少"。 MemAvailable≈MemFree+可回收的 Cached+SReclaimable
# 快速查看:真正可用内存
free -h
# total used free shared buff/cache available
# Mem: 15Gi 6.2Gi 512Mi 256Mi 8.8Gi 8.5Gi
# ^^^^ ^^^^^
# 别看这个 看这个!
6.5 cgroup — 容器/服务级内存限制
在容器化和 systemd 时代,cgroup 是管理进程内存的标准方式。
cgroup v2(现代 Linux 默认)
# 查看 cgroup 内存限制
cat /sys/fs/cgroup/<cgroup-path>/memory.max # 硬限制
cat /sys/fs/cgroup/<cgroup-path>/memory.high # 软限制(超过后内核加速回收)
cat /sys/fs/cgroup/<cgroup-path>/memory.current # 当前使用
# 查看 OOM 事件
cat /sys/fs/cgroup/<cgroup-path>/memory.events
# low 0 ← 触发 memory.low 保护的次数
# high 5 ← 超过 memory.high 的次数
# max 0 ← 达到 memory.max 的次数
# oom 0 ← 触发 OOM 的次数
# oom_kill 0 ← 被 OOM Kill 的次数
Docker 容器内存
# 启动时限制内存
docker run -m 512m --memory-swap 1g myapp
# 查看容器内存使用
docker stats <container>
# 查看 cgroup 详情
docker inspect <container> | jq '.[0].HostConfig.Memory'
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.current
systemd 服务内存限制
# /etc/systemd/system/myapp.service
[Service]
MemoryMax=512M # 硬限制(超过触发 OOM Kill)
MemoryHigh=400M # 软限制(超过后内核积极回收)
MemorySwapMax=0 # 禁用 swap
OOMScoreAdjust=-500 # 降低被系统级 OOM Kill 的概率
6.6 其他有用工具
# vmstat — 内存 + swap 活动
vmstat 1 5
# 关注 si(swap in)和 so(swap out),持续非零说明 swap thrashing
# smem — PSS/USS 排序
sudo smem -t -k -s pss
# htop — 交互式进程查看器
# 按 Shift+M 按内存排序
# Setup → Columns 可以添加 VIRT/RES/SHR
# valgrind — 内存泄漏检测(开发时用)
valgrind --leak-check=full ./myapp
# perf — 内存相关性能分析
perf record -e page-faults -g -p <PID> sleep 10
perf report
7. Node.js 特有的内存知识
7.1 V8 Heap 架构
Node.js 使用 V8 引擎,有自己独立的内存管理层:
Node.js 进程内存
├── V8 Heap(JS 对象,受 V8 GC 管理)
│ ├── New Space (Young Generation) ~ 1-8MB
│ │ ├── Semi-space A (From)
│ │ └── Semi-space B (To) ← Scavenge GC(频繁,< 1ms)
│ ├── Old Space ~ 默认上限 ~1.7GB
│ │ ├── Old Pointer Space(含指针的对象)
│ │ └── Old Data Space(纯数据对象) ← Mark-Sweep-Compact GC
│ ├── Large Object Space(> 256KB 的大对象)
│ ├── Code Space(JIT 编译的机器码)
│ └── Map Space(Hidden Class / 对象形状信息)
├── V8 外部内存(ArrayBuffer、TypedArray 的底层 buffer)
├── C++ 堆(libuv、native addons)
├── 线程栈(主线程 + worker threads + libuv 线程池)
└── 共享库(libc、libstdc++、libssl...)
7.2 关键参数
# 查看默认内存限制
node -e "console.log(v8.getHeapStatistics())"
# 设置 Old Space 上限
node --max-old-space-size=4096 app.js # 4GB
# 设置 New Space 大小(每个 semi-space)
node --max-semi-space-size=64 app.js # 64MB
# 在 NODE_OPTIONS 中设置(推荐用于生产环境)
export NODE_OPTIONS="--max-old-space-size=4096"
默认限制(V8 自动根据可用内存调整,近似值):
| 系统内存 | Old Space 默认上限 |
|---|---|
| < 2GB | ~512MB |
| 2-4GB | ~1GB |
| > 4GB | ~1.7GB |
| 64 位,8GB+ | ~2GB(某些 Node 版本更高) |
7.3 RSS 只涨不缩现象
这是 Node.js 开发者最困惑的问题之一:RSS 持续增长,即使 V8 Heap 已经 GC 释放了内存。
V8 Heap: ████████░░░░░░░░ (500MB used / 1GB allocated)
RSS: ██████████████████ (1.8GB,比 V8 Heap 大很多,而且不降)
原因分析:
1. glibc malloc 不归还内存:V8 通过 malloc() 从操作系统获取内存。GC 释放对象后,V8 调用 free(),但 glibc 的 ptmalloc2 倾向于缓存释放的内存(放入 free list),不调用 brk()/munmap() 归还给 OS。
2. 内存碎片化:堆中间有少量存活对象,整个区域就无法归还。
3. V8 自身保留:V8 维护多个 memory space,GC 后保留一定的预分配空间,不会立即缩容。
4. 外部内存:Buffer、TypedArray 的底层 C++ 内存不受 V8 Heap 统计,但计入 RSS。
实际影响:
- 这通常不是内存泄漏! 如果
process.memoryUsage().heapUsed稳定,RSS 不降是正常的。 - 判断标准:看
heapUsed是否持续增长,而不是 RSS。
// 监控 Node.js 内存
setInterval(() => {
const mem = process.memoryUsage();
console.log({
rss: (mem.rss / 1024 / 1024).toFixed(1) + 'MB',
heapTotal: (mem.heapTotal / 1024 / 1024).toFixed(1) + 'MB',
heapUsed: (mem.heapUsed / 1024 / 1024).toFixed(1) + 'MB',
external: (mem.external / 1024 / 1024).toFixed(1) + 'MB',
arrayBuffers: (mem.arrayBuffers / 1024 / 1024).toFixed(1) + 'MB',
});
}, 10000);
7.4 用 jemalloc 替换 glibc malloc
解决 RSS 不降的有效方案:用 jemalloc 替代 glibc ptmalloc2。jemalloc 更积极地将空闲内存归还 OS。
# 安装 jemalloc
sudo apt install libjemalloc2
# 用 LD_PRELOAD 替换(无需重新编译 Node.js)
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 node app.js
# 或在 systemd service 中
# [Service]
# Environment=LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
7.5 Node.js 内存泄漏排查
// 方法1: 生成 heap snapshot
const v8 = require('v8');
const fs = require('fs');
// 手动触发
v8.writeHeapSnapshot(); // 生成 .heapsnapshot 文件
// 方法2: 通过 --inspect 远程调试
// node --inspect app.js
// Chrome DevTools → Memory → Take Heap Snapshot
// 方法3: 用 clinic.js
// npx clinic doctor -- node app.js
// npx clinic heap -- node app.js
常见泄漏模式:
| 模式 | 示例 | 排查方法 |
|---|---|---|
| 全局缓存无限增长 | `const cache = {}; cache[key] = data;` | Heap snapshot 对比,找增长最快的对象类型 |
| 事件监听器未移除 | `emitter.on(...)` 但忘了 `off(...)` | `process._getActiveHandles().length` |
| 闭包意外捕获 | 定时器闭包持有大对象引用 | Heap snapshot 查看 retainers |
| Stream 未消费 | 可读流数据堆积在内部 buffer | 检查 `stream.readableLength` |
总结速查表
| 问题 | 看什么 | 命令 | |
|---|---|---|---|
| 进程用了多少内存? | PSS | `sudo smem -k -s pss` 或 `smaps_rollup` | |
| 杀掉它能释放多少? | USS | `smaps` → `Private_Dirty + Private_Clean` | |
| 系统还有多少可用内存? | MemAvailable | `free -h` → available 列 | |
| 为什么被 OOM Kill? | oom_score + dmesg | `dmesg \ | grep oom` |
| 进程在频繁 swap? | si/so | `vmstat 1` | |
| Node.js 是否泄漏? | heapUsed 趋势 | `process.memoryUsage()` 定时打印 | |
| 容器内存限制? | cgroup | `memory.max` / `memory.current` |
参考资料
- Linux Kernel Documentation: Memory Management
- Understanding Linux Process Memory — Brendan Gregg 的 Linux 性能分析指南
- /proc/meminfo 详解
- V8 Memory Management — V8 官方博客:垃圾回收机制
- Node.js Memory Management Best Practices — Node.js 官方 Heap Snapshot 指南
- smem - Report memory usage
- OOM Killer 源码
- man proc(5) — /proc 文件系统完整文档
- jemalloc vs ptmalloc2 — Facebook 工程博客