从栈到堆,一文彻底搞懂 JavaScript 内存机制与闭包的底层原理

57 阅读4分钟

在前端面试中,“讲讲 JavaScript 的内存机制”“闭包是如何产生的”“为什么基本类型存在栈里,对象存在堆里”这几个问题几乎必考。今天我们不背结论,而是结合 V8 引擎的真实执行过程,用最通俗的语言 + 完整代码示例,把这整套机制彻底讲透。

一、JavaScript 执行的核心:调用栈(Call Stack)

JavaScript 是单线程语言,所有代码的执行都依赖一个东西——调用栈(Call Stack),它就位于栈内存中。

栈的特点:先进后出、自动管理、大小固定、访问速度极快。

每当函数被调用时,V8 就会在栈中压入一个执行上下文(Execution Context),包含:

  • Variable Environment(变量环境)
  • Lexical Environment(词法环境)
  • outer(外部词法环境引用 → 作用域链)
  • this 绑定

JavaScript

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

执行过程:

  1. 全局执行上下文入栈
  2. foo 执行上下文入栈
  3. bar 执行上下文入栈 → 执行 → 出栈
  4. foo 出栈
  5. 全局出栈

这就是调用栈的完整生命周期。

二、为什么基本类型放栈,对象放堆?

这是 V8 引擎为了性能做出的最关键设计。

基本数据类型(原始类型)直接存储在栈中

JavaScript

function foo() {
    var a = 1;        // 直接在栈中分配 8 字节(Number)
    var b = "极客时间"; // 字符串在 V8 中也是原始类型,直接存栈
    var c = true;
}
  • 体积小、固定大小
  • 复制时是值拷贝
  • 栈内存回收极快(只需要移动栈顶指针)

JavaScript

function demo1() {
    var a = 1;
    var b = a;  // 值拷贝
    a = 2;
    console.log(b); // 1,互不影响
}
demo1();

引用类型(对象)存储在堆中,栈中只存引用地址

JavaScript

function demo2() {
    var obj1 = { name: "极客时间" }; // 栈中存的是地址 0x123abc
    var obj2 = obj1;                 // 拷贝的是地址!
    obj1.name = "极客邦";
    console.log(obj2.name); // 极客邦,指向同一块堆内存
}
demo2();

为什么不把大对象也放栈里?

因为执行上下文切换非常频繁,如果栈里塞满大对象,会导致:

  • 栈空间不够用(栈默认只有 1~2MB)
  • 上下文切换时复制大量数据,性能灾难
  • 栈要求内存连续,大对象容易造成碎片

所以 V8 的选择是:

  • 栈内存:只存原始值 + 引用地址,保持小而连续
  • 堆内存:专门存放对象、数组、函数等大块数据

这才是“栈存基本类型,堆存引用类型”的真正原因!

三、JavaScript 是动态弱类型语言

JavaScript

let x;
console.log(typeof x); // undefined

x = 42;
console.log(typeof x); // number

x = "极客时间";
console.log(typeof x); // string

x = null;
console.log(typeof x); // object(历史遗留 bug)

V8 内部用“类型标签 + 值”存储变量:

  • 低 1~3 位作为类型标记(Tag)
  • null 的二进制全为 0,所以类型标签是 000 → 被识别为 Object

推荐判断类型方式:

JavaScript

Object.prototype.toString.call(null);    // "[object Null]"
Object.prototype.toString.call(undefined); // "[object Undefined]"

四、闭包的本质:堆内存中的 Closure 对象

这是最容易被误解的点。

很多人以为闭包是函数,其实闭包是 V8 在编译阶段创建的一个堆对象!

看经典闭包代码:

HTML

<script>
    function foo() {
        var myName = "极客时间";
        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("极客邦");
    bar.getName();        // 1
    console.log(bar.getName()); // 极客邦
</script>

V8 执行的真实过程:

  1. 编译 foo 函数时,发现内部函数引用了外部变量 myName、test1
  2. V8 立即在堆内存中创建一个 closure(foo) 对象,把这些变量存进去
  3. innerBar 中的函数其 [[Environment]] 指向这个 closure 对象
  4. foo 执行完毕,栈上上下文被销毁,但堆上的 closure(foo) 依然被 innerBar 持有,无法被 GC

五、内存回收机制简述

  • 栈内存:函数执行完,栈顶指针下移,自动回收
  • 堆内存:标记-清除 + 分代回收(新生代 Scavenge,老生代 Mark-Sweep/Mark-Compact)

只要有引用链指向堆对象,就不会被回收——这也是闭包内存泄漏的常见原因。

六、真实面试题解析

面试官:为什么闭包会造成内存泄漏?怎么解决?

回答: 闭包本身不是泄漏,是“意外持有”导致的。常见场景:

JavaScript

// 坏例子:事件监听器持有闭包
element.onclick = (function() {
    var bigData = new Array(1000000).fill('极客时间');
    return function() {
        console.log(bigData.length);
    };
})();

解决方法:

  • 及时解绑事件
  • 使用弱引用 WeakMap/WeakSet
  • 手动置空:element.onclick = null

七、总结:一张图记住全部核心

text

栈内存(Call Stack)              堆内存(Heap)
┌─────────────────┐         ┌─────────────────────┐
│ 全局执行上下文   │         │ closure(foo)        │
│ foo 执行上下文   │         │ myName: "极客邦"    │
│ bar 执行上下文   │         │ test1: 1            │
└─────────────────┘         └─────────────────────┘
       ↑                            ↑
   原始值 + 引用地址             大对象、闭包数据

记住三句话:

  1. 栈存原始值 + 引用地址,保证执行上下文切换极快
  2. 堆存大对象 + closure 对象,保证内存灵活分配
  3. 闭包的本质是编译阶段在堆中创建的 closure