什么是JavaScript引擎
我们知道,浏览器内核主要是由两部分组成的:渲染引擎和JavaScript引擎。
渲染引擎:负责HTML解析、布局、渲染等等相关的工作
JavaScript引擎:解析、执行JavaScript代码
JavaScript引擎非常多,我们来介绍几个比较重要的
SpiderMonkey:第一款JavaScript引擎,由Brendan Eich开发(也就是JavaScript作者),在1996年发布
Chakra:Chakra 最初是 Internet Explorer 9 的 JavaScript 引擎,并在后续成为了 Edge 浏览器的引擎,直到 Microsoft 转向 Chromium 架构并采用了 V8。
JavaScriptCore:JavaScriptCore 是 WebKit 浏览器引擎的一部分,主要用于 Apple 的 Safari 浏览器,它也被用在所有 iOS 设备的应用中。
V8引擎:V8 是 Chrome 浏览器和 Node.js 的 JavaScript 引擎,也是我们后续讲解的重点。
接下来以V8为例,讲解一下JavaScript代码具体的执行过程。
V8是怎样帮助我们执行JS代码的呢?
因为我们的代码是需要在CPU中运行的,而CPU只认识机器码,而我们所编写的JS代码是一种高级语言,CPU并不认识它。我们的JS代码需要先转换成汇编代码,然后汇编代码转换成机器码,这样以后CPU才能执行。而这个转换过程就是由V8引擎来完成的。转换过程具体如下
(1)解析 (Parse):JavaScript 代码首先被解析器处理,转化为抽象语法树(AST)。这是代码编译的初步阶段,主要将代码结构转换为V8内部可进一步处理的格式。
(2)AST:抽象语法树(AST)是源代码的树形表示,用于表示程序结构。之后,AST 会被进一步编译成字节码。
(3)Ignition:Ignition 是 V8 的解释器,它将 AST 转换为字节码。字节码是一种低级的、比机器码更抽象的代码,它可以快速执行,但比直接的机器码慢。(字节码可以由Ignition执行,当然底层也是需要转换成机器码的)虽然字节码可以由Ignition运行,但是如果一段代码被反复调用,如果都由Ignition来执行的话,那这段字节码就需要反复转成机器码,这个时候这些字节码就需要经过TurboFan的优化,转换成优化的机器码。
(4)字节码(Bytecode):字节码是介于源代码和机器码之间的中间表示,它为后续的优化和执行提供了一种更标准化的形式。字节码是由 Ignition 生成,可被直接解释执行,同时也是优化编译器 TurboFan 的输入。
(5)TurboFan:TurboFan 是 V8 的优化编译器,它接收从 Ignition 生成的字节码并进行进一步优化。
比如如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能。
当然还会包括很多其他的优化手段,如死代码消除(Dead Code Elimination)等,总之V8有很多手段可以提高代码执行效率。
(6)机器码:经过 TurboFan 处理后,字节码被编译成机器码,即直接运行在计算机硬件上的低级代码。这一步是将 JavaScript 代码转换成 CPU 可直接执行的指令,大大提高了执行速度。
(7)运行时优化:在代码执行过程中,V8 引擎会持续监控代码的执行情况。如果发现之前做的优化不再有效或者有更优的执行路径,它会触发去优化(Deoptimization)。去优化是指将已优化的代码退回到优化较少的字节码状态,然后重新编译以适应新的运行情况。
V8三大模块
那么V8引擎包括哪些部分(部件),它们的作用是什么呢?
(1) Parse模块:Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码
(2)Ignition模块:Ignition模块是一个解释器,会将AST转换成ByteCode(字节码)同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算)。如果函数只调用一次,Ignition会解释执行ByteCode
(3)TurboFan:TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码。比如如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能。但是,机器码实际上也会被去优化为ByteCode( Deoptimization ),这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来进行相加操作的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会进行去优化,转换成字节码
Parse模块是怎么把JavaScript代码转换成AST树的
(1)Blink将源码交给V8引擎,Stream获取到源码并且进行编码转换
(2)Scanner会进行词法分析(lexical analysis),词法分析会将代码转换成tokens
(3) 接下来tokens会被转换成AST树,经过Parser和PreParser。Parser就是直接将tokens转成AST树结构,PreParser称之为预解析
为什么需要预解析PreParser
预解析一方面的作用是快速检查一下是否有语法错误,另一方面也可以进行代码优化
这是因为并不是所有的JavaScript代码,在一开始时就会被执行。那么对所有的JavaScript代码进行解析,必然会影响网页的运行效率。
所以V8引擎就实现了Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容,而对函数的
全量解析是在函数被调用时才会进行
比如我们在一个函数outer内部定义了另外一个函数inner,那么inner函数就会进行预解析
生成AST树后,会被Ignition转成字节码(bytecode)并且可以执行字节码,之后的过程就是代码的执行过程
内存管理
不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可
以自动帮助我们管理内存。不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:
第一步:分配申请你需要的内存(申请)
第二步:使用分配的内存(存放一些东西,比如对象等)
第三步:不需要使用时,对其进行释放
对于开发者来说,JavaScript 的内存管理是自动的。因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。以大部分现代的编程语言都是有自己的垃圾回收机制。垃圾回收的英文是Garbage Collection,简称GC。而JS引擎会有一个 垃圾回收器来帮助我们自动管理内存
GC怎么知道哪些对象是不再使用
(1)常见的GC算法 – 引用计数(Reference counting)
<1>每个对象都有一个关联的计数器,通常称为“引用计数”。
<2>当一个对象有一个引用指向它时,那么这个对象的引用就+1;
<3>如果另一个变量也开始引用该对象,引用计数加1;如果一个变量停止引用该对象,引用计数减1。
<4>当一个对象的引用为0时,这个对象就可以被销毁掉;
这个算法有一个很大的弊端就是会产生循环引用,当然我们可以通过一些方案,比如弱引用来解决(WeakMap就是弱引用)
(2)常见的GC算法 – 标记清除(mark-Sweep)
标记清除的核心思路是可达性(Reachability)
<1>垃圾回收器标记所有对象为不可达的对象。
<2>垃圾回收器从根对象开始遍历所有的对象,遍历的过程中将遍历到的对象标记为可达的对象,随后收集那些在标记阶段未被标记为可达的对象。这些对象被视为垃圾,垃圾回收器将会对这些垃圾进行回收。
这个算法可以很好的解决循环引用的问题。当然除了标记清除法之外,类似于V8引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法。
(3)标记整理法:由于标记清除法在回收垃圾的时候,会产生不连续的内存碎片,这些内存碎片导致的问题是,内存的浪费,因为如果现在需要为一个比较大的对象分配内存空间,但是现在可用的内存空间都是碎片化的,这可能导致没有足够大的连续内存空间分配给这个大对象,这就造成了内存的浪费。
所以这个时候就要用到标记整理法。他的清除逻辑和标记清除算法基本相似不过进行了优化,它会在清除结束之后将活着的对象进行整理,向一端移动,同时清理掉内存的边界。以上的标记清除法和标记整理法适用于大的对象,或者是老的对象。而对于新创建的小对象,V8采用的是分代回收的策略。
(4)分代垃圾回收
对于新创建的小对象,这些对象被认为是存活时间较短的对象,这些对象被存放在新生代空间中,如上图的(Young geeration)部分的空间,可以发现,这块空间又分成了左右两块相等大小的空间。其中一块称为from空间,另一块称为To空间,新创建的对象会放入到from空间中,当From空间快被写满时,就需要执行一次垃圾清理操作。
在垃圾回收过程中,首先要对From空间中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,把存活的对象复制到To空间,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
完成复制后,From空间和To空间进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
对象晋升策略
当新生代中的对象经过2次垃圾回收(也就是2次从from空间到to空间)之后,第三次垃圾回收的时候如果这些对象仍然不需要被回收,那么它们就会被复制到老生代中。而老生代的垃圾回收则是由主垃圾回收机制进行管理的,也就是使用标记清除法。
增量标记
如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,这会让js代码在执行过程中产生明显的延迟。所以引擎试图将垃圾标记工作分成多个子过程来做。同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,这样用户就不会感觉到明显的卡顿了我们把这个算法称为增量标记(Incremental Marking)算法
闲时收集
垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
V8引擎详细的内存图解析
<1> 新生代空间 (New Space / Young Generation)
作用:主要用于存放生命周期短的小对象。这部分空间较小,但对象的创建和销毁都非常频繁。
组成:新生代内存被分为两个半空间:From Space 和 To Space。
新生代空间垃圾回收过程:
(1)初始时,对象被分配到 From Space 中。
(2)使用复制算法(Copying Garbage Collection)进行垃圾回收。当进行垃圾回收时,活动的对象(即仍然被引用的对象)被复制到 To Space 中,而非活动的对象(不再被引用的对象)被丢弃。
(3)完成复制后,From Space 和 To Space 的角色互换,新的对象将分配到新的 From Space 中,原 To Space 成为新的 From Space。
<2> 老生代空间 (Old Space / Old Generation)
作用:存放生命周期长或从新生代晋升过来的对象。当对象在新生代中经历了一定数量的垃圾回收周期后(通常是两次),且仍然存活,它们被认为是生命周期较长的对象。
分为二个主要区域:
(1)老指针空间 (Old Pointer Space):主要存放包含指向其他对象的指针的对象。
(2)老数据空间 (Old Data Space):用于存放只包含原始数据(如数值、字符串)的对象,不含指向其他对象的指针。
<3> 大对象空间 (Large Object Space):用于存放大对象,如超过新生代大小限制的数组或对象。这些对象直接在大对象空间中分配,避免在新生代和老生代之间的频繁复制操作。
<4> 代码空间 (Code Space) :存放编译后的函数代码。
<5>单元空间 (Cell Space):用于存放小的数据结构,比如闭包的变量环境,比如AO对象。
<6> 属性单元空间 (Property Cell Space):存放属性值。主要针对全局变量或者经常被访问的对象属性值,对于访问频繁的全局变量或者局部对象的属性值来说,V8在这里存储是为了提高它的访问效率。
<7> 映射空间 (Map Space):存放对象的映射(即对象的类型信息,描述对象的结构)。
当你定义一个 Person 构造函数时,可以通过它创建出来person1和person2。
这些实例(person1 和 person2)本身存储在堆内存的相应空间中,具体是新生代还是老生代取决于它们的生命周期和大小。
每个实例都会持有一个指向其映射的指针,这个映射指明了如何访问对象中的属性,比如 name 和 age属性(目的是访问属性效果变高)
比如说,你的person1里面只有name和age属性,而你想访问person1.address,V8可以通过查看这个映射空间来快速确定该对象是否有该属性,而不需要真正去查找对象的属性。
堆内存 (Heap Memory) 与 栈 (Stack)
堆内存:JavaScript 对象、字符串等数据存放的区域,按照上述分类进行管理。
栈:用于存放执行上下文中的变量、函数调用的返回地址(继续执行哪里的代码)等,栈有助于跟踪函数调用的顺序和局部变量