(转载)全面分析总结JS内存模型
原文链接:
https://juejin.im/post/5e6a1f406fb9a07cae13781e
数据类型与内存
数据类型分类
主要分为两大类:基本数据类型、复杂数据类型,详细分类如下。
- 基本数据类型:
String、Number、Boolean、Null、Undefined、Symbol
- 复杂数据类型:
Object 以及所有继承自 Object 的类型
对于不同的数据类型有不同的内存区域存储数据,基本数据类型直接存储在栈内存,复杂数据类型存储在堆内存。
内存分类
JS 中的内存分类与 JS 引擎有关,在浏览中一般是 V8 引擎;要进行内存区分主要是为了进行垃圾回收,比如在 V8 的垃圾回收机制中会根据新生代、老生代内存采用不同回收算法来保证垃圾回收效率。
JS 内存空间分为栈(stack)内存和堆(heap)内存,栈内存是栈结构存储基本数据类型和指向堆内存的指针,堆内存存储复杂数据类型。
变量声明与赋值
核心点总结
- 变量声明的本质是变量名与栈内存地址进行绑定,不直接与堆内存进行绑定。
- 声明的基本数据类型会将值存储在栈内存中,声明的复杂数据类型会将值存储在堆内存中并将其在堆中的内存地址作为值存到栈内存中。
- const 声明常量本质是指的是声明的变量名所指向的栈内存地址不可改变,但是栈中对应的值可以改变。
- 基本数据类型赋值是在栈内存中申请新的内存区域保存值并将其指向的内存地址绑定到原有变量上。
- 复杂数据类型赋值是在堆内存中申请新的内存区域保存值并将其指向的内存地址作为值在栈内存中申请新的内存区域保存将其在栈中的内存地址绑定到变量上。
详解变量声明与赋值
基本数据类型
1 | let index = 23; |
基础数据类型直接将值存储在栈内存中,变量绑定到值在栈中对应的地址。
1 | let _index = index; |
声明另一个变量 _index
并赋值为 index,其实是将 _index
和 index
变量绑定到 index
指向的内存地址。
1 | index = 45; |
修改变量 index
的值为基本数据类型,其实是在栈内存中分配内存存储值然后将得到的内存地址绑定到变量 index
。
复杂数据类型
1 | let students = []; |
复杂数据类型在声明时是在堆内存上分配内存空间存储其值,将分配的堆内存空间地址作为值存储在栈内存上,变量直接绑定的是栈上内存地址。
通过引用来修改复杂数据
1 | let _students = students; |
_status = students
赋值语句只是将两个变量指向同一个栈内存地址,push()
语句将在堆内存中分配新空间存储新的数组并将其在堆内存的地址存储到栈中。
更复杂的例子
1 | let obj = { name: '小明' }; |
[obj]
属于复杂类型中引用复杂类型是通过指针引用处理,虽然通过 obj=null
来清除了 obj
对于对象 {index:'小明'}
的绑定,但是 arr
对该对象任然存在引用。
详解常量声明与赋值
声明基本数据类型为常量过程与基本数据类型的声明过程相同
1 | const index = 1; |
对声明为基本数据类型的常量进行赋值会发生结果
在将 index
变量绑定到新产生的内存地址时报错:不允许修改常量绑定的内存地址。
声明复杂数据类型为常量过程与基本数据类型的声明过程相同。
1 | const students = []; |
对声明为复杂数据类型的常量进行赋值会产生如下结果
在将 students
变量绑定到新产生的内存地址时报错:不允许修改常量绑定的内存地址。
深复制与浅复制
上面说的复杂数据类型通过指针指向了同一块堆内存空间,深、浅复制主要区别就在于复制值的时候是否新分配堆内存空间来保存原值的拷贝。
对于对象或数组类型,当我们将 a 赋值给 b,然后更改 b 中的属性,a 也会随着变化。 也就是说 a 和 b 指向了同一块内存,所以修改其中任意的值,另一个值都会随之变化,这就是 浅复制(拷贝)。
深复制(拷贝) 则是在上述 a 赋值给 b 过程分配了新堆内存空间来存储拷贝的值,同时在存在复杂数据类型的嵌套属性(递归遍历)也要用同样方式处理,最后复制出来的新数据对象下的任意层级的复杂对象都有新的堆内存存储相应的值。
垃圾回收
有内存就必然有 垃圾回收(GC),JS 中栈内存多数是在函数执行时使用(根据函数调用顺序也叫做调用栈),函数执行完后即开始栈内存的垃圾回收。堆内存由于存在多个栈内存中的指针指向它以及堆内存较大等原因,需要采用特定的垃圾回收算法处理。
垃圾回收的关键在于如何判断内存已经不再使用然后将其释放掉
引用计数算法
主要是 IE 等旧浏览器在采用,通过计数器分析变量的引用次数,清除没有引用到的变量。对于存在循环引用的情况则无法处理,比如:
1 | function cycle() { |
其中 o1 引用了 o2,o2 引用了 o1,在 cycle 函数执行完 o1,o2 都没有再次引用到,但是引用计数算法判断两者都存在引用。
Scavenge 算法
用于 V8 中新生代内存,将新生代内存一分为二:From 和 To,在 From 与 To 之间转换的过程中完成垃圾回收。
标记清除算法
早期 V8 中堆内存采用的一种清除算法,全局扫描堆内存找出未使用到的对象进行标记并清除,由于未进行内存整理会存在内存碎片。
标记整理算法
全局扫描堆内存找出未使用到的对象边整理边清除,解决了标记清除算法导致的内存碎片问题。
增量式清除、整理
堆内存大小一般较大,在采用前几种算法进行垃圾回收时需要扫描全堆,导致 JS 执行逻辑长时间暂停。增量式清除、整理是将标记清除或标记整理拆分为一个步进,轮流执行 JS 逻辑和一个步进,最大程度较少 JS 执行逻辑暂停时间。
实例分析
以在浏览器控制台运行下面这段代码为例(暂时不考虑 ES6 语法兼容性)。
1 | const fn = (arr) => { |
变量声明(依次执行)
依次执行:堆内存储函数 fn、栈内存存储常量整数 index、堆内存存储数组 array
调用函数(函数调用栈)
依次执行:变量 arr、_arr
指向数组 array、存储 join 方法返回的字符串到栈内存
清除函数调用栈
函数调用栈中的变量 arr、_arr
属于函数作用域,此时已经不可访问将被清除。
清除堆栈内存
整个 JS 逻辑执行完成,函数 fn、常量 index、数组 array、函数 fn 返回的字符串都将会清除。
若在浏览器控制台中,执行完上述 JS 逻辑并未退出控制台,上述清除堆栈内存将在关闭控制台后执行。
常见问题分析
闭包导致内存泄露
闭包就是通过返回一个函数间接地使外部有机会访问到函数内部的变量,扩展了 JS 中函数作用域的范围。
创建闭包方法如下:
1 | const generateFn = () => { |
1 | const obj2 = generateFn()(); // 此时 obj2 就指向了上面定义的 obj |
下面用法会导致内存泄露:
1 | window.fn = generateFn(); // 返回的函数绑定至了全局,没有主动清除 |
generateFn 生成了一个引用 obj 的函数,同时将其绑定至了全局对象 window,导致 fn 不会被回收,而 fn 引用了 obj 使 obj 也不会被回收,于是产生了内存泄露。
WeakSet、WeakMap 的弱引用
WeakSet 和 WeakMap 是 ES6 中两种新的数据结构,它们对于值的引用都不计入垃圾回收机制。WeakSet 只能存储不重复的对象,WeakMap 只能以对象为 key 来存储 key-value 对。对应对象在外部变为不可访问时,其对应的存储记录也将自行丢失。
WeakMap 分析
通过分析下面这段代码来说明其弱引用的特性。
1 | let obj = { index: 0 }; |
将 obj 对象添加进 WeakSet 实例中,此时可以通过 obj 和 vs 变量来访问到 obj 对象,通过 obj=null 清除对象,此时 vs 中的 obj 对象的引用也会自动清除。
在 GC 完成之后(这里直接 console.log 打印还是可以看到 obj 对象的,因为 GC 没有完成),可以看到 vs 的 items 是空的。
另外,在研读 React 源码的过程中发现其中 DOMEventListenerMap.js 中有对 WeakMap 实际应用,下篇文章将会深入研究一下 WeakMap、WeakSet 等实际的应用。