# 一文搞懂 JavaScript 内存机制:从栈和堆,到闭包为什么“活得更久”

0 阅读11分钟

如果你学 JavaScript 学到一定程度,迟早会遇到这些问题:

  • 为什么基本数据类型和引用数据类型的表现不一样?
  • 为什么函数执行完了,里面的变量好像还“活着”?
  • 闭包到底是怎么形成的?
  • 为什么说理解内存机制,能让你真正看懂 JS?

很多人学 JS 会先背概念:
“基本类型存在栈里,引用类型存在堆里,闭包可以访问外部变量……”

但如果只停留在背诵层面,遇到复杂代码还是会懵。 要学会理解底层,这样才不会遇到面试和代码时无从下手。

所以这篇文章我们不只讲结论,还会从JavaScript 的执行机制和内存机制出发,去理解为什么会这样。


一、先搞清楚:JavaScript 到底是怎么执行代码的?

在理解内存之前,先简单看一下 JS 的执行方式。

JavaScript 代码执行时,核心离不开两个东西:

  • 调用栈(Call Stack)
  • 执行上下文(Execution Context)

可以把它理解成:
JS 每执行一段代码,都会先准备好一个“工作环境”,然后放到调用栈里,按顺序执行。

1. 调用栈:JS 执行的主角

调用栈就像一个叠起来的盘子:

  • 新函数执行,就压栈
  • 函数执行完,就出栈

例子

function foo() {
  bar();
}

function bar() {
  console.log('bar');
}

foo();

执行过程大致是:

  1. 全局代码进入调用栈
  2. 执行 foo()
  3. foo() 入栈
  4. foo() 内部执行 bar()
  5. bar() 入栈
  6. bar() 执行完,出栈
  7. foo() 执行完,出栈
  8. 全局执行完毕

你可以把它理解为:
调用栈负责记录“谁在执行、执行到哪了”。


二、执行上下文:函数运行时的“工作场所”

每次函数执行,都会创建一个执行上下文。
它里面主要包含这些内容:

  • 变量环境
  • 词法环境
  • 外部词法作用域链(outer)
  • this

虽然这些名词听起来有点抽象,但它们本质上都在回答一个问题:

函数执行时,能访问哪些变量?当前 this 指向谁?

1. 变量环境

变量环境主要存放 var 声明的变量和函数声明。

例子

function demo() {
  console.log(a);
  var a = 10;
}
demo();

在代码真正执行前,JS 会先进行“预扫描”,把 var a 提前声明出来。

所以这段代码实际上更像这样:

function demo() {
  var a;
  console.log(a); // undefined
  a = 10;
}

这也是为什么 var 会出现“变量提升”的现象。


2. 词法环境

词法环境更关注“代码写在哪里”,也就是变量的作用域是如何嵌套的。

它会配合外部词法作用域链一起工作,决定一个变量到底去哪找。

例子

function outer() {
  let name = 'outer';

  function inner() {
    console.log(name);
  }

  inner();
}
outer();

inner() 中没有 name,会去外层作用域找,于是找到 outer() 里的 name

这就是作用域链的本质:
当前作用域找不到,就向外层一层层找。


3. this

this 也是执行上下文的重要部分,它指向谁,取决于函数怎么调用。

例子

const obj = {
  name: 'Jack',
  say() {
    console.log(this.name);
  }
};

obj.say(); // Jack

这里 say() 是通过 obj.say() 调用的,所以 this 指向 obj

如果单独拿出来调用:

const fn = obj.say;
fn(); // 可能是 undefined 或 window.name,取决于环境

this 的指向会发生变化。


三、JavaScript 的内存空间:栈和堆

文档里提到,JavaScript 的内存空间主要可以理解为:

  • 代码空间
  • 栈内存
  • 堆内存

其中我们最常接触的是 栈内存堆内存


1. 栈内存:快、连续、适合小而固定的数据

栈内存主要用来存放:

  • 简单数据类型
  • 函数执行时的上下文信息

它的特点是:

  • 分配快
  • 回收快
  • 空间相对固定
  • 数据连续

例子

let a = 1;
let b = true;
let c = 'hello';

这些简单数据类型,通常会直接存在栈内存中。

你可以把栈理解为:
速度快、管理简单,但空间有限。


2. 堆内存:大、灵活、适合复杂对象

堆内存用来存放复杂数据类型,比如:

  • 对象
  • 数组
  • 函数
  • 日期对象等

堆的特点是:

  • 空间大
  • 适合动态分配
  • 不要求连续
  • 分配和回收成本更高

例子

let obj = {
  name: 'Tom',
  age: 18
};

这个对象本体会被存放在堆内存中,而变量 obj 本身通常会在栈里保存一个“引用地址”。

换句话说:

  • 栈里存的是“地址”
  • 堆里存的是“真正的数据”

四、为什么 JavaScript 要把数据分成栈和堆?

这是个很重要的问题。

如果所有数据都放在栈里,会发生什么?

问题 1:栈空间有限

复杂对象可能很大,如果也放在栈里,会让栈变得非常庞大。
而栈本来是为了快速执行上下文切换的,过大的栈会影响效率。

问题 2:对象大小不固定

对象、数组的内容是动态变化的,不像数字、布尔值那么简单。
栈更适合存放小而确定的数据,堆更适合存放动态变化的数据。

例子

let arr = [1, 2, 3];
arr.push(4, 5, 6);

如果数组完全放在栈里,随着内容变化,管理会非常麻烦。
放在堆里更灵活,也更符合它的特点。


五、简单数据类型和复杂数据类型到底有什么区别?

这是 JS 的基础重点,也是很多人经常搞混的地方。


1. 简单数据类型:直接存值

简单数据类型包括:

  • number
  • string
  • boolean
  • null
  • undefined
  • symbol
  • bigint

它们通常是直接存储值

例子

let a = 10;
let b = a;
b = 20;

console.log(a); // 10
console.log(b); // 20

因为 b = a 时,复制的是值本身,所以 b 改变不会影响 a


2. 复杂数据类型:存引用

复杂数据类型包括:

  • object
  • array
  • function

它们更像是“指向堆内存的地址”。

例子

let obj1 = {
  name: 'Lucy'
};

let obj2 = obj1;
obj2.name = 'Lily';

console.log(obj1.name); // Lily
console.log(obj2.name); // Lily

这里 obj2 = obj1 复制的不是对象本体,而是同一个引用地址。
所以改 obj2obj1 也会受到影响。


六、Object 类型:为什么 key 是字符串或 Symbol?

文档里提到:

Object 类型 key:value,key string | symbol

这是因为对象的属性键,本质上是字符串或 Symbol。

例子

const obj = {
  1: 'one',
  true: 'yes'
};

console.log(obj['1']);   // one
console.log(obj['true']); // yes

你会发现数字 1 和布尔值 true 作为 key,最终都会被转换成字符串。

这也是为什么对象属性访问时,通常写成:

obj['name']

而不是依赖“其他类型的 key”。


七、闭包:从内存机制角度理解,才真正不容易忘

接下来是重点:闭包

很多人对闭包的理解停留在一句话:

函数可以访问并记住它定义时的词法作用域。

这句话没错,但太抽象了。
我们从内存角度来理解,会更清晰。


1. 闭包到底是什么?

闭包本质上是:

内部函数引用了外部函数中的变量,而这些变量在外部函数执行完后依然没有被销毁。

也就是说,内部函数“把外部变量带走了”。


2. 为什么会形成闭包?

第一步:扫描内部函数是否引用了外部变量

JS 在编译阶段会检查内部函数是否使用了外部函数的变量。

第二步:把这些变量保存到堆中

如果内部函数引用了外部变量,那么这些变量就会被保存在一个特殊的堆空间里,形成一个 closure

这样,即使外部函数执行结束,这些变量也不会立刻被回收。
因为内部函数还在引用它们。


3. 例子:最经典的闭包

function foo() {
  let myName = 'Tom';

  function getName() {
    console.log(myName);
  }

  return getName;
}

const fn = foo();
fn(); // Tom

这个例子发生了什么?

  1. foo() 执行
  2. myName 被定义在 foo() 的作用域里
  3. getName() 引用了 myName
  4. foo() 执行结束后,按理说它的局部变量应该被释放
  5. 但因为 getName() 还在引用 myName
  6. 所以 myName 被保留下来,形成闭包
  7. 最后执行 fn() 时,仍然可以访问到 myName

4. 再看一个修改变量的例子

function createCounter() {
  let count = 0;

  return function () {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3

这里 count 没有随着 createCounter() 执行完毕而消失。
因为返回的内部函数一直在引用它。

这就是闭包最实用的地方:
让变量保持私有,同时又能被内部函数持续访问。


八、闭包为什么常常被说成“变量不会被释放”?

更准确地说,不是变量“永远不会被释放”,而是:

只要还有函数引用它,它就不会被回收。

例子

function test() {
  let data = 'important';

  return function () {
    console.log(data);
  };
}

let fn = test();
fn(); // important
fn = null;

fn = null 后,内部函数的引用被断开了。
如果没有其他地方引用闭包中的变量,那么这些内存最终会被垃圾回收机制回收。

所以闭包并不是“内存泄漏”的代名词。
真正的问题是:如果你不再使用闭包,但又一直持有引用,就可能造成内存占用增加。


九、闭包的优点和风险

1. 优点:可以封装私有变量

例子

function createUser() {
  let password = '123456';

  return {
    getPassword() {
      return password;
    }
  };
}

const user = createUser();
console.log(user.getPassword()); // 123456

password 被封装在函数内部,外部无法直接访问,保护了数据安全。


2. 优点:可以保存状态

例子

function add() {
  let num = 0;

  return function () {
    num += 1;
    return num;
  };
}

const count = add();
console.log(count()); // 1
console.log(count()); // 2

闭包使得函数可以“记住上一次的状态”。


3. 风险:容易造成内存占用

如果闭包里保存了大量数据,而这些数据长期不释放,就可能让内存占用变高。

例子

function bigData() {
  let arr = new Array(1000000).fill(1);

  return function () {
    console.log(arr.length);
  };
}

如果 bigData() 的返回值一直被引用,arr 也会一直留在内存中。

所以使用闭包时要注意:

  • 不需要时及时解除引用
  • 避免把大对象长期留在闭包中

十、为什么说“理解内存机制,才能真正理解闭包”?

因为闭包不是一个“语法特性”,它更像是 JS 作用域和内存管理配合后的结果。

你可以这样记:

  • 作用域链决定“能不能访问”
  • 内存引用决定“能不能活着”
  • 闭包就是“内部函数访问外部变量,并让变量存活下来”

简单总结例子

function outer() {
  let name = 'JS';

  return function inner() {
    console.log(name);
  };
}
  • inner() 可以通过作用域链找到 name
  • inner() 被返回后仍然持有对 name 的引用
  • 所以 name 不会马上被回收
  • 这就是闭包

十一、常见误区:闭包不是“函数套函数”这么简单

很多初学者会误以为:

只要函数里面有函数,就是闭包。

这其实不准确。

不是所有嵌套函数都是闭包

例子

function outer() {
  function inner() {
    console.log('hello');
  }
  inner();
}
outer();

这里虽然函数嵌套了,但 inner() 没有引用外部变量,也没有在外部被返回或持久保存。
所以严格来说,这并不是我们通常所说的“典型闭包”。

真正的闭包,关键在“引用了外部变量,并且这个引用被保留了”


十二、总结一下

想真正理解 JavaScript 的内存机制,我总结出几个核心点:

1. JS 的执行核心是调用栈

函数执行时入栈,执行完出栈。

2. 执行上下文决定变量和 this 的访问方式

它包含变量环境、词法环境、outer 作用域链和 this。

3. 栈内存适合简单数据类型

特点是快、连续、好管理。

4. 堆内存适合复杂数据类型

特点是空间大、灵活,但分配和回收更耗时。

5. 闭包的本质是“外部变量被内部函数引用并保留下来”

这也是为什么函数执行完后,某些变量依然能被访问。


结语

JavaScript 看起来“灵活”,其实背后有一套非常清晰的执行和内存逻辑。
理解了栈、堆、执行上下文、作用域链,再去看闭包,你会发现它并不是神秘的黑魔法,而是 JS 语言设计的自然结果。

真正的高手,不能是背会了闭包,而是能从内存角度解释闭包。 不然可面不了大厂