簡介
雖然 JavaScript 會使用垃圾收集機制自動管理記憶體,但這無法取代應用程式中的有效記憶體管理。JavaScript 應用程式與原生應用程式一樣,會遇到記憶體流失和膨脹等記憶體相關問題,但同時也必須處理垃圾收集暫停的問題。Gmail 等大型應用程式與小型應用程式面臨同樣的問題。請繼續閱讀,瞭解 Gmail 團隊如何使用 Chrome 開發人員工具找出、隔離及修正記憶體問題。
2013 年 Google I/O 大會
我們在 2013 年 Google I/O 大會上發表了這份資料。請觀看下方影片:
Gmail 發生問題…
Gmail 團隊面臨一項嚴重問題。我們越來越常聽到,Gmail 分頁在資源有限的筆電和桌機上,消耗了數 GB 的記憶體,最後導致整個瀏覽器當機。例如 CPU 使用率達到 100%、應用程式沒有回應,以及 Chrome 顯示「糟糕!網頁顯示發生問題」分頁。團隊甚至不知道該從何著手診斷問題,更別說要修正問題了。他們不知道問題有多普遍,而現有工具無法擴充至大型應用程式。該團隊與 Chrome 團隊合作,共同開發出新的記憶體問題分類技術、改良現有工具,並收集實際環境中的記憶體資料。不過,在介紹工具之前,我們先來瞭解 JavaScript 記憶體管理的基本概念。
記憶體管理基本概念
在 JavaScript 中有效管理記憶體前,您必須先瞭解基本概念。本節將介紹原始類型、物件圖,並定義一般記憶體膨脹和 JavaScript 記憶體流失。JavaScript 中的記憶體可以概念化為圖表,因此圖論在 JavaScript 記憶體管理和堆積分析器中扮演重要角色。
原始類型
JavaScript 有三種原始類型:
- 數字 (例如 4、3.14159)
- Boolean (true 或 false)
- 字串 (「Hello World」)
這些原始類型無法參照任何其他值。在物件圖中,這些值一律是葉節點或終止節點,也就是說,這些值一律不會有傳出邊緣。
容器類型只有一種:物件。在 JavaScript 中,物件是關聯陣列。非空白物件是內部節點,具有指向其他值 (節點) 的傳出邊緣。
陣列呢?
JavaScript 中的陣列實際上是具有數值鍵的物件。這是簡化後的說法,因為 JavaScript 執行階段會最佳化類陣列物件,並在幕後將其表示為陣列。
術語
- 值:原始類型、物件、陣列等項目的例項。
- 變數:參照值的名稱。
- 屬性:物件中參照值的名稱。
物件圖
JavaScript 中的所有值都是物件圖的一部分。圖表會從根節點開始,例如 window 物件。GC 根是由瀏覽器建立,並在網頁卸載時刪除,因此您無法控管 GC 根的生命週期。全域變數實際上是視窗的屬性。
值何時會變成垃圾資料?
如果沒有從根層級到值的路徑,值就會變成垃圾。換句話說,從根節點開始,徹底搜尋堆疊框架中所有有效的物件屬性和變數,如果無法取得值,該值就會變成垃圾。
JavaScript 中的記憶體流失是什麼?
JavaScript 記憶體流失最常見的原因,是 DOM 節點無法從網頁的 DOM 樹狀結構存取,但仍由 JavaScript 物件參照。雖然新式瀏覽器越來越難不小心造成洩漏,但還是比想像中容易。假設您將元素附加到 DOM 樹狀結構,如下所示:
email.message = document.createElement("div");
displayList.appendChild(email.message);
之後,您從顯示清單中移除該元素:
displayList.removeAllChildren();
只要 email 存在,message 參照的 DOM 元素就不會移除,即使該元素已從網頁的 DOM 樹狀結構中卸除也一樣。
什麼是膨脹?
如果網頁使用的記憶體超過最佳 Page Speed 所需的記憶體,網頁就會膨脹。記憶體流失也會間接導致膨脹,但這並非設計所致。沒有大小限制的應用程式快取,是記憶體膨脹的常見原因。此外,網頁也可能因主機資料而膨脹,例如從圖片載入的像素資料。
什麼是垃圾收集?
垃圾收集是 JavaScript 回收記憶體的方式。瀏覽器會決定何時執行這項操作。在收集期間,系統會暫停網頁上的所有指令碼執行作業,並從 GC 根目錄開始周遊物件圖,找出實際值。所有無法連線的值都會歸類為垃圾。記憶體管理工具會回收垃圾值的記憶體。
V8 垃圾回收器詳細資料
為進一步瞭解垃圾收集的運作方式,我們來詳細探討 V8 垃圾收集器。V8 使用分代收集器。記憶體分為兩個世代:新生代和舊生代。新生代的分配和收集速度很快,而且頻率很高。舊世代的分配和收集速度較慢,頻率也較低。
分代收集器
V8 使用兩代收集器。值的年齡定義為自配置以來配置的位元組數。在實務上,值的年齡通常會以其存活的新生代收集次數來估算。當值達到一定年齡後,就會進入年老代。
實際上,新分配的值不會存在太久。一項針對 Smalltalk 程式的研究顯示,在新生代收集後,只有 7% 的值會留存。對執行階段進行類似研究後發現,平均有 90% 到 70% 的新分配值不會進入舊世代。
年輕世代
V8 中的新生代堆積會分成兩個空間,分別命名為 from 和 to。記憶體會從 to 空間分配。分配速度非常快,直到 to 空間填滿,就會觸發新生代收集。新生代收集會先交換來源和目標空間,掃描舊的目標空間 (現在是來源空間),並將所有有效值複製到目標空間,或將其保留到舊生代。一般來說,新生代收集作業需要 10 毫秒 (ms)。
直覺上,您應該瞭解應用程式每次分配都會讓您更接近耗盡空間,並產生 GC 暫停。遊戲開發人員請注意:為確保影格時間為 16 毫秒 (達到每秒 60 個影格的必要條件),應用程式必須不進行任何分配,因為單一新生代收集會耗用大部分的影格時間。
舊世代
V8 的舊世代堆積會使用標記壓縮演算法進行收集。每當值從新生代轉移至舊世代時,就會發生舊世代分配。每當發生舊世代收集時,也會完成新世代收集。應用程式會暫停幾秒鐘。在實務上,這是可以接受的,因為舊世代收集作業並不頻繁。
V8 GC 摘要
使用垃圾收集機制自動管理記憶體,有助於提高開發人員的工作效率,但每次分配值時,都會更接近垃圾收集暫停。垃圾收集暫停會導致卡頓,破壞應用程式的使用體驗。瞭解 JavaScript 如何管理記憶體後,您就能為應用程式做出正確的選擇。
修正 Gmail 問題
過去一年來,Chrome 開發人員工具推出許多功能並修正錯誤,比以往更強大。此外,瀏覽器本身也對 performance.memory API 進行了重要變更,讓 Gmail 和其他應用程式能從現場收集記憶體統計資料。有了這些強大工具,原本看似不可能的任務,很快就變成一場刺激的追蹤遊戲。
工具和技巧
實際資料和 performance.memory API
自 Chrome 22 起,performance.memory API 預設為啟用。對於 Gmail 等長期運作的應用程式,實際使用者的資料非常寶貴。這項資訊可讓我們區分進階使用者 (每天花 8 到 16 小時使用 Gmail,每天收到數百封郵件) 和一般使用者 (每天花幾分鐘使用 Gmail,每週收到十幾封郵件)。
這個 API 會傳回三項資料:
- jsHeapSizeLimit:JavaScript 堆積的記憶體上限 (以位元組為單位)。
- totalJSHeapSize:JavaScript 堆積分配的記憶體量 (以位元組為單位),包括可用空間。
- usedJSHeapSize:目前使用的記憶體量 (以位元組為單位)。
請注意,API 會傳回整個 Chrome 程序的記憶體值。雖然這不是預設模式,但在某些情況下,Chrome 可能會在同一個轉譯器程序中開啟多個分頁。也就是說,除了包含應用程式的分頁外,performance.memory 傳回的值可能還包含其他瀏覽器分頁的記憶體用量。
大規模測量記憶體
Gmail 檢測 JavaScript,使用 performance.memory API 收集記憶體資訊,大約每 30 分鐘一次。許多 Gmail 使用者會讓應用程式連續開啟好幾天,因此團隊可以追蹤記憶體用量隨時間增加的情況,以及整體記憶體用量統計資料。團隊在 Gmail 中加入檢測機制,從隨機抽樣的使用者收集記憶體資訊,幾天內就取得足夠資料,瞭解一般使用者遇到記憶體問題的普遍程度。他們設定基準,並使用傳入的資料串流追蹤進度,以達成減少記憶體消耗的目標。這項資料最終也會用於找出任何記憶體迴歸。
除了追蹤用途外,欄位測量結果也能深入瞭解記憶體用量與應用程式效能之間的關聯性。一般人認為「記憶體越多,效能越好」,但 Gmail 團隊發現,記憶體用量越大,Gmail 常見動作的延遲時間就越長。有了這項發現,他們更有動力控制記憶體用量。
使用開發人員工具時間軸找出記憶體問題
解決任何效能問題的第一步,是證明問題確實存在、建立可重現的測試,並對問題進行基準測量。如果沒有可重現的程式,就無法可靠地評估問題。如果沒有基準評估,就無法得知成效提升幅度。
DevTools 的「時間軸」面板是證明問題存在的理想工具。這項工具會完整概述載入網頁應用程式或網頁,以及與其互動時,時間花費在哪些地方。從載入資源到剖析 JavaScript、計算樣式、垃圾收集暫停和重新繪製,所有事件都會繪製在時間軸上。為了調查記憶體問題,「時間軸」面板也提供記憶體模式,可追蹤分配的記憶體總量、DOM 節點數量、視窗物件數量,以及分配的事件監聽器數量。
證明問題存在
首先,請找出您懷疑會導致記憶體流失的一連串動作。開始記錄時間軸,並執行一連串動作。使用底部的垃圾桶按鈕,強制執行完整的垃圾收集作業。如果經過幾次疊代後,您看到鋸齒形狀的圖表,表示您分配了許多短暫存在的物件。不過,如果一連串動作不應導致任何保留記憶體,且 DOM 節點數量未降回起始基準,您就有充分理由懷疑發生記憶體流失。
確認問題存在後,您可以使用開發人員工具堆積分析器,找出問題來源。
使用開發人員工具堆積分析器找出記憶體流失
「分析器」面板提供 CPU 分析器和堆積分析器。堆積剖析的運作方式是擷取物件圖的堆積快照。在建立快照之前,系統會對新舊世代進行垃圾收集。換句話說,您只會看到建立快照時存在的值。
堆積分析器功能繁多,本文無法詳盡介紹,但您可以在 Chrome 開發人員網站上找到詳細說明文件。本文將著重介紹堆積分配量分析器。
使用堆積分配量分析器
堆積分配量分析器結合了堆積分析器的詳細快照資訊,以及「時間軸」面板的增量更新和追蹤功能。開啟「Profiles」面板,啟動「Record Heap Allocations」設定檔,執行一連串動作,然後停止錄製以進行分析。配置分析器會在錄製期間定期擷取堆積快照 (頻率高達每 50 毫秒一次),並在錄製結束時擷取最後一個快照。
頂端的長條表示在堆積中發現新物件的時間。每個長條的高度對應最近分配的物件大小,長條的顏色則表示這些物件是否仍存在於最終堆積快照中:藍色長條表示在時間軸結束時仍存在的物件,灰色長條表示在時間軸期間分配的物件,但已進行垃圾收集。
在上述範例中,某項動作執行了 10 次。範例程式會快取五個物件,因此預期會出現最後五個藍色長條。但最左側的藍色長條表示可能有問題。接著,您可以使用上方時間軸中的滑桿,放大該特定快照,並查看最近在該時間點分配的物件。點選堆積中的特定物件,即可在堆積快照底部查看其保留樹狀結構。檢查物件的保留路徑,應該就能充分瞭解物件未收集的原因,並可視需要變更程式碼,移除不必要的參照。
解決 Gmail 記憶體不足的問題
透過上述工具和技術,Gmail 團隊找出幾類錯誤:無限制快取、無限成長的回呼陣列 (等待永遠不會發生的事件),以及事件監聽器意外保留目標。修正這些問題後,Gmail 的整體記憶體用量大幅減少。99% 的使用者記憶體用量比以往減少 80%,中位數使用者的記憶體用量則減少近 50%。
由於 Gmail 使用的記憶體較少,GC 暫停延遲時間也隨之縮短,整體使用者體驗也因此提升。
此外,Gmail 團隊收集記憶體用量統計資料後,發現 Chrome 內部有垃圾收集迴歸問題。具體來說,當 Gmail 的記憶體資料開始顯示已分配記憶體總量與即時記憶體之間的差距大幅增加時,系統發現了兩個片段化錯誤。
行動號召
請思考下列問題:
- 我的應用程式使用多少記憶體?您可能使用了過多記憶體,這與一般認知相反,反而會對整體應用程式效能造成負面影響。我們很難確切知道正確的數字,但請務必確認網頁使用的任何額外快取,對效能有可衡量的影響。
- 我的網頁是否沒有記憶體流失? 如果網頁發生記憶體流失,不僅會影響網頁效能,也會影響其他分頁。使用物件追蹤器,縮小記憶體流失的範圍。
- 我的網頁多久執行一次 GC?您可以使用 Chrome 開發人員工具中的時間軸面板查看任何 GC 暫停。如果網頁經常執行 GC,可能是因為配置頻率過高,導致新生代記憶體耗盡。
結論
我們在危機中起步。涵蓋 JavaScript 和 V8 記憶體管理的核心基本概念。您已瞭解如何使用這些工具,包括最新版 Chrome 的新物件追蹤工具功能。Gmail 團隊運用這項知識,解決了記憶體用量問題,並提升了效能。網頁應用程式也能比照辦理!