JavaScript内存管理与执行上下文
- 前言:从一道经典面试题说起
- [栈内存 vs 堆内存:数据存储的两种方式](#栈内存 vs 堆内存:数据存储的两种方式)
- 执行上下文
- 作用域链:变量查找的路径
- 内存泄漏的常见模式与解决方案
- 回到开头的面试题
- 结语
当我们写下一行
let a = 10;,这个数字 10 究竟存储在内存的哪个角落?为什么闭包能记住变量?本篇文章将彻底揭开 JavaScript 内存管理的神秘面纱。
前言:从一道经典面试题说起
javascript
function createFunctions() {
var result = [];
for (var i = 0; i < 3; i++) {
result[i] = function() {
return i;
};
}
return result;
}
var functions = createFunctions();
console.log(functions[0]());
console.log(functions[1]());
console.log(functions[2]());
上述代码的输出结果是:3 3 3 。这时候很多人可能开始有疑问了:为什么不是 0 1 2 呢?
要理解这个问题,我们需要深入到JavaScript的内存管理和执行上下文机制中。让我们先从内存的两种基本存储区域开始。
栈内存 vs 堆内存:数据存储的两种方式
栈内存
栈内存的特点是:有序、按值访问、大小固定(空间有限)、访问快速,通常用来存储基本数据类型:
javascript
let a = 10; // 数字
let b = "hello"; // 字符串
let c = true; // 布尔值
let d = null; // null
let e = undefined; // undefined
let f = Symbol("id"); // Symbol
let g = 100n; // BigInt
其在栈内存的存储方式示例如下:
text
|---------|
| g:100n | <-- 最新
| f:Symbol|
| e:undefined|
| d:null |
| c:true |
| b:"hello"|
| a:10 | <-- 最早
|---------|
堆内存
堆内存的特点是:无序、大小动态、访问相对较慢,通常用来存储引用数据类型:
javascript
let obj1 = { name: "Alice", age: 25 }; // 对象
let arr1 = [1, 2, 3, 4, 5]; // 数组
let func1 = function() { return "Hello"; }; // 函数
let date1 = new Date(); // Date对象
let map1 = new Map(); // Map对象
其在内存的存储分两种存储:在栈内存中,存储堆内存的引用地址;在堆内存中,存放实际数据:
javascript
// 栈内存存储的是堆内存的引用地址
let obj1 = 0x0012A3B4; (内存地址)
// 堆内存(地址0x0012A3B4):
{
name: "zhangsan" // 字符串"zhangsan"本身可能在堆的另一个位置
age: 25 // 基本类型,直接存储在对象结构中
}
内存分配实例分析
基本类型 - 栈存储
javascript
let x = 10;
let y = x; // 复制值
y = 20;
console.log(x); // 10 - x不变
引用类型 - 堆存储
javascript
let obj1 = { value: 10 };
let obj2 = obj1; // 复制引用(内存地址)
obj2.value = 20;
console.log(obj1.value); // 20 - obj1中的属性值也变了!
执行上下文
什么是执行上下文?
执行上下文 是 JavaScript 代码执行的环境,包含变量、函数、参数等信息。每当函数被调用时,都会创建一个新的执行上下文。
执行上下文分为三类:
- 全局上下文:有且仅有一个。
- 函数上下文:每次函数调用时,都会创建一个函数上下文对象。
- eval上下文:知道即可,基本不使用。
全局上下文
全局上下文 为最外层的上下文,根据执行环境和宿主环境,表示的全局上下文对象可能不一样。在浏览器中,全局上下文就是我们常说的 window 对象。因此,所有通过 var 定义的全局变量和函数,都会成为 window 对象的属性和方法。而使用 let/const 的顶级声明是不会定义在全局上下文中的,但在作用域链解析上效果是一样的。
全局上下文只有在应用程序退出前才会被销毁,比如关闭网页或退出浏览器。
函数上下文
每个函数在调用时,都有自己的上下文。当代码执行流进入函数时,函数的上下文会被推到一个上下文栈上。在函数执行完成后,上下文栈会弹出该函数上下文。
执行上下文的创建过程
执行上下文的创建过程分为两阶段:
- 阶段一:创建阶段:在该阶段中,函数会被调用,但并不会执行
- 阶段二:执行阶段:在该阶段中,会逐行执行代码
以下面的代码为例,我们可以观察这段代码在两个阶段中都发生了什么事:
javascript
function example(a, b) {
var c = 10;
let d = 20;
const e = 30;
function inner() {
console.log("内部函数声明");
}
var funcExpr = function() {
console.log("函数表达式");
};
}
阶段一:创建阶段
上述代码在创建阶段会进行以下操作:
- 创建变量对象(Variable Object)
- 建立作用域链(Scope Chain)
- 确定this的值
创建阶段完成后,其对应的完整变量对象如下:
javascript
// 变量对象(Variable Object):
{
arguments: { 0: a, 1: b, length: 2 },
a: undefined, // 参数 0
b: undefined, // 参数 1
c: undefined, // var声明
inner: <function>, // 函数声明(完全提升)
funcExpr: undefined // 函数表达式(只提升声明)
// 注意:let/const声明的d、e不在变量对象中
}
阶段二:执行阶段
接上面的例子,在执行阶段会进行以下操作:
- 参数赋值:a = 实参1, b = 实参2
- 逐行执行代码:
- 执行 var c = 10; // c现在为10
- 执行 let d = 20; // d现在为20(在词法环境中)
- 执行 const e = 30; // e现在为30(在词法环境中)
- funcExpr被赋值函数表达式
执行阶段完成后,其对应的完整活动对象如下:
javascript
{
arguments: { 0: a, 1: b, length: 2 },
a: 实参1的值,
b: 实参2的值,
c: 10,
inner: <function>,
funcExpr: <function expression>
}
作用域链:变量查找的路径
什么是作用域链?
作用域链 是 JavaScript 中用于查找变量的链条结构。当代码在一个执行上下文中访问变量时,JavaScript 引擎会沿着这个链条一层层向上查找。
简单来说,作用域链就是一个变量查找的路径图,它决定了哪些变量可以被访问,以及查找这些变量的顺序。
在后面的文章中,会专门讲解作用域与作用域链,本篇文章只是简单介绍。
作用域链是如何形成的?
每个执行上下文都有三个核心属性:
- 变量对象(VO):存储当前上下文中定义的变量和函数。
- 作用域链(Scope Chain):一个包含当前VO和所有父级VO的列表。
- this值:当前执行上下文的对象引用。
作用域链的形成过程:
- 函数定义时就确定了它的词法作用域(静态作用域)。
- 函数调用时,会创建执行上下文,并将当前的变量对象添加到作用域链的最前端。
- 然后添加父级的作用域,一层层直到全局作用域。
我们来看一个简单的示例:
javascript
var globalVar = "全局变量";
function outer() {
var outerVar = "outer的变量";
function inner() {
var innerVar = "inner的变量";
console.log(innerVar); // 在当前作用域找到
console.log(outerVar); // 在父作用域找到
console.log(globalVar); // 在全局作用域找到
}
inner();
}
outer();
inner函数的作用域链结构如下:
-
0\] inner的变量对象(包含innerVar)
-
2\] 全局变量对象(包含globalVar)
-
先在 [0] 中找 innerVar:找到了,输出,执行下一行代码。
-
在 [0] 中找 outerVar:没找到,往外查找:在 [1] 中找 outerVar:找到了,输出,执行下一行代码。
-
在 [0] 和 [1] 中找 globalVar:没找到,往外查找:在 [2] 中找 globalVar :找到了,输出。
-
如果一直找到最外层都没找到:undefined。
作用域链的关键特性
- 静态性(词法作用域):作用域链在函数定义时就已经确定,而不是在调用时确定的。
- 链式结构:像链条一样一环扣一环,从当前作用域指向外层作用域。
- 单向性:只能从内层作用域访问外层作用域的变量,不能反向访问。
- 与执行上下文相关:每次函数调用都会创建新的执行上下文,但作用域链基于函数定义位置确定。
ES6的块级作用域
ES6引入的 let/const 带来了块级作用域,这改变了作用域链的结构:
javascript
{
let blockVar = "块级变量";
var functionVar = "函数变量";
{
// 可以访问外层的blockVar
console.log(blockVar); // "块级变量"
}
}
console.log(functionVar); // "函数变量" - var是函数作用域
// console.log(blockVar); // ReferenceError - let是块级作用域
块级作用域的作用域链与函数作用域类似,不同的是块级作用域只在代码块内部有效。
内存泄漏的常见模式与解决方案
意外的全局变量
忘记使用 let/const导致变量提升
javascript
function createGlobal() {
accidentalGlobal = "oops"; // 成了全局变量!
// 应该是:let accidentalGlobal = "oops";
}
this指向全局
javascript
function badThis() {
this.leak = "global leak"; // 如果作为普通函数调用,this指向window
}
badThis(); // window.leak = "global leak"
console.log(window.leak); // "global leak"
解决方案:严格模式
javascript
"use strict";
function strictFunc() {
// accidentalGlobal = "error"; // ReferenceError
// this.leak = "error"; // this是undefined
}
闭包引用未释放
javascript
function createHeavyClosure() {
const hugeData = new Array(1000000).fill("data");
return function() {
// 即使不使用hugeData,它也被闭包引用着
return true;
};
}
let closures = [];
for (let i = 0; i < 100; i++) {
closures.push(createHeavyClosure());
}
解决方案
1. 及时解除引用
javascript
closures.length = 0; // 允许垃圾回收
2. 避免不必要的闭包
javascript
function createLightClosure() {
// 如果内部函数不需要访问外部变量
let hugeData = new Array(1000000).fill("data");
// 返回的函数不使用hugeData,但hugeData仍然被引用
const result = function() {
return true;
};
// 显式解除对大数据的引用
hugeData = null;
return result;
}
DOM引用未清理
问题:DOM元素被JS引用,即使从页面移除也不会被回收:
javascript
let elements = {
button: document.getElementById("myButton"),
container: document.getElementById("myContainer")
};
// 即使从DOM移除
document.body.removeChild(elements.button);
// elements.button仍然引用DOM元素,不会被垃圾回收
console.log(elements.button); // 仍然可以访问
解决方案:移除引用
javascript
elements.button = null;
elements.container = null;
回到开头的面试题
现在我们来重新分析开头的面试题:
javascript
function createFunctions() {
var result = [];
for (var i = 0; i < 3; i++) {
result[i] = function() {
return i;
};
}
return result;
}
var functions = createFunctions();
console.log(functions[0]());
console.log(functions[1]());
console.log(functions[2]());
代码解释:
var i是函数作用域,整个createFunctions只有一个i- 循环创建了3个函数,它们都共享同一个变量
i的引用 - 当循环结束时,
i的值为3 - 调用这些函数时,它们都返回当前
i的值:3
那么,如果我们想正常输出:0 1 2 ,该怎么处理呢?
解决方案
使用let:块级作用域
javascript
function createFunctions() {
var result = [];
for (let i = 0; i < 3; i++) {
result[i] = function() {
return i;
};
}
return result;
}
使用闭包创建独立作用域
javascript
function createFunctions() {
var result = [];
for (var i = 0; i < 3; i++) {
result[i] = (function(j) {
return j;
})(i);
}
return result;
}
结语
JavaScript的内存管理和执行上下文机制是其核心所在,掌握这些概念,我们就能真正理解JavaScript的行为,写出更健壮、高效的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!