在前端面试中,“讲讲 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();
执行过程:
- 全局执行上下文入栈
- foo 执行上下文入栈
- bar 执行上下文入栈 → 执行 → 出栈
- foo 出栈
- 全局出栈
这就是调用栈的完整生命周期。
二、为什么基本类型放栈,对象放堆?
这是 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 执行的真实过程:
- 编译 foo 函数时,发现内部函数引用了外部变量 myName、test1
- V8 立即在堆内存中创建一个 closure(foo) 对象,把这些变量存进去
- innerBar 中的函数其 [[Environment]] 指向这个 closure 对象
- 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 │
└─────────────────┘ └─────────────────────┘
↑ ↑
原始值 + 引用地址 大对象、闭包数据
记住三句话:
- 栈存原始值 + 引用地址,保证执行上下文切换极快
- 堆存大对象 + closure 对象,保证内存灵活分配
- 闭包的本质是编译阶段在堆中创建的 closure