摘要:极客时间《浏览器工作原理与实践》 V8 工作原理学习笔记,栈空间和堆空间、垃圾回收、编译器和解释器
栈空间和堆空间
JavaScript 是一种弱类型的、动态的语言。支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言。在使用之前就需要确认其变量数据类型的称为静态语言,而在运行过程中需要检查数据类型的语言称为动态语言。
JavaScript 中的数据类型一种有 8 种:
内存空间
JavaScript 的内存模型一共分为三部分:代码空间、栈空间、堆空间。原始类型的数据值都是直接保存在“栈”中的,而引用类型在栈空间中只是保留了对象的引用地址,引用类型的值是存放在“堆”中的。
通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了的话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。大的数据一般存放在堆空间。
JavaScript 中的赋值操作是:原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址,也就是将栈空间的数据复制一份。
闭包
1 | function fna() { |
站在内存模型的角度来分析这段代码的执行流程:
1、当 JavaScript 引擎执行到 fna 函数时,首先会编译,并创建一个空执行上下文 fna,然后将其压入调用栈 Call Stack;
2、在编译过程中,遇到内部函数 getTest,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 fna 函数中的 test 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“Closure (fna)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 test 变量。
3、返回 b 后,执行上下文 fna 出栈
4、当调用 getTest 方法时,会按照作用域链 [[Scopes]] 的顺序开始查找,先查找 Closure (fna) 再查 Global。
有这个过程可以看出产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中。
有趣的是,将函数 fna 改为块级作用域,就是函数级作用域内部引用外部块级作用域的变量
1 | { |
垃圾回收
如果垃圾数据没有回收,堆内存由于某种原因程序未释放或无法释放,造成内存泄漏,会导致程序运行速度减慢甚至系统崩溃等严重后果。垃圾数据回收分为手动回收和自动回收两种策略。C/C++ 就是使用手动回收策略,何时分配内存、何时销毁内存都是由代码控制的。JavaScript、Java、Python 等语言采用的是自动垃圾回收的策略,产生的垃圾数据是由垃圾回收器来释放,无需手动通过代码来释放。原始数据类型是存储在栈空间中的,引用类型的数据是存储在堆空间中的,那么垃圾回收也分为栈中的垃圾数据回收和堆中的垃圾数据回收两种。
栈中的垃圾数据回收
调用栈有一个用于记录当前执行状态的指针 ESP,它指向调用栈中某个函数的执行上下文,就表示当前正在执行该函数。当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。虽然该函数的执行上下文仍保存在栈内存中,但是已经是无效内存,因为当再次调用另外一个函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文。
堆中的垃圾数据回收
垃圾回收的策略都是建立在代际假说(The Generational Hypothesis)的基础之上,代际假说有两个特点:
1、大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
2、不死的对象,会活得更久
V8 中把堆分为新生代和老生代两个区域。新生代中存放的是生存时间短的对象,它通常只支持 1~8M 的容量。而老生代中存放生存时间久的对象,它支持的容量就大很多了。同时,V8 也分别使用两个不同的垃圾回收器来处理这两块区域,以便更高效地实施垃圾回收:副垃圾回收器主要负责新生代的垃圾回收,主垃圾回收器主要负责老生代的垃圾回收。
垃圾回收器的工作流程
不论什么类型的垃圾回收器,它们都有一套共同的执行流程:
1、标记空间中活动对象和非活动对象;
2、回收非活动对象所占据的内存;
3、做内存整理。
副垃圾回收器
副垃圾回收器主要负责新生区的垃圾回收,大多数小的对象都会被分配到新生区,垃圾回收比较频繁。新生代中用 Scavenge 算法来处理:把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。
工作流程:新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来。这个复制过程,就相当于完成了内存整理操作,复制后空闲区域就没有了内存碎片。接着对象区域与空闲区域进行角色翻转,原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
每次执行清理操作时,都需要复制对象从对象区域到空闲区域,很耗时。为了执行效率,新生区的空间一般都比较小。而且 JavaScript 引擎采用了对象晋升策略,如果经过两次垃圾回收依然还存活的对象,就会被移到老生区。
主垃圾回收器
主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。主垃圾回收器是采用标记-清除(Mark-Sweep)的算法进行垃圾回收的。
工作流程:
1、标记过程阶段。从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据;
2、清除过程阶段。将内存中没有被标记为活动对象的元素清除掉。
当对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片。而碎片过多会又会导致大对象无法分配到足够的连续内存,于是又产生了标记-整理(Mark-Compact)算法。标记-整理算法与标记-清除算法的标记过程是一样的,但后续步骤不是对垃圾数据直接清除,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
由于 JavaScript 是运行在主线程之上,一旦执行垃圾回收算法,正在执行的 JavaScript 脚本都要暂停,待垃圾回收完毕后再恢复脚本执行,这叫做全停顿(Stop-The-World)。为了不让用户因为垃圾回收任务而感受到页面卡顿,V8 采用增量标记(Incremental Marking)算法,将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成。
编译器和解释器
编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件。每次运行程序时,都直接运行该二进制文件。如果编译过程发生错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。
解释型语言在每次运行时都需要通过解释器对程序进行动态解释和执行。
V8 执行代码
V8 在执行过程中既有解释器 Ignition(点火器),又有编译器 TurboFan(涡轮增压)。V8 依据 JavaScript 代码生成 AST 和执行上下文,再基于 AST 生成字节码,然后通过解释器执行字节码,通过编译器来优化编译字节码。具体执行过程:
1、生成抽象语法树(AST)和执行上下文
先是分词(tokenize)即词法分析,将源码拆解成一个个 token。token 是指语法上不可能再分的、最小的单个字符或字符串。比如var a = 'test'
可分为四个 token:关键字“var”、标识符“a” 、赋值运算符“=”、字符串“test”
然后是解析(parse)即语法分析,根据语法规则将前面分词得到的 token 数据转为 AST。之后生成执行上下文。
2、生成字节码
解释器 Ignition 根据 AST 生成字节码,并解释执行字节码。字节码就是介于 AST 和机器码之间的一种代码,字节码需要通过解释器将其转换为机器码后才能执行。
3、执行代码
通常情况下,解释器 Ignition 会逐条解释执行一段代码。但如果发现有被重复执行多次的热点代码(HotSpot),那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,之后就可以执行该段代码的机器码,这样就提升了执行效率。字节码配合解释器和编译器这项技术被称为即时编译(JIT),JIT 的工作过程: