JS是在创建对象的时候 自动 分配内存,并且在不再使用的时候 自动 回收。很多人认为JS自动做了这些事情,就可以不需要担心内存管理,这是不正确的想法。
内存的生命周期
内存的生命周期,在大多数程序语言中基本一致:
- 分配内存
- 使用(读,写)内存
- 不再使用时释放内存
JS在第一点和第三点都是显示的,第二点是显示的。
内存结构
JS引擎的内存主要分为三部分:栈内存,堆内存,代码空间。
栈内存
栈数据结构,先进后出的数据结构。
存储方面:
- 原始类型:Number String Boolean Null undefined Symbol
- 函数调用的上下文
- 执行堆内存对象的引用指针
空间方面:
- 按值访问
- 空间较小
- 由系统自动分配和释放
原始类型值的存储
js
let a = 10; // Number
let b = 'text'; // String
let c = true; // Boolean
函数调用的上下文
每个函数调用都会创建一个栈帧,包含了:函数的参数,局部变量,返回地址,当前执行的上下文。
js
function foo(x) {
let y = 20;
return x + y;
}
function bar() {
let a = 5;
return foo(a);
}
bar(); // 调用过程形成调用栈
上述代码中的栈分配情况
lua
|-----------|
| foo栈帧 | ← 当前执行点
|-----------|
| bar栈帧 |
|-----------|
| 全局上下文 |
|-----------|
当函数执行完毕之后,会将函数从栈中弹出,继续执行剩余的代码。执行完毕,栈清空。
需要注意的是,当栈内存被占满时,会报错:堆栈溢出。
引用类型的指针 对于引用类型,栈中存储的是指向堆内存中地址的指针
js
let obj = { name: 'kaixin' }; // 指针存储在栈,对象在堆
栈内存优化
- 避免深度递归,改用迭代或者尾递归优化
js
// 原递归版本(n很大时会栈溢出)
function factorialRecursive(n) {
if (n === 1) return 1;
return n * factorialRecursive(n - 1);
}
// 尾递归优化
function factorial(n, total = 1) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
// 迭代处理
function factorial(n) {
// 处理边界情况
if (n < 0) return NaN; // 负数没有阶乘
if (n === 0 || n === 1) return 1; // 0!和1!都为1
let result = 1;
// 使用循环从2乘到n
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
堆内存
存储方面:
- 存储引用类型数据(Object,Array等)
- 闭包中引用的变量
存储方面
- 按照引用地址访问
- 空间较大
- 内存不连续
- 通过垃圾回收机制释放
存储引用类型
js
let obj1 = { name: 'kaixin' }; // 对象存储在堆内存
let obj2 = obj1; // 复制的是引用
obj2.name = 'bukaixin'; // 修改会影响obj1,因为引用指向了堆内存中的同一个地址。
存储闭包
js
function createCounter() {
let count = 0; // 本应在栈中,但因闭包提升到堆中
return function() {
return ++count;
}
}
const counter = createCounter();
平时我们接触到的 内存泄露 问题就是发生在堆内存。
代码空间
这块内存空间专门用于存储和管理可执行代码的区域。
- 保存源码和通过AST转换后字节码和机器码。
- 优化执行代码速率
- 内联缓存
- 安全隔离代码
(这一块涉及到JS执行代码的具体过程,在本节中就不赘述)
垃圾回收机制
垃圾回收的目的是监控内存分配和确定已经分配出去的内存什么时候不再需要从而释放这一块内存。
引用计数法
现代JS引擎不再使用引用计数。
最开始的时候,JS引擎使用的引用计数垃圾回收算法:记录每个对象被引用的次数,当引用次数为0的时候回收。
缺点:无法处理循环应用,
js
function problem() {
let objA = {};
let objB = {};
objA.someProp = objB;
objB.anotherProp = objA;
}
当这个函数调用结束之后,离开作用域,他们是不再被需要的,分配的内存空间也应该是被回收的。但是由于循环引用,每个对象的引用次数都不为0,这样的结果就是他们两个都不会被标记为垃圾,也就不会被回收。
标记清除法
原理:从根对象出发,标记所有可达对象,清除未标记对象。
垃圾回收器定期的从根对象开始,找到能引用的所有对象,再从这些对象找到对应能引用的对象。这个过程将能找到所有可达对象,并且收集所有不能达对象。
标记清楚法解决了循环引用的问题,因为循环引用的两个对象,从跟对象出发,是为不可达对象。
使用分代收集进行优化:将堆内存分为新生代和老生代
- 新生代:本身内存空间就小,存放生命周期短,内存占用小的对象,使用Scavenge算法(复制方式)
- 老生代:存放生命周期长,占用内存大或从新生代晋升的对象(经过两次垃圾回收还存活,就会被晋升)使用标记-清除(因为内存较大,使用复制效率就会变低)或标记-整理算法(经过垃圾回收之后,内存可能不连续,所以要进行整理)。
聪明的你会发现上面会有一个问题,JS是运行在主线程之上的,一旦开始执行垃圾回收机制,JS脚本就会暂停(全停顿),当垃圾回收开始运行时,需要标记,清理再整理,如果数据过多,JS线程暂停执行的时间就会变长,就会影响到页面的性能和响应,所以就有了优化手段:
- 增量标记 :将标记过程分成多个小步骤,分片执行(类似requestIdleCallback)减少阻塞
- 惰性清理:标记阶段完成之后,不立即执行内存清理工作,而是延迟清理过程,避免长时间阻塞主线程。
新手前端,文中如有错误请指正(抱拳)