數棧技術分享:利用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