起源: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
指针
这里展现了这种根本状况,显示了以后半空间的前后状态:
如果以后的半空间用完了可用内存(Top
和 Limit
太靠近),那么 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::SeqOneByteString
(v8::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++ 堆栈上的
HandleScope
和Local
对象会被删除,但只有在它们 C++ 对象析构函数被调用后才会被删除。这些析构函数从垃圾收集器的根列表中删除了所有的句柄。它们不再在作用域中,所以底层堆对象可能曾经成为垃圾
译者附:析构函数(destructor)与构造函数相同,当对象完结其生命周期,如对象所在的函数已调用结束时,零碎主动执行析构函数。析构函数往往用来做“清理善后”的工作(例如在建设对象时用 new 开拓了一片内存空间,delete 会主动调用析构函数后开释内存)
最初,援用咱们的 1 + 1
字符串的 source
变量,当初曾经筹备好在咱们的客户端应用程序中传递到下一行
Local<Script> script =
Script::Compile(context, source).ToLocalChecked();
下一节……
在堆上调配 1 + 1
的字符串显然有很多工作要做。心愿它能阐明 V8 外部架构的一些局部,以及在零碎的不同局部如何示意数据。在将来的博文中,我会更多地钻研咱们的简略表达式是如何被解析和执行的,这将暴露出更多对于 V8 的运作形式
在本系列博文的第 2 局部,我将深入研究 _编译缓存 _是如何工作的,以防止编译代码超过必要的工夫
附录:
第一次翻译文章,感激 deepL、百度翻译、谷歌翻译
作者受权: