把 V8 的「内存黑盒」掀开一角:从执行上下文到作用域链,搞懂 JS 为何会“泄漏”

75 阅读8分钟

JS 的内存管理是自动的,但“自动”不等于“与你无关”。一旦你写出错误的引用持有(闭包 / 全局变量 / DOM 引用),问题往往不是“报错”,而是越来越卡、越来越慢、越来越难定位


目录导航

    1. 为什么学内存:你写的每一行代码都在占内存
    1. 内存管理的生命周期:申请 → 使用 → 释放
    1. 为什么这章坚持讲 var:它是理解执行过程的“历史入口”
    1. 执行上下文栈(调用栈):代码到底按什么顺序跑?
    1. 变量提升、undefinedis not defined:差在哪?
    1. LHS / RHS:用“查询视角”把提升讲透
    1. 函数为什么也能“提升”:编译阶段到底做了什么
    1. AO / GO / VO / VE:术语别背,先抓住结构
    1. 作用域链:变量到底沿着什么路径被找到
    1. var 的一个经典坑:全局属性覆盖(浏览器 vs Node 的差异)
    1. 留几道题:用来检验你是不是真懂了

1)为什么学内存:你写的每一行代码都在占内存

在 JS 里,原始值、对象、函数……都会占用内存;只不过引擎帮你“自动管理”,所以它看起来像黑盒。黑盒的代价是:你写错了引用关系,不会立刻炸,而是悄悄把内存越撑越大,最后变成“线上性能事故”。

这也是这章的核心目标:
用“执行过程视角”把内存模型讲清楚——你至少要知道:

  • 变量/函数在执行前后分别存在哪里
  • 为什么某些值是 undefined,某些直接 ReferenceError
  • 作用域链怎么找变量,闭包为什么能“把东西留住”

2)内存管理的生命周期:申请 → 使用 → 释放

不管哪门语言,内存管理逃不开三步:

  1. 申请内存
  2. 使用内存(存对象/值/函数等)
  3. 不再需要时释放内存

区别在于:

  • C/C++ 这类是“手动管理”(malloc/free),能力强但容易踩坑
  • Java/JavaScript/Python 这类是“自动管理”,靠 GC(垃圾回收)做释放,写起来更安全,但你仍然需要理解引用关系,否则照样内存泄漏

关键点:GC 回收的是“不可达对象”,不是“你觉得不用了的对象”。你写的引用链,决定了它可不可达。


3)为什么这章坚持讲 var:它是理解执行过程的“历史入口”

很多人会说:我日常只用 let/const,学 var 干嘛?
原因很现实,也很重要:

  • ES6 之前只有 var,大量存量项目仍在用,不懂你就维护不了
  • var 的缺陷(提升、全局挂载、作用域边界模糊)恰好能逼你把“执行过程”想清楚
  • 真正理解 var,你才会明白 let/const 到底修复了什么

这也是为什么后面我会用 var 来讲:提升、执行上下文、作用域链这些“底层规则”。


4)执行上下文栈(调用栈):代码到底按什么顺序跑?

JS 引擎内部有一个结构:执行上下文栈(ECS / Call Stack) ,用来管理“当前正在执行的上下文”。

你可以把它理解成:

  • 先把全局执行上下文 GEC 压栈,开始跑全局代码
  • 每调用一个函数,就创建一个函数执行上下文 FEC 压栈
  • 函数执行完,FEC 出栈,回到上一个上下文继续跑

这就是“为什么递归会爆栈”“为什么某些函数先打印后执行”的底层原因。


5)变量提升、undefinedis not defined:差在哪?

先看这个经典片段:

console.log(num1);

var num1 = 20
var num2 = 30
var result = num1 + num2

console.log(result);

为什么第一行打印 undefined,不是报错?核心原因:提升

5.1 三种常见 undefined

  • 变量声明未赋值
  • 访问对象不存在的属性
  • 函数没有显式 return

在上面例子里,属于第一种:声明提升了,但赋值还没发生

5.2 undefined vs ReferenceError: xxx is not defined

  • undefined有这个变量,只是当前时刻它还没有值(或就是 undefined)
  • is not defined连变量都不存在(没有声明/没有绑定记录),引擎找不到“引用”就直接抛错

6)LHS / RHS:用“查询视角”把提升讲透

很多人对提升的理解停留在“会被提到上面”。但真正写复杂代码时,你需要一个更稳的视角:LHS / RHS 查询

  • LHS(Left-Hand Side) :找“容器”,为了赋值
  • RHS(Right-Hand Side) :找“值”,为了读取

把“打印 num1”这件事换成查询语言:
console.log(num1) 触发 RHS 查询:我要读取 num1 的值 → 引擎沿作用域查找绑定 → 找到了声明(被提升过)→ 但未赋值 → 返回 undefined

图示(保留原图):

图4-1 赋值的过程

扩展图(保留原图):


7)函数为什么也能“提升”:编译阶段到底做了什么?

你会发现:函数在声明前也能调用(函数声明形式):

foo2()
function foo2(){
  console.log('XiaoWu')
}

这背后离不开“编译阶段”。文档里用了一张图概括:语法分析 → 预编译 → 即时编译 → 优化。

(保留原图)

图4-3 预编译与即时编译

更“严谨”的说法是:规范层面不是 AO/VO 这些对象,而是 Environment Record / Lexical Environment 等结构;但为了建立直觉,用 AO/GO 讲清执行过程非常有效——后面你再替换成规范术语会更顺。


8)AO / GO / VO / VE:术语别背,先抓住结构

先记一个对你最有用的结构化结论:

  • GO(Global Object) :全局对象(浏览器里常见是 window),全局变量在这里能被访问到
  • AO(Activation Object) :函数执行时的“局部环境”(函数上下文里用来承载局部变量、形参等的抽象容器)
  • VO(Variable Object) :更泛化的叫法,表示“变量所在环境”;全局时近似 GO,函数时近似 AO
  • VE(Variable Environment) :规范/新表述方向:把 VO 更明确成“环境”概念(你可以先把它当成 VO 的升级叫法)

你真正需要掌握的是:变量在哪个环境里被创建函数调用时会创建新的环境环境之间如何连接(作用域链)


9)作用域链:变量到底沿着什么路径被找到?

作用域链本质是:**当前环境 + 父级环境 + 父级的父级……**形成的一条“向外查找链”。在任意执行上下文中,查变量会先查当前,再沿链向外。

(保留原图)

图4-8 作用域链的描述

再看这个用来“打穿直觉”的例子:

foo(123)
var m = 666
function foo(num){
  console.log('1', m);
  var m = 10
  console.log('2', m);
  var n = 20
}

它会输出:undefined10。原因不是玄学,而是:函数内部 var m 先提升到当前 AO 的顶端,所以第一次打印时,当前作用域里已经“有 m 了”,只是没赋值。

(保留原图)

图4-4 全局函数执行内存图

(保留原图)

图4-6 函数内存执行过程

(保留原图)

图4-7 函数内存执行结束

你会看到一个更重要的“内存启示”:

函数执行结束,执行上下文出栈;如果没有任何引用再指向 AO(或其内部对象),它就会变成“不可达”,后续会被 GC 回收。

这句话就是你未来理解“闭包为什么会泄漏/为什么能保留数据”的关键入口。


10)var 的一个经典注意点:全局属性覆盖(浏览器 vs Node)

看这个例子(保留原文核心案例与结论):

var name = "XiaoWu"
foo(123)
function foo(num){
  console.log("1", m);
  var n = 10
  var m = 20

  function bar(){
    console.log("2", name)
  }
  bar()
}

输出:

1 undefined
2 XiaoWu

这里注意一个很“坑”的点:如果你把全局 name 的声明注释掉,在浏览器里你可能仍然得到 XiaoWu,这常常来自:

  • var 在全局作用域下会“挂到”全局对象上
  • 浏览器的 window 本身就可能已有 name 这个属性
  • 于是你以为你删了变量,实际上你只是改写了全局对象属性,产生了“黑魔法效果”

(保留原图)

图4-13 var 的全局覆盖缺陷

同时,Node 环境下全局对象不是 window,行为会不同(甚至直接报错),这也是很多同学“浏览器能跑、Node 不行”的来源之一。


11)留几道题:用来检验你是不是真懂了

把题留在这里,建议你先“脑内执行一遍”,再去跑代码对答案(非常练功)。

//面试题1
var n = 100
function foo(){
  n = 200
}
foo()
console.log(n)

//面试题2
function foo(){
  console.log(m)
  var m = "小吴"
  console.log(m);
}
var m = "coderwhy"
foo()

//面试题3
var n = 100
function foo1(){
  console.log("这是foo1内部",n);
}
function foo2(){
  var n = 200
  console.log("这是foo2内部",n);
  foo1()
}
foo2()
console.log("这是最外层",n);

//面试题4
var a = 100
function foo(){
  console.log(a)
  return
  var a = 200
}
foo()

//面试题5
function foo(){
  var a = b = 10
}
foo()
console.log(a);
console.log(b);

收个尾:这章你真正应该带走的“底层能力”

  • 代码运行不是“从上到下”这么简单,它受 ECS(调用栈)+ 编译阶段信息 控制
  • undefined 多半是“有声明但没赋值”,is not defined 多半是“压根没声明/没绑定”
  • 作用域链是“当前环境向外找”的规则,它解释了:为何闭包能拿到外层变量,也解释了为何错误引用会导致内存无法回收

如果你愿意,我可以在你下一章(垃圾回收/闭包)里把这套“引用可达性”模型继续往下推:

  • V8 分代回收(新生代/老生代)
  • 标记清除/标记整理的直觉
  • 真实项目里最常见的泄漏模式与排查路径(Performance/Memory 面板 + 快照对比)