JavaScript内存管理与执行上下文

JavaScript内存管理与执行上下文

当我们写下一行 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 代码执行的环境,包含变量、函数、参数等信息。每当函数被调用时,都会创建一个新的执行上下文。

执行上下文分为三类:

  1. 全局上下文:有且仅有一个。
  2. 函数上下文:每次函数调用时,都会创建一个函数上下文对象。
  3. eval上下文:知道即可,基本不使用。

全局上下文

全局上下文 为最外层的上下文,根据执行环境和宿主环境,表示的全局上下文对象可能不一样。在浏览器中,全局上下文就是我们常说的 window 对象。因此,所有通过 var 定义的全局变量和函数,都会成为 window 对象的属性和方法。而使用 let/const 的顶级声明是不会定义在全局上下文中的,但在作用域链解析上效果是一样的。

全局上下文只有在应用程序退出前才会被销毁,比如关闭网页或退出浏览器。

函数上下文

每个函数在调用时,都有自己的上下文。当代码执行流进入函数时,函数的上下文会被推到一个上下文栈上。在函数执行完成后,上下文栈会弹出该函数上下文。

执行上下文的创建过程

执行上下文的创建过程分为两阶段:

  1. 阶段一:创建阶段:在该阶段中,函数会被调用,但并不会执行
  2. 阶段二:执行阶段:在该阶段中,会逐行执行代码

以下面的代码为例,我们可以观察这段代码在两个阶段中都发生了什么事:

javascript 复制代码
function example(a, b) {
    var c = 10;
    let d = 20;
    const e = 30;
    
    function inner() {
        console.log("内部函数声明");
    }
    
    var funcExpr = function() {
        console.log("函数表达式");
    };
}

阶段一:创建阶段

上述代码在创建阶段会进行以下操作:

  1. 创建变量对象(Variable Object)
  2. 建立作用域链(Scope Chain)
  3. 确定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不在变量对象中
}

阶段二:执行阶段

接上面的例子,在执行阶段会进行以下操作:

  1. 参数赋值:a = 实参1, b = 实参2
  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函数的作用域链结构如下:

  1. 0\] inner的变量对象(包含innerVar)

  2. 2\] 全局变量对象(包含globalVar)

  3. 先在 [0] 中找 innerVar:找到了,输出,执行下一行代码。

  4. 在 [0] 中找 outerVar:没找到,往外查找:在 [1] 中找 outerVar:找到了,输出,执行下一行代码。

  5. 在 [0] 和 [1] 中找 globalVar:没找到,往外查找:在 [2] 中找 globalVar :找到了,输出。

  6. 如果一直找到最外层都没找到:undefined。

作用域链的关键特性

  1. 静态性(词法作用域):作用域链在函数定义时就已经确定,而不是在调用时确定的。
  2. 链式结构:像链条一样一环扣一环,从当前作用域指向外层作用域。
  3. 单向性:只能从内层作用域访问外层作用域的变量,不能反向访问。
  4. 与执行上下文相关:每次函数调用都会创建新的执行上下文,但作用域链基于函数定义位置确定。

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]()); 

代码解释:

  1. var i 是函数作用域,整个 createFunctions 只有一个 i
  2. 循环创建了3个函数,它们都共享同一个变量 i 的引用
  3. 当循环结束时,i 的值为3
  4. 调用这些函数时,它们都返回当前 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的行为,写出更健壮、高效的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

相关推荐
Hi_kenyon2 小时前
理解vue中的ref
前端·javascript·vue.js
jin1233223 小时前
基于React Native鸿蒙跨平台地址管理是许多电商、外卖、物流等应用的重要功能模块,实现了地址的添加、编辑、删除和设置默认等功能
javascript·react native·react.js·ecmascript·harmonyos
2501_920931703 小时前
React Native鸿蒙跨平台医疗健康类的血压记录,包括收缩压、舒张压、心率、日期、时间、备注和状态
javascript·react native·react.js·ecmascript·harmonyos
落霞的思绪3 小时前
配置React和React-dom为CDN引入
前端·react.js·前端框架
Hacker_Z&Q3 小时前
CSS 笔记2 (属性)
前端·css·笔记
Anastasiozzzz3 小时前
LeetCode Hot100 295. 数据流的中位数 MedianFinder
java·服务器·前端
橙露4 小时前
React Hooks 深度解析:从基础使用到自定义 Hooks 的封装技巧
javascript·react.js·ecmascript
Exquisite.4 小时前
Nginx
服务器·前端·nginx
2501_920931704 小时前
React Native鸿蒙跨平台使用useState管理健康记录和过滤状态,支持多种健康数据类型(血压、体重等)并实现按类型过滤功能
javascript·react native·react.js·ecmascript·harmonyos