來源:https://medium.com/compilers/calculating-1-1-in-javascript-1cecb6e9610javascript
我是一個編譯器愛好者,一直在學習 V8 JavaScript 引擎的工做原理。固然,學習東西最好的方式就是寫出來,因此這也是我在這裏分享經驗的緣由。我但願這也能讓其餘人感興趣。
譯者注:
翻譯已得到做者受權。
因爲我對一部分名詞、c++ 語法也不怎麼了解,因此結合本身的理解以及上下文作了一些「譯者注」,能夠有取捨的參考
毫無疑問 1 + 1 = 2
,可是 V8 JavaScript 的引擎是如何計算出來的呢?java
題外話,我最喜歡的一個面試問題是:「_從輸入 URL 到頁面加載發生了什麼?_」
_
這是一個很好的問題,由於它能展現一我的相關知識的深度和廣度,能從回答這個問題的過程當中,發現哪些部分是他最感興趣的node
這是一系列博文中的第一篇,將探討 V8 在 1 + 1
被輸入以後的一切。首先,咱們將關注 V8 如何在其堆內存中存儲 1 + 1
字符串。這聽起來很簡單,但它徹底值得這一整篇的博文!c++
要計算 1 + 1
,你可能最早採起的方法是啓動 NodeJS,或者打開 Chrome 開發者控制檯,而後簡單地輸入 1 + 1
。但爲了展現 V8 的內部結構,我決定修改 hello-world.cc
,這是 V8 源代碼中的一個標準示例應用程序git
我把原來打印 "Hello World"
的代碼,用 1 + 1
的表達式代替github
// 建立一個包含 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
變量中typescript
hello-world.cc
程序。但一般狀況下,它是整個 Chrome 瀏覽器、NodeJS 運行時系統或任何其餘嵌入了 V8 JavaScript 引擎的軟件如今咱們來詳細瞭解一下這個流程,重點是:c#
如上所述,在客戶端應用程序和堆工廠(實際建立對象的地方)之間必須進行大量的轉換工做。大部分的工做都在 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 位機器上),指向了堆中對象所在的地址
其中有趣的部分是:
如前所述,這不是一個功能完善的字符串類,而是一個指向堆中字符串實際內容地址的指針。在 64 位的機器上,這個「指針」將是一個 8 字節的 unsigned long
(無符號長整形),其類型別名爲 Address
。請注意,堆上的數據(在圖的右邊)實際上並非一個真正的 C++ 對象,因此沒有必要把這個 Address
看成一個指向強類型的東西(如 String *
)的指針來處理
可是,你可能想知道爲何要先有一個間接層,而不直接訪問 Heap Block 呢?當你考慮到垃圾收集會致使對象在堆中移動時,會知道這種方法是有意義的。重要的是,數據能夠移動,而不會讓客戶端應用程序感到困惑
譯者注:Heap Block - 內存塊
要說明的是,在 Generational Garbage Collection(代際垃圾收集)中,對象首先在 _新生代_(New Space)中分配,若是它們存活的時間足夠長,就會被移到 _老生代_(Old Space)中。爲了實現這一目的,垃圾收集器會將Heap Block 複製到新的堆空間,而後更新 Address
值指向新的內存地址。鑑於 SeqOneByteString
對象自己的內存地址仍然和以前徹底相同,客戶端軟件不會注意到這個變化。
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 位的指針值
每一個對象都有一個內部的哈希值,但在這個例子中,它默認爲 kEmptyHashField
(值爲3),表示哈希值尚未計算出來
這是字符串中的字節數(5)(兩個 1
,兩個
,一個 +
)
正如你所指望的那樣,接下來存儲的是 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 寄存器中,傳遞給函數,或者做爲返回值提供。值得注意的是,當垃圾回收發生時,垃圾回收器不須要定位或更新這些值HandleScope
和Local
對象會被刪除,但只有在它們 C++ 對象析構函數被調用後纔會被刪除。這些析構函數從垃圾收集器的根列表中刪除了全部的句柄。它們再也不在做用域中,因此底層堆對象可能已經成爲垃圾譯者附: 析構函數(destructor)與構造函數相反,當對象結束其生命週期,如對象所在的函數已調用完畢時,系統自動執行析構函數。析構函數每每用來作「清理善後」 的工做(例如在創建對象時用 new 開闢了一片內存空間,delete 會自動調用析構函數後釋放內存)
最後,引用咱們的 1 + 1
字符串的 source
變量,如今已經準備好在咱們的客戶端應用程序中傳遞到下一行
Local<Script> script = Script::Compile(context, source).ToLocalChecked();
在堆上分配 1 + 1
的字符串顯然有不少工做要作。但願它能說明 V8 內部架構的一些部分,以及在系統的不一樣部分如何表示數據。在將來的博文中,我會更多地研究咱們的簡單表達式是如何被解析和執行的,這將暴露出更多關於 V8 的運做方式
在本系列博文的第 2 部分,我將深刻研究 _編譯緩存 _是如何工做的,以免編譯代碼超過必要的時間
第一次翻譯文章,感謝 deepL、百度翻譯、谷歌翻譯
做者受權: