当前位置: 首页 > 科技观察

V8是如何执行JavaScript代码的?

时间:2023-03-19 14:52:54 科技观察

大家好,我是CUGGZ。今天分享一下V8引擎执行JavaScript的原理和垃圾回收机制。1.JS代码执行过程在讲V8执行JavaScript代码的机制之前,我们先来看看编译型语言和解释型语言的区别。(1)编译型语言和解释型语言我们知道机器无法直接理解代码。因此,在执行程序之前,需要将代码翻译成机器可以理解的机器语言。根据语言的执行流程,计算机语言可以分为编译型语言和解释型语言:编译型语言:在代码运行之前,编译器直接将相应的代码转换成机器码。使用编译结果;解释型语言:需要将代码转换为机器码,与编译型语言的区别在于需要在运行时进行转换。解释型语言的执行速度比编译型语言慢,因为解释型语言每次执行都需要对源代码进行一次转换。Java、C++等语言是编译型语言,而JavaScript是解释型语言,其整体执行速度会比编译型语言稍慢。V8是众多浏览器的JS引擎中性能最好的一个,是Chrome的核心。Node.js也是基于V8引擎开发的。编译型语言和解释型语言代码执行的具体流程如下:两者的执行流程如下:在编译型语言的编译过程中,编译器会先对源代码依次进行词法分析和语法分析,并生成抽象语法树(AST),然后优化代码,最后生成处理器可以理解的机器码。如果编译成功,将生成一个可执行文件。但是,如果在编译过程中出现语法或其他错误,编译器就会抛出异常,最终的二进制文件将无法生成成功。解释器在解释解释型语言的过程中,还会对源代码进行词法分析和语法分析,生成抽象语法树(AST),但是会根据抽象语法树生成字节码,最后根据word段代码执行程序并输出结果。(2)V8执行代码过程V8在执行过程中使用了解释器和编译器。其执行过程如下:解析阶段:V8引擎将JS代码转化为AST(抽象语法树);点火阶段:解释器将AST转化为字节码,字节码的解析和执行也会在下一阶段信息中提供优化编译的要求;TurboFan阶段:编译器利用前一阶段收集到的信息,将字节码优化成可执行的机器码;Orinoco阶段:垃圾回收阶段,回收程序中不再使用的内存空间。这里的前三步是JavaScript的执行过程,最后一步是垃圾回收的过程。我们来看看V8执行JavaScript的过程。①生成抽象语法树的过程就是将源代码转化为抽象语法树(AST),并生成执行上下文,即代码执行过程中的环境信息。将JS代码解析成AST主要分为两个阶段:词法分析:这个阶段会将源代码拆分成最小的、不可分割的词法单元,称为token。例如,代码vara=1;通常被分解为五个词汇单元:var,a,=,1,;。JavaScript中直接忽略代码中的空格。简单的说就是将JavaScript代码解析成令牌(Tokens)。语法分析:这个过程就是将上一步生成的token数据按照语法规则转换成AST。如果源代码符合语法规则,这一步就顺利完成。如果源代码中存在语法错误,则此步骤将终止并抛出语法错误。简而言之,将标记组装成抽象语法树(AST)。通过词法分析,将代码逐字符解析,生成类似如下结构的记号(Token)。这些token类型各不相同,包括关键字、标识符、符号、数字等,代码vara=1;会被转换成如下的token:Keyword(var)Identifier(name)Punctuator(=)Number(1)在解析阶段,token会被用来生成一个抽象语法树。在树生成过程中,根据语法规则删除并生成不必要的符号标记。我们来看两段代码://第一段代码vara=1;//第二段代码functionsum(a,b){returna+b;}将这两段代码转换成AST抽象语法树返回的JSON如下:第一段代码,编译结果:{“类型”:“程序”,“开始”:0,“结束”:10,“正文”:[{“类型”:“VariableDeclaration”,“开始”:0,“结束”:10,“声明”:[{“类型”:“VariableDeclarator”,“开始”:4,“结束”:9,“id”:{“类型”:“标识符”,“开始”:4,“结束”:5,“名称”:"a"},"init":{"type":"Literal","start":8,"end":9,"value":1,"raw":"1"}}],"kind":"var"}],"sourceType":"module"}其结构大致如下:第二段代码,编译结果:{"type":"Program","start":0,"end":38,"body":[{"type":"FunctionDeclaration","start":0,"end":38,"id":{"type":"Identifier","start":9,"结束”:12,“名称”:“sum"},"expression":false,"generator":false,"async":false,"params":[{"type":"Identifier","start":14,"end":15,"name":"a"},{"type":"标识符","start":16,"end":17,"name":"b"}],"body":{"type":"BlockStatement","start":19,"end":38,"body":[{"type":"ReturnStatement","start":23,"end":36,"argument":{"type":"BinaryExpression","start":30,"end":35,"left":{"type":"Identifier","start":30,"end":31,"name":"a"},"operator":"+","right":{"type":"标识符","start":34,"end":35,"name":"b"}}}]}}],"sourceType":"module"}其结构大致如下:可以看出,AST只是源代码语法结构的抽象表示,而计算机不会直接去识别JS代码,将其转化为抽象语法树只是识别过程的第一步。AST的结构与代码的结构非常相似。其实AST也可以看作是代码的结构化表示。编译器或者解释器后续的工作都需要依赖AST。AST的应用场景:AST是一种非常重要的数据结构,很多地方都会用到AST。比如在Babel中,Babel是一个代码转码器,可以将ES6代码转成ES5代码。Babel的工作原理是先将ES6源码转为AST,再将ES6语法AST转为ES5语法AST,最后使用ES5AST生成JavaScript源码。除了Babel,ESLint还使用了AST。ESLint是一个检查JavaScript编写规范的插件。它的检测过程也需要将源代码转换成AST,然后用AST来检查代码的规范性。除了以上应用场景,AST的应用场景还有很多:JS反编译,语法分析;代码高亮;关键词匹配;代码压缩。②生成字节码有了抽象语法树AST和执行上下文之后,就轮到解释器出现了,它会根据AST生成字节码,并解释执行字节码。在V8的早期版本中,直接通过AST转为机器码。直接将AST转换为机器码存在一些问题:直接转换会导致内存占用过大的问题,因为所有的抽象语法树都是生成为机器码的,而机器码比字节码占用的内存要多得多;有些JavaScript使用场景更适合使用解释器,解释器解析成字节码,有些代码不需要生成机器码,从而尽可能减少内存占用过多的问题。为了解决内存使用问题,字节码被引入到V8引擎中。那么什么是字节码呢?为什么内存使用问题可以通过引入字节码来解决?字节码是介于AST和机器码之间的代码。它需要转换成机器码才能执行。字节码是对机器码的抽象描述。与机器码相比,它的代码量更小,可以减少内存消耗。解释器除了可以不经优化地快速生成字节码外,还可以执行部分??字节码。③生成机器码生成字节码后,进入执行阶段。其实这一步就是将字节码生成机器码。一般来说,如果字节码是第一次执行,解释器会一个一个解释执行。在字节码执行过程中,如果发现热点代码(重复执行的代码,并且运行次数超过一定阈值,会被标记为热点代码),那么后台编译器会编译热点的字节码转化为高效机器码,然后再次执行这段优化后的代码时,只需要执行编译后的机器码,提高了代码的执行效率。带有解释器和编译器的字节码技术就是即时编译(JIT)。在V8中,就是解释器在解释和执行字节码的同时收集代码信息。当它发现某部分代码变热时,编译器登场,将热字节码转换为机器码,并将转换后的机器码保存起来以备后用。因为V8引擎是多线程的,编译器的编译线程和生成的字节码不会在同一个线程上,这样可以和解释器配合使用,互不影响。以下是JIT技术的工作机制:解释器得到AST后,根据需要进行解释执行。也就是说,如果一个函数没有被调用,它就不会被解释执行。在这个过程中,解释器会收集一些重复的、可优化的操作来生成分析数据,然后将生成的字节码和分析数据传递给编译器,编译器会根据分析数据生成高度优化的机器码。优化后的机器代码的功能与缓存非常相似。当解释器再次遇到同样的内容时,可以直接执行优化后的机器码。当然,优化后的代码有时可能会运行失败(比如函数参数类型改变),那么它会再次反优化成字节码,交给解释器。整个过程如下图所示:(3)执行流程优化如果JavaScript代码必须在执行前完全解析,可能会面临以下问题:代码执行时间变长:一次解析所有代码会增加代码时间的运行时间。消耗更多内存:解析出的AST和根据AST编译出的字节码会存放在内存中,会占用更多的内存空间。占用磁盘空间:编译后的代码会缓存在磁盘上,占用磁盘空间。因此,V8引擎采用延迟解析:在解析过程中,只对不会立即执行的函数进行预解析;只有当函数被调用时,函数才会被完全解析。进行预解析时,只校验函数语法是否有效,解析函数声明,判断函数作用域,不生成AST,实现预解析,即Pre-Parser解析器。以下面的代码为例:functionsum(a,b){returna+b;}consta=666;constc=996;总和(1,1);V8解析器从上到下解析代码,当解析器遇到函数声明和,发现并没有立即执行,所以会用Pre-Parser解析器进行预解析。在此过程中,只会解析函数声明,不会解析函数内部代码,也不会生成函数内部代码。AST。然后解释器会将AST编译成字节码并执行。解释器会按照自上而下的顺序执行代码,首先执行consta=666;andconstc=996;,然后执行函数调用sum(1,1),然后Parser解析器会继续解析函数中的代码,生成AST,然后交给解释器编译执行。2.垃圾回收(1)JS内存管理机制。计算机编程语言都运行在相应的代码引擎上。内存的使用过程可以分为以下三个步骤:分配所需的系统内存空间;使用分配的内存进行读取或写入等操作;当不需要内存时,释放或归还其空间。在JavaScript中,当一个变量被创建时,系统会自动为该对象分配相应的内存,见下面的例子:vara=123;//分配堆栈内存给数值变量varetf="ARK";//分配字符串Allocatestackmemory//为对象及其值分配堆内存varobj={name:'tom',age:13};//为数组及其值分配内存vara=[1,null,"str"];//为函数分配内存functionsum(a,b){returna+b;}JavaScript中的数据分为两类:基本类型:这些类型在内存中占据固定的内存空间,它们的值存储在栈空间,可以直接按值访问;引用类型:由于引用类型值的大小不固定,栈内存中的存储地址指向堆内存中的对象,通过引用访问。栈内存中的基本类型,操作系统可以直接处理;而堆内存中的引用类型会经常变化,大小不固定,需要JavaScript引擎通过垃圾回收机制来处理。所谓垃圾回收,就是JavaScript代码在运行时,需要分配内存空间来存放变量和值。当变量不再参与运算时,系统需要回收占用的内存空间。Javascript有一个自动垃圾回收机制,它会定期释放不再使用的变量和对象占用的内存。原理是找到不再使用的变量,然后释放它们占用的内存。JavaScript中有两种变量:局部变量和全局变量。全局变量的生命周期会持续卸载页面;而局部变量是在函数中声明的,它们的生命周期从函数执行开始,直到函数执行结束。在这个过程中,局部变量会把它们的值存放在堆或栈中,当函数执行结束时,这些局部变量不再被使用,它们占用的空间也会被释放。但是,当外部函数使用局部变量时,其中一种情况是闭包。函数执行后,函数外的变量仍然指向函数内的局部变量。这个时候局部变量还在使用,所以不会被回收。.(2)V8垃圾回收流程首先我们看一下Chrome浏览器的垃圾回收流程:①使用V8目前使用的可访问性算法来判断堆中的对象是否为活动对象。该算法使用一些GCRoots作为初始存活对象的集合,从GCRoots对象开始,遍历GCRoot中的所有对象:遍历GCRoot的对象是可访问的,必须保证这些对象应该是可访问的保存在内存中,可访问的对象称为活动对象;没有被GCRoots遍历过的对象是不可访问的,这些不可访问的对象可能会被回收,不可访问的对象称为非活动对象。②回收非活动对象占用的内存,实际上就是在所有标记完成后,将内存中标记为可回收的对象全部清理掉。③内存整理一般来说,对象被频繁回收后,内存中会出现大量不连续的空间,这些不连续的内存空间称为内存碎片。内存中出现大量内存碎片后,如果需要分配大块连续内存,可能会出现内存不足的情况,所以最后一步就是对这些内存碎片进行整理。这一步其实是可选的,因为有些垃圾收集器不会产生内存碎片。以上就是一般的垃圾回收流程。目前V8使用两个垃圾收集器:主垃圾收集器和辅助垃圾收集器。我们来看看V8是如何实现垃圾回收的。在V8中,堆被分为两个区域:新生代和老年代。新生代存放生命周期短的对象,老年代存放生命周期长的对象:新生代通常只支持1-8M的容量,而老年代支持的容量要大得多。针对这两个方面,V8使用了两种不同的垃圾收集器来更高效地实现垃圾收集:二级垃圾收集器:负责新生代的垃圾收集。PrimaryGarbageCollector:负责老年代的垃圾回收。①副垃圾收集器(新生代)辅助垃圾收集器主要负责新生代的垃圾收集。大多数对象最初会在新生代分配,新生代相对较小,分为两个空间:从空间(对象区)和到空间(自由区)。新添加的对象将存储在对象区域中。当对象区快满时,需要进行一次垃圾清理操作:首先在对象区标记垃圾。标记完成后,将进入垃圾清理阶段。二级垃圾回收器会将这些幸存的对象复制到空闲区,同时它也会对这些对象进行有序的排列。这个复制过程相当于完成了内存整理操作。复制后,空闲区不会有内存碎片:复制后,对象区和空闲区的角色颠倒了,即原来的对象区变成了空闲区,原来的空闲区变成了对象区,这种算法称为Scavenge算法,从而完成垃圾对象的回收。同时,这种角色互换操作也可以让新生代中的两个区域无限重复使用:但是,二级垃圾收集器每次执行清理操作时,都需要将存活的对象从对象区域复制到可用空间。Region,复制操作是需要时间成本的,如果新区空间设置的太大,那么每次清理的时间就会过长,所以为了执行效率,一般新区的空间都会设置的比较小。也是因为新生区的空间不大,所以很容易被幸存的物体塞满。一旦二级垃圾回收器充满了被监控的对象,它就会进行垃圾回收。同时,二级垃圾回收器也会采用对象提升策略,即将那些经过两次垃圾回收还活着的对象移动到老年代。②主垃圾收集器(老年代)主垃圾收集器主要负责老年代的垃圾收集。除了在新生代提升的对象外,一些大的对象会直接分配给老年代。因此老年代的对象有两个特点:对象占用空间大;对象会存活很长时间。因为老年代中的对象比较大,如果要在老年代中使用Scavenge算法进行垃圾回收,那么复制这些大对象会耗费大量时间,导致回收执行效率低下。会浪费空间。因此,主垃圾收集器使用标记清除算法进行垃圾收集。该方法分为标记和清除两个阶段:标记阶段:从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能够到达的元素称为活动对象,而元素达不到的可以判断为垃圾数据。清理阶段:主垃圾收集器会直接清理标记为垃圾的数据。这两个阶段如图所示:标记垃圾数据,然后清除。这就是标记清除算法。但是,对一块内存多次执行mark-and-sweep算法后,会产生大量不连续的内存碎片。.但是过多的碎片会导致大对象无法分配足够的连续内存,所以引入了另一种算法——markcollat??ion。该算法的标记过程仍然与标记清除算法相同。先标记可回收对象,但是后面的步骤并不直接清理可回收对象,而是让所有存活的对象移动到一端,然后直接清理这一端以外的内存:③全停顿我们知道JavaScript是一个在主线程上运行的单行语言。一旦垃圾回收算法执行完毕,需要暂停正在执行的JavaScript脚本,待垃圾回收完成后再恢复脚本执行。这种行为称为完全暂停。主垃圾收集器执行一个完整的垃圾收集过程如下图所示:在V8新生代的垃圾收集中,由于空间小,存活对象少,全停顿的影响并不大。但是在老年代,如果在垃圾回收过程中主线程被占用的时间过长,主线程就不能做其他事情了。它需要等待垃圾收集操作完成才能做其他事情,这可能会导致页面损坏。卡顿现象。为了减少老年代垃圾回收带来的滞后,V8将标记过程逐一分解为子标记过程。同时,垃圾回收标记和JavaScript应用逻辑交替执行,直到标记阶段完成。该算法称为增量标记。算法。如下图所示:使用增量标记算法,可以将一个完整的垃圾回收任务拆分成很多小任务。这些小任务的执行时间比较短,可以穿插在其他JavaScript任务中,这样在代码执行的时候,用户就不会感觉到因为垃圾回收任务导致的页面卡顿。(3)减少垃圾回收虽然浏览器可以自动回收垃圾,但是当代码比较复杂的时候,垃圾回收的成本比较高,所以应该尽量减少垃圾回收:优化数组:清空一个数组时,最简单的方法就是给它赋值[],但同时会创建一个新的空对象,可以将数组的长度设置为0,达到清空数组的目的。优化对象:尽可能重用对象,对不再使用的对象设置为null,尽快回收。优化函数:如果循环中的函数表达式可以重复使用,则应尽可能放在函数外。