JavaScript 内存机制与闭包详解:从底层原理到工程实践

62 阅读11分钟

 引言:为什么你需要深入理解 JS 的内存机制?

JavaScript 表面上看是一门“简单”的语言——变量无需声明类型、函数可以嵌套、对象灵活多变。但正是这种“自由”背后,隐藏着一套精密的内存管理机制作用域系统。如果你只停留在“能跑就行”的层面,迟早会在性能优化、内存泄漏排查、闭包陷阱等问题上栽跟头。

本文将结合代码示例,并融合《你不知道的 JavaScript(上卷)》的核心思想,系统性地拆解 JS 的内存模型、执行上下文、调用栈、堆栈分工、垃圾回收机制以及闭包的本质。我们将用生动的比喻 + 严谨的技术细节 + 实际代码演示,带你彻底搞懂这些概念。


第一部分:JavaScript 是什么语言?—— 动态弱类型的本质

3.js 看 JS 的动态弱类型特性

// 3.js
var bar;
console.log(typeof bar); // "undefined"
bar = 12;                // number
console.log(typeof bar); // "number"
bar = "极客时间";        // string
console.log(typeof bar); // "string"
bar = true;              // boolean
console.log(typeof bar); // "boolean"
bar = null;              // 特殊值(历史 bug:typeof null === "object")
console.log(typeof bar); // "object"
bar = {name:"极客时间"}; // object
console.log(typeof bar); // "object"

这段代码完美展示了 JS 的两大特性:

  • 动态类型(Dynamic Typing) :变量的类型在运行时确定,而非编译时。
  • 弱类型(Weak Typing) :允许隐式类型转换(如 "1" + 2 → "12")。

对比 C 语言(静态强类型)

4.c

int main() {
    int a = 10;          // 必须声明为 int
    char* b = "hello";   // 必须声明为 char*
    bool c = true;       // C99 后支持 bool
    return 0;
}

C 语言要求:

  • 编译前必须明确每个变量的类型;
  • 类型一旦确定,不能改变;
  • 手动管理内存(malloc/free)。

而 JS 完全相反:类型灵活、自动内存管理。这是 Web 开发高效性的基础,但也带来了“不确定性”。

关键结论:JS 的动态弱类型设计,决定了它必须依赖运行时引擎(如 V8) 来动态推断类型、分配内存、回收垃圾。


第二部分:JS 内存模型 —— 栈与堆的分工协作

JS 的内存主要分为两大部分:栈内存(Stack)堆内存(Heap) 。它们各司其职,共同支撑程序运行。

1. 栈内存(Stack):快速、有序、临时

  • 用途:存放基本数据类型(Primitive Types)和函数调用信息

  • 基本类型包括(共 7 种):

    • number
    • string
    • boolean
    • undefined
    • null
    • symbol(ES6)
    • bigint(ES2020)

注意:虽然 typeof null === "object",但 null 仍属于基本类型,存储在栈中。

  • 特点

    • 内存连续、分配/释放极快;
    • 大小固定(通常几 MB);
    • 遵循 LIFO(后进先出)原则;
    • 调用栈(Call Stack) 管理。

示例:1.js —— 值拷贝

// 1.js
function foo(){
  var a = 1;     // a 存在栈中
  var b = a;     // b 拷贝 a 的值(不是引用!)
  a = 2;
  console.log(a); // 2
  console.log(b); // 1 → 不受影响
}
foo();

这里,ab 是两个独立的栈变量。修改 a 不会影响 b,因为它们是值拷贝

![](<> "点击并拖拽以移动")


2. 堆内存(Heap):大容量、无序、持久

  • 用途:存放复杂数据类型(引用类型) ,如:

    • Object
    • Array
    • Function
    • DateRegExp
  • 特点

    • 内存不连续,分配较慢;
    • 容量大(受限于系统内存);
    • 通过引用(指针) 访问;
    • 垃圾回收器(GC) 管理生命周期。

示例:2.js —— 引用共享

// 2.js
function foo(){
  var a = {name: &#34;极客时间&#34;}; // 对象创建在堆中,a 是栈中的引用
  var b = a;                  // b 拷贝的是引用(地址),不是对象本身
  a.name = &#34;极客邦&#34;;
  console.log(a); // {name: &#34;极客邦&#34;}
  console.log(b); // {name: &#34;极客邦&#34;} → 共享同一堆对象!
}
foo();

关键点:

  • ab 都是指向堆中同一对象的“地址卡”;
  • 修改对象属性,所有引用都会看到变化;
  • 这就是引用传递的本质。

![](<> "点击并拖拽以移动")

重要区分

  • 基本类型:值存储在栈中,赋值 = 值拷贝;
  • 引用类型:值存储在堆中,变量 = 栈中的引用(指针)。

完整内存模型:

![](<> "点击并拖拽以移动")


3. 为什么这样设计?—— 性能与安全的权衡

“V8 引擎需要用栈来维护程序执行期间上下文的状态。如果栈空间太大了,不连续的(对象也放栈中),会影响上下文切换效率。”

  • 栈切换要快:函数调用频繁(如递归、事件回调),栈帧必须小且连续;
  • 堆适合大对象:对象大小不确定、生命周期长,不适合放栈;
  • 避免栈溢出:若把大对象放栈,极易导致 RangeError: Maximum call stack size exceeded

因此,JS 引擎采用“小数据放栈,大数据放堆”的策略,是最优解。


第三部分:执行上下文与调用栈 —— 程序运行的舞台

每次函数调用,JS 引擎都会创建一个执行上下文(Execution Context) ,并压入调用栈(Call Stack)

执行上下文包含什么?

每个执行上下文包含三个核心部分:

  1. 变量环境(Variable Environment)

    • 存放 var 声明的变量、函数声明;
    • 创建阶段就初始化(hoisting)。
  2. 词法环境(Lexical Environment)

    • 存放 let/const、块级作用域变量;
    • 更现代的作用域实现方式。
  3. This 绑定

    • 当前上下文的 this 值。

《你不知道的 JavaScript》强调:作用域是在词法阶段(写代码时)就确定的,不是运行时决定的。这就是“词法作用域(Lexical Scope)”。

调用栈工作流程

1.js 为例:

function foo(){ ... }
foo(); // 调用
  1. 全局上下文入栈;
  2. 调用 foo() → 创建 foo 的执行上下文,压入栈顶;
  3. 执行 foo 内部代码;
  4. foo 执行完毕 → 上下文弹出栈;
  5. 回到全局上下文。

![](<> "点击并拖拽以移动")

如果递归太深,栈会溢出(Stack Overflow)。


第四部分:闭包(Closure)—— 作用域的“时光胶囊”

闭包是 JS 最强大也最易误解的特性之一。让我们从现象到本质层层剖析。

什么是闭包?

闭包 = 函数 + 其词法作用域的引用

更准确地说:当一个内部函数引用了外部函数的变量,并且这个内部函数在外部函数执行完毕后仍可被访问,就形成了闭包

闭包的底层实现

“闭包内部函数引用的自由变量 → JS 判断有闭包 → 堆空间中创建 closure(foo)”

这意味着:

  1. 引擎在编译阶段(函数定义时)扫描内部函数;
  2. 如果发现内部函数使用了外部变量(自由变量),就不会让这些变量随栈帧销毁
  3. 而是将它们提升到堆内存,并创建一个 closure 对象;
  4. 内部函数持有一个指向该 closure 的隐藏引用([[Scope]])。

经典闭包示例

function createCounter() {
  var count = 0; // 外部变量
  return function() {
    count++;      // 引用外部变量 → 形成闭包
    console.log(count);
  };
}

var counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3

发生了什么?

步骤描述
1createCounter() 被调用,创建执行上下文,count = 0
2返回匿名函数,该函数引用了 count
3createCounter 执行完毕,正常情况下栈帧应销毁
4但因存在闭包,V8 将 count 移至堆,并创建 closure(createCounter)
5counter 变量持有该匿名函数,函数内部可访问 closure 中的 count
6每次调用 counter(),都操作同一个 count

闭包的本质延长了外部变量的生命周期,使其不随函数返回而销毁。

例如:

    function foo() {
    var myName = &#34;极客时间&#34;
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(&#34;极客邦&#34;)
bar.getName()
console.log(bar.getName())

![](<> "点击并拖拽以移动")


闭包的三大用途

  1. 封装私有变量

    var module = (function() {
      var privateVar = &#34;secret&#34;;
      return {
        getSecret: () => privateVar
      };
    })();
    console.log(module.getSecret()); // &#34;secret&#34;
    // 外部无法直接访问 privateVar
    
  2. 回调函数保持状态

    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 100); // 输出 3,3,3(经典问题)
    }
    // 用闭包修复:
    for (var i = 0; i < 3; i++) {
      (function(j) {
        setTimeout(() => console.log(j), 100);
      })(i); // 输出 0,1,2
    }
    
  3. 函数工厂

    function makeMultiplier(x) {
      return function(y) { return x * y; };
    }
    var double = makeMultiplier(2);
    console.log(double(5)); // 10
    

闭包的陷阱:内存泄漏

如果闭包引用了大型对象,且长时间不释放,会导致内存无法回收

function attachListeners() {
  var largeData = new Array(1000000).fill(&#34;data&#34;);
  document.getElementById(&#34;btn&#34;).onclick = function() {
    console.log(&#34;clicked&#34;);
    // 即使没用 largeData,闭包仍持有引用!
  };
}

解决方案

  • 不需要时,手动置空引用:largeData = null;
  • 使用 let/const 替代 var,缩小作用域;
  • 避免不必要的闭包。

第五部分:垃圾回收(GC)—— 内存的“清洁工”

JS 不需要手动释放内存,靠自动垃圾回收

1. 栈内存回收

  • 函数执行完毕 → 整个栈帧直接丢弃(指针偏移);
  • 极快,无开销。

2. 堆内存回收:标记-清除算法(Mark-and-Sweep)

V8 使用的主要 GC 算法:

  1. 标记阶段:从根对象(如全局对象、当前执行上下文)出发,遍历所有可达对象,标记为“存活”;
  2. 清除阶段:未被标记的对象视为垃圾,回收其内存。

关键原则只要还有引用指向对象,就不会被回收

闭包与 GC 的关系

  • 闭包中的变量之所以不被回收,是因为内部函数仍持有对 closure 的引用
  • 一旦内部函数也被释放(如 fn = null),closure 失去引用,下次 GC 时就会被清理。

第六部分:综合案例分析

文件 1.js → 基本类型 & 栈行为

function foo(){
  var a = 1;
  var b = a; // 值拷贝
  a = 2;
  console.log(a); // 2
  console.log(b); // 1
}

展示:栈内存的独立性、值传递。


文件 2.js → 引用类型 & 堆共享

var a = {name:&#34;极客时间&#34;};
var b = a;
a.name = &#34;极客邦&#34;;
console.log(b.name); // &#34;极客邦&#34;

展示:堆对象的共享性、引用传递。


文件 3.js → 动态类型 & 类型转换

var bar = undefined;
bar = 12;
bar = &#34;极客时间&#34;;
...

展示:JS 的动态弱类型本质。


文件 4.c → 静态类型对比

int a = 10;
char* b = &#34;hello&#34;;

对比:C 需手动管理类型和内存,JS 自动化。


第七部分:最佳实践与建议

  1. 理解变量存储位置:基本类型(栈) vs 引用类型(堆);
  2. 慎用闭包:只在必要时使用,避免内存泄漏;
  3. 及时释放大对象obj = null
  4. 优先使用 const/let:块级作用域更安全,减少意外闭包;
  5. 避免全局变量:它们永远不会被 GC 回收;
  6. 使用 Chrome DevTools 分析内存:Memory 面板可检测泄漏。

结语:从“会用”到“懂原理”

JavaScript 的内存机制和闭包,不是玄学,而是工程设计的必然结果。V8 引擎通过栈与堆的分工、词法作用域的静态绑定、闭包的堆提升、自动垃圾回收等机制,在开发效率运行性能之间找到了精妙的平衡。

正如 Kyle Simpson 在《你不知道的 JavaScript》中所说:

“作用域和闭包不是为了让你写出炫技的代码,而是为了让你写出可预测、可维护、可推理的代码。”

当你真正理解了这些底层机制,你写的每一行 JS,都将更有底气。


参考资料

  1. 《你不知道的 JavaScript(上卷)》— Kyle Simpson
  2. V8 官方文档:[v8.dev/](v8.dev/ "v8.dev/&#34;)
  3. MDN Web Docs: [Memory Management](developer.mozilla.org/en-US/docs/… "Memory Management")
  4. 《深入浅出 Node.js》— 朴灵(含 V8 内存模型章节)
  5. Google Developers: [JavaScript Memory Profiling](developer.chrome.com/docs/devtoo… "JavaScript Memory Profiling")
  6. 示例代码仓库:lesson_zp: AI + 全栈学习仓库——内存机制