「譯」JavaScript 是如何計算 1+1 的 - Part 1 建立源碼字符串

來源:https://medium.com/compilers/calculating-1-1-in-javascript-1cecb6e9610javascript

我是一個編譯器愛好者,一直在學習 V8 JavaScript 引擎的工做原理。固然,學習東西最好的方式就是寫出來,因此這也是我在這裏分享經驗的緣由。我但願這也能讓其餘人感興趣。
譯者注:
翻譯已得到做者受權。
因爲我對一部分名詞、c++ 語法也不怎麼了解,因此結合本身的理解以及上下文作了一些「譯者注」,能夠有取捨的參考

image.png

毫無疑問 1 + 1 = 2,可是 V8 JavaScript 的引擎是如何計算出來的呢?java

題外話,我最喜歡的一個面試問題是:「_從輸入 URL 到頁面加載發生了什麼?_」
_
這是一個很好的問題,由於它能展現一我的相關知識的深度和廣度,能從回答這個問題的過程當中,發現哪些部分是他最感興趣的node

這是一系列博文中的第一篇,將探討 V8 在 1 + 1 被輸入以後的一切。首先,咱們將關注 V8 如何在其堆內存中存儲 1 + 1 字符串。這聽起來很簡單,但它徹底值得這一整篇的博文!c++

1、客戶端應用(The Client Applicant)


要計算 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

image.png

  • 應用程序 - 這表明了 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 算法是一種執行垃圾回收的著名算法

如今咱們來詳細瞭解一下這個流程,重點是:c#

  • API 層如何決定建立什麼類型的字符串,以及它在堆中的存儲位置
  • 字符串的內部內存佈局是怎樣的。這取決於字符串裏字符的範圍
  • 如何從堆中分配空間。在咱們的例子中,須要 20 個字節
  • 最後,如何將指向字符串的指針返回給應用程序,用於將來進行垃圾回收

2、肯定存儲字符串的方式和位置


如上所述,在客戶端應用程序堆工廠(實際建立對象的地方)之間必須進行大量的轉換工做。大部分的工做都在 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),在這裏,堆內存被分配,字符串的內容被寫入該內存

3、字符串的內存結構


在 V8 內部,咱們的 1 + 1 字符串被表示爲 v8::Internal::SeqOneByteString 類的一個實例 (見 src/objects/string.h)。若是你像大多數面向對象的開發者同樣,你會指望 SeqOneByteString 有許多公共方法,以及一些私有屬性,好比一個字符數組或一個存儲字符串長度的整數。然而,事實並不是如此! 相反,全部內部對象類實際上只是指向堆中存儲這些數據地址的指針

譯者注:對象類 - 定義屬性的命名集合,並將它們分類爲必需屬性集和可選屬性集

src/objects/objects.h 中的代碼註釋能夠看出,大約有 150 個內部類的父類是 v8::Internal::Object。這些類中都只包含了一個 8 字節的值(在 64 位機器上),指向了堆中對象所在的地址

image.png

其中有趣的部分是:

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 字節的邊界)。

4、從堆中分配內存

咱們簡單地提到,工廠類從堆中分配一塊內存(在咱們的例子中是 20 個字節),而後用對象的數據填充該塊。剩下的一個問題是這 20 個字節是 _如何 _分配的

在 Cheney 的垃圾收集算法中,新生代(New Space)被分爲兩個半空間。爲了在堆中分配一個內存塊,分配器肯定在當前半空間的 Limit,和該半空間的當前 Top 之間是否有足夠的可用字節。若是有足夠的空間,算法返回下一個塊的地址,而後按請求的字節數遞增 Top 指針

這裏展現了這種基本狀況,顯示了當前半空間的先後狀態:

image.png

若是當前的半空間用完了可用內存(TopLimit 太接近),那麼 Cheney 算法的收集部分就會開始。一旦收集完成,全部的 _活 _對象將被複制到第二個半空間的開始,而全部的 _死 _對象(殘留在第一個半空間中)將被丟棄。不管怎樣,一個半空間都能保證其全部 _使用過 _的空間都在底部,而全部的 _空閒的 _空間都在頂部,因此它老是會像上圖同樣

不過在咱們的狀況下,當前的半空間有不少空閒的內存,因此咱們切掉 20 個字節,而後增長 Top 指針。不須要進行垃圾收集,也不涉及第二個半空間。在 V8 代碼中,有許多特殊狀況須要考慮,但最後 20 個字節的分配是由 src/heap/new-spaces-inl.h 中的 NewSpace::AllocateFastUnaligned 方法處理的

5、返回一個句柄


句柄(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::SeqOneByteStringv8::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 是對堆對象的引用,儘管如今多了一層間接性。這張圖將解釋:

image.png

譯者注:
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++ 堆棧上的 HandleScopeLocal 對象會被刪除,但只有在它們 C++ 對象析構函數被調用後纔會被刪除。這些析構函數從垃圾收集器的根列表中刪除了全部的句柄。它們再也不在做用域中,因此底層堆對象可能已經成爲垃圾
譯者附: 析構函數(destructor)與構造函數相反,當對象結束其生命週期,如對象所在的函數已調用完畢時,系統自動執行析構函數。析構函數每每用來作「清理善後」 的工做(例如在創建對象時用 new 開闢了一片內存空間,delete 會自動調用析構函數後釋放內存)

最後,引用咱們的 1 + 1 字符串的 source 變量,如今已經準備好在咱們的客戶端應用程序中傳遞到下一行

Local<Script> script = 
    Script::Compile(context, source).ToLocalChecked();

下一節……

在堆上分配 1 + 1 的字符串顯然有不少工做要作。但願它能說明 V8 內部架構的一些部分,以及在系統的不一樣部分如何表示數據。在將來的博文中,我會更多地研究咱們的簡單表達式是如何被解析和執行的,這將暴露出更多關於 V8 的運做方式

在本系列博文的第 2 部分,我將深刻研究 _編譯緩存 _是如何工做的,以免編譯代碼超過必要的時間

附錄:

第一次翻譯文章,感謝 deepL、百度翻譯、谷歌翻譯

做者受權:
image.png

相關文章
相關標籤/搜索