前言
"世界需要一种什么样的语言?"基于应用场景、编程理念和设计目标等的不同,在计算机系统中我们需要以不同方式实现某种语言。JavaScript诞生之初作为一款用于Navigator浏览器上的脚本语言,其设计目标是让网页具备动态交互能力。JS的诞生为Web开发敲开新的大门,而为满足Web开发过程中逐渐复杂的交互、灵活的设计需求,也使得JS弱类型动态性的语言特性愈发鲜明。而这又和JavaScript的内存机制有何关联?
引子
在此之前我们先看两段JS代码来引出我们对这个问题的思考
js
let a = 1
let b = a
a = 2
console.log(b); //输出为:1
js
let a = { name: '徐可欣', age: 18 }
let b = a
a.age = 19
console.log(b.age); //输出为:19
或许我们会有这样的疑问
-
为什么在
let a = 1; let b = a; a = 2;
的操作后,b
的值仍然是1
而没有随着a
的改变而改变呢?毕竟一开始看起来b
好像是 "绑定" 在了a
上。这种赋值操作在内存层面具体是怎样进行的呢? -
为什么在
let a = { name: '徐可欣', age: 18 }; let b = a; a.age = 19;
之后,b.age
的值会变成19
呢?明明只是修改了a
的属性值呀。a
和b
与它们所指向的对象在内存中的关系如何体现呢?
其实,在JS的类型中分为两个大类,原始类型 和引用类型 。当我们把变量赋值给一个变量时,解析器首先要确认的就是这个值是原始类型值还是引用类型值。原始类型存储在栈中 ,在栈中存放的是对应的值;引用类型的对象存储于堆中 ,在栈中存放的是指向堆内存的地址。在JS中我们可以直接操作保存在栈内存空间的值,而不允许直接访问堆内存中的位置,我们只能操作对象的引用,即在栈内存中指向堆内存的一个地址。因此,原始类型按值访问,引用类型的值按引用访问。故而,原始类型的赋值是值的复制,生成两个相同的值;引用类型赋值是将保存对象的内存地址赋值给另一个变量,也就是两个变量指向堆内存中同一个对象。
如果我们理解这些便能解答以上两个疑问,当执行上层代码let b = a;
时,实际上是在栈内存中把a
的值1
复制了一份给b
,所以后续a
的值改变并不会影响到b
,它们在栈内存中是相互独立存储的值。当执行下层代码let b = a;
时,因为a
和b
指向同一个对象,所以修改a.age
,b.age
也会同时修改。
解决了这些我们是否又会有疑问:既如此,那JS的内存空间究竟是什么样的呢,何时分配空间,又如何分配空间呢?
如若盲点太多不能理解,没关系,我们往下看。
语言的类型
我们先看这段C语言代码
c
int main(){
int a = 1
char* b = 'hello'
bool c = false
c=a //不会报错 隐式类型转换
}
- 为什么在C语言中我们使用了不同的关键字如int,char等去定义了不同类型的变量,而在JS中我们用的是let呢?这不会使得JS中的变量类型混乱而难以区分吗?而在C语言
c=a
这行代码中将字符类型的a赋值给布尔类型的b为什么没有报错呢?
为什么呢?简单来说JavaScript是动态语言,在运行时检查数据类型 。它可以用let(es6前只有var,但与之后出现的let,const同样不能标明变量的类型 )声明任何变量,即在编写代码时不用确定变量的数据类型,运行阶段JS引擎会根据变量第一次被赋予的值记录下变量的数据类型。而C语言是静态语言,编译时变量的数据类型就可确定 ,多数静态类型语言要求在使用变量之前必须声明数据类型。同时C语言是门弱类型语言,支持隐式类型转换 (隐式类型转换是指在运算符操作时,编译器自动将不同类型的数据转换为相同类型的数据 ),即在c=a
这行代码中可以将字符类型的a赋值给布尔类型的c。
由此我们将语言的类型划分出这四种:
- 在使用前就需要确定其变量的数据类型 -- 静态语言
- 在运行的过程中检查数据的类型 -- 动态语言
- 支持隐式类型转换的语言 --- 弱类型语言
- 不支持隐式类型转换的语言 --- 强类型语言
JS的数据类型
我们知道JavaScript是动态弱类型语言,在运行的过程中检查数据的类型,并且支持隐式类型转换。既然语言类型的划分和数据类型有关,那么我们就来探讨一下JS的数据类型吧。 JavaScript中的数据类型可分为两大类:
js
// 原始类型(Primitive Types)
let num = 1 // Number
let str = 'text' // String
let bool = true // Boolean
let nul = null // Null
let undef = undefined // Undefined
let sym = Symbol() // Symbol
let bigInt = 10n // BigInt
// 引用类型(Reference Types)
let obj = {} // Object
let arr = [] // Array
let func = function(){}// Function
以上便是JS的数据类型,分为原始类型 和引用类型。
JS的内存空间
了解完JS有这些数据类型,那么我们应该将它们放哪里呢。就像我是谁,要到哪里去。
在 JavaScript 中,程序运行时内存被划分为三个核心区域:代码空间 、栈空间 和堆空间。它们分别承担不同的职责,共同支撑代码的执行。以下是三者的详细解析:
一、代码空间(Code Space)
存储内容
- 静态代码:用户编写的 JavaScript 代码(函数、变量声明、逻辑语句等)。
- 可执行指令:JavaScript 引擎将代码编译后的机器码或字节码。
特点
- 只读性:通常情况下,代码空间是只读的,防止运行时修改代码逻辑。
- 预加载:代码在解析阶段被加载到代码空间,执行时直接调用。
javascript
// 代码空间存储以下内容:
function add(a, b) {
return a + b;
}
const result = add(1, 2);
二、栈空间(Stack)
存储内容
- 函数调用上下文:包括参数、局部变量、返回地址等。
- 原始类型值 :如
number
、boolean
、string
(小字符串可能被优化存储)。
机制
- 后进先出(LIFO) :函数调用时压栈(Push),执行完毕后出栈(Pop)。
- 调用栈:维护函数调用关系,记录当前执行路径。
javascript
function outer() {
const a = 10;
inner();
}
function inner() {
const b = 20;
console.log(a + b); // 访问outer的变量a(闭包)
}
outer(); // 调用栈顺序:outer → inner
栈溢出(Stack Overflow)
原因:递归过深或函数调用层级过多,超出栈空间容量。
javascript
function infiniteLoop() {
infiniteLoop(); // 导致栈溢出
}
infiniteLoop();
三、堆空间(Heap)
存储内容
- 引用类型 :对象(
Object
)、数组(Array
)、函数(Function
)等。 - 大字符串:部分引擎将长字符串存入堆中以优化内存。
特点
- 动态分配:按需分配内存,支持复杂数据结构。
- 共享性:多个变量可引用同一堆地址,修改会相互影响。
javascript
const obj = { key: 1 }; // obj在栈中存储堆地址,对象内容存放在堆中
const arr = [1, 2, 3]; // 数组内容存放在堆中
四、三者的协作关系
内存区域 | 存储内容 | 生命周期 | 访问速度 |
---|---|---|---|
代码空间 | 静态代码、可执行指令 | 程序运行期间固定 | 快 |
栈空间 | 函数上下文、原始类型 | 函数调用时动态创建 | 极快 |
堆空间 | 引用类型、大字符串 | 对象不再被引用时回收 | 较慢 |
执行流程示例
- 代码加载 :函数
add
被存入代码空间。 - 调用栈压栈 :执行
add(1, 2)
时,参数1
和2
(原始类型)存入栈,函数体从代码空间读取。 - 堆分配 :若函数内部创建对象(如
new Date()
),对象存入堆,栈中保存其地址。 - 返回结果:函数执行完毕,栈帧弹出,堆中的对象由垃圾回收管理。
五、小试牛刀
我们借助这段代码再现场景加以理解
js
function foo() {
var a = 1
var b = a
var c = {name: 'xxx'}
var d = c
}
foo()
我们定义了一个foo然后调用它。
编译器扫描全局代码,发现 function foo(){}
声明,将 foo
函数对象存入堆内存 当执行foo()
时,创建函数执行上下文(EC),声明变量 a/b/c/d
并初始化为 undefined
压入调用栈顶部:
go
```
|----------------|
| foo() 执行上下文 |
|----------------|
| 全局执行上下文 |
|----------------| <-- 栈底
```
var a = 1
:在栈中分配内存,直接存储原始值1
var b = a
:复制a
的值到新栈位置,b
独立存储1
css
栈空间(Stack)
+-------+-------+
| a=1 | b=1 |
+-------+-------+
-
var c = {name: 'xxx'}
:- 堆中创建对象
{name: 'xxx'}
,假设地址为#0x100
- 栈中变量
c
存储堆地址#0x100
var d = c
:复制地址#0x100
到d
,形成共享引用
- 堆中创建对象
lua
栈空间 堆空间(Heap)
+---------------+ +-------------------+
| c → #0x100 | | #0x100: {name:'xxx'} |
| d → #0x100 | +-------------------+
+---------------+
函数执行完毕
markdown
- 函数上下文弹出栈,栈变量`a/b/c/d`被销毁
- 堆中的对象`#0x100`因无变量引用,等待垃圾回收
总结
- 栈: 原始类型(原始类型的值一般都很小)
- 堆: 引用类型 (要占据的内存很大)
- 栈的设计本身就很小:因为如果栈设计的很大的话,那么栈中函数的上下文切换效率就会大大降低
- 原始类型的复制是值的复制,引用类型的赋值,是引用地址的复制
与闭包的结合
一、闭包的本质
让我们通过一个经典案例,揭示闭包如何突破函数作用域的限制,实现跨上下文的状态保持:
javascript
function createCounter() {
let count = 0; // 原始类型变量
const config = { // 引用类型变量
max: 100
};
return {
increment: () => {
if (count < config.max) count++;
},
getCount: () => count
};
}
const counter = createCounter();
counter.increment();
console.log(counter.getCount()); // 1
内存演变过程
-
函数执行阶段 先调用counter = createCounter();createCounter 执行上下文入栈
arduino┌──────────────────┐ ┌──────────────────┐ │ createCounter EC │ │ Heap │ ├──────────────────┤ ├──────────────────┤ │ count → 0 (栈) │ │ 闭包对象#closure1 │ │ config → #obj1 │───────▶ { max:100 } │ └──────────────────┘ └──────────────────┘
-
函数返回后 createCounter EC销毁,闭包还在堆内
less┌──────────────────┐ ┌──────────────────┐ │ Global EC │ │ Heap │ ├──────────────────┤ ├──────────────────┤ │ counter → #func1 │───────▶ 闭包对象#closure1 │ └──────────────────┘ │ count → 0 (堆) │ │ config → #obj1 │ └──────────────────┘
-
执行 increment() 由于需要用到那两个变量就从堆中取闭包
lua┌──────────────────┐ ┌──────────────────┐ │ increment() EC │ │ Heap │ ├──────────────────┤ ├──────────────────┤ │ │ │ 闭包对象#closure1 │ │ [[Scope]] → │───────▶ count → 1 (堆) │ │ #closure1 │ │ config → #obj1 │ └──────────────────┘ └──────────────────┘
1. 闭包形成的必要条件
- 内部函数引用外部变量
当检测到函数内部存在对外部变量的引用时,JS引擎会将相关变量提升到堆内存。 - 函数被外部引用
返回的函数或对象必须被外部作用域持有引用,否则闭包对象会被回收。
2. 闭包的内存结构
lua
▲ 函数作用域链 ▼ 闭包对象(堆内存)
┌──────────────────┐ ┌──────────────────┐
│ │ │ │
│ 当前执行上下文 │ │ 闭包变量副本 │
│ [[Scopes]] │─────▶│ myname → 'bbbb' │
│ │ │ test1 → 1 │
└──────────────────┘ └──────────────────┘
3. 示例代码解析
js
function foo() {
var myname = 'aaaaa'
let test1 = 1
const test2 = 2
var innerBar = {
setName: function (name) {
myname = name // 捕获myname
},
getName: function () {
console.log(test1); // 捕获test1
return myname
}
}
return innerBar
}
// 执行阶段内存变化:
1. 调用foo()时创建执行上下文:
- 栈中存储:myname='aaaaa', test1=1, test2=2
- innerBar对象存入堆(地址#obj1)
2. 返回innerBar后,foo()上下文出栈:
- 由于setName/getName引用了myname和test1:
→ 创建闭包对象#closure1:
{ myname: 'aaaaa', test1: 1 }
→ test2未被引用,随栈销毁
3. 后续操作:
bar.setName('bbbb') → 修改闭包对象中的myname
bar.getName() → 读取闭包对象中的test1和myname
三、闭包的内存特征
1. 堆内存的持久化
变量类型 | 存储位置 | 生命周期 |
---|---|---|
被闭包引用的变量 | 堆内存(闭包对象) | 直到闭包不再被引用 |
未被引用的变量 | 栈内存 | 函数执行完毕立即销毁 |
常见面试问题:为什么栈不能分配得太大?
栈的大小直接影响函数上下文切换效率,需在空间与时间之间权衡。
-
栈的核心职责:存储函数调用上下文(参数、局部变量、返回地址等),维持调用栈的层级关系。
-
上下文切换的代价:
- 当 CPU 切换线程或函数时,需保存当前栈状态(如栈指针、寄存器值)并加载新状态。
- 栈越大,需保存 / 恢复的数据越多,切换耗时越长,导致性能下降。
-
理解:
- 栈类似裤兜,设计过长(过大)会使取物(上下文切换)效率降低,即使只携带少量物品(小函数),也需冗余移动。
总结回答 :
"栈的设计需平衡效率与资源。较小的栈能快速完成上下文切换,而大栈会增加切换延迟并浪费内存。就像裤兜,设计过长(过大)会使取物(上下文切换)效率降低,即使只携带少量物品(小函数),也需冗余移动。这一机制是操作系统为优化性能做出的选择。"
扩展思考:
- 避免栈溢出:过大的栈易导致递归或深层调用时溢出。
- 优化策略:将大对象存入堆,减少递归深度,或改用迭代实现。