关于javascript:译JavaScript-是如何计算-11-的-Part-1-创建源码字符串

3次阅读

共计 8493 个字符,预计需要花费 22 分钟才能阅读完成。

起源:https://medium.com/compilers/calculating-1-1-in-javascript-1cecb6e9610

我是一个编译器爱好者,始终在学习 V8 JavaScript 引擎的工作原理。当然,学习货色最好的形式就是写进去,所以这也是我在这里分享教训的起因。我心愿这也能让其他人感兴趣。

译者注:
翻译已取得作者受权。
因为我对一部分名词、c++ 语法也不怎么理解,所以联合本人的了解以及上下文做了一些「译者注」,能够有取舍的参考

毫无疑问 1 + 1 = 2,然而 V8 JavaScript 的引擎是如何计算出来的呢?

题外话,我最喜爱的一个面试问题是:「_从输出 URL 到页面加载产生了什么?_」
_
这是一个很好的问题,因为它能展现一个人相干常识的深度和广度,能从答复这个问题的过程中,发现哪些局部是他最感兴趣的

这是一系列博文中的第一篇,将探讨 V8 在 1 + 1 被输出之后的所有。首先,咱们将关注 V8 如何在其堆内存中存储 1 + 1 字符串。这听起来很简略,但它齐全值得这一整篇的博文!

一、客户端利用(The Client Applicant)


要计算 1 + 1,你可能最先采取的办法是启动 NodeJS,或者关上 Chrome 开发者控制台,而后简略地输出 1 + 1。但为了展现 V8 的内部结构,我决定批改 hello-world.cc,这是 V8 源代码中的一个规范示例应用程序

我把原来打印 "Hello World" 的代码,用 1 + 1 的表达式代替

// 创立一个蕴含 JavaScript 源代码的字符串
Local<String> source = String::NewFromUtf8Literal(isolate, "1 + 1");

// 编译源代码
Local<Script> script = 
    Script::Compile(context, source).ToLocalChecked();

// 运行该脚本以取得后果
Local<Value> result = script->Run(context).ToLocalChecked();

// 将后果转换为 Number 并打印进去
Local<Number> number = Local<Number>::Cast(result);
printf("%f\n", number->Value());

译者注:为了便于不懂 C++ 的同学了解代码含意,提供一些变量的阐明和一份 TS 模式的表白(仅用于辅助了解代码!不代表实在逻辑!)

  • isolate(隔离)- 在 V8 中一个 isolate 是 V8 的一份实例。在 blink 中 isolate 和线程是 1 : 1 的关系。主线程与一个 isolate 相关联,一个工作线程与一个隔离相关联
  • context(上下文)- context 是 V8 中全局变量范畴的概念。简略的说,一个 Window 对象对应于一个 context。例如 <iframe> 和 parent frame 的有不同的 Window 对象,所以不同的 frame 具备不同的 context
  • Literal(字面量)– value 代表值,literals 代表如何表白一个值。比方 15 是一个值,这个值是惟一的,但表白的形式有很多种:例如阿拉伯数字 15,用中文 十五,用英文 fifteen,用 16 进制 0xF。15 是 value,前面的种种都是 literal
  • 双冒号 :: 能够当成为 js 里的 .String::NewFromUtf8Literal 就是 String.`NewFromUtf8Literal`
  • 箭头函数 -> 能够当成为 js 里的 .script->Run(context) 就是 script.Run(context)
// 导入类 String、Script,导入类型汇合 Local
import {String, Script, Number, Local} from  'v8'

const source: Local["String"] = String.NewFromUtf8Literal(isolate, "1 + 1");

const script: Local["Script"] = Script.Compile(context, source).ToLocalChecked();

const result: Local["Value"] = script.Run(context, source).ToLocalChecked();

const number: Local["Number"] = Number.Cast(result);

console.log(number.Value());

疾速浏览这段代码并大略理解一下。这些 C++ 代码看起来难以了解,但正文会应该能帮到你。在这篇博文中,咱们次要关注第一句代码,即在 V8 堆中调配一个新的 1 + 1 字符串

Local<String> source = String::NewFromUtf8Literal(isolate, "1 + 1");

为了了解这段代码,咱们先从所波及的一系列 V8 模块开始。在此图中,执行流程是由左至右,返回值从右至左传回,插入到 soruce 变量中

  • 应用程序 – 这代表了 V8 的客户端,在咱们的例子中,它是 hello-world.cc 程序。但通常状况下,它是整个 Chrome 浏览器、NodeJS 运行时零碎或任何其余嵌入了 V8 JavaScript 引擎的软件
  • V8 内部 API – 这是一个面向客户端的 API,提供对 V8 性能的拜访。尽管它是用 C++ 实现的,但 API 是围绕着各种 JavaScript 概念来塑造的,如数字、字符串、数组、函数和对象,容许以各种形式创立和操作它们
  • 堆工厂 – V8 引擎外部(不通过 API 裸露)是一个在堆上创立各种数据对象的「工厂」。令人诧异的是,可用的工厂办法集与内部 API 提供的办法有很大的不同,所以很多转换是在 API 层外部实现的
  • New Space – V8 的堆非常复杂,但新调配的对象通常存储在 New Space 中,通常被称为 _新生代_。咱们在这里就不具体介绍了,然而 New Space 是应用 Cheney 算法来治理的,Cheney 算法是一种执行垃圾回收的驰名算法

当初咱们来具体理解一下这个流程,重点是:

  • API 层如何决定创立什么类型的字符串,以及它在堆中的存储地位
  • 字符串的外部内存布局是怎么的。这取决于字符串里字符的范畴
  • 如何从堆中调配空间。在咱们的例子中,须要 20 个字节
  • 最初,如何将指向字符串的指针返回给应用程序,用于将来进行垃圾回收

二、确定存储字符串的形式和地位


如上所述,在客户端 应用程序 堆工厂(理论创建对象的中央)之间必须进行大量的转换工作。大部分的工作都在 src/api/api.cc 中进行

让咱们从客户端应用程序的调用开始:

String::NewFromUtf8Literal(isolate, "1 + 1");

第一个参数是「Isolate(隔离)」,它是 V8 的次要外部数据结构,代表运行时零碎的状态,与其余可能存在的 V8 实例隔离。要了解这一点,能够设想关上了多个浏览器窗口,每个窗口都有一个齐全独立的 V8 实例在运行,每个实例都有本人的隔离堆。咱们不会多谈 isolate 参数,只须要晓得到很多 API 的调用都须要这个参数

String::NewFromUtf8Literal 办法 (见 src/api/api.cc) 首先进行根本的字符串长度查看,同时也决定如何在内存中存储字符串。思考到咱们只提供了两个参数,第三个 type 参数默认为NewStringType::kNormal,示意字符串应该作为惯例对象在堆上调配。另一种办法是传递NewStringType::kInternalized,示意须要对字符串进行去反复解决。这个个性对于防止存储同一个常量字符串的多个正本十分有用

外部会接着调用 NewString 办法(见 [src/api/api.cc](https://github.com/v8/v8/blob/8.8.276/src/api/api.cc)),它调用 factory->NewStringFromUtf8(string)。请留神,这里的 string 曾经被映射到一个外部的 Vector 数据结构中,而不是一个一般的 C++ 字符串,因为堆工厂有一套与内部 API 齐全不同的办法。当返回值传回客户端应用程序时,这种差别将在前面变得更加显著

NewStringFromUtf8 外部(见 src/heap/factory.cc),决定了字符串的最佳存储格局。当然,UTF-8 是一种不便的格局,能够存储宽泛的 Unicode 字符,然而当只应用根本的 ASCII 字符时 (例如 1 + 1) V8 会以「1 个字节」的格局存储字符串。为了做出这个决定,字符串的字符被传递到 Utf8Decoder decoder(utf8_data) 中(在 src/strings/unicode-decoder.h 中申明)

当初咱们曾经决定调配一个 1 字节的字符串,应用一般的(不是内部化的)办法,下一步是调用NewRawOneByteString(见 src/heap/factory-base.cc),在这里,堆内存被调配,字符串的内容被写入该内存

三、字符串的内存构造


在 V8 外部,咱们的 1 + 1 字符串被示意为 v8::Internal::SeqOneByteString 类的一个实例 (见 src/objects/string.h)。如果你像大多数面向对象的开发者一样,你会冀望 SeqOneByteString 有许多公共办法,以及一些公有属性,比方一个字符数组或一个存储字符串长度的整数。然而,事实并非如此! 相同,所有外部 对象类 实际上只是指向堆中存储这些数据地址的指针

译者注:对象类 – 定义属性的命名汇合,并将它们分类为必须属性集和可选属性集

src/objects/objects.h 中的代码正文能够看出,大概有 150 个外部类的父类是 v8::Internal::Object。这些类中都只蕴含了一个 8 字节的值(在 64 位机器上),指向了堆中对象所在的地址

其中乏味的局部是:

SeqOneByteString 对象

如前所述,这不是一个功能完善的字符串类,而是一个指向堆中字符串理论内容地址的指针。在 64 位的机器上,这个「指针」将是一个 8 字节的 unsigned long(无符号长整形),其类型别名为 Address。请留神,堆上的数据(在图的左边)实际上并不是一个真正的 C++ 对象,所以没有必要把这个 Address 当作一个指向强类型的货色(如 String *)的指针来解决

然而,你可能想晓得为什么要先有一个间接层,而不间接拜访 Heap Block 呢?当你思考到垃圾收集会导致对象在堆中挪动时,会晓得这种办法是有意义的。重要的是,数据能够挪动,而不会让客户端应用程序感到困惑

译者注:Heap Block – 内存块

要阐明的是,在 Generational Garbage Collection(代际垃圾收集)中,对象首先在 _新生代_(New Space)中调配,如果它们存活的工夫足够长,就会被移到 _老生代_(Old Space)中。为了实现这一目标,垃圾收集器会将 Heap Block 复制到新的堆空间,而后更新 Address 值指向新的内存地址。鉴于 SeqOneByteString 对象自身的内存地址依然和之前完全相同,客户端软件不会留神到这个变动。

Compressed Pointer To Map (Heap Block 的第 0-3 个字节)(指向 Map 的压缩指针)

JavaScript 是一种动静类型的语言,这意味着 _变量 _没有类型,然而 _存储在变量中的值 _却有类型。「map」是 V8 将堆中的每个对象与其数据类型形容关联起来的形式。毕竟,如果对象没有被标记上它的类型,Heap Block 就会变成一个串无意义的字节

除了提到 maps 也是存储在 _只读空间 _中的一种堆对象之外,咱们不会对 1 + 1 字符串的 map 进行更多的具体介绍。Maps(也被称为形态或暗藏类)能够变得非常复杂,只管咱们的常量字符串通过调用read_only_roots().one_byte_string_map()(见 src/heap/factory-base.cc)应用了一个事后定义的 map

译者注:heap object – 堆对象。是在程序运行时依据须要随时能够被创立或删除的对象,在虚构的程序空间中存在一些闲暇存储单元,这些闲暇存储单元组成的所谓的堆

乏味的是,尽管这个 map 字段是指向另一个堆对象的指针,但它奇妙地应用了指针压缩,在一个 32 位的字段中存储了一个 64 位的指针值

Object Hash Value (Heap Block 的第 4-7 个字节)(对象哈希值)

每个对象都有一个外部的哈希值,但在这个例子中,它默认为 kEmptyHashField(值为 3),示意哈希值还没有计算出来

String Length (Heap Block 的第 8-11 个字节)(字符串长度)

这是字符串中的字节数(5)(两个 1,两个 ,一个 +

The Characters and the Padding (Heap Block 的第 12-19 个字节)(字符和填充物)

正如你所冀望的那样,接下来存储的是 5 个单字节字符。此外,为了确保将来的堆对象依据 CPU 的架构要求进行对齐,还额定减少了 3 个字节的填充(将对象对齐到 4 字节的边界)。

四、从堆中分配内存

咱们简略地提到,工厂类从堆中调配一块内存(在咱们的例子中是 20 个字节),而后用对象的数据填充该块。剩下的一个问题是这 20 个字节是 _如何 _调配的

在 Cheney 的垃圾收集算法中,新生代(New Space)被分为两个半空间。为了在堆中调配一个内存块,分配器确定在以后半空间的 Limit,和该半空间的以后 Top 之间是否有足够的可用字节。如果有足够的空间,算法返回下一个块的地址,而后按申请的字节数递增 Top 指针

这里展现了这种根本状况,显示了以后半空间的前后状态:

如果以后的半空间用完了可用内存(TopLimit 太靠近),那么 Cheney 算法的收集局部就会开始。一旦收集实现,所有的 _活 _对象将被复制到第二个半空间的开始,而所有的 _死 _对象(残留在第一个半空间中)将被抛弃。无论怎样,一个半空间都能保障其所有 _应用过 _的空间都在底部,而所有的 _闲暇的 _空间都在顶部,所以它总是会像上图一样

不过在咱们的状况下,以后的半空间有很多闲暇的内存,所以咱们切掉 20 个字节,而后减少 Top 指针。不须要进行垃圾收集,也不波及第二个半空间。在 V8 代码中,有许多非凡状况须要思考,但最初 20 个字节的调配是由 src/heap/new-spaces-inl.h 中的 NewSpace::AllocateFastUnaligned 办法解决的

五、返回一个句柄


句柄(Handle)是 C++ 程序设计中常常提及的一个术语。它并不是一种具体的、固定不变的数据类型或实体,而是代表了程序设计中的一个狭义的概念。

句柄个别是指获取另一个对象的办法 – 一个狭义的指针,它的具体模式可能是一个整数、一个对象或就是一个实在的指针,而它的目标就是 建设起拜访与被拜访对象之间的惟一的分割

当初咱们有了一个指针,指向齐全填充了字符串的内容(包含长度、哈希值和映射)的 Heap Block,这个指针必须返回给客户端应用程序。如果你还记得,客户端调用了这行代码

Local<String> source = String::NewFromUtf8Literal(isolate, "1 + 1");

然而,source 的类型到底是什么,Local<String> 到底是什么意思?这里有两个要害的观察点:

将外部类转换为外部类

首先,咱们先回顾一下,V8 应用 v8::internal::SeqOneByteString 类存储了咱们的字符串对象,乏味的是它只是一个指向堆上数据的指针。然而,客户端应用程序冀望数据的类型是 v8::String,这是 V8 API 的一部分

你可能会感到诧异,v8::internal::SeqOneByteStringv8::internal::String 的一个子类)与v8::String 处于一个齐全不同的类层次结构。事实上,所有的外部类都是在 src/objects 目录下应用v8::internal 命名空间定义的,而外部类则是在 include/v8.h 中应用 v8 命名空间定义的

重温咱们之前探讨过的 NewFromUtf8Literal 办法(见 src/api/api.cc),在将对象指针返回给客户端应用程序之前的最初一步是将后果从 v8::internal::String 转化为 v8::String

return Utils::ToLocal(handle_result);

这个转换是通过定义在 src/api/api-inl.h 中的宏来实现的

译者注:宏(Macro)实质上就是代码片段,通过别名来应用。在编译前的预处理中,宏会被替换为实在所指代的代码片段

治理好垃圾回收的「根」

其次,咱们来讨论一下 Local<String> 的含意(顺便说一下,它是 v8::Local<v8::String> 的缩写)。_Local_ 的概念是当字符串对象不再被须要时,咱们如何解决它的垃圾回收

任何 JavaScript 开发人员都晓得,当对象没有残余的援用时,就会进行垃圾回收。回收算法从「根」开始,而后遍历整个堆,找到所有可达到的对象。根是一个非堆(non-heap)援用,比方一个全局变量,或者依然在作用域中的基于堆栈(stack-based)的局部变量。如果这些变量被调配了新的值,或者它们来到了作用域(它们的封装函数完结),它们已经指向的数据当初有可能是垃圾

译者注:堆栈就是栈,这个「堆」并不是数据结构意义上的堆(Heap),而是动态内存调配意义上的堆 – 用于治理动静生命周期的内存区域

hello-world.cc 程序的状况下,咱们在 C++ 栈中也有指针,能够援用堆对象。这些指针没有对应的 JavaScript 变量名,因为它们只存在于 C++ 程序的上下文中(比方 hello-world.cc,或者 Chrome,或者 NodeJS)。例如:

Local<String> source = ...

在这种状况下,source 是对堆对象的援用,只管当初多了一层间接性。这张图将解释:

译者注:
ptr to heap = pointer to heap 指向内存块的指针

直觉上的指向:source 指向 1 + 1
实际上的指向:source 指向 ptr to heap 指向 Heap Block(其中蕴含了 1 + 1

右边是 C++ 堆栈,随着程序的执行,堆栈从上往下增长,左边是咱们后面看到的内存块。当客户端程序执行时,它会将一个 HandleScope 对象推送到本地 C++ 栈上(见 src/samples/hello-world.cc)。接下来,调用 String::NewFromUtf8Literal() 的返回值作为一个 Local<String> 对象存储在 C++ 栈上

看起来咱们又减少了一层间接性,但这样做是有益处的

  • 寻根更容易HandleScope 对象是一个存储堆对象的「句柄」(也就是指针)的中央。你还记得,这正是咱们的 SeqOneByteString 对象,一个指向底层堆数据的 8 字节指针。当垃圾收集启动时,V8 会迅速扫描 HandleScope 对象,找到所有的根指针。而后,如果底层堆数据被挪动,它能够更新这些指针。
  • 本地指针易于治理 – 与相当大的 HandleScope 相比,Local<String> 对象是 C++ 堆栈上的一个 8 字节的值,它能够和其余任何 8 字节的值(如指针或整数)在雷同的上下文中应用。特地是,它能够存储在 CPU 寄存器中,传递给函数,或者作为返回值提供。值得注意的是,当垃圾回收产生时,垃圾回收器不须要定位或更新这些值
  • 打消作用域很容易 – 最初,当客户端应用程序中的 C++ 函数实现后,C++ 堆栈上的 HandleScopeLocal 对象会被删除,但只有在它们 C++ 对象析构函数被调用后才会被删除。这些析构函数从垃圾收集器的根列表中删除了所有的句柄。它们不再在作用域中,所以底层堆对象可能曾经成为垃圾

译者附:析构函数(destructor)与构造函数相同,当对象完结其生命周期,如对象所在的函数已调用结束时,零碎主动执行析构函数。析构函数往往用来做“清理善后”的工作(例如在建设对象时用 new 开拓了一片内存空间,delete 会主动调用析构函数后开释内存)

最初,援用咱们的 1 + 1 字符串的 source 变量,当初曾经筹备好在咱们的客户端应用程序中传递到下一行

Local<Script> script = 
    Script::Compile(context, source).ToLocalChecked();

下一节……

在堆上调配 1 + 1 的字符串显然有很多工作要做。心愿它能阐明 V8 外部架构的一些局部,以及在零碎的不同局部如何示意数据。在将来的博文中,我会更多地钻研咱们的简略表达式是如何被解析和执行的,这将暴露出更多对于 V8 的运作形式

在本系列博文的第 2 局部,我将深入研究 _编译缓存 _是如何工作的,以防止编译代码超过必要的工夫

附录:

第一次翻译文章,感激 deepL、百度翻译、谷歌翻译

作者受权:

正文完
 0