本文是根據本身的理解翻譯而來,若有疑惑可查看原文 JavaScript engine fundamentals: optimizing prototypes。javascript
本次暫定翻譯三篇文章:java
上一篇文章已經討論了現代 JavaScript 引擎的工做流程:react
咱們也指出了引擎間的相同之處和編譯環節中的差別。爲何會這樣?爲何一些引擎的編譯器比其餘引擎多?結論是基於更快地生成代碼和生成更好的代碼二者間的考量。編程
解釋器能夠很快地生成字節碼,可是字節碼的效率不高。另外一方面,優化編譯器雖然會稍微花費些時間,卻能夠生成效率更高的機器碼。緩存
下圖是 V8 模型,V8 的解釋器稱爲 Ignition,是全部引擎中最快的解釋器(從原始字節碼執行速度的角度)。V8 的優化編譯器稱爲 TurboFan,它最終會生成高度優化的機器碼。多線程
啓動速度和執行速度是一些 JavaScript 引擎選擇添加優化層的理由。好比,SpiderMonkey 就在解釋器和 IonMonkey 編譯器間添加了 Baseline 層。架構
解釋器能夠快速生成字節碼,可是字節碼執行的速度比較慢。Baseline 會花些時間生成代碼,但一樣會提供性能更好的代碼。最後,IonMonkey 會花更長的時間去生成機器碼,並可以更高效地執行。併發
來用一個具體的例子,看看不一樣引擎之間的處理差別。在這個循環裏,一些代碼重複執行。frontend
let result = 0;
for (let i = 0; i < 4242424242; ++i) {
result += i;
}
console.log(result);
複製代碼
在 Ignition 解釋器中,V8 開始執行字節碼。在某個時刻引擎測定代碼是 hot 的,就會啓動 TurboFan frontend
, TurboFan frontend
是負責整合分析數據(profiling data)並構建代碼的初級機器碼錶現形式。這些東西會被送處處在其餘線程的 TurboFan
優化程序中做進一步優化。編程語言
當優化程序進行時,V8 繼續執行字節碼。在某個時刻,優化程序生成可執行代碼後,流程會接着執行下去。
一樣,SpiderMonkey 也是在解釋器中開始執行字節碼。可是它有 Baseline
層,hot 代碼會被送到這裏。一旦 Baseline
編譯器生成了 Baseline
代碼,流程會接着執行下去。
Baseline
代碼運行一段時間後,SpiderMonkey 最終會啓動 IonMonkey frontend
和 優化程序(相似於 V8)。IonMonkey 在優化時,Baseline
也會保持着運行狀態。當優化完成後,已優化的代碼將取代 Baseline
代碼被執行。
Chakra 的架構和 SpiderMonkey 很類似。Chakra 嘗試並行作不少事情同時又不會阻塞主線程。Chakra 會將字節碼和編譯器可能須要的分析數據複製出來,併發往編譯器專用進程。
當代碼準備穩當後,引擎開始執行 SimpleJIT 代碼。這種方式的好處在於複製所停留的時間遠遠小於編譯器( 編譯器 frontend)所用的時間。缺點就是,這種啓發式複製(copy heuristic)會使得某種優化所必須的信息丟失,所以這是在用代碼質量換取時間。
在 JavaScriptCore,全部的優化編譯器和主線程併發運行;主線程只是觸發了另外一個線程的編譯任務。而後編譯器經過複雜的加鎖從主線程獲取分析數據(profiling data)。
這種方式的優勢是減小了因 JavaScript 優化引起的麻煩。缺點就是須要處理複雜的多線程問題和各類操做所帶來的加鎖成本。
咱們討論了快速生成代碼,也討論了生成快速的代碼。可是,還要有一點須要注意:內存的使用!爲了解釋這個,這裏有個兩數字相加的例子。
function add(x, y) {
return x + y;
}
add(1, 2);
複製代碼
如下是 V8 的 Ignition 編譯器生成的字節碼:
StackCheck
Ldar a1
Add a0, [0]
Return
複製代碼
不要在乎字節碼的細節,重點是知道它只有 4 個指令!
當代碼編程 hot 時,TurboFan 會生成如下高度優化過的機器碼:
leaq rcx,[rip+0x0]
movq rcx,[rcx-0x37]
testb [rcx+0xf],0x1
jnz CompileLazyDeoptimizedCode
push rbp
movq rbp,rsp
push rsi
push rdi
cmpq rsp,[r13+0xe88]
jna StackOverflow
movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize
movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize
movq rdx,rbx
shrq rdx, 32
movq rcx,rax
shrq rcx, 32
addl rdx,rcx
jo Deoptimize
shlq rdx, 32
movq rax,rdx
movq rsp,rbp
pop rbp
ret 0x18
複製代碼
和字節碼相比較,這裏的代碼會顯得不少!一般來講,字節碼會比機器碼緊湊得多,尤爲對比高度優化過的機器碼。另外一方面,字節碼須要解釋器來運行,而優化過的代碼則能夠被處理器直接執行。
這是 JavaScript 引擎不「優化一切」的緣由之一(僅優化 「hot function」)。正如咱們早先看到的,生成優化過的機器碼會用很長的時間,除此以外,咱們剛纔也知道了優化過的機器碼會佔用用更多的內存空間。
先前的文章闡述了 JavaScript 引擎使用 Shapes 和 Inline Caches 優化對象屬性的訪問。再次說明,引擎將對象的 Shape 和對象的值分開存儲。
結合 Shapes 和 Inline Caches 能夠加快代碼中同一位置的屬性重複性訪問。
咱們已經知道了如何快速訪問 JavaScript 對象上的屬性,咱們再看看 JavaScript 最近新增的特性:類。下面是 JavaScript 中類的語法:
class Bar {
constructor(x) {
this.x = x;
}
getX() {
return this.x;
}
}
複製代碼
看似是個新概念,其實就是基於原型的語法糖。
function Bar(x) {
this.x = x;
}
Bar.prototype.getX = function getX() {
return this.x;
};
複製代碼
在這裏,咱們給 Bar.prototype
這個對象添加屬性 getX
,這和其它普通對象添加屬性沒有區別,由於在 JavaScript中, 原型也是個對象!像 JavaScript 這種基於原型的編程語言,方法能夠經過原型共享,而字段則存儲在實例中。
讓咱們看看經過 Bar 建立實例 foo 會發生什麼?
const foo = new Bar(true);
複製代碼
建立出來的實例(foo)擁有一個只包含屬性 'x'
的 shape。foo 的原型指向 Bar.prototype
。
Bar.prototype
也有屬於本身的 shape,它包含一個 getX
屬性,這個屬性的值是個返回 this.x
的函數(getX
)。Bar.prototype
的原型是 Object.prototype
。Object.prototype
是原型鏈的根源,所以它的原型是 null
。
若是你用同一個類又建立了一個實例,那麼這兩個實例將共享 shape,兩個實例也會指向同一個 Bar.prototype
。
ok,咱們已經知道了定義一個類並用類建立實例的過程。那麼,若是咱們在實例上調用一個方法,又會發生什麼呢?
class Bar {
constructor(x) { this.x = x; }
getX() { return this.x; }
}
const foo = new Bar(true);
const x = foo.getX();
// ^^^^^^^^^^
複製代碼
你能夠認爲方法的調用分爲兩個步驟:
const x = foo.getX();
// is actually two steps:
const $getX = foo.getX;
const x = $getX.call(foo);
複製代碼
步驟 1:加載方法,這個方法只不過是原型上的屬性(而它剛好是個函數)。步驟 2:用實例去調用這個方法(從新綁定 this
)。先看步驟 1:
開始時,引擎從實例 foo 上查找並發現 foo 的 shape 上沒有 getX
屬性,因而它不得不沿着原型鏈向上查找。到達 Bar.prototype
後,開始查找它的 shape 並找到了 getX
屬性。而後根據 getX
的屬性信息(Offset:0)在 Bar.prototype
上找到了 getX
函數。
JavaScript 的靈活性使得原型鏈可能會突變,例如:
const foo = new Bar(true);
foo.getX();
// → true
Object.setPrototypeOf(foo, null);
foo.getX();
// → Uncaught TypeError: foo.getX is not a function
複製代碼
這個例子中,foo.getX()
被調用了兩次,可是每次都會有不一樣的含義,不一樣的結果。因此說,儘管原型在 JavaScript 中只是個對象,可是提高原型屬性的訪問速度依然比常規對象更具備挑戰性。
一般狀況下,原型屬性的加載是個很是頻繁的操做:每次方法調用都會去加載屬性!
class Bar {
constructor(x) { this.x = x; }
getX() { return this.x; }
}
const foo = new Bar(true);
const x = foo.getX();
// ^^^^^^^^^^
複製代碼
以前,咱們討論了使用 Shapes 和 ICs 優化常規對象的屬性訪問。那麼,咱們可否使用相同的策略去優化原型屬性的重複性訪問呢?下面,咱們看看屬性是如何加載的。
在這個案例中,爲了提升重複加載的速度,咱們須要知道三件事:
getX
且沒有改變過。這意味着 foo 沒有添加、刪除屬性,或改變屬性特性。Bar.prototype
。這意味着,foo 的原型沒有經過 Object.setPrototypeOf()
或 __proto__
的方式改變過。Bar.prototype
的 shape 包含 getX
且沒有改變過。這意味着 Bar.prototype
沒有添加、刪除屬性,或改變屬性特性。通常狀況下,這意味着咱們須要檢查 1 遍實例自己,還有因每增長一個原型就就要增長的 2 遍檢查直到找到咱們想要的屬性。1+2N
(N 表示原型鏈上直到找到存在屬性的原型的原型數量) 遍的檢查看上去還不是特別糟糕,由於這時的原型鏈還比較短 —— 可是引擎會常常處理有着很長原型鏈的對象,就好比常見的 DOM 類。
const anchor = document.createElement('a');
// → HTMLAnchorElement
const title = anchor.getAttribute('title');
複製代碼
現有個 HTMLAnchorElement
並調用 getAttribute()
方法。這簡單的 anchor 元素涉及到 6 個原型!getAttribute()
不是 HTMLAnchorElement
原型上的方法,而是原型鏈上靠近頂部的原型上。
getAttribute()
是在 Element.prototype
上發現的。這意味着咱們每次調用 anchor.getAttribute()
時,都須要作如下這些事:
getAttribute
不存在於 anchor
對象自己;anchor
的原型是 HTMLAnchorElement.prototype
;getAttribute
屬性;HTMLElement.prototype
;getAttribute
屬性;Element.prototype
;getAttribute
。一共須要 7 次檢測!而這種狀況很常見,因而引擎千方百計去減小屬性(原型上)加載時的檢查次數。
回到更早的例子,當咱們從 foo 訪問 getX
時,共作了 3 次檢查:
class Bar {
constructor(x) { this.x = x; }
getX() { return this.x; }
}
const foo = new Bar(true);
const $getX = foo.getX;
複製代碼
在查找屬性的過程當中,每一個牽涉到的原型都須要作缺失檢查(確認屬性是否存在)。若是咱們可以在屬性確認的步驟裏同時檢測原型鏈,那將會減小總的檢查次數。引擎也正是這麼作的:
每一個 shape 都指向了原型,這意味着 foo 的原型改變時,引擎會自動過渡到新的 shape。如今咱們只須要檢查對象的 shape 就能夠同時檢測屬性是否存在以及原型鏈的導向。
鑑於此,因爲檢查的次數從 1+2N
下降到 1+N
,因此原型上屬性的訪問速度也變快了。因爲在原型鏈上查找屬性的時間複雜度是線性的,因此依然仍是很耗時的。引擎使用了不一樣的方法讓檢查的次數趨於常量,尤爲是同一屬性的連續加載(訪問)。
爲此,V8 特別處理了原型的 shapes。每一個原型都有一個獨一無二的 shape,這個 shape 不會被其它的對象共享(特別是其它的原型對象),每個原型的 shape 都有與之關聯的 ValidityCell
。
若是與之關聯的原型被修改,或該原型的上游(原型的原型,原型的原型的原型……)被修改,ValidityCell
都會被標記爲無效。讓咱們看看這是到底怎麼一回事?
爲了加快原型上後續的屬性加載,V8 使用 ICs 保存着 4 個字段:
代碼第一次執行時,ICs 開始工做了,它要緩存屬性在原型上的偏移量 「Offset
」,屬性所在的原型 「Prototype
」(本例中的 Bar.prototype
),實例的 shape 「Shape」(本例中 foo 的 shape),還有就是與原型的 shape 相關聯的 ValidityCell
「ValidityCell
」,這個原型是實例 shape 直接連接的那個原型(本例中的 Bar.prototype
)。
若在下一次 ICs 命中時,引擎會檢查 shape (實例的 shape)和 ValidityCell
。若是還有效,引擎會直接從 ICs 中提取信息,根據 Prototype
和 Offset
字段獲取屬性信息,這樣就跳過了以前繁瑣的查找步驟。
當原型發生改變時,一個新的 shape 將會生成,同時先前的 ValidityCell
將會失效。所以在下次執行時,ICs 將不會起做用,性能天然不會好。
回過頭來再看看以前的 DOM,例如,Object.prototype
發生變化後,影響的將不只僅是自身,還包括鏈的下游 EventTarget.prototype
、Node.prototype
、Element.prototype
,直到 HTMLAnchorElement.prototype
。
事實上,當你在代碼中修改了 Object.prototype
,就意味着將性能棄之不顧。因此,不要那樣作!
讓咱們看一個具體的例子,有一個 Bar 類,它有 loadX
方法。咱們將使用類的實例調用 loadX
若干次。
class Bar { /* … */ }
function loadX(bar) {
return bar.getX(); // IC for 'getX' on `Bar` instances.
}
loadX(new Bar(true));
loadX(new Bar(false));
// IC in `loadX` now links the `ValidityCell` for
// `Bar.prototype`.
Object.prototype.newMethod = y => y;
// The `ValidityCell` in the `loadX` IC is invalid
// now, because `Object.prototype` changed.
複製代碼
此時,ICs 中會記錄調用 loadX
後的相關信息。而後咱們修改了 Object.prototype
—— 全部原型的根源 —— ValidityCell
會被標記爲無效的,ICs 不會在下次執行時命中,性能也會變得糟糕。
改變 Object.prototype
是一個很差的行爲,它使得原型鏈下游全部的 ICs 失去做用。這有另外一個不推薦的行爲。
Object.prototype.foo = function() { /* … */ };
// Run critical code:
someObject.foo();
// End of critical code.
delete Object.prototype.foo;
複製代碼
咱們拓展了 Object.prototype
,它使得原型鏈下游的全部 ICs 失效。而後調用原型上的新方法,引擎便從頭開始爲原型屬性的訪問構建新的 ICs。最後,咱們刪除了以前添加的方法。
清除,聽着挺不錯的,其實在這種狀況下,只會更糟。刪除 Object.prototype
上的屬性就意味着修改 Object.prototype
,全部的 ISc 將再一次失效,引擎也將會再一次地重頭開始構建 ICs。
咱們知道了 JavaScript 引擎是如何存儲對象和類的,也知道了 Shapes、Inline Caches 和 ValidityCells 是如何幫助優化原型操做的。基於這些知識點,咱們可使用一些 JavaScript 編程技巧來提高性能:不要亂動原型(若是你真的,真的須要這麼幹,那麼,至少要在代碼運行前)。