跳至主要内容

優化 V8 記憶體使用

· 閱讀時間約 8 分鐘
V8 記憶體衛生工程師 Ulan Degenbaev、Michael Lippautz、Hannes Payer 和 Toon Verwaest

記憶體使用是 JavaScript 虛擬機性能權衡空間中的重要維度。在過去幾個月中,V8 團隊分析並顯著減少了若干被認為是現代網頁開發模式代表性網站的記憶體佔用量。在這篇博客中,我們呈現了分析所使用的工作負載和工具,概述了垃圾回收器中的記憶體優化,並展示了我們如何減少 V8 解析器及其編譯器所使用的記憶體。

基準測試

為了分析 V8 並發現對最多數使用者有影響的優化,定義可重現、有意義且模擬常見現實場景的 JavaScript 使用情境的工作負載至關重要。一個非常適合這項任務的工具是 Telemetry,這是一個性能測試框架,能在 Chrome 中執行腳本化的網站交互,並記錄所有伺服器響應,以便在我們的測試環境中能夠預測性地重播這些交互。我們選擇了一組流行的新聞、社交和媒體網站,並為它們定義了以下常見的使用者交互:

瀏覽新聞和社交網站的工作負載:

  1. 打開一個流行的新聞或社交網站,例如 Hacker News。
  2. 點擊第一個鏈接。
  3. 等待新網站載入完成。
  4. 向下滾動幾頁。
  5. 點擊返回按鈕。
  6. 在原網站上點擊下一個鏈接,並重複第 3-6 步幾次。

瀏覽媒體網站的工作負載:

  1. 在一個流行的媒體網站上打開一篇內容,例如 YouTube 上的視頻。
  2. 通過等待幾秒鐘來消耗該內容。
  3. 點擊下一個內容,並重複第 2-3 步幾次。

一旦工作流程被捕捉,它可以在 Chrome 的開發版本上重播任意次數,例如每次有新的 V8 版本時。在播放過程中,V8 的記憶體使用量會在固定的時間間隔內採樣以獲得有意義的平均值。基準測試可以在這裡找到。

記憶體可視化

一般來說,優化性能的一個主要挑戰是獲得虛擬機內部狀態的清晰圖景,以跟踪進展或權衡潛在的取捨。對記憶體使用量進行優化則意味著在運行時準確跟踪 V8 的記憶體消耗。有兩類記憶體必須跟踪:分配給 V8 的托管堆的記憶體以及分配在 C++ 堆上的記憶體。V8 堆統計功能是開發者用於深入瞭解這兩類記憶體的機制。當在運行 Chrome (54 或更新版本) 或 d8 命令行界面時指定 --trace-gc-object-stats 標誌,V8 會在控制台上輸出與記憶體相關的統計數據。我們構建了一個定製工具,V8 堆可視化工具,來可視化這些輸出。該工具提供了托管堆和 C++ 堆的基於時間線的視圖。工具還提供某些內部數據類型的記憶體使用量詳細拆解以及每種類型的基於大小的直方圖。

在我們的優化工作中,常見的工作流程包括從時間線視圖中選擇佔據堆中大部分空間的實例類型,如圖 1 所示。一旦選擇了一個實例類型,工具就會顯示此類型用途的分佈。在此示例中,我們選擇了 V8 的內部 FixedArray 數據結構,這是一個無類型的類似向量的容器,廣泛用於虛擬機的各種地方。圖 2 顯示了一個典型的 FixedArray 分佈,其中大部分記憶體可歸於某個特定的 FixedArray 使用情景。在這種情況下,FixedArrays 被用作稀疏 JavaScript 陣列(我們稱之為 DICTIONARY_ELEMENTS)的後端存儲。根據此信息,我們可以回到實際代碼,驗證此分佈是否確實是預期行為,或者是否存在優化的機會。我們使用該工具識別了若干內部類型的低效。

圖 1: 托管堆和堆外記憶體的時間線視圖

圖 2: 實例類型分佈圖

圖 3 顯示了 C++ 堆的內存消耗,主要由區域內存(V8 用於短期使用的臨時內存區域,下面會詳細討論)組成。由於區域內存主要被 V8 的解析器和編譯器廣泛使用,因此峰值對應於解析和編譯事件。一個表現良好的執行僅包括峰值,表明內存在不再需要時立即被釋放。相反,平台期(即長時間的高內存消耗)表明仍有優化的空間。

圖 3: 區域內存

早期採用者還可以嘗試集成到 Chrome 的追蹤基礎架構。因此,您需要使用 --track-gc-object-stats 運行最新的 Chrome Canary 並 捕捉追蹤,包括類別 v8.gc_stats。數據將顯示在 V8.GC_Object_Stats 事件中。

JavaScript 堆大小減少

垃圾回收吞吐量、延遲和內存消耗之間存在固有的權衡。例如,可以通過使用更多內存來減少垃圾回收延遲(這會導致用戶可見的卡頓),以避免頻繁的垃圾回收調用。對於低內存的移動設備,即內存少於 512 MB 的設備,將延遲和吞吐量優於內存消耗的優先級可能會導致內存不足崩潰以及 Android 上的標籤被掛起。

為了更好地為這些低內存移動設備平衡適當的權衡,我們引入了一種特別的內存減少模式,該模式調整了幾個垃圾回收的啟發式方法以降低 JavaScript 垃圾回收堆的內存使用量。

  1. 在完成一次完整的垃圾回收後,V8 的堆增長策略根據存在的一些額外緩衝決定下一次垃圾回收的發生時間。在內存減少模式中,V8 使用更少的緩衝,導致更頻繁的垃圾回收從而降低內存使用量。
  2. 此外,該估算被視為一個硬性限制,迫使未完成的增量標記工作在主垃圾回收暫停中完成。通常,當不在內存減少模式中時,未完成的增量標記工作可能導致超過該限制以便僅在完成標記後觸發主垃圾回收暫停。
  3. 通過執行更積極的內存壓縮進一步減少內存碎片。

圖 4 顯示了自 Chrome 53 起對低內存設備的一些改進。最顯著的是,移動版《紐約時報》基準的平均 V8 堆內存消耗減少了約 66%。總的來說,我們觀察到該基準集合的平均 V8 堆大小減少了 50%。

圖 4: 自 Chrome 53 起在低內存設備上的 V8 堆內存減少

最近引入的另一項優化不僅減少了低內存設備上的內存,也改善了性能較佳的移動和桌面計算機。將 V8 堆頁大小從 1 MB 減少到 512 kB,當未存在許多活動對象時導致更小的內存占用,並且總內存碎片下降高達 2 倍。它也使 V8 能夠執行更多的壓縮工作,因為較小的工作塊允許壓縮工作線程能並行完成更多工作。

區域內存減少

除了 JavaScript 堆,V8 還使用堆外內存來進行內部 VM 操作。最大的內存塊通過稱為 zones 的內存區域分配完成。Zones 是一種基於區域的內存分配器,能夠快速分配並進行批量釋放,當 zone 被銷毀時 zone 分配的所有內存將一次性被釋放。Zones 在 V8 的解析器和編譯器中廣泛使用。

Chrome 55 的一大改進來自於減少背景解析過程中的內存消耗。背景解析允許 V8 在頁面加載時解析腳本。內存可視化工具幫助我們發現,背景解析器在代碼已經編譯後仍然長時間保留整個 zone。通過在編譯後立即釋放 zone,我們顯著減少了 zone 的壽命,從而降低了平均和峰值內存使用量。

另一項改進來自於解析器生成的_抽象語法樹_節點中的字段更好地打包。之前,我們依賴於C++編譯器在可能的情況下將字段打包在一起。例如,兩個布林值只需要兩個位,應該位於同一個字中,或者位於前一個字剩餘未使用的部分內。C++編譯器並不總是找到最壓縮的打包方式,因此我們改為手動打包位。這不僅降低了峰值內存使用量,還提升了解析器和編譯器的性能。

圖5顯示了自Chrome 54以來的峰值區域內存改進,平均減少了所測試網站的約40%。

圖5:桌面版自Chrome 54以來的V8峰值區域內存減少

未來幾個月我們將繼續努力減少V8的內存佔用。我們計劃針對解析器進行更多的區域內存優化,並專注於內存介於512 MB至1 GB之間的設備。

更新: 上述所有改進使得在_低內存設備_上的Chrome 55整體內存消耗相比Chrome 53減少了最多35%。其他設備類型僅從區域內存的改進中受益。