1. 程式人生 > >JavaScript V8引擎

JavaScript V8引擎

部分 抽象語法樹 undefine 時也 再次 獲取 清除 內核 兩種

一、瀏覽器內核—渲染引擎

渲染,就是根據描述或者定義構建數學模型,生成圖像的過程。

瀏覽器內核主要的作用是將頁面轉變成可視化/可聽化的多媒體結果,通常也被稱為渲染引擎。將HTML/CSS/JavaScript文本及其他相應的媒體類型資源文件轉換成網頁。

技術分享圖片

上圖中實線框內模塊是所有移植的共有部分,虛線框內不同的廠商可以自己實現。下面進行介紹:

  WebCore 是各個瀏覽器使用的共享部分,包括HTML解析器、CSS解析器、DOM和SVG等。

  JavaScriptCore是WebKit的默認引擎,在谷歌系列產品中被替換為V8引擎。

  WebKit Ports是WebKit中的非共享部分,由於平臺差異、第三方庫和需求的不同等原因,不同的移植導致了WebKit不同版本行為不一致,它是不同瀏覽器性能和功能差異的關鍵部分。

  WebKit嵌入式編程接口,供瀏覽器調用,與移植密切相關,不同的移植有不同的接口規範。

技術分享圖片

  瀏覽器內核的層次結構,渲染引擎解析網頁資源,調用第三方庫進行渲染繪制。

技術分享圖片

  從上面兩圖可以大概看出渲染引擎的內部組成和大模塊。WebCore,負責解析HTML、CSS生成DOM樹等渲染進程,js引擎負責解析和執行js邏輯進程。

二、JavaScript引擎

  1、JS引擎與渲染引擎關系

  技術分享圖片

  JavaScript引擎和渲染引擎的關系如上圖所示。渲染引擎使用JS引擎的接口來處理邏輯代碼並獲取結果。JS引擎通過橋接接口訪問渲染引擎中的DOM及CSSOM(性能低下)。

  2、JS引擎工作流程

  技術分享圖片

  JavaScript本質上是一種解釋型語言,與編譯型語言不同的是它需要一遍執行一邊解析,而編譯型語言在執行時已經完成編譯,可直接執行,有更快的執行速度(如上圖所示)。

  技術分享圖片

  上圖描述了JS代碼執行的過程。具體過程先不看,一個JS引擎主要包括以下幾個部分:

  編譯器。將源代碼經過詞法分析,語法分析(你不知的js待查)編譯成抽象語法樹,在某些引擎中還包含將抽象語法樹轉化成字節碼。

   解釋器。在某些引擎中,解釋器主要是接收字節碼,解釋執行這個字節碼,同時也依賴垃圾回收機制等。

   JIT工具。將字節碼或者抽象語法樹轉換成本地代碼。

   垃圾回收器和分析工具(Profiler)。負責垃圾回收和收集引擎中的信息,幫助改善引擎的性能和功效。

三、V8 編譯與執行

  1、數據表示

  Js語言中,只有基本數據類型Boolean、Number、String、Null、Undefined,其他都是對象。

  在V8中,數據的表示分成兩個部分。第一個部分是數據的實際內容,它們是變長的,而且內容的類型也不一樣,如String、對象等;第二部分是數據的句柄,大小是固定的,包含指向第一部分數據的指針。除了極少數的數據例如整型數據,其他的內容都是從堆中申請內存來存儲,因為句柄本身能夠存儲整型,同時也能快速訪問。

  2、句柄Handle

  V8需要進行垃圾回收,並需要移動這些數據內容,如果直接使用指針的話就會出問題或者需要比較大的開銷。使用句柄就不存在這些問題,只需要修改句柄中的指針即可,使用者使用的還是句柄,它本身沒有發生變化。

  技術分享圖片

  由上圖可以看出,一個Handle對象的大小是4字節(32位機器)或者8字節(64位機器);不同於JavascriptCore引擎,後者是使用8個字節來表示數據的句柄。小整數(只有31位可以使用)直接在Value_中獲取值,而無須從堆中分配。

  因為堆中存放的對象都是4字節對齊的,所以指向它們的指針的最後兩位都是00,這兩位其實是不需要的。在V8中,它們被用來表示句柄中包含數據的類型。

  技術分享圖片

  對象句柄的實現在V8中包含3個成員。第一個是隱藏類指針,為對象創建的隱藏類;第二個指向這個對象包含的屬性值;第三個指向這個對象包含的元素。

  3、編譯過程

   包括兩個階段:編譯和執行。還有一個重要的特點就是延遲思想,使得很多js代碼的編譯直到運行時被調用才會發生(以函數單位),減少時間開銷。

Js代碼經過編譯器,生成抽象語法樹,再通過JIT全代碼生成器直接生成本地代碼。減少抽象樹到字節碼的轉換時間。

生成本地代碼後,為了性能考慮,通過 數據分析器 來采集一些信息,以幫助決策哪些本地代碼需要優化,以生成效率更高的本地代碼,這是一個逐步改進的過程。

  4、優化回滾

  編譯器會做比較樂觀和大膽的預測,就是認為這些代碼比較穩定,變量類型不會發生改變,來生成高效的本地代碼。當引擎發現一些變量的類型已經發生變化的時候,V8會將優化回滾到之前的一般情況。

技術分享圖片

  如上,函數ABC被調用很多次之後,數據分析器認為函數內的代碼的類型都已經被獲知了,但是當對於unkonwn的變量發生賦值是,V8只能將代碼回滾到一個通用的狀態。

  優化回滾是一個很費時的操作,而且會將之前優化的代碼恢復到一個沒有特別優化的代碼,這是一個非常不高效的過程。

  

  5、隱藏類和內嵌緩存

  V8使用類和偏移位置思想,將本來需要字符串匹配來查找屬性值的算法改進為,使用類似C++編譯器的偏移位置的機制來實現,這就是隱藏類。

隱藏類根據對象擁有相同的屬性名和屬性值的情況,分為不同的組(類型)。對於相同的組,將這些屬性名和對應的偏移位置保存在一個隱藏類中,組內的對象共享該信息。同時,也可以識別屬性不同的對象。

  技術分享圖片

  

  如圖,使用構造函數創建了兩個對象a、b。這兩個對象包含相同的屬性名,在V8中它們被歸為同一個組,也就是隱藏類,這些屬性在隱藏類中有相同的偏移值。對象a、b可以共享這個分組的信息,當訪問這些對象的時候,根據隱藏類的偏移值就可以知道它們的位置並進行訪問。

因為JavaScript是動態類型語言,所以當加入代碼 d.z = 2。那麽b對象所對應的將是一個新的隱藏類,這樣a、b將屬於不同的組。

  function add(a) { return a.x };

  訪問對象屬性基本過程為:獲取隱藏類的地址,根據屬性名查找偏移值,計算該屬性的堆內存地址。這一過程還是比較耗費時間,實際上會用到緩存機制,叫做內嵌緩存。

  基本思想是將使用之前查找的結果(隱藏類和偏移值)緩存起來,再次訪問時可以避免多次哈希表查找的問題。

  註意:當對象內的屬性值出現多個類型是,那麽緩存失誤的概率就會高很多。退回到之前的方式來查找哈希表。

  

四、V8內存分配

  主要講兩點:1、內存的劃分使用2、對於JS代碼的垃圾回收機制

  1、小內存區塊Zone類

  管理一系列的小塊內存,這些小內存的生命周期類似,可以使用一個Zone對象。

  Zone對象先對自己申請一塊內存,然後管理和分配一些小內存。當一塊小內存被分配之後,不能被Zone回收,只能一次性回收Zone分配的所有小內存。例如:抽象語法樹的內存分配和使用,在構建之後,會生成本地代碼,然後其內存被一次性全部收回,效率非常高。

  但是有一個嚴重的缺陷,當一個過程需要很多內存,Zone將需要分配大量的內存,卻又不能及時回收,會導致內存不足情況。 

  2、堆內存

  V8使用堆來管理JavaScript使用的數據、以及生成的代碼、哈希表等。為了更方便地實現垃圾回收,同很多虛擬機一樣,V8將堆分成三個部分。年輕代、年老代、和大對象。

  技術分享圖片

  

  年輕分代:為新創建的對象分配內存空間,經常需要進行垃圾回收。為方便年輕分代中的內容回收,可再將年輕分代分為兩半,一半用來分配,另一半在回收時負責將之前還需要保留的對象復制過來。

  年老分代:根據需要將年老的對象、指針、代碼等數據保存起來,較少地進行垃圾回收。

  大對象:為那些需要使用較多內存對象分配內存,當然同樣可能包含數據和代碼等分配的內存,一個頁面只分配一個對象。

  當在代碼中聲明變量並賦值時,所使用對象的內存就分配在堆中。如果已申請的堆空閑內存不夠分配新的對象,將繼續申請堆內存,知道堆的大小達到V8的限制為止。

  V8的內存使用限制:64位系統中約為1.4G,32位系統中約為0.7G。在瀏覽器頁面中足夠使用;在node中則會有不足,導致Node無法直接操作大內存對象,在打個node進程的情況下,無法充分利用計算機的內存資源。

內存的限制有兩方面原因,防止瀏覽器的一個頁面占用太多系統內存資源,另一方面,V8垃圾回收的效率問題。對於1.5G內存,做一次小的垃圾回收需要50毫秒以上,做一次全量的回收甚至要1秒以上,這個過程會阻塞JS線程的執行。

五、垃圾回收

  V8的垃圾回收策略主要基於分代式回收機制。按對象的存活時間將內存的垃圾回收進行不同的分代,然後分別對不同的內存使用更高效的算法。

  1、新生代Scavenge(清除)算法

  主要采用Cheney(人名)算法。一種采用復制的方式實現的垃圾回收算法。

 1、將新生代堆內存分一為二,每一部分空間稱為semispace。其中一個處於使用之中的稱為from空間,另一個處於閑置稱為to空間。

   2、當我們分配對象時,先是在From空間中進行分配。

   3、垃圾回收時,檢查from空間內的存活對象,一是否經歷過清除回收,二to空間是否已經使用了25%(保證新分配有足夠的空間)。 

.   4、將這些存活對象復制到to空間中。非存活對象占用的空間將會被釋放。

   5、完成復制後,from空間與to空間角色發生對換。

   註:實際使用的堆內存是新生代中的兩個semispace空間大小,和老生代所用內存大小之和。

  如何判斷對象是否存活呢?作用域?是一套存儲和查詢變量的規則。這套規則決定了內存裏對象能否訪問。

  特點:

     清除算法是典型的犧牲空間換取時間的算法,無法大規模地應用到所有回收中,卻非常適合應用在新生代生命周期短的變量。

  2、Mark-Sweep老生代標記清除

  1、 標記階段遍歷堆中的所有對象,並標記活著的對象

  2、 清除階段,只清除沒有被標記的對象。

  最大的問題是,在進行一次標記清除之後會出現不連續的狀態。這種內存碎片會對後續的內存分配造成問題。很可能需要分配一個大對象時,所有的碎片空間都無法完成,就會提前觸發垃圾回收,而這次全量回收是不必要的。

  3、Mark-Compact老生代標記整理

  在標記清除的基礎上發展而來,在整理的過程中

  1、 將活著的對象往一段移動

  2、 移動完成後,直接清理掉邊界外的內存

  4、Incremental Marking增量標記

  垃圾回收的過程都需要將應用邏輯暫停下來。

  為了降低全量回收帶來的停頓時間,在標記階段,將原本一口氣要完成的動作改為增量標記。垃圾回收與應用邏輯交替執行到標記階段完成。最大停頓時間較少的1/6左右.

  後續還引入了延遲清理與增量整理,讓清理和整理動作也變成增量式的。

六、高效內存使用

  1、作用域

  函數在每次被調用時會創建對應的作用域(自身的執行環境,和變量對象),作用域中聲明的局部變量分配在改作用域中。執行結束後,作用域銷毀,做死亡標記,在下次垃圾回收時被釋放。

  變量的主動釋放,解除引用關系:

  如果變量是全局變量,由於全局作用域需要直到進程退出才能釋放,導致引用的對象常駐內存(老生代)。可以通過delete來刪除引用關系,或者將變量重新賦值。但是在V8中通過delete刪除對象的屬性有可能幹擾V8的優化。

  閉包:

  當函數執行結束,但作用域(變量對象)仍需要被引用時,其變量對象不能被標記失效,占用的內存空間不會得到清除釋放。除非不再有引用,才會逐步釋放。

  在正常的js執行中,無法立即回收的內存有閉包和全局變量這兩種情況。要避免這些變量無限制地增加,導致老生代中的對象增多,甚至內存泄漏。

  2、內存泄漏

  實質:應當回收的對象出現意外,而沒有被回收,變成了常駐在老生代中的對象。

通常,造成內存泄漏的原因有如下:

  1、 緩存

    緩存無限制增長,長期存在於老生代,占據大量內存。

    策略:對緩存增加數量和有效期限制。

    使用Redis等進程外緩存,不占用V8緩存限制,進程間可以共享緩存。

  2、 隊列消費不及時

    Js可以通過隊列(數組對象)來完成許多特殊的需求。在消費者—生產者模型中經常充當中間產物。當消費速度低於生成速度時,將會形成堆積。

    如:日誌記錄,采用低效率的數據庫寫入時,可能會是寫入事件堆積。

    策略:使用高效的消費方式。監控隊列的長度。

  3、 作用域得不到釋放

    大量閉包的使用,要註意釋放作用域。

JavaScript V8引擎