JavaScript 程式碼執行

以大家開發常用的 chrome 瀏覽器或 Node 舉例,我們的 JavaScript 程式碼是透過 V8 執行的。但 V8 是怎麼執行程式碼的呢?當我們輸入

const foo = {foo:‘foo’}

時 V8 又做了什麼?筆者先丟擲以上問題,我們接著往下看。

JavaScript 儲存

在程式碼執行時,最重要的前提便是有一個能夠儲存狀態的地方,這便是我們所述的堆疊空間。我們的基礎型別是儲存在棧中的,會自動進行回收;而複合型別是儲存在堆中的,透過 GC 操作進行空間釋放。這一過程對於使用者來說是隱式的,因此使用者必須按照 JavaScript 的規範來寫程式碼,如果沒有符合規範,那 GC 就無法正確的回收空間,因此會造成 ML 現象,更嚴重的就會造成 OOM。

為了更直觀的看清每一種型別在記憶體中的儲存形式,筆者建立了一個基礎型別變數

Foo

,複合型別

Bar

,以及一個宣告

John

,並給出它們在記憶體堆疊中的狀態圖:

利用 V8 深入理解 JavaScript 設計

關於 GC

透過上述分析,我們提到了 GC 會對無效物件進行回收以及空間釋放,對於使用者而言,不管是基礎型別還是複合型別他們的宣告與釋放都是自動的。但實際上

關於堆的回收是手動的

,只是在 V8 層面已經幫我們實現了而已,並且這一過程也不是完全免費的(

write barrier

)。但這一自動的過程讓很大部分開發人可以完全忽視它的存在,顯然 JavaScript 是故意設計如此。

利用 V8 深入理解 JavaScript 設計

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

也是一門系統級別的語言,FireFox 的 JavaScript 引擎

TraceMonkey

就用此語言進行編寫。值得一提的是

TraceMonkey

的前身

SpiderMonkey

就是世界上第一款 JavaScript 引擎。當然,這裡所謂直接操作記憶體堆疊內容,還是經過了硬體的一些對映,我們的高階語言在 OS 的上層,因此 OS 依舊給程式造成了直接操作記憶體的假象。

回到 JavaScript ,顯然它

並不是一門定義在“系統級別”的語言

,更多的是更上游的

應用級別

語言,因此語言的設計以及應用場景都更加趨向於把一些底層的概念進行隱藏。除了語言的定位以外,JavaScript 是一門動態型別的語言,這意味著在語言執行時有非常多的執行資訊,裡面記錄著諸如

全域性執行上下文

全域性作用域

原型鏈繼承

資訊等等,正因為這些特性必須在執行時才可以完成,因此又多了一個需要 V8 的理由,同時也引出了 V8 中直譯器的作用。

關於 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 (虛擬機器)

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 深入理解 JavaScript 設計

完成初始化工作後,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 )]

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

- length: 2

0: 0x3a50082d2535

1: 0x3a500804494d

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

,也就是

a0。foo

。最後的

[1]

表示反饋向量索引,反饋向量包含用於效能最佳化的 runtime 資訊。簡要來說是把

obj。foo

放入累加器。

緊接著

AddSmi [1], [0]

表示讓累加器中的值和 [1] 相加,由於這是數字 1 因此沒有存在對應的表中。最後累加器中的值已經儲存為 2。最後的

[0]

表示反饋向量索引

由於我們定義了一個變數來儲存累加器結果,因此位元組碼也對應了響應的儲存碼

Star0

表示取出對應累加器的值並存儲到暫存器 r0 中。

LdaConstant [1]

表示取對應表中的第

[i]

個元素存入累加器,也就是取出

1: 0x3a500804494d

, 存入累加器。

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

重新編譯回字節碼。

利用 V8 深入理解 JavaScript 設計

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

方式只支援接收表示式,如果使用函式語句執行

IIFE

,那麼 V8 會隱性地把函式語句

foo

理解成函式表示式

foo

,從而執行。

在 ES6 出現模組作用域之前,JavaScript 中沒有私有作用域的概念,因此在多人開發專案的時候,常常會使用單例模式,以 IIFE 的模式建立一個 namespace 以減少全域性變數命名衝突的問題。因此 IIFE 最大的特點是執行不會汙染環境,函式和函式內部的變數都不會被其他部分的程式碼訪問到,外部只能獲取到 IIFE 的返回結果。

function

foo

obj

{

const

bar

=

obj

foo

+

1

return

bar

+

‘1’

})({

foo

1

})

既然函式本質是物件,那麼函式是如何獲得和其他物件不一樣的可呼叫特性的呢?

V8 內部為了處理函式的可呼叫特性,會給每個函式加入隱藏屬性,如下圖所示:

利用 V8 深入理解 JavaScript 設計

隱藏屬性分別是函式的

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 深入理解 JavaScript 設計

完成儲存分解後,物件的存取會根據索引值的類別去對應的屬性中進行查詢,如果是對屬性值的全量索引,那麼 V8 會從

elements

中按升序讀取元素,再去

properties

中讀取剩餘的元素。

值得注意的是 V8 對 ECMAScript 的實現是惰性的,在記憶體中 V8 並沒有對

element

元素升序排列,而是使用了 Unicode 編碼進行排列。看下列 V8 原始碼,即透過 Unicode 字典順序進行排列:

comparefn

=

function

x

y

{

if

x

===

y

return

0

if

%

_IsSmi

x

&&

%

_IsSmi

y

))

{

return

%

SmiLexicographicCompare

x

y

);

}

x

=

TO_STRING

x

);

y

=

TO_STRING

y

);

if

x

==

y

return

0

else

return

x

<

y

-

1

1

};

利用 V8 深入理解 JavaScript 設計

在 chrome 中的顯示

物件內屬性

V8 將物件按屬性分為兩類後,簡化了物件查詢效率,但是也會多一個步驟,例如筆者現在需要訪問

Foo。bar3

,v8 需要先訪問相應的物件

Foo

,再訪問相應的

properties

才能取到

bar3

對應的值,為了簡化操作, V8 會為物件的

properties

屬性預設分配 10 個物件內屬性(in-object properties)如下圖所示:

利用 V8 深入理解 JavaScript 設計

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

in-object 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 已經降級處理。

利用 V8 深入理解 JavaScript 設計

編譯器最佳化

以上文的程式碼為例,我們再建立一個更大的物件例項

const

foos

=

new

Foo

10000

10000

);

由於我們建立物件的建構函式是固定的結構,因此理論上會觸發監控器標記熱點程式碼,交給編譯器進行對應的最佳化,我們來看看 V8 的輸出記錄

[marking 0x2ca4082d26e5 for optimized recompilation, reason: small function]

[compiling method 0x2ca4082d26e5 (target TURBOFAN) using TurboFan OSR]

[optimizing 0x2ca4082d26e5 (target TURBOFAN) - took 1。135, 3。040, 0。287 ms]

[marking 0x2ca4082d26e5 for optimized recompilation, reason: small function]

[compiling method 0x2ca4082d26e5 (target TURBOFAN) using TurboFan OSR]

[optimizing 0x2ca4082d26e5 (target TURBOFAN) - took 0。596, 1。681, 0。050 ms]

可以看見確實輸出了對應的最佳化記錄,但筆者沒有對其進行更深入的研究,若有同學知道更多關於編譯器最佳化的細節歡迎補充。

關於 _proto_

JavaScript 的繼承非常有特點,是使用原型鏈的方式進行繼承,用

_proto_

作為連結的橋樑。但是 V8 內部是非常不建議直接使用

_proto_

直接操作物件的繼承,因為這涉及到 V8 隱藏類相關,會破壞 V8 在物件例項生成時已經做好的隱藏類最佳化與相應的類偏移(class transition)操作。

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

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

+

1

n

// 表示式被 V8 轉換為

const

foo

=

Number

1

)。

toString

()

+

‘1’

+

String

null

+

String

undefined

+

BigInt

1

n

)。

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),處理步驟如下:

利用 V8 深入理解 JavaScript 設計

如果 object 為基本型別,直接返回結果

否則,呼叫 valueOf 方法,如果返回一個原始值,則 JavaScript 將其返回。

否則,呼叫 toString 方法,如果返回一個原始值,則 JavaScript 將其返回。

否則,JavaScript 丟擲一個

TypeError

異常。

對應 ToPrimitive(object, String),處理步驟如下:

利用 V8 深入理解 JavaScript 設計

如果 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

無法轉換成基礎型別。

總結

利用 V8 深入理解 JavaScript,這個標題起的有點狂,但對於筆者來說透過對此學習確實更進一步理解了 JavaScript 甚至其他語言的工作機制,同時對前端和技術棧等概念有了更深層次的思考。

本文主要透過日常簡單的程式碼儲存引出 V8 相關以及計算機科學的一些概念,從 JavaScript 的定位推匯出當前設計的原因,以及結合 V8 工作流程給出一個宏觀的認識;接著透過詳細的步驟完整的展現了 V8 編譯流水線每個環節的產物;透過分析 JavaScript 物件引出其儲存規則;最後透過型別系統引出 V8 對不同型別資料進行互動的規則實現。

對於 V8 龐大而複雜的執行結構來說本文只闡述了鳳毛麟角,文中有太多的話題可以用來延伸引出更多值得研究的學問,希望同學們透過本文可以有所收穫和思考,如果文中有錯誤歡迎在評論區指出。

參考資料

Concurrent marking in V8

How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code

維基百科-型別系統

ECMAScript® 2015 Language Specification

ECMAScript

Understanding V8’s Bytecode

透過位元組碼保護Node。js原始碼之原理篇