硬核解析:从栈堆分配看JavaScript的执行上下文

5 阅读6分钟

很多 JavaScript 开发者在写业务代码时游刃有余,但一旦涉及到性能优化或内存泄漏排查,往往就感到力不从心。这通常是因为我们对 JS 底层的内存管理机制缺乏直观的认识。

今天,我们结合 V8 引擎的执行机制,从内存分配的角度,重新审视 JavaScript 的运行原理,特别是那个让无数人又爱又恨的“闭包”。

一、为什么 JS 需要区分栈内存和堆内存?

在 C 或 C++ 这样的静态语言中(如提供的 C 语言示例),变量在编译阶段就确定了类型和大小:

int main(){
    int a = 1;        // 固定大小
    char* b = "hello"; 
    // a = b;         // 编译器直接报错,类型不匹配
    return 0;
}

但在 JavaScript 中,我们是动态弱类型语言。一个变量今天可以是数字,明天可以是字符串,后天甚至可以变成对象:

var bar;          // 声明时是 undefined
bar = 10;         // 变成 number
bar = '极客时间';  // 变成 string
bar = {name: '极客时间'}; // 变成 object

这种灵活性给内存管理带来了挑战。如果所有数据都混在一起存放,引擎在切换执行上下文(Execution Context)时会非常慢。为了解决这个问题,V8 引擎将内存主要划分为两个区域:栈内存(Stack)堆内存(Heap)

1. 栈内存:执行上下文的高速公路

栈内存的主要任务是维护程序的执行状态

当函数被调用时,V8 会在栈中创建一个“执行上下文”。这个上下文包含了函数内的局部变量、参数等。栈的特点是空间小、连续、操作极快

想象一下,函数调用就像入栈,函数返回就像出栈。栈顶指针只需要简单地上下移动,就能瞬间完成上下文的切换。如果栈里存放的都是巨大的对象,指针移动的成本就会变高,程序执行效率就会大打折扣。

因此,**简单数据类型(Number, String, Boolean, Null, Undefined)**直接存储在栈内存中。

2. 堆内存:复杂数据的仓库

对于复杂数据类型(Object, Array, Function),由于它们的大小不固定且结构复杂,V8 选择将它们存放在堆内存中。

堆内存的特点是空间大、不连续、分配耗时

在栈内存中,对于引用类型,存储的仅仅是一个内存地址(指针)。这个地址指向堆内存中实际存储数据的区域。

让我们看一个具体的例子来理解这种“二传手”机制:

function foo() {
    var a = "极客时间";             // 基本类型,值直接存在栈里
    var c = { name: '极客时间' };   // 引用类型,栈里存地址 1003,堆里 1003 存对象
    var d = c;                      // 拷贝地址,d 也指向 1003
}

在这个例子中:

  • 变量 a 的值 "极客时间" 直接占据了栈空间。
  • 变量 c 在栈中存储的是 1003(假设的地址),而真正的对象 { name: '极客时间' } 躺在堆内存的 1003 位置。
  • 当执行 var d = c 时,并不是把整个对象拷贝了一份,而是把地址 1003 拷贝给了 d。这就是为什么修改 d.name 会影响 c.name 的原因——它们指向的是堆里的同一块地盘。

PixPin_2025-12-31_11-27-11.png 设计权衡: 这种设计是典型的“空间换时间”和“效率换灵活”。

  • 优点: 执行上下文切换极快(只移动指针),基本类型操作高效。
  • 缺点: 堆内存的分配和回收(GC)相对耗时,且容易产生内存碎片。

二、执行上下文:变量的家

当我们深入函数内部,会发现执行上下文内部还细分了变量环境词法环境

在函数执行初期,变量会被声明并初始化为 undefined。这解释了为什么在 JS 中会出现“变量提升”现象——因为在代码执行前,变量环境已经搭建好了。

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

在这个简单的赋值过程中,栈内存里的值发生了拷贝。b 拿到了 a 当时的值 1,之后 a 变成了 2,互不影响。这是基本类型在栈内存中的独立性的体现。

三、闭包:内存视角的深度解读

闭包是 JS 中最难理解的概念之一。通常我们说“闭包是函数内部访问外部变量”,但这只是表象。从内存机制来看,闭包是变量逃离了栈内存,进入了堆内存的过程。

让我们通过一段经典的闭包代码来剖析:

function foo() {
    var myName = '极客时间';
    let test1 = 1;
    
    var innerBar = {
        getName: function() {
            console.log(test1);
            return myName;
        },
        setName: function(newName) {
            myName = newName;
        }
    };
    
    return innerBar;
}

var bar = foo();
bar.setName('极客帮');
console.log(bar.getName()); 

1. 编译阶段的扫描

当 JS 引擎编译 foo 函数时,它会进行词法分析。它发现 innerBar 对象里的 getNamesetName 函数引用了外部变量 myNametest1

2. 堆内存中的“避难所”

正常情况下,当 foo 函数执行完毕,它的执行上下文应该从栈中弹出,里面的局部变量 myNametest1 应该被销毁。

但是,因为存在闭包(内部函数引用了外部变量),V8 引擎判断这些变量不能被销毁。于是,引擎会在堆内存中创建一个特殊的对象,我们可以称之为 closure(foo)

这个 closure(foo) 对象里保存了 { myName: '极客时间', test1: 1 }

3. 指针的指向

此时,栈内存中的 innerBar 对象(也就是返回给全局变量 bar 的那个对象),它的内部函数 getNamesetName 的作用域链(Scope Chain),不再指向已经销毁的 foo 的栈帧,而是指向了堆内存中的那个 closure(foo) 对象。

PixPin_2025-12-31_11-46-45.png

PixPin_2025-12-29_21-06-12.png 这就是上图所展示的核心逻辑:

  • 栈内存: foo 函数执行结束后,栈帧弹出。
  • 堆内存: closure(foo) 对象依然存活,因为它被 bar 对象中的方法所引用。

闭包的优缺点分析

理解了内存机制,我们就能更客观地看待闭包:

  • 优点(数据私有化): 通过闭包,我们将变量保护在堆内存的特定对象中,外部无法直接访问,只能通过暴露的方法(如 setName)来修改。这实现了类似面向对象中的“私有属性”。
  • 缺点(内存泄漏风险): 如果闭包使用不当,导致堆内存中的对象无法被垃圾回收(GC),就会造成内存泄漏。例如,如果 bar 一直存在,那么 closure(foo) 里的 myName 就永远无法释放,即使你再也不需要它了。

四、总结

JavaScript 的内存机制是 V8 引擎为了平衡执行效率语言灵活性而做出的精妙设计。

  1. 栈内存负责快,存放执行上下文和基本类型,保证函数调用和切换的高效。
  2. 堆内存负责大,存放复杂对象和闭包变量,提供动态扩展的能力。
  3. 闭包的本质,是将本该随函数结束而销毁的栈变量,转移到了堆内存中长期保存。

作为开发者,理解这些底层原理,不仅能帮我们写出更规范的代码,更能让我们在遇到性能瓶颈时,知道该从哪里下手——是减少了不必要的对象创建(减轻堆压力),还是优化了函数调用层级(减轻栈压力)。