数栈技术分享:利用V8深刻理解 JavaScript 设计

1、JavaScript 代码运行

以你们开发经常使用的 Chrome 浏览器或 Node 举例,咱们的 JavaScript 代码是经过 V8 运行的。但 V8 是怎么执行代码的呢?当咱们输入 const foo = {foo:'foo'} 时 V8 又作了什么?笔者先抛出以上问题,咱们接着往下看。前端

2、JavaScript 存储

在代码运行时,最重要的前提即是有一个可以存储状态的地方,这即是咱们所述的堆栈空间。node

咱们的基础类型是保存在栈中的,会自动进行回收;而复合类型是保存在堆中的,经过GC操做进行空间释放。这一过程对于用户来讲是隐式的,所以用户必须按照 JavaScript 的规范来写代码,若是没有符合规范,那 GC 就没法正确的回收空间,所以会形成 ML 现象,更严重的就会形成 OOM。python

为了更直观的看清每一种类型在内存中的存储形式,笔者建立了一个基础类型变量 Foo,复合类型 Bar,以及一个声明 John,并给出它们在内存堆栈中的状态图:git

一、关于 GC

经过上述分析,咱们提到了 GC 会对无效对象进行回收以及空间释放,对于用户而言,无论是基础类型仍是复合类型他们的声明与释放都是自动的。但实际上关于堆的回收是手动的,只是在 V8 层面已经帮咱们实现了而已,而且这一过程也不是彻底免费的(write barrier)。但这一自动的过程让很大部分开发人能够彻底忽视它的存在,显然 JavaScript 是故意设计如此。github

write barrier 用于在异步三色标记算法进行中通知 GC 目前对象图变动的全部操做,以保证三色标记法在异步过程当中的准确性, v8 插入的 write barrier 代码。
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}

二、JavaScript 的定位

使用过 C / C++ 的同窗必定对手动操做内存和释放内存有很深的体会,同时 GO 和 D 也存在着指针的概念。通常来讲,若是一门语言是定位在“系统级别”均可以直接操做内存空间,除了上述提到的语言外,Rust也是一门系统级别的语言,FileFox 的 VM TraceMonkey 就用此语言进行编写。算法

值得一提的是 TraceMonkey 的前身 MoonMonkey 就是世界上第一款 JavaScript 引擎。固然,这里所谓直接操做内存堆栈内容,仍是通过了硬件的一些映射,咱们的高级语言在 OS 的上层,所以 OS 依旧给程序形成了直接操做内存的假象。npm

回到 JavaScript ,显然它并非一门定义在“系统级别”的语言,更多的是更上游的应用级别语言,所以语言的设计以及应用场景都更加趋向于把一些底层的概念进行隐藏。编程

除了语言的定位之外,JavaScript 是一门动态类型的语言,这意味着在语言运行时有很是多的运行信息,里面记录着诸如全局执行上下文、全局做用域、原型链继承 信息等等,正由于这些特性必须在运行时才能够完成,所以又多了一个须要 V8 的理由,同时也引出了 V8 中解释器的做用。浏览器

1)关于 CPU

在介绍解释器以前,咱们先来看看 CPU。如今的 CPU 很复杂,咱们先把 CPU 纯粹化,即拥有简单的指令集、ALU、寄存器。它在执行代码的时候思想其实很简单,就是一大串if ... else ...来判断当前的指令代码,解析指令。安全

换言之,CPU 的基本工做只是按照操做码进行计算和跳转,它不会检查程序是否正确,只要操做码匹配上就会执行,天然也不会管内容的堆栈中究竟是什么数据。如下是 RISC-V 处理器代码片断,能够看到其只是经过判断指令,执行相应操做。

while(1){
    iters++;
    if((iters % 500) == 0)
      write(1, which_child?"B":"A", 1);
    int what = rand() % 23;
    if(what == 1){
      close(open("grindir/../a", O_CREATE|O_RDWR));
    } else if(what == 2){
      close(open("grindir/../grindir/../b", O_CREATE|O_RDWR));
    } else if(what == 3){
      unlink("grindir/../a");
    } else if(what == 4){
      if(chdir("grindir") != 0){
        printf("grind: chdir grindir failed\n");
        exit(1);
      }
      unlink("../b");
      chdir("/");
    } else if(what == 5){
      close(fd);
      fd = open("/grindir/../a", O_CREATE|O_RDWR);
    } else if(what == 6){
      close(fd);
      fd = open("/./grindir/./../b", O_CREATE|O_RDWR);
    } else if(what == 7){
      write(fd, buf, sizeof(buf));
    } else if(what == 8){
      read(fd, buf, sizeof(buf));
    } else if(what == 9){
      mkdir("grindir/../a");
      close(open("a/../a/./a", O_CREATE|O_RDWR));
      unlink("a/a");
    } else if(what == 10){
      mkdir("/../b");
      close(open("grindir/../b/b", O_CREATE|O_RDWR));
      unlink("b/b");
    } else if(what == 11){
      unlink("b");
      link("../grindir/./../a", "../b");
    } else if(what == 12){
      unlink("../grindir/../a");
      link(".././b", "/grindir/../a");
    } else if(what == 13){
      int pid = fork();
      if(pid == 0){
        exit(0);
      } else if(pid < 0){
        printf("grind: fork failed\n");
        exit(1);
      }
      wait(0);
    } else if(what == 14){
      int pid = fork();
      if(pid == 0){
        fork();
        fork();
        exit(0);
      } else if(pid < 0){
        printf("grind: fork failed\n");

那么回到 V8,V8 的解释器的做用之一就是记录程序的运行时状态,能够作到跟踪内存状况,变量类型监控,以保证代码执行的安全性。在 C / C++ 中手动操做内存的语言中若是内存出现小越界并不必定会致使程序崩溃,但结果确定会出问题,但这样排查又很耗时间。

既然我已经提到了V8解释器相关的概念,那咱们对此继续进行扩展,正因JavaScript 是一门动态类型的语言,所以须要解释器对编码进行处理,因此早期的 JavaScript 引擎运行代码的速度很慢,所以解释器有一个很大的特色,那就是启动速度快,执行速度慢。

为了改善这个问题,所以 V8 最先引入了即时编译(JIT)的概念,后来其余引擎也相继引入,所以如今流行的大部分 JavaScript 引擎都拥有该特性。它主要使用了权衡策略,同时使用了解释器和编译器。

编译器具备启动速度慢,执行速度快的特色。他们是这样配合工做的:代码转换成 AST 后先交给解释器进行处理,若是解释器监控到有部分 JavaScript 代码运行的次数较多,而且是固定结构,那么就会标记为热点代码并交给编译器进行处理,编译器会把那部分代码编译为二进制机器码,并进行优化,优化后的二进制代码交给 CPU 执行速度就会获得大幅提高。

同时这又引出一个须要 V8 的理由:因为不一样的 CPU 的指令集是不一样的,所以为了作到跨平台确定得作一层抽象,而 V8 就是这层抽象,以脱离目标机代码的机器相关性。

谈到这里,同窗们也必定清楚了咱们为何须要 V8 以及 V8 底层大体是如何执行一段 JavaScript 代码的,但笔者在上述过程当中最主要的仍是引出咱们须要 V8 的缘由,因此我规避了不少 V8 编译时产生的细节。

简要来讲,JavaScript 是一门应用定位的语言,为了方便作到安全性,跨平台,运行时状态的控制等需求,因此咱们选择在真实机器上再套一层进行处理,也能够叫这层为 VM (虚拟机)。

3、V8 编译过程

下面咱们在详细论述一下 V8 是如何执行 JavaScript 代码的,根据前面所述 V8 为了提高执行效率,混合使用了解释执行与编译执行,也就是咱们所说的即时编译(Just In Time),目前使用这类方式的语言也有好多好比 Java 的 JVM, lua 脚本的 LuaJIT 等等,当咱们执行编码:

foo({foo: 1});

function foo(obj) {
  const bar = obj.foo + 1
  
  return bar + '1'
}

咱们能够发现 foo 是能够执行的,在 JavaScript 语言中咱们称这种现象为变量提高,但从另外一个角度理解,注意我上面写的称呼了么?编码;咱们所写的程序代码只是给人类看的,对于机器来讲只是无心义的字符,正所以因此也叫高级语言。因此最终的执行和咱们写的编码彻底能够不对等,所以不能彻底按照咱们的编码去理解执行。

但是机器是如何处理咱们的编码的呢?因为编码字符串对于机器来讲并不容易操做,所以咱们会把它转换成 AST (抽象语法树),使用这种树状的数据结构,能够很是清晰有效的操做咱们的编码,把其最终编译为机器能够理解的机械语言。

那么 V8 是如何处理变量提高的呢,很显然在 V8 启动执行 JavaScript 代码以前,它就须要知道有哪些变量声明语句,把其置入做用域内。

根据如上分析,咱们能够知道 V8 在启动时,首先须要初始化执行环境,而 V8 中主要的初始化操做为:

  • 初始化“堆空间”、“栈空间”
  • 初始化全局上下文环境,包括执行过程当中的全局信息,变量等
  • 初始化全局做用域。而函数做用域以及其余子做用域是执行时才存在的
  • 初始化事件循环系统

完成初始化工做后,V8 会使用解析器把编码结构化成 AST,下面咱们看一下 V8 生成的 AST 是什么样的,执行的编码以笔者上文中的例子为准。

[generating bytecode for function: foo]
--- AST ---
FUNC at 28
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "foo"
. PARAMS
. . VAR (0x7fe5318086d8) (mode = VAR, assigned = false) "obj"
. DECLS
. . VARIABLE (0x7fe5318086d8) (mode = VAR, assigned = false) "obj"
. . VARIABLE (0x7fe531808780) (mode = CONST, assigned = false) "bar"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 50
. . . INIT at 50
. . . . VAR PROXY local[0] (0x7fe531808780) (mode = CONST, assigned = false) "bar"
. . . . ADD at 58
. . . . . PROPERTY at 54
. . . . . . VAR PROXY parameter[0] (0x7fe5318086d8) (mode = VAR, assigned = false) "obj"
. . . . . . NAME foo
. . . . . LITERAL 1
. RETURN at 67
. . ADD at 78
. . . VAR PROXY local[0] (0x7fe531808780) (mode = CONST, assigned = false) "bar"
. . . LITERAL "1"

以上是 V8 输出的 AST 语法树格式,虽然展现上并非很直观,但它在本质上和 babel / acorn 等 JavaScript Parser 所编译的 AST Tree 是同样的,它们均遵循 ESTree 规范。将其转换成咱们的熟悉的格式以下:

{
  "type": "Program",
  "body": [
    {
      "type": "FunctionDeclaration",
      "id": {
        "type": "Identifier",
        "name": "foo"
      },
      "params": [
        {
          "type": "Identifier",
          "name": "obj"
        }
      ],
      "body": {
        "type": "BlockStatement",
        "body": [
          {
            "type": "VariableDeclaration",
            "declarations": [
              {
                "type": "VariableDeclarator",
                "id": {
                  "type": "Identifier",
                  "name": "bar"
                },
                "init": {
                  "type": "BinaryExpression",
                  "left": {
                    "type": "MemberExpression",
                    "object": {
                      "type": "Identifier",
                      "name": "obj"
                    },
                    "property": {
                      "type": "Identifier",
                      "name": "foo"
                    },
                  },
                  "operator": "+",
                  "right": {
                    "type": "Literal",
                    "value": 1,
                    "raw": "1"
                  }
                }
              }
            ],
          },
          {
            "type": "ReturnStatement",
            "start": 51,
            "end": 67,
            "argument": {
              "type": "BinaryExpression",
              "left": {
                "type": "Identifier",
                "name": "bar"
              },
              "operator": "+",
              "right": {
                "type": "Literal",
                "value": "1",
                "raw": "'1'"
              }
            }
          }
        ]
      }
    }
  ],
}

对编码转换 AST 后,就完成了对 JavaScript 编码的结构化表述了,编译器就能够对源码进行相应的操做了,在生成 AST 的同时,还会生成与之对应的做用域,好比上述代码就会产生以下做用域内容:

Global scope:
global { // (0x7f91fb010a48) (0, 51)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0x7f91fb010ef8) local[0]
  // local vars:
  VAR foo;  // (0x7f91fb010e68)

  function foo () { // (0x7f91fb010ca8) (20, 51)
    // lazily parsed
    // 2 heap slots
  }
}
Global scope:
function foo () { // (0x7f91fb010c60) (20, 51)
  // will be compiled
}

上面这行代码生成了一个全局做用域,咱们能够看到 foo 变量被添加进了这个全局做用域中。

一、字节码

完成上述步骤后,解释器 Ignition 会根据 AST 生成对应的字节码

因为 JavaScript 字节码目前并无和 JVM 或 ESTree 那样标准化,所以其格式会与 V8 引擎版本紧密相关。

二、看懂一段字节码

字节码是机器码的抽象,若是字节码采用和物理 CPU 相同的计算模型进行设计,那字节码编译为机器码会更容易,这就是说解释器经常是寄存器或堆栈。换言之 Ignition 是具备累加器的寄存器。

V8 的字节码头文件 bytecodes.h 定义了字节码的全部种类。把这些字节码的描述块组合在一块儿就能够构成任何 JavaScript 功能。

不少的字节码都知足如下正则 /^(Lda|Sta).+$/ 它们当中的 a 代指累加器 (accumulator),主要用于描述把值操做到累加器寄存器中,或把当前在累加器中的值取出并存储在寄存器中。所以能够把解释器理解成是带有累加器的寄存器

上述事例代码经过 V8 解释器输出的 JavaScript 字节码以下:

[generated bytecode for function: foo (0x3a50082d25cd <SharedFunctionInfo foo>)]
Bytecode length: 14
Parameter count 2
Register count 1
Frame size 8
OSR nesting level: 0
Bytecode Age: 0
         0x3a50082d278e @    0 : 28 03 00 01       LdaNamedProperty a0, [0], [1]
         0x3a50082d2792 @    4 : 41 01 00          AddSmi [1], [0]
         0x3a50082d2795 @    7 : c6                Star0
         0x3a50082d2796 @    8 : 12 01             LdaConstant [1]
         0x3a50082d2798 @   10 : 35 fa 03          Add r0, [3]
         0x3a50082d279b @   13 : ab                Return
Constant pool (size = 2)
0x3a50082d275d: [FixedArray] in OldSpace
 - map: 0x3a5008042205 <Map>
 - length: 2
           0: 0x3a50082d2535 <String[3]: #foo>
           1: 0x3a500804494d <String[1]: #1>
Handler Table (size = 0)
Source Position Table (size = 0)

咱们先来看看 foo 函数的字节码输出,LdaNamedProperty a0, [0], [1] 将 a0 命名的属性加载到累加器中,a[i]中的 i 表示的是 arguments[i-1] 的也就是函数的第 i 个参数。

那么这个操做就是取出函数的第一个参数放入累加器,后面跟着的 [0] 表示 的是 0: 0x30c4082d2535 <String[3]: #foo> ,也就是 a0.foo。

最后的 [1] 表示反馈向量索引,反馈向量包含用于性能优化的 runtime 信息。简要来讲是把 obj.foo 放入累加器。

紧接着 AddSmi [1], [0] 表示让累加器中的值和 [1] 相加,因为这是数字 1 所以没有存在对应的表中。最后累加器中的值已经存储为 2。

最后的 [0] 表示反馈向量索引因为咱们定义了一个变量来存储累加器结果,所以字节码也对应了响应的存储码 Star0 表示取出对应累加器的值并存储到寄存器 r0 中。

LdaConstant [1] 表示取对应表中的第 [i] 个元素存入累加器,也就是取出 1: 0x3a500804494d <String[1]: #1>, 存入累加器。

Add r0, [3] 表示当前累加器的值 '1' 与寄存器 r0 的值:2 进行累加,最后的 [3] 表示反馈向量索引

最后的 Return 表示返回当前累加器的值 '21'。返回语句是函数 Foo() 的介绍,此时 Foo 函数的调用者能够再累加器得到对应值,并进一步处理。

三、字节码的运用

因为字节码是机器码的抽象,所以在运行时会比咱们的编码直接交给 V8 来的更加友好,由于若是对 V8 直接输入字节码,就能够跳过对应的使用 Parser 生成对应 AST 树的流程,换言之在性能上会有较大的提高,而且在安全性上也有很是好的保障。

由于字节码经历了完整的编译流程,抹除了源码中携带的额外语义信息,其逆向难度能够与传统的编译型语言相比。

在 npm 上发现了 Bytenode,它是 做用于 Node.js 的字节码编译器( bytecode compiler ),能把 JavaScript 编译成真正的 V8 字节码从而保护源代码,目前笔者也看见有人进行过这方面应用的详细分享,详情可见文末的参考文献-用字节码包含 node.js 源码之原理篇。

四、即时编译的解释执行与编译执行

生成字节码后,V8 编译流程有两条链路能够选择,常规代码会直接执行字节码,由字节码的编译器直接执行。处理字节码的 parser 笔者没有对其了解,姑且能够先理解成字节码最后以 gcc 处理成机器代码执行。

当咱们发现执行代码中有重复执行的代码,V8 的监控器会将其标记为热点代码,并提交给编译器 TurboFan 执行,TurboFan 会将字节码编译成 Optimized Machine Code,优化后的机器代码执行效率会得到极大的提高。

可是 JavaScript 是一门动态语言,有很是多的运行时状态信息,所以咱们的数据结构能够在运行时被任意修改,而编译器优化后的机器码只可以处理固定的结构,所以一旦被编译器优化的机器码被动态修改,那么机器码就会无效,编译器须要执行 反优化 操做,把 Optimized Machine Code 从新编译回字节码。

 

4、JavaScript Object

JavaScript 是一门 基于对象(Object-Based) 的语言,能够说 JavaScript 中除了 null,undefined 之类的特殊表示外大部分的内容都是由对象构成的,咱们甚至能够说 JavaScript 是创建在对象之上的语言。

可是 JavaScript 从严格上讲并非一门面向对象的语言,这也是由于面向对象语言须要天生支持封装、继承、多态。可是 JavaScript 并无直接提供多态支持,可是咱们仍是能够实现多态,只是实现起来仍是较为麻烦。

JavaScript 的对象结构很简单,由一组建和值构成,其中值能够由三种类型:

  • 原始类型:原始类型主要包括:null、undefined、boolean、number、string、bigint、symbol,以相似栈数据结构存储,遵循先进后出的原则,并且具备 immutable 特色,好比咱们修改了 string 的值,V8 会返回给咱们一个全新的 string。
  • 对象类型:JavaScript 是创建在对象之上的语言,因此对象的属性值天然也能够是另外一个对象。
  • 函数类型:若是函数做为对象的属性,咱们通常称其为方法。

一、Function

函数做为 JavaScript 中的一等公民,它能很是灵活的实现各类功能。其根本缘由是 JavaScript 中的函数就是一种特殊的对象。

正由于函数是一等公民的设计,咱们的 JavaScript 能够很是灵活的实现闭包和函数式编程等功能。函数能够经过函数名称加小括号进行调用:

function foo(obj) {
  const bar = obj.foo + 1
  return bar + '1'
}

foo({foo: 1});

也可使用匿名函数,IIFE 方式调用,实际上 IIFE 方式只支持接收表达式,可是下例的函数是语句,所以 V8 会隐性地把函数语句 foo 理解成函数表达式 foo,从而运行。

在 ES6 出现模块做用域以前,JavaScript 中没有私有做用域的概念,所以在多人开发项目的时候,经常会使用单例模式,以 IIFE 的模式建立一个 namespace 以减小全局变量命名冲突的问题。所以 IIFE 最大的特色是执行不会污染环境,函数和函数内部的变量都不会被其余部分的代码访问到,外部只能获取到 IIFE 的返回结果。
(function foo(obj) {
  const bar = obj.foo + 1
  
  return bar + '1'
})({foo: 1})

既然函数本质是对象,那么函数是如何得到和其余对象不同的可调用特性的呢?V8 内部为了处理函数的可调用特性,会给每一个函数加入隐藏属性,以下图所示:

隐藏属性分别是函数的 name 属性和 code 属性。

  • name 属性造就被浏览器普遍支持,可是直到 ES6 才将其写入标准,ES6 以前的 name 属性之因此能够获取到函数名称,是由于 V8 对外暴露了相应的接口。Function 构造函数返回的函数实例,name 属性的值为 anonymous
(new Function).name // "anonymous"
  • code 属性表示的是函数编码,以 string 的形式存储在内存中。当执行到一个函数调用语句时,V8 会从函数对象中取出 code 属性值,而后解释执行这段函数代码。V8 没有对外暴露 code 属性,所以没法直接输出。

二、About JavaScript

JavaScript 能够经过 new 关键字来生成相应的对象,不过这中间隐藏了不少细节致使很容易增长理解成本。

实际上这种作法是出于对市场的研究,因为 JavaScript 的诞生时期,Java 很是的流行,而 JavaScript 须要像 Java ,但又不能和 Java 进行 battle。

所以 JavaScript 不只在名字上蹭热度,同时也加入了 new。因而构造对象变成了咱们看见的样子。这在设计上又显得不太合理,但它也的确帮助推广了 JavaScript 热度。

另外 ES6 新增了 class 特性,但 class 在根源上仍是基于原型链继承那一套东西,在发展历史中人们尝试在 ES4 先后为了实现真正的类而作努力,然而都失败了,所以最终决定不作真正正确的事,所以咱们如今使用的 class 是真正意义上的 JS VM 语法糖,但这和咱们在项目中使用 babel 转换成函数后再执行本质上有区别,V8 在编译类的时候会给予相应的关键字进行处理。

三、Object Storage

JavaScript是基于对象的,所以对象的值类型也很是丰富。它为咱们带来灵活的同时,对象的存储数据结构用线性数据结构已经没法知足需求,得使用非线性的数据结构(字典)进行存储。这就带来了对象访问效率低下的问题。所以 V8 为了提高存储和查找效率,采用了一套复杂的存储策略。

首先咱们建立对象 foo,并打印它,相关代码以下所示:

function Foo() {
  this["bar3"] = 'bar-3'
  this[10] = 'foo-10'
  this[1] = 'foo-1'
  this["bar1"] = 'bar-1'
  this[10000] = 'foo-10000'
  this[3] = 'foo-3'
  this[0] = 'foo-0'
  this["bar2"] = 'bar-2'
}

const foo = new Foo()

for(key in foo){
  console.log(`key: ${key} value:${foo[key]}`)
}

代码输出的结果以下

key: 0 value:foo-0
key: 1 value:foo-1
key: 3 value:foo-3
key: 10 value:foo-10
key: 10000 value:foo-10000
key: bar3 value:bar-3
key: bar1 value:bar-1
key: bar2 value:bar-2

仔细观察后,能够发现 V8 隐式处理了对象的排列顺序。

  • key 为数字的属性被优先打印,并升序排列。
  • key 为字符串的属性按照被定义时的顺序进行排列。

之因此会出现这样的结果是由于 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据建立时的顺序升序排列。V8 做为 ECMAScript 的实现固然须要准守规范。

为了优化对象的存取效率,V8 经过 key把对象分红两类。

  • 对象内 key 为数字的属性称为 elements(排序属性),此类属性经过浪费空间换取时间,直接下标访问,提高访问速度。当 element 的序号十分不连续时,会优化成为 hash 表。
  • 对象内 key 为字符串的属性称为 properties(常规属性),经过把对象的属性和值分红线性数据结构和属性字典结构后,以优化本来的彻底字典存储。properties 属性默认采用链表结构,当数据量很小时,查找也会很快,但数据量上升到某个数值后,会优化成为 hash 表。上述对象在内存中存储如图所示:

完成存储分解后,对象的存取会根据索引值的类别去对应的属性中进行查找,若是是对属性值的全量索引,那么 V8 会从 elements 中按升序读取元素,再去 properties 中读取剩余的元素。

值得注意的是 V8 对 ECMAScript 的实现是惰性的,在内存中 V8 并无对 element 元素升序排列。

四、对象内属性

V8将对象按属性分为两类后,简化了对象查找效率,可是也会多一个步骤,例如笔者如今须要访问 Foo.bar3,v8 须要先访问相应的对象 Foo,再访问相应的 properties 才能取到bar3 对应的值,为了简化操做, V8 会为对象的 properties 属性默认分配 10 个对象内属性(in-object properties)以下图所示:

 

当 properties 属性不足 10 个时,全部的 properties 属性都可以成为对象内属性,当超过 10 个时,超过 10 的properties属性,从新回填到properties中采用字典结构进行存储。使用对象内属性后,再次查找对应的属性就方便多了。

对象内属性是能够动态扩充的。The number of in-object properties is predetermined by the initial size of the object。但笔者目前没有见到对象内属性经过动态扩容大于 10 个的状况。

分析到这里,同窗们能够思考下平常开发中有哪些操做会很是不利于以上规则的实现效率,好比 delete 在通常状况下是不建议使用的,它对于对象属性值的操做,由于删除元素后会形成大量的属性元素移动,并且properties也可能须要重排到对象内属性,均为额外性能的开销;在不影响代码语义流畅性的状况下,可使用 undefined 进行属性值的重设置,或者使用 Map 数据结构,Map.delete 的优化较优。

对象内属性不适用于全部场景,在对象属性过多或者对象属性被频繁变动的状况下, V8 会取消对象内属性的分配,所有降级为非线性的字典存储模式,这样虽然下降了查找速度,可是却提高了修改对象的属性的速度。例如:

function Foo(_elementsNum, _propertiesNum) {
  let [eNum, pNum] = [_elementsNum, _propertiesNum];
  // set elements
  while (eNum > 0) {
    this[eNum] = `element${eNum}`;
    eNum--;
  }
  // set property
  while (pNum > 0) {
    let ppt = `property${pNum}`;
    this[ppt] = ppt + 'value';
    pNum--;
  }
}

const foos = new Foo(100, 100);
console.log(foos);

实例化 foos 对象后,咱们观察对应内存的 properties,能够发现全部的 property${i} 属性都在 properties 中,由于数量过多已经被 V8 已经降级处理。

1)编译器优化

以上文的代码为例,咱们再建立一个更大的对象实例

const foos = new Foo(10000, 10000);

因为咱们建立对象的构造函数是固定的结构,所以理论上会触发监控器标记热点代码,交给编译器进行对应的优化,咱们来看看 V8 的输出记录

[marking 0x2ca4082d26e5 <JSFunction Foo (sfi = 0x2ca4082d25f5)> for optimized recompilation, reason: small function]
[compiling method 0x2ca4082d26e5 <JSFunction Foo (sfi = 0x2ca4082d25f5)> (target TURBOFAN) using TurboFan OSR]
[optimizing 0x2ca4082d26e5 <JSFunction Foo (sfi = 0x2ca4082d25f5)> (target TURBOFAN) - took 1.135, 3.040, 0.287 ms]
[marking 0x2ca4082d26e5 <JSFunction Foo (sfi = 0x2ca4082d25f5)> for optimized recompilation, reason: small function]
[compiling method 0x2ca4082d26e5 <JSFunction Foo (sfi = 0x2ca4082d25f5)> (target TURBOFAN) using TurboFan OSR]
[optimizing 0x2ca4082d26e5 <JSFunction Foo (sfi = 0x2ca4082d25f5)> (target TURBOFAN) - took 0.596, 1.681, 0.050 ms]

能够看见确实输出了对应的优化记录,但笔者没有对其进行更深刻的研究,如有同窗知道更多关于编译器优化的细节欢迎补充。

2)关于 proto

JavaScript 的继承很是有特色,是使用原型链的方式进行继承,用 _proto_ 做为连接的桥梁。可是 V8 内部是很是不建议直接使用 _proto_ 直接操做对象的继承,由于这涉及到 V8 隐藏类相关,会破坏 V8 在对象实例生成时已经作好的隐藏类优化与相应的类偏移(class transition)操做。

5、JavaScript 类型系统

JavaScript 中的类型系统是很是基础的知识点,但它也是被应用地最普遍灵活,状况复杂且容易出错的,主要缘由在于类型系统的转换规则繁琐,且容易被工程师们忽视其重要性。

在CPU中对数据的处理只是移位,相加或相乘,没有相关类型的概念,由于它处理的是一堆二进制代码。但在高级语言中,语言编译器须要判断不一样类型的值相加是否有相应的意义。

例如同 JavaScript 同样是弱类型语言的 python 输入如下代码 1+'1'

In[2]: 1+'1'

Traceback (most recent call last):
  File "..", line 1, in run_code
 exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-2-0cdad81f9201>", line 1, in <module>
 1+'1'
TypeError: unsupported operand type(s) for +: 'int' and 'str'

能够看见抛出对应 TypeError 的错误,可是这段代码在 JavaScript 中不会报错,由于这被 V8 类型系统认为是有意义的代码。

console.log(1+'1') 

// 11

形成上述现象结果的内在是类型系统。类型系统越强大,那编译器可以检测的内容范围也越大。它能影响的不仅是类型的定义,还有对于类型的检查,以及不一样类型以前操做交互的定义。

在维基百科中,类型系统是这样定义的:在计算机科学中,类型系统(type system)用于定义如何将编程语言中的数值和表达式归类为许多不一样的类型,如何操做这些类型,这些类型如何互相做用。类型能够确认一个值或者一组值具备特定的意义和目的(虽然某些类型,如抽象类型和函数类型,在程序运行中,可能不表示为值)。类型系统在各类语言之间有很是大的不一样,也许,最主要的差别存在于编译时期的语法,以及运行时期的操做实现方式。

一、类型系统基本转换

ECMAScript 定义了 JavaScript 中具体的运算规则。

1.Let lref be the result of evaluating AdditiveExpression.
2.Let lval be GetValue(lref).
3.ReturnIfAbrupt(lval).
4.Let rref be the result of evaluating MultiplicativeExpression.
5.Let rval be GetValue(rref).
6.ReturnIfAbrupt(rval).
7.Let lprim be ToPrimitive(lval).
8.ReturnIfAbrupt(lprim).
9.Let rprim be ToPrimitive(rval).
10.ReturnIfAbrupt(rprim).
11.If Type(lprim) is String or Type(rprim) is String, then
    a.Let lstr be ToString(lprim).
    b.ReturnIfAbrupt(lstr).
    c.Let rstr be ToString(rprim).
    d.ReturnIfAbrupt(rstr).
    e.Return the String that is the result of concatenating lstr and rstr.
12.Let lnum be ToNumber(lprim).
13.ReturnIfAbrupt(lnum).
14.Let rnum be ToNumber(rprim).
15.ReturnIfAbrupt(rnum).
16.Return the result of applying the addition operation to lnum and rnum. See the Note below

规则比较复杂,咱们慢慢分解进行介绍。以加法为例,先来看看标准类型,若是是数字和字符串进行相加,其中只要出现字符串,V8 会处理其余值也变成字符串,例如:

const foo = 1 + '1' + null + undefined + 1n

// 表达式被 V8 转换为
const foo = Number(1).toString() + '1' + String(null) + String(undefined) + BigInt(1n).toString()

// "11nullundefined1"

若是参与运算的内容并非基础类型,根据 ECMAScript 规范来看,V8 实现了一个 ToPrimitive 方法,其做用是把复合类型转换成对应的基本类型。ToPrimitive 会根据对象到字符串的转换或者对象到数字的转换,拥有两套规则:

type NumberOrString = number | string

type CheckType<T> = T extends NumberOrString ? NumberOrString : never

type PrototypeFunction<T extends NumberOrString> = (input: Record<string, any>, flag:T) => CheckType<T>

type ToPrimitive = PrototypeFunction<NumberOrString>

从上述 TypeScript 类型能够得知,虽然对象都会使用 ToPrimitive 进行转换,但根据第二个参数的传参不一样,最后的处理也会有所不一样。下面会给出不一样参数所对应的 ToPrimitive 处理流程图:

对应 ToPrimitive(object, Number),处理步骤以下:

  • 若是 object 为基本类型,直接返回结果
  • 不然,调用 valueOf 方法,若是返回一个原始值,则 JavaScript 将其返回。
  • 不然,调用 toString 方法,若是返回一个原始值,则 JavaScript 将其返回。
  • 不然,JavaScript 抛出一个 TypeError 异常。

对应 ToPrimitive(object, String),处理步骤以下:

  • 若是 object 为基本类型,直接返回结果
  • 不然,调用 toString 方法,若是返回一个原始值,则 JavaScript 将其返回。
  • 不然,调用 valueOf 方法,若是返回一个原始值,则 JavaScript 将其返回。
  • 不然,JavaScript 抛出一个 TypeError 异常。

其中 ToPrimitive 的第二个参数是非必填的,默认值为 number 可是 date 类型是例外,默认值是 string。

下面咱们来看几个例子,验证一下:

/*
例一
*/
{ foo: 'foo' } + { bar: 'bar' }
// "[object Object][object Object]"

/*
例二
*/
{
  foo: 'foo',
  valueOf() {
    return 'foo';
  },
  toString() {
    return 'bar';
  },
} +
{
  bar: 'bar',
  toString() {
    return 'bar';
  },
}
// "foobar"

/*
例三
*/
{
  foo: 'foo',
  valueOf() {
    return Object.create(null);
  },
  toString() {
    return Object.create(null);
  },
} +
{
  bar: 'bar',
}
// Uncaught TypeError: Cannot convert object to primitive value

/*
例四
*/
const date = new Date();
date.valueof = () => '123';
date.toString = () => '456';
date + 1;
// "4561"

其中例三会报错,由于 ToPrimitive 没法转换成基础类型。

6、总结

利用 V8 深刻理解 JavaScript,这个标题可能起的有点狂,但对于笔者来讲经过对此学习确实更进一步理解了 JavaScript 甚至其余语言的工做机制,同时对前端和技术栈等概念有了更深层次的思考。
本文主要经过平常简单的代码存储引出V8相关以及计算机科学的一些概念,从JavaScript 的定位推导出当前设计的缘由,以及结合 V8 工做流程给出一个宏观的认识;接着经过详细的步骤完整的展示了 V8 编译流水线每一个环节的产物;经过分析 JavaScript 对象引出其存储规则;最后经过类型系统引出 V8 对不一样类型数据进行交互的规则实现。

对于 V8 庞大而复杂的执行结构来讲本文只阐述了百里挑一,文中有太多的话题能够用来延伸引出更多值得研究的学问,但愿同窗们经过本文能够有所收获和思考,若是文中有错误欢迎在评论区指出。

数栈是云原生—站式数据中台PaaS,咱们在github和gitee上有一个有趣的开源项目:FlinkX,FlinkX是一个基于Flink的批流统一的数据同步工具,既能够采集静态的数据,也能够采集实时变化的数据,是全域、异构、批流一体的数据同步引擎。你们喜欢的话请给咱们点个star!star!star!

github开源项目:https://github.com/DTStack/flinkx

gitee开源项目:https://gitee.com/dtstack_dev_0/flinkx