39 源码解读:V8 执行 JS 代码的全过程 你好,我是LMOS。

前面我们学习了现代浏览器架构,也大致了解了浏览器内核的工作原理。在浏览器的内核中,V8 是一个绕不开的话题。在浏览器中,Chrome 的重要地位不用赘述,而V8不仅是 Chrome 的核心组件,还是 node.js 等众多软件的核心组件,所以,V8的重要程度亦不用多言。

不过,V8涉及到的技术十分广泛,包括操作系统、编译技术、计算机体系结构等多方面知识,为了带你先从宏观角度系统学习和了解V8项目,这节课我会从源码理解讲起,带你了解了V8 执行 JS 代码的全过程。

如何阅读 V8 源码和搭建 V8 开发环境

前面两节课,我带你简单了解了 Chromium 和 Webkit 项目的目录结构,在这里我们继续看一下如何学习 V8 源码。

Chromium 项目中包含了可运行的 V8 源码,但是从调试的角度看,我们一般使用 depot_tools来编译调试 V8 源码,它是V8的编译工具链,下载和编译代码都需要用到它,你可以直接点击 depot_tools bundle 下载。

解压后,我们需要将 depot_tools 工具添加到环境变量,注意这些操作需要你保证本机可以访问 Google 浏览器。

我们以 Mac 系统为例,添加到环境变量的代码命令如下: export PATH=pwd/depot_tools:”$PATH”

然后,你可以在命令行中测试 depot_tools 是否可以使用:

gclient sync

下载完 depot_tools 后,我们就可以下载 V8 代码进行编译调试了“

mkdir v8 cd v8 fetch v8 cd v8/src

下载好 V8 源码后,我们需要使用 GN 来配置工程文件。下面是我们用到的几个编译的参数:

is_component_build = true // 编译成动态链接库以减少体积 is_debug = true // 开启调试 v8_optimized_debug = true // 关闭一些代码优化 symbol_level = 0 将所有的debug符号放在一起,加速二次编译和链接过程; ide=vs2022 / ide=xcode // 选择编译 IDE

我们这节课就不展开讲解 gn 命令了,如果你有兴趣了解更多内容,可以自行查阅资料。说回正题,我们继续聊配置工作。

Windows 的配置情况如下 : gn gen out.gn/x64.release –args=’is_debug=true target_cpu=”x64” v8_target_cpu=”arm64” use_goma=true is_component_build=true v8_optimized_debug = true symbol_level = 0’

我们再来看看 Mac 下的情况,Mac 下我们需要更新 xcode 依赖,代码如下:

sudo xcode-select -s /Applications/Xcode.app/Contents/Developer gn gen out/gn –ide=xcode

执行完成后,我们可以通过 IDE 进入相应的工程文件下,后面是我的操作截图,供你参考:

图片

我们看到,在工程文件下有一个名为 samples 的目录,上图中打开的文件 hello-world.cc 也是这个目录下的一个文件,它是 V8 项目中的一个实例文件,我们后面的学习也会从hello-world.cc文件入手。

我们来看一下这个文件的具体代码: int main(int argc, char/* argv[]) { // Initialize V8. v8::V8::InitializeICUDefaultLocation(argv[0]); v8::V8::InitializeExternalStartupData(argv[0]); std::unique_ptr<v8::Platform> platform = v8::platform::NewDefaultPlatform(); v8::V8::InitializePlatform(platform.get()); v8::V8::Initialize(); // Create a new Isolate and make it the current one. v8::Isolate::CreateParams create_params; create_params.array_buffer_allocator = v8::ArrayBuffer::Allocator::NewDefaultAllocator(); v8::Isolate/* isolate = v8::Isolate::New(create_params); { v8::Isolate::Scope isolate_scope(isolate); // Create a stack-allocated handle scope. v8::HandleScope handle_scope(isolate); // Create a new context. v8::Local<v8::Context> context = v8::Context::New(isolate); // Enter the context for compiling and running the hello world script. v8::Context::Scope context_scope(context); { // Create a string containing the JavaScript source code. v8::Local<v8::String> source = v8::String::NewFromUtf8Literal(isolate, “‘Hello’ + ‘, World!’”); // Compile the source code. v8::Local<v8::Script> script = v8::Script::Compile(context, source).ToLocalChecked(); // Run the script to get the result. v8::Local<v8::Value> result = script->Run(context).ToLocalChecked(); // Convert the result to an UTF8 string and print it. v8::String::Utf8Value utf8(isolate, result); printf(“%s\n”, /*utf8);

我们简单看看 hello-world.cc 这个文件,它是用 C++ 程序编写的,主要做了下面几件事:

  • 初始化了 V8 程序;-
  • 运行了一段基于 JavaScript 语言程序的 “hello world” 并输出;-
  • 运行了一段基于 JavaScript 语言程序的加法运算并输出;-
  • 执行完成后卸载了 V8。

上节课我们有提到,V8 是一个 JS 的执行引擎,在这个 helloworld 的代码中,除去运行JS 代码的两部分,其它的代码都是为 JS 代码运行提供的准备工作。

我们现在就看一下运行时都做了哪些基本的准备工作。

V8 在运行时的表现

上面代码是 hello-world 代码的主函数,也是核心的部分。我们梳理一下关键过程有哪些:首先hello- world代码的主函数调用了 v8::V8::Initialize() 方法对 V8 进行初始化;然后,调用了 v8::Isolate::New 来创建 Isolate;接着,创建完成后调用了 v8::Script::Compile 来进行编译;最后,调用 script->Run 用来执行 JS 代码。

我们后面会围绕上述关键过程做分析。你可以结合下面这张图,看看 hello-world.cc 的执行过程,还有这个过程里涉及到的核心方法和重要数据结构。

图片

好,让我们进入具体分析环节,先从内存申请开始说起。

V8启动时的内存申请

申请内存从 InitReservation 方法开始,它主要处理的操作就是为 V8 引擎向 OS 申请内存,代码在 src/utils/allocation.cc 这个目录中: // Reserve a region of twice the size so that there is an aligned address // within it that’s usable as the cage base. VirtualMemory padded_reservation(params.page_allocator, params.reservation_size /* 2, reinterpret_cast<void/*>(hint)); if (!padded_reservation.IsReserved()) return false; // Find properly aligned sub-region inside the reservation. Address address = VirtualMemoryCageStart(padded_reservation.address(), params); CHECK(padded_reservation.InVM(address, params.reservation_size));

申请内存的时候,InitReservation 会先申请两倍的内存,保证内存对齐,再从两倍内存中找到一个适合对齐地址,这是 V8 真正使用的内存地址。这块申请出来的内存后面的工作里用得上。完成申请后,还会再调用 padded_reservation.Free() 方法,将刚开始申请的内存释放掉。

下面我带你看看 VirtualMemoryCage 数据结构,它是 V8 内存管理的主要数据结构。V8的内存方式采用的段页式,和 OS 的内存数据结构比较类似,但区别是 V8 只有一个段,OS 会有多段,但是 V8 会有很多页。

VirtualMemeoryCage 的数据结构位于allocation.h 文件中,如下所示: // +————+———–+———– ~~~ -+ // | … | … | … | // +————+———–+———— ~~~ -+ // ^ ^ ^ // start cage base allocatable base // // <————> <——————-> // base bias size allocatable size // <——————————————–> // reservation size

reservation size 是 V8 实际申请的内存,start 是内存基址,cage base 是页表的位置,allocatable 是 V8 可分配内存的开始,用来创建 Isolate。

Isolate

Isolate是一个完整的V8实例,有着完整的堆和栈。V8是虚拟机,Isolate才是运行JavaScript的宿主。一个Isolate是一个独立的运行环境,包括但不限于堆管理器(heap)、垃圾回收器(GC)等。

在同一个时间,有且只有一个线程能在Isolate中运行代码,也就是说同一时刻,只有一个线程能进入Iisolate,而多个线程可以通过切换来共享同一个Isolate。

Isolate 对外的接口是 V8_EXPORT ,定义在 include/v8.h 文件中,其他程序可以调用它。这个接口也可以理解为JavaScript的运行单元,多个线程也就是多个任务,它们可以共享一个运行单元,主要涉及到几个 V8 的概念:

  • Context:上下文,所有的JS代码都是在某个V8 Context中运行的。
  • Handle:一个指定JS对象的索引,它指向此JS对象在V8堆中的位置。
  • Handle Scope:包含很多handle的集合,用来统一管理多个handle,当Scope被移出堆时,它所管理的handle集合也会被移除。

Isolate 还有一个对内的数据结构 V8_EXPORT_PRIVATE,也是一个核心的数据结构,内部的很多重要的结构都会用到它,后面编译流程我还会讲到。

编译

V8 的编译流程也是 V8 的核心流程,我们先简单看下编译的大概流程:

  • tokenize (分词):将 JS 代码解析为 Token 流,Token 是语法上的不可拆分的最小单位;
  • parse (解析):语法分析,将上一步生成的 token 流转化为 AST 结构,AST 被称为抽象语法树;
  • ignite (解释):通过解释器,生成字节码。

接着,我们再看看这个过程的关键数据结构 V8_EXPORT_PRIVATE ParseInfo,代码在 src/parsing/parse-info.cc 目录下: ParseInfo 这个数据结构就是JS 代码生成token,再生成 AST 的过程,AST 的数据结构位置在 src/ast/ast.h。

生成 AST后,解释器会根据 AST生成字节码,并解释执行字节码。字节码是介入 AST和机器码之间的一种数据结构,你先留个印象,我们后面再详细说。

代码执行

经过编译,最终生成了字节码。我们继续来看 Exectuion 这个数据结构,这个结构承载着 JS 代码运行过程前后的相关信息: class Execution final : public AllStatic { public: // Whether to report pending messages, or keep them pending on the isolate. enum class MessageHandling { kReport, kKeepPending }; enum class Target { kCallable, kRunMicrotasks }; // Call a function, the caller supplies a receiver and an array // of arguments. // // When the function called is not in strict mode, receiver is // converted to an object. // V8_EXPORT_PRIVATE V8_WARN_UNUSED_RESULT static MaybeHandle Call( Isolate/* isolate, Handle callable, Handle receiver, int argc, Handle argv[]);

通过前面关键过程和数据结构的讲解,相信你已经基本了解了 V8 运行时的核心流程,下面我们从宏观层面看一下这个过程。

V8 编译 —— V8 执行 JS 的过程

JS代码是给人看的,并不能由机器直接运行,需要很多中间步骤的转换,执行这些步骤的就是JS解析器。

主要过程是这样:首先对JS源代码进行词法分析,将源代码拆分成一个个简单的词语(即Token);然后,以这些Token为输入流进行语法分析,形成一棵抽象语法树(即AST),并检查其语法上的错误;最后,由语法树生成字节码,由JS解析器运行。下面我们分别讨论这几个步骤。

词法分析

词法分析是将 JS 代码拆分成对应的 Token,Token 是能拆分的最小单位,固定 type 表述类型/属性,value 表示对应的值,如下图 Token。

图片 [{ “type”: “Keyword”, “value”: “let” }, { “type”: “Identifier”, “value”: “name” }, { “type”: “Punctuator”, “value”: “=” }, { “type”: “string”, “value”: “LMOS” }]

语法分析

在进行词法分析转为 Token 之后,解析器会继续根据生成的 Token 生成对应的 AST。说起AST,相信前端同学并不陌生,也是热词之一,无论是在 Vue、React 中表示虚拟 DOM ,或者表示 Babel 对 JS 的转译,都需要先将其转化为对应的 AST。

字节码

在解析器(Parser)将 JS 代码解析成 AST 之后,解释器(Ignition)根据 AST 来生成字节码(也称中间码)。前文提到 CPU 只能识别机器码,对字节码是识别不了的,这里就衍生出一个问题,如果 CPU 识别不了字节码,那为什么还要在中间插一步来耗费资源转成字节码呢?效率不是很低吗?

在计算机学科里聊效率,都逃避不了时间和空间这两个概念,绝大部分的优化都是空间换时间或时间换空间,两者的平衡,效率如何达到最高,是一个很值得深入研究的问题。

拿之前版本的 V8 引擎执行 JS 来说,是没有转字节码这一步骤的,而是直接从 AST 转成机器码,这个过程称为编译过程,所以每次拿到 JS 文件的时候,首先都会编译,而这个过程还是比较浪费时间的,这是一件比较头疼的事情,需要一个解决办法。

V8 中的优化细节

V8 执行 JS 的主要过程我们说完了,其实在这个过程中,V8 利用 JIT 的能力做了很多方面的优化,现在我们看一下具体有哪些。

缓存机器码

一个网页只要第一次打开过,关闭再次去打开,大部分情况下,还是和原来 JS 文件一致的,除非开发者修改了代码,但这个可以暂时不考虑。毕竟哪个网站也不会一天闲得无聊,不停地修改,上传替换。

按照这个思路,既然绝大多数情况下,文件不会修改,那编译后的机器码可以考虑缓存下来,这样一来,下次再打开或者刷新页面的时候就省去编译的过程了,可以直接执行了。

存储机器码可以分成两种情况:一个是浏览器未关闭时候,直接存储到浏览器本地的内存中;一个是浏览器关闭了,直接存储在磁盘上,而早期的 V8 也确实是这么做的,典型的牺牲空间换时间。

热代码

在代码中,常常会有同一部分代码,被多次调用,同一部分代码如果每次都需要解释器转二进制代码再去执行,效率上来说,会有些浪费,所以在 V8 模块中会有专门的监控模块,来监控同一代码是否多次被调用,如果被多次调用,那么就会被标记为热代码,这有什么作用呢?我们继续往下看。

优化编译器

TurboFan (优化编译器) 这个词,相信关注手机界的同学并不陌生,华为、小米等这些品牌,在近几年产品发布会上都会出现这个词,主要的能力是通过软件计算能力来优化一系列的功能,使得效率更优。

接着热代码继续说,当存在热代码的时候,V8 会借助 TurboFan 将为热代码的字节码转为机器码并缓存下来,这样一来,当再次调用热代码时,就不再需要将字节码转为机器码。当然,热代码相对来说还是少部分的,所以缓存也并不会占用太大内存,并且提升了执行效率,同样此处也是牺牲空间换时间。

反优化

JS 语言是动态语言,非常之灵活,对象的结构和属性在运行时是可以发生改变的,我们设想一个问题:如果热代码在某次执行的时候,突然其中的某个属性被修改了,那么编译成机器码的热代码还能继续执行吗?

答案是肯定不能。这个时候就要使用到优化编译器的反优化了,它会将热代码退回到 AST 这一步,这个时候解释器会重新解释执行被修改的代码;如果代码再次被标记为热代码,那么会重复执行优化编译器的这个步骤。

总结

这节课我们先通过编译源码的方式搭建了 V8 的环境,又通过 V8 项目中的 hello_world 项目一步步学习了 V8 执行 JS代码的过程,最后,我们又从宏观角度了解了 V8 执行 JS 代码的全过程。

这节课的要点,你可以结合后面的导图看一下。

图片

在这个过程中,我们通过 V8 项目的关键代码和数据结构深入的了解了 V8 这个项目。在学习一个开源巨石项目的过程中,我们要掌握一定的学习方式,切不可以在初学习的阶段就过度自底而上地纠结于各种代码细节。

我们可以通过这样的方式进行学习:

  • 初步建立印象:自顶而上的了解项目的结构和架构,形成一个初步的宏观视觉;
  • 梳理主线:进入程序源码的角度,理解代码的主要脉络,建议从一个简单的例子入手;
  • 关注重要过程:关注过程中的关键代码输入输出,运行过程中的几个重要中间阶段、重要中间结果和数据结构;
  • 查漏补缺:补充细节知识点的查漏补缺,结合自己情况深入学习。

V8 在执行 JS 的过程中又可以进行很多优化,具体方式就是在运行 JS 过程中持续记录代码语句执行情况,以及变量类型的变化情况。若推测代码执行次数较多(热点代码)且变量类型较固定时,就会调用优化器优化这部分代码,缓存这部分机器码 + 跳过这部分类型判断逻辑,从而实现性能优化。

思考题

V8 在执行 JS 的过程中可以做哪些性能优化?

欢迎你在留言区与我交流讨论,也推荐你把课程分享给更多朋友。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e8%ae%a1%e7%ae%97%e6%9c%ba%e5%9f%ba%e7%a1%80%e5%ae%9e%e6%88%98%e8%af%be/39%20%e6%ba%90%e7%a0%81%e8%a7%a3%e8%af%bb%ef%bc%9aV8%20%e6%89%a7%e8%a1%8c%20JS%20%e4%bb%a3%e7%a0%81%e7%9a%84%e5%85%a8%e8%bf%87%e7%a8%8b.md