V8是如何执行javascript代码的?

更新于 阅读 5

V8是如何执行javascript代码的?

参考文章

Javascript是一门解释性语言,在众多的浏览器JS引擎中V8是性能表现表现最好的一个。

编译器和解释器

我们编写的代码不能直接被机器理解,所以在执行前需要翻译,将代码翻译成机器能读懂的语言,所以语言可以分为编译型语言和解释型语言:

  • 编译型语言:代码在运行前编译器直接将代码编译成机器码,运行时不需要编译,直接使用编译后的结果; 比如C/C++、Go等语言;
  • 解释型语言:代码也需要转换成机器码,与编译型语言的区别是代码在运行时转换。所以解释型语言的执行速度通常慢于编译型语言。比如javascript、python、shell等。

编译型语言和解释型语言执行流程如下:

https://file.vwood.xyz/2024/09/23/upload_r000u5rscvjepgvc9z0bkjcptxbe8ki7.png
  1. 编译型语言的编译过程中,编译器首先会对源代码进行词法分析、语法分析,生成抽象语法树;然后词义分析生成中间代码;再优化代码得到机器能够理解的机器码。如果编译成功将生成二进制文件,否则编译器会抛出异常。
  2. 解释型语言的解释过程中,首先会对源代码进行词法分析、语法分析,生成抽象语法树,然后生成字节码,最后通过字节码来执行,并得到结果。

V8执行javascript代码的过程

V8执行javascript的过程如下:

  1. Parse阶段:将javascript源码转为抽象语法树(AST),其中包括词法分析、语法分析;
  2. Ignition阶段:根据AST生成字节码,并解释执行字节码;
  3. TurboFan阶段:编译器根据Ignition阶段的信息,将字节码优化为可直接执行的机器码;
  4. Orinoco阶段:垃圾回收阶段,将程序不使用的内存空间回收;

Parse阶段:生成抽象语法树

将源代码生成抽象语法树,并生成执行上下文。

var name = "Jack"; function foo () { return name; } name = "Tom"; foo();

生成抽象语法树分为两个阶段,词法分析和语法分析:

  1. 词法分析:就是将字符串拆成最小的、不可再拆的字符和字符串,称为token;比如var name = "jack;"会被分割为var、name、=、"Jack"、;这五个词法单元
  2. 语法分析:这个过程就是将上一步生成的token根据语法规则转为AST,如果源码语法符合规则,这一步就会顺利完成;如果语法错误就会在这一步就会终止,并抛出语法错误,简单来说就是将令牌组装成一颗语法树。

通过词法分析会对代码字符逐个进行解析,生成类似下面的token,这些令牌各不相同。代码var name = "Jack;"会被转为如下tokens:

[ { "type": "Keyword", "value": "var" }, { "type": "Identifier", "value": "name" }, { "type": "Punctuator", "value": "=" }, { "type": "String", "value": "\"Jack\"" }, { "type": "Punctuator", "value": ";" } ]

词法分析在线查看

语法分析就会用tokens生成一颗语法树,下面看生成的结果:

{ "type": "Program", "body": [ { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "name" }, "init": { "type": "Literal", "value": "Jack", "raw": "\"Jack\"" } } ], "kind": "var" } ], "sourceType": "script" }

语法树结构如下

https://file.vwood.xyz/2024/09/23/upload_qdognebd7gsik0jcvm5b468jbm5t6030.png

可以看到AST只是源代码的抽象表达形式,计算机也不会去识别js代码,转成抽象语法树也只是识别这一过程中的第一步。AST的结构和代码的结构非常相似,可以将其视为代码的结构化表示,后续的编译器和解释器都需要依赖于AST;

开发过程中使用到的eslint、babel、代码高亮、格式化等等都会使用到AST。

生成字节码

有了抽象语法树和执行上下文后,就轮到解释器上场了,它会根据AST生成字节码,并解释执行字节码。

为什么需要字节码,而不是直接转成机器码?

其实在V8的早期版本中,是通过AST直接转换成机器码的,但占用内存过大,因为将AST全部转成机器码相比字节码占用的内存空间多很多。

为什么字节码就能解决内存占用问题?

字节码是介于AST和机器码之间的一种代码,需要解释器转为机器码后才能执行,字节码是对机器码的一种抽象描述,相对于机器码而言,它的代码量更小,从而节省内存消耗,解释器除了可以快速生成没有优化过的字节码外,还可以执行部分字节码。

将上面的代码复制到本地,使用命令node --print-bytecode xxx.js可以打印出字节码。

生成机器码

生成字节码后,就进入执行阶段,这一阶段将字节码转成机器码。

如果字节码是第一次执行,那么解释器就逐条解释执行。在执行字节码的过程中,如果发现有热代码(就是重复执行的代码,运行次数超过阈值后就被标记为热代码),那么后台的编译器就会将该段编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行优化后的机器码即可,这样提升了代码的执行效率。

字节码配合解释器和编译器的技术就是即时编译(JIT)。在V8中就是解释器在执行过程中,发现某一部分代码变热后,就通过编译器把热点代码转为机器码,并把转换后的机器码保存起来,以备下次使用。

因为V8引擎是多线程,编译器的编译线程和生成字节码不在同一个线程上,这样可以配合解释器一起使用,不受另一方的影响。JIT技术的工作机制。

https://file.vwood.xyz/2024/09/23/upload_s1rcwfi8kzphsebb706tvbalmls5i6uo.png

解释器在得到AST后,会按需进行解释和执行。也就是说如果某个函数没有调用,就不会去解释执行它,。在这个过程中会将一些重复可优化的代码收集起来生成分析数据,然后将生成的字节码和分析数据传给编译器,编译器会生成高度优化的机器码。

优化后的机器码的作用和缓存类似,当解释器再次遇到相同的内容时,直接执行优化后的机器码。当然优化后的代码可能无法执行(比如函数参数类型改变时),就会反优化为字节码交给解释器。

https://file.vwood.xyz/2024/09/23/upload_986q7266mmtha72sxcvwy18e581w6g2q.png
执行过程优化

如果javascript代码在执行前都要完全经过解析才能执行,那么将面临如下问题:

  1. 代码执行时间过长:一次性解析所有代码会增加执行时间,并且部分代码可能还使用不到;
  2. 消耗更多内存:解析完的AST和根据AST解释生成的字节码都会放在内存中,会占用更多的内存空间;
  3. 占用磁盘空间:编译后的代码会放在磁盘中,占用磁盘空间;

所以V8使用了延迟解析,在解析过程中对于不立即执行的函数进行预解析,只有当调用时才进行全量解析。

进行预解析时,只验证函数是否有效、解析函数声明、确定函数作用域,不生成AST,而实现预解析的,就是Pre-Parse解析器。

比如下面的代码:

var name = "Jack"; function foo () { return name; } name = "Tom"; foo();

V8的解析是自上而下的,当遇到var name = "jack"时,先执行,然后遇到foo函数,发现不是立即执行,就使用Pre-Parser进行预解析,只是解析函数的声明,不解析函数的内部代码,不会为函数内部代码生成AST;

之后解释器会把AST编译为字节码,先执行var name = "Jack"name = "Tom",当执行函数调用foo(),这时Parser才会继续解析函数内部代码、生成AST,再给解释器解释执行。

垃圾回收

JS内存管理机制

变成语言都运行在对应的代码引擎上,使用内存过程可以分为三个步骤:

  1. 分配所需的内存空间
  2. 使用分配到的内存进行读或写操作
  3. 不使用内存时,释放空间

在javascript中,当创建变量时会自动给变量分配内存空间,根据变量的数据类型来决定是在栈还是堆中分配内存:

  1. 基本类型:这些类型在内存中会占用固定大小的空间,它们的值都保存在栈中,可以直接通过值来访问;
  2. 引用类型:这些类型的值大小不固定,值保存在堆中,栈中保存指向堆中值的地址,是通过引用来访问的;
var name = "jack"; // 分配栈空间 var age = 18;// 分配栈空间 // 对象以及包含的值分配堆空间 var person = { name: "tom", age: 18, }

栈中的基本数据类型,可以通过操作系统自动回收;堆内存中的引用类型,由于经常变化,大小不固定,所以需要javascript引擎通过垃圾回收机制来处理。

所谓的垃圾回收是指:Javascript运行时,需要通过内存空间来存储变量和值。当变量不再参与运行时,就需要系统回收被占用的内存空间。Javascript具有自动垃圾回收机制,会定期对不使用的变量、对象所占用的内存进行释放,原理就是找到不再使用的对象,释放器占用的内存。

Javascript中存在两种变量:全局变量和局部变量。全局变量的声明周期会持续到页面卸载;局部变量声明于函数中,它的生命周期从函数执行开始,一直到函数执行结束,在这个过程中,局部变量存储于栈或者堆中,当函数执行结束时,这些变量不再使用,它们所占用的空间就会被释放;不过当局部变量被外部函数使用时,比如闭包,在函数执行结束后,函数外部变量依然指向函数内部的局部变量,此时局部变量依然被使用,所以不会回收。

V8垃圾回收过程
  1. 通过GC Root标记内存中活动对象和非活动对象

    目前V8采用的可访问性算法来判断堆中的对象是否是活动对象。这个算法是将一些GC Root作为初始存活的对象的集合,从GC Roots对象出发,遍历GC Root中所有对象。

    • 通过GC Root遍历到的对象是可访问的,必须保证这些对象应该在内存中保留,可访问的对象称为活动对象。
    • 通过GC Roots没有遍历到的对象是不可访问的,这些不可访问的对象可以被回收,不可访问的对象称为非活动对象。
  2. 回收非活动对象占用的内存

    就是在所有标记完成后,统一清理内存中所有被标记为可回收的对象。

  3. 内存管理

    一般来说,频繁回收对象后,内存中会存在大量不连续的空间,这些不连续空间称为内存碎片。当内存中存在大量的内存碎片后,如果分配较大的连续内存时,就会出现内存不足的情况,所以最后一步需要整理内存中的碎片。

以上就是大致的垃圾回收过程。目前V8有两个垃圾回收器:主垃圾回收器和副垃圾回收器。

在V8中,会把堆分为新生代和老生代两个区域,新生代存放的是生存时间较短的对象,老生代存放生存时间久的对象。

https://file.vwood.xyz/2024/09/23/upload_4xiuafb076hjwu7nzjop5niunod06hje.png

新生代一般只支持 1~ 8M的容量,而老生代支持的容量要大得多。对于这两块区域,V8使用了不同的垃圾回收器,以便高效的实施垃圾回收。

  • 副垃圾回收器:负责新生代的垃圾回收
  • 主垃圾回收器:负责老生代的垃圾回收
副垃圾回收器(新生代)

副垃圾回收器主要负责新生代对象的垃圾回收,大多数的对象最开始都会被分配在新生代,该存储空间相对较小,分为两个空间:from space(对象区)和to space(空闲区)。

新对象的加入都会放在对象区,当对象区域快被写满时,就需要执行一次垃圾清理操作:首先对对象区的对象进行标记,标记完成之后,就进入对象清理阶段。副垃圾回收器会把这些存活的对象赋值到空闲区,同时还会把这些对象有序的排列起来。这个复制过程就相当于完成了内存整理,复制后空闲区就没有内存碎片了。

https://file.vwood.xyz/2024/09/23/upload_y429tzr1s3al0c6nw674qxtjqku13zpt.png

复制完成后,对象区域和空闲区域进行角色翻转,原来的对象区变为空闲区,原来的空闲区变为对象区,这种算法称为Scavenge算法。这样就完成了垃圾对象的回收操作,同时,这种翻转操作还能让新生代的两块区域无限重复的进行下去:

https://file.vwood.xyz/2024/09/23/upload_xihxnssxustaub7g89vlu5ii8ph5be3d.png

不过,副垃圾回收器每次执行清理操作时,都会将对象从对象区复制到空闲区,复制操作需要时间成本,如果新生区对象空间设置太大,那么每次清理的时间就会久,所以为了执行效率,新生区的空间都设置得相对较小。也就是因为新生区空间不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控到对象装满了,就执行垃圾回收操作。同时,副垃圾回收器还会进行晋升操作,也就是移动那些经过两次垃圾回收操作还存活的对象到老生代中。

主垃圾回收(老生代)

主垃圾回收器主要负责老生代的垃圾回收。除了新生代晋升的对象,一些较大的对象也是直接分配在老生代中,因此老生代的主要特别有:

  1. 对象占用空间较大
  2. 对象存活时间久

由于老生代中的对象较大,若要在老生代中使用Scavenge算法进行垃圾回收,复制这些对象将会花费很长的时间,从而导致执行效率不高,还有浪费大量的内存空间,所以主垃圾回收器使用标记清除进行垃圾回收。

这种方式有标记和清除两个阶段:

  • 标记阶段:从一组跟元素开始,递归遍历这对根元素,在这个遍历过程中,能到达的元素称为活动对象,不能到达的元素可以判定为垃圾数据;
  • 清除阶段:主垃圾回收器直接清理被标记为垃圾的数据;

https://file.vwood.xyz/2024/09/23/upload_jswnewzdwj9m28ck86bb9cbzj0iwvoh7.png

对数据进行标记再清理,这就是标记清除算法。不过对一块内存多次执行标记清除后,内存中会存在大量不连续的内存碎片。内存碎片会导致给大对象无法分配到足够的连续空间,于是又引入了另一个算法---标记整理。

这种算法的标记过程依然与标记清除的标记过程一样,先标记可回收的对象,但后续不是直接对垃圾对象进行清理,而是让所有存活的对象都向一端移动,然后清理掉这一端之外的内存:

https://file.vwood.xyz/2024/09/23/upload_59jwwcchhhy4gex0q2icyss4toby25am.png

全停顿

Javascript是单线程,运行在主线程上。一旦执行垃圾回收算法,都需要将Javascript脚本暂停下来,等垃圾回收完毕后再继续执行。这种行为称为全停顿。

在V8新生代的垃圾回收中,因其空间小,且存活对象较少,所以全停顿影响不大。但老生代中,如果执行垃圾回收的过程中,占用主线程时间过久,主线程是不能干其他事情的,需要等待垃圾回收操作完才能做其他事情,这将可能造成页面卡顿现象。

https://file.vwood.xyz/2024/09/23/upload_57fujjgt2mcsogb4d6b0574d9u2jq79z.png

为了降低老生代的垃圾回收造成额卡顿,V8将标记过程拆分成一个个的子标记过程,同时让垃圾回收标记和Javascript应用逻辑交替进行,知道标记过程完成,这个过程称为增量标记算法。

使用增量标记算法可以把一个完整的垃圾回收过程拆分为很多小的任务,这些小的任务执行时间短,可以穿插在其他的Javascript任务中间进行,这个当执行任务时,就不会让用户因为垃圾回收任务感觉页面卡顿了。

https://file.vwood.xyz/2024/09/23/upload_dr8fzr27uv9au4wqqeqesdi4o398dvio.png

惰性清理

增量标记完成后,惰性清理就开始了,用来真正的释放内存空间。如果当前的内存空间足够程序正常运行,那就没必要立即清理,让Javascript先执行,清理操作延迟执行,同时清理也不需一次清理完所有的内存,垃圾回收器会按需清理,直到清理完所有页。

由于增量标记会在每个小任务后执行Javascript代码,所以堆中的对象指针可能会发生变化,所以需要写屏障来记录引用关系的变化。

然后增量标记解决了长时间中断导致页面卡顿问题,但是总体算下来,垃圾回收的总时长增加了。

所以可以看出增量标记的特点:

  1. 解决了长时间卡顿问题,但总的垃圾回收时长并没减少,反而增加了。
  2. 由于写屏障机制的成本,增量标记会降低应用程序的吞吐量。

由于写屏障机制会导致增量标记会降低应用程序的吞吐量,所以可以使用额外的工作线程来提高吞吐量,以下两种方法可以在工作线程上进行标记:并行标记和并发标记。

并行标记

主线程和辅助线程同时执行同样的工作,让辅助线程来分担主线程的GC工作,垃圾回收的时间就等于总时间除以辅助线程的数量。因为没有Javascript执行,所以只需要确保只有一个辅助线程在访问对象就行了。

https://file.vwood.xyz/2024/09/23/upload_f3ozf539qo75mqi5lcpotyafhr32q5z4.png

并发标记

并发标记是主线执行Javascript代码,垃圾回收由辅助线程进行。这种方式也会面临增量标记的问题,多个辅助线程同时访问同一个变量,引起读/写竞争;以及堆中的对象变化,导致之前的工作无效。但优势是,主线程可以自由的执行Javascript,不会被阻塞,尽管也要进行写屏障操作。

https://file.vwood.xyz/2024/09/23/upload_d2riff5czpx4em2fxtesj2h0tpt7xzq5.png

V8目前使用的技术

副垃圾回收器

目前新生代对象使用并行标记,在整理阶段,启动多个辅助线程,每个辅助线程将活动对象复制到空闲区,并行的整理。在每次尝试将活动对象赋值到空闲区是必须保证原子化的读/写操作。无论哪个哪个辅助线程将活动对象成功复制后,都必须更新这个对象的执行,并且维护这个活动对象留下的转发地址。

https://file.vwood.xyz/2024/09/23/upload_8pdu7i2kf05qqt6o6z0nu9ox049tfxhc.png

主垃圾回收器

主垃圾回收器主要使用的是并发标记,一旦堆的内存分配接近极限时,将启动并发标记。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用。在Javascript执行的时候,并发标记在后台进行。写屏障技术在辅助线程进行并发标记的时候会一直追踪每一个Javascript对象的新引用。当并发标记完成或者动态分配内存的到达极限时,主线程会执行最终的快速标记步骤(只进行check操作,因为标记操作已在辅助线程中完成),在这个阶段主线程将会被暂停,这段时间也就是主垃圾回收器执行的所有时间;在这个阶段主线程将扫描根集已确认所有的对象都完成了标记,然后辅助线程就会去做更新指针和整理内存的工作。并非所有的内存页都会被整理。在暂停的时候主线程会启动并发清理的任务,这些任务都是并发执行的,并不会影响并行内存页的整理工作和Javascript执行。

https://file.vwood.xyz/2024/09/23/upload_a11md895q5e9glk6fk7c0zm6hpxz1m8n.png

最后

本文整理于如下文章:

https://github.com/yacan8/blog/issues/33

https://www.51cto.com/article/718899.html