本文是极客时间上《浏览器工作原理与实践》课程的学习笔记。
栈空间和堆空间
如果想学好前端,那么就必须要搞清楚 JavaScript 的内存机制。
JavaScript 是什么类型的语言
我们把使用之前需要确认其变量数据类型的称为静态语言。
我们把运行过程中需要检查数据类型的语言称为动态语言。
而 JavaScript 就是一种动态语言。
我们把支持隐式类型转换的语言称为弱类型语言。
我们把不支持隐式类型转换的语言称为强类型语言。
所以 JavaScript 是弱类型语言。
JavaScript 变量的类型是可变的,判断变量类型,可以通过 typeof
运算符。这里需要注意运算符对于值为 null 的变量显示的结果也是 object
。这是 JavaScript 的历史 bug,需要知道一下。
JavaScript 类型
JavaScript 一共有 8 中类型
- Boolean
- Null
- Undefined
- Number
- String
- Symbol
- Object
- BigInt —— JavaScript 最新的类型,提供了一种方法来表示大于 253 - 1 的整数。
其中 Object 是以键值对的形式出现的。而且对象类型被称为引用类型,而其他 7 种类型被称为原始类型。因为它们在内存中存放的位置是不一样的。
引用类型存在堆中,原始类型存在栈中。
内存空间
JavaScript 执行过程中一共有三种类型的内存
- 代码空间:存储可执行代码的。
- 栈空间:它就是调用栈。
- 堆空间:一个更大的存储数据空间。
原始类型的数据都是保存在栈中的,引用类型的数据都是保存在堆中的。
赋值过程
当赋值行为是原始类型时,会在调用栈中进行变量的创建和赋值操作。
当赋值行为是引用类型时,会将数据分配到堆空间里面,分配后该对象会有一个堆中的地址,然后在将该数据地址赋值给栈中的变量。
所以,原始类型赋值的是数据,而引用类型赋值的只是一个引用地址。
为什么要分堆栈
因为栈除了保存变量,还需要处理程序执行期间的上下文状态。不宜过大,影响性能。
而堆空间很大,能存放很多大的数据。
原始类型的赋值会完整赋值变量值,而引用类型的赋值是复制引用地址。
闭包也是存在于堆空间的
- 在代码编译过程中,如果 JavaScript 引擎判断函数中形成了闭包,会在堆空间创建一个
closure(foo)
的对象,用来保存闭包所需的变量。 - 当外部函数执行完毕后,外部函数执行上下文被销毁,但是闭包内的变量还保存在堆中。
简单来说,产生闭包的核心有两步:
- 需要扫描内部函数,判断是否产生闭包。
- 把内部函数引用的外部变量保存到堆中
closure(foo)
。
查看堆内存的方法
你可以:
1:打开“开发者工具”
2:在控制台执行上述代码
3:然后选择“Memory”标签,点击"take snapshot" 获取V8的堆内存快照。
4:然后“command+f"(mac) 或者 "ctrl+f"(win),搜索“setName”,然后你就会发现setName对象下面包含了 raw_outer_scope_info_or_feedback_metadata,对闭包的引用数据就在这里面。
关于深拷贝
- lodash 的 deepClone。
- JSON.parse(JSON.stringify(obj)) 但是这个不适用于有函数的。
- 递归遍历对象,这个还不如用 lodash
- Object.assign() 方法可以拷贝一层。
垃圾回收
通常情况下,垃圾数据回收分为手动回收和自动回收两种策略。
如 C/C++ 就是使用手动回收策略。
如 JavaScript、Java、Python 就是自动回收策略,产生的垃圾数据是由垃圾回收器来释放的。
调用栈中的数据时如何回收的
首先知道下记录当前执行状态的指针(称为 ESP)
当执行函数时,比如函数1中调用函数2,那么函数1会先被压入调用栈,然后是函数2入栈。这时候 ESP 是指向函数2的。当函数2执行完成后,ESP 就下移指向了函数1,而这个下移操作就是销毁函数2的执行上下文的过程。
当 ESP 下移到函数1时,虽然函数2还在调用栈中,但是它已经是无效(垃圾)内存了。当函数1中再调用函数3时,函数2的内存就会被直接覆盖掉,用来存放另外一个函数的执行上下文。
所以说,JavaScript 引擎会通过向下移动 ESP 来销毁函数保存在栈中的执行上下文。
堆中的数据时如何回收的
要回收堆中的垃圾数据,就需要用到 JavaScript 中的垃圾回收器了。
代际假说
- 大部分对象在内存中存在的时间很短,简单来说就是很多对象一经分配内存,很快就变得不可访问。
- 不死的对象,会获得更久。
垃圾回收算法有很多种,各有优劣。
所以,在 V8 中会把堆分为新生代和老生代两个区域。新生代中存放是生存时间短的对象,老生代中存放的是生存时间久的对象。
新生代通常只支持 1-8M 的容量,而老生代的容量就大很多。对于两代区域,V8 提供了两个不同的垃圾回收器,以便更高效的实施垃圾回收。
- 副垃圾回收器,主要负责新生代的垃圾回收。
- 主垃圾回收器,主要负责老生代的垃圾回收。
垃圾回收器的工作流程
不论什么类型的垃圾回收期,它们都有一套共同的执行流程。
- 标记空间中活动对象和非活动对象。
- 回收非活动对象所占据的内存。
- 内存管理。
副垃圾回收器
分为对象区域和空闲区域,新加入的对象都会被存放到对象区域。当对象区域快写满时,进行垃圾清理操作。
首先对对象区域内的垃圾做标记,标记完成后,把非垃圾的存活的对象复制到空闲区域中有序排列起来。完成复制后对象区域和空闲区域的角色反转。
这种角色反转的操作还能让新生代中的两块区域无限重复使用下去。
对象晋升策略
由于新生代区域空间不大,所以当经过两次垃圾回收依然存活的对象,会被移动到老生代区域中。
主垃圾回收期
特点
- 对象占用空间大
- 对象存活时间长
主垃圾回收期是采用标记 - 清除 的算法进行垃圾回收的。
标记:对调用栈进行递归遍历,遍历过程中,能到达的元素成为活动对象,没有达到的元素可以判断为垃圾数据。
清除:直接清除掉标记为垃圾数据所占内存的内容。
由于清除过程不像是副垃圾回收器那样整理排序,所以会出现大量不连续的内存碎片。于是产生了另外一种算法 —— 标记-整理
标记:标记出所有活动数据。
整理:将所有数据向一端移动。
全停顿
全停顿:由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再回复脚本执行。
这显然会影响代码运行的速度,新生代的垃圾回收内存小还好说,老生代的往往会有占用大内存的对象,这会引起明显的卡顿。那么如何解决这个问题呢?
答案是使用增量标记算法:将完整的 标记-清除-整理 过程拆分为一个个单独的小任务,并且穿插在 JavaScript 任务中间执行,这样就大大降低了垃圾回收所带来的的延迟卡顿。
如何判断 JavaScript 是否有内存泄漏
通过chrome的Perfomance面板记录页面的活动,然后在页面上进行各种交互操作,过一段时间后(时间越长越好),停止记录,生成统计数据,然后看timeline下部的内存变化趋势图,如果是有规律的周期平稳变化,则不存在内存泄漏,如果整体趋势上涨则说明存在内存泄漏。
编译器和解释器
编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译后会直接保留机器能够读懂的二进制文件,这样之后每次运行程序直接读二进制文件就不需要再进行编译了。如 Java、C。
解释型语言编写的程序,每次运行程序都需要通过解释器对代价进行动态解释和执行。如 Python、JavaScript。
编译过程
编译型语言:词法分析 - 语法分析 - 生成抽象语法树 - 优化代码 - 生成二进制代码 - 执行二进制代码
解释型语言:词法分析 - 语法分析 - 生成抽象语法树 - 生成字节码 - 执行程序
V8 是如何执行一段 JavaScript 代码的?
在 V8 执行 JavaScript 过程中,既有解释器,又有编译器。
- 生成抽象语法树(AST)和执行上下文
- 生成字节码 —— 使用字节码而不是直接编译成机器码是因为字节码可以减少系统的内存使用。
- 执行代码
关于 AST
类似于 HTML 和 DOM 树,JavaScript 属于方便程序员理解的高级语言,而对于机器理解起来就很困难。所以要将高级语言转为抽象语法树。
其实不止是 JavaScript 在使用 AST,很多其他语言也会使用。而且像 babel、eslint 这类常用工具也是通过 AST 来分析语法的。
AST 的解析过程是先对代码进行分词,再解析成 AST。
如果要看 JavaScript 代码的 AST 语法树可以看下这个 https://resources.jointjs.com/demos/javascript-ast 链接。
代码执行
当 JavaScript 从 AST 被解释器转为字节码后,会逐条解释并执行字节码。
热点代码:出现一段代码重复执行多次的代码,那么后台的编译器就会将这段热点代码从字节码编译为更高效的机器码。这样再遇到热点代码直接执行机器语言就好了。
上面的技术被称为“即时编译(JIT)”。
所以说,V8 执行时间越久,执行效率越高。
JavaScript 的性能优化
对于当前的 V8 技术,主要关注以下三点优化内容:
- 提升单次脚本的执行速度,避免 JavaScript 的长任务霸占主线程。
- 避免大的内联脚本(<script></script>),因为在解析 HTML 的过程中,解析和编译也会占用主线程。
- 减少 JavaScript 文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。