虚拟内存让每个进程都以为自己独占一整块连续地址,缺页中断才是「内存不够」真正发生的地方,也是 V8 堆上限的由来。
页面滚动久了越来越卡、内存曲线一路向上不回落,这是泄漏;Node 服务跑到 2 GB 直接 OOM,这是堆上限。两个现象一个在用户态对象图里,一个在页表和 V8 的堆参数里,得分开看。
| 维度 | 栈 Stack | 堆 Heap |
|---|---|---|
| 管理者 | 运行时自动,跟着函数调用进出 | 手动或 GC(V8 用 GC) |
| 分配方式 | 移动栈指针 SP,一条指令 | 空闲链表/分代空间,找块、可能触发 GC |
| 大小 | 固定(V8 64 位默认约 984 KB,--stack-size 可调) | 动态增长,有上限 |
| 速度 | 纳秒级,命中 cache | 慢,需分配器介入,可能碎片化 |
| 失败表现 | RangeError: Maximum call stack size exceeded | JavaScript heap out of memory |
| 存放内容 | 基本类型值、函数帧、返回地址 | 对象、数组、闭包、字符串 |
递归写深了爆栈、大数组把堆撑爆,是两种完全不同的故障,排查手段也不一样。
物理内存是所有进程共享的,但每个进程看到的都是一份私有的、连续的地址空间。虚拟内存同时解决三件事:
SIGSEGV 而不是改坏别人的数据0x0 开始的连续空间,实际背后是散落在物理内存和磁盘 swap 里的页代价是每次访存都要多做一次地址翻译,这个开销由 MMU 和 TLB 兜住。
地址空间按固定大小切页,x86-64 常见 4 KB 一页(大页 2 MB / 1 GB)。虚拟页号 VPN 经过多级页表翻译成物理页号 PPN,x86-64 的四级页表把 48 位虚拟地址拆成 9 + 9 + 9 + 9 + 12。
一次访存的完整链路:
SIGSEGV,进程崩溃常见的 page fault 分三种:major fault 要读磁盘,最慢;minor fault 只需分配物理页或从页缓存映射;还有一类是写时复制(COW)触发的,fork() 之后父子进程共享页,谁写谁复制。
Linux 把地址空间一分为二:低半部分是用户空间,每个进程各自独立;高半部分是内核空间,所有进程共享同一份物理内存,但页表里的 U/S 位禁止用户态访问。32 位时代是经典的 3 GB / 1 GB 划分;64 位下用户空间到 0x00007fff_ffffffff 为止,内核空间在高处。
用户态调 malloc 只是扩展堆的 brk 指针或 mmap 一段匿名页,真正分配物理页发生在第一次写入触发 page fault 的时候。这就是为什么 不一定立刻吃内存,而 会。
new Array(1e8)fill(0)| 空间 | 算法 | 触发条件 | 特点 |
|---|---|---|---|
| 新生代 New Space | Scavenge(复制) | 空间满 | 两个 semi-space,存活对象复制到另一半,两次存活晋升老生代 |
| 老生代 Old Space | Mark-Sweep + Mark-Compact | 空间阈值 | 三色标记,先清除再压缩,有增量与并发标记减少停顿 |
| 大对象空间 | 直接分配 | 对象 > 阈值 | 大数组、长字符串不进新生代 |
| Code / Map 空间 | 专用 | — | 存 JIT 代码与隐藏类 |
新生代默认只有几 MB 到十几 MB(--max-semi-space-size),因为复制算法要求空间减半,且大部分对象活不过一次 GC —— 分代假设就是这么来的。
为什么有堆上限:GC 的标记阶段要遍历整个对象图,堆越大、单次停顿越长;同时 32 位地址空间装不下大堆。Node 64 位默认老生代约 2 GB(旧版本 1.4 GB),超过就 JavaScript heap out of memory。
node --max-old-space-size=4096 server.js # 放宽到 4 GB,单位 MB
node --trace-gc server.js # 打印每次 GC 的类型与耗时
// 1. 闭包长期持有大对象:listener 不摘,闭包就不释放
function mount() {
const huge = new Array(1e6).fill('payload')
window.addEventListener('resize', () => console.log(huge.length))
}
// 2. 意外全局变量:非严格模式下未声明赋值
function leak() { cache = new Map() }
// 3. 定时器未清理:组件卸载了 interval 还在跑
setInterval(() => poll(), 1000)
// 4. Detached DOM:节点已移除,JS 还捏着引用
let detached = document.querySelector('#panel')
detached.remove() // detached 仍指向那块 DOM
// 5. 无上限缓存
const memo = new Map() // 只增不减
前四种是「该断的引用没断」,第五种是「策略缺失」。WeakMap、WeakRef、FinalizationRegistry 能让引用不阻止回收,但不能替代明确的清理逻辑。
# Delta 持续为正,它就在泄漏。Detached,或在 Class filter 里找 Detached HTMLDivElement,顺着 Retainers 面板往上看是谁还引用着它。performance.memory.usedJSHeapSize 只能看总量趋势,定位不到对象,别拿它当结论。
很多人以为闭包一定导致泄漏。 实际上闭包只是延长了变量的生命周期,只要闭包本身随组件一起被回收就没有问题;真正泄漏的是闭包被长期存活的引用(全局事件、定时器、缓存)抓住。
很多人以为把变量赋成 null 就立刻释放内存。 实际上那只是断开一个引用,回收要等下一轮 GC;如果还有别的引用指向同一对象,赋 null 没有任何作用。
栈和堆的区别?
栈由运行时自动管理,随函数调用分配释放,连续且快,大小固定,溢出是 RangeError;堆动态分配、由 GC 回收,慢且可能碎片化,溢出是 OOM。JS 里基本类型和函数帧在栈上,对象在堆上。
为什么要虚拟内存?
三个作用:进程间隔离、允许提交超过物理内存的配额、给每个进程连续的地址空间幻觉。实现靠分页加页表,访问未映射的页触发 page fault 由内核补上,非法地址则 SIGSEGV。
V8 为什么分代? 大部分对象朝生夕死,新生代用 Scavenge 复制算法,只扫少量存活对象,成本低;活过两轮的对象晋升老生代,用标记清除加压缩处理,频率低但单次成本高。分代把 GC 成本压到与存活对象数成正比。
怎么定位内存泄漏? 先看内存曲线是否单调上升,再用三次 Heap snapshot 对比找持续增长的对象类型,用 Allocation timeline 看未回收的分配栈,之后用 Detached 筛选确认是否是游离 DOM。找到持有链后再决定断开哪条引用。