有效管理 Gmail 規模的記憶體

John McCutchan
John McCutchan
Loreena Lee
Loreena Lee

簡介

雖然 JavaScript 採用垃圾收集來自動管理記憶體,但卻無法取代應用程式中有效的記憶體管理方法。JavaScript 應用程式發生與原生應用程式相同的記憶體相關問題 (例如記憶體流失和爆炸),但也必須處理垃圾收集暫停作業。無論是 Gmail 等大型應用程式,還是在處理小型應用程式時,都面臨同樣的問題。請繼續閱讀下文,瞭解 Gmail 團隊如何使用 Chrome 開發人員工具辨識、隔離及修正記憶體問題。

2013 年 Google I/O 大會講座

我們在 2013 年 Google I/O 大會上發表了這些內容。歡迎觀看下方影片:

Gmail 發生問題...

Gmail 團隊遇到一個嚴重的問題。人們越來越常聽到 Gmail 分頁在資源受限的筆記型電腦和桌上型電腦上佔用多個 GB 的記憶體,結果通常就是關閉整個瀏覽器後的結果。CPU 的案例包括將 CPU 固定在 100% 問題、應用程式無回應,以及 Chrome 很傷心的分頁 (「他死了,阿傑」)。該團隊很失望,不知道該如何開始診斷問題,更別說是解決問題的。他們不知道問題有多大,可用的工具也無法向上擴充至大型應用程式。這些團隊與 Chrome 團隊攜手合作,共同開發出將記憶體問題分類、改善現有工具,以及開放從外收集記憶體資料的新技術。但在開始使用工具前,讓我們先來探討 JavaScript 記憶體管理的基本概念。

記憶體管理基本概念

您必須先瞭解基礎知識,才能有效管理 JavaScript 的記憶體。本章節會說明原始類型、物件圖,並會說明記憶體流失的概況,並介紹 JavaScript 中的記憶體流失。JavaScript 中的記憶體概念可視為圖表,而由於這個圖表理論是 JavaScript 記憶體管理和堆積分析器中的一部分。

基本類型

JavaScript 有三種原始類型:

  1. 數字 (例如:4、3.14159)
  2. Boolean (true 或 false)
  3. 字串 (「Hello World」)

這些基本類型無法參照任何其他值。在物件圖表中,這些值一律是分葉或終止的節點,表示這些值永遠不會有傳出邊緣。

只有一個容器類型:物件。在 JavaScript 中,物件是關聯陣列。非空白物件是內部節點,其外側邊緣與其他值 (節點) 之間。

陣列的簡介

JavaScript 中的陣列實際上是包含數字鍵的物件。這個過程很簡單,因為 JavaScript 執行階段會最佳化類似陣列的物件,並以陣列的形式呈現這些物件。

術語

  1. 值 - 原始類型、物件、陣列等的例項。
  2. 變數 - 參照值的名稱。
  3. 屬性 - 物件中參照某個值的名稱。

物件圖表

JavaScript 中的所有值都是物件圖表的一部分。圖表從根層級開始,例如視窗物件。GC 根的生命週期是由瀏覽器建立,並在網頁卸載時銷毀,因此您無權控管 GC 根的生命週期。全域變數實際上是視窗中的屬性。

物件圖表

特定價值何時會成為垃圾?

如果沒有從根目錄到值的路徑,值就會變成垃圾。換句話說,從根層級開始徹底搜尋「堆疊框架」中存在的所有物件屬性和變數,即使無法觸及某個值,也會成為垃圾。

垃圾圖表

什麼是 JavaScript 中的記憶體流失?

JavaScript 中發生記憶體流失的常見原因,是當某個 DOM 節點無法從網頁的 DOM 樹狀結構存取,但仍由 JavaScript 物件參照時,最常發生這種問題。雖然新世代的瀏覽器越來越難在無意間造成洩漏資料,但這其實沒有什麼困難。假設您將元素附加至 DOM 樹狀結構,如下所示:

email.message = document.createElement("div");
displayList.appendChild(email.message);

稍後,您將元素從顯示清單中移除:

displayList.removeAllChildren();

只要 email 存在,即使訊息參照的 DOM 元素已從頁面的 DOM 樹狀結構卸離,訊息所參照的 DOM 元素也不會移除。

什麼是 Bloat?

使用的記憶體不足以提升網頁速度時,網頁會毀損。記憶體流失也會間接造成記憶體流失,但這並非出自設計。不受限於任何大小的應用程式快取,是記憶體過大的常見來源。此外,您的網頁可能會由主機資料載入,例如從圖片載入的像素資料。

什麼是垃圾收集?

在 JavaScript 中收回記憶體的方式就是垃圾收集。發生這種狀況時,瀏覽器會自行判斷。在收集期間,系統會暫停執行網頁上的所有指令碼,而透過物件圖形的遍歷發現有效值時,會從 GC 根目錄開始執行。所有無法連線的值都會歸類為垃圾。記憶體管理員會收回垃圾值的記憶體。

V8 垃圾收集器詳細資料

為進一步瞭解垃圾收集的發生方式,讓我們來詳細瞭解 V8 垃圾收集器。V8 使用世代收集器。記憶可分成兩代:新舊一代。年輕世代的配置及收集作業速度飛快且頻繁。舊世代的分配和收集速度較慢,頻率也較低。

一代收集器

V8 使用兩代收集器。值的存在時間定義為自分配後分配的位元組數。實際上,一個值的實際存在時間通常透過其存活的年輕一代收藏數量近似值。當某個值夠舊,就會繼續沿用到舊的。

實際上,重新分配的值不會長存。針對 Smalltalk 計畫的研究,顯示只有 7% 的價值在年輕收集過後仍然活下來。多個執行階段的類似研究發現,平均分配到 90% 至 70% 的新分配值從未延續到舊代。

年輕代

V8 中的年輕堆積分成兩個空格,命名範圍為 和 。記憶體會從各空間分配。分配速度非常快,直到空間用盡為止,此時就會觸發年輕的收集作業。年輕一代集合會先將舊元素替換為太空,屆時會掃描舊到太空 (現在從太空中),所有即時值都會複製到另一個空間,或強化到舊的代子。一般的年輕一代會按照 10 毫秒 (ms) 的順序進行收集。

您應該瞭解,應用程式的每項配置作業都會使您更耗盡空間,並造成 GC 暫停。遊戲開發人員請注意,為確保達到 16 毫秒的影格時間 (需要達到每秒 60 個影格數),應用程式不得不分配任何資源,因為一次年輕的集合會佔用大部分的影格時間。

年輕代堆積

舊版

V8 中的舊版堆積使用標記密集演算法進行收集。舊版分配作業會在特定值從年輕世代留至舊代時啟動。每當存在舊代的收集行為時,也會完成年輕的收集作業。您的應用程式會每幾秒暫停一次。這在實務上是可接受的,因為舊的集合很少頻繁。

V8 GC 摘要

自動管理記憶體和垃圾收集機制能有效提高開發人員的工作效率,但每次分配一個價值時,都會更接近暫停垃圾收集的時間。暫停垃圾收集會引進卡頓,造成應用程式的感覺受損。現在您已經瞭解 JavaScript 管理記憶體的方式,現在可以為應用程式選擇合適的選擇。

修正 Gmail 問題

過去一年來,Chrome 開發人員工具已推出許多功能和錯誤修正項目,因此功能比以往更強大。此外,瀏覽器本身也對 Performance.memory API 進行了重大變更,因此 Gmail 和其他應用程式可從欄位收集記憶體統計資料。我們推出這些酷炫工具,過去聽起來像是一項不可能的任務,很快就成為追蹤罪犯的樂趣。

工具和技術

欄位資料和效能.memory API

自 Chrome 22 版起,performance.memory API 預設為啟用。就 Gmail 等長時間執行的應用程式而言,真實使用者的資料非常重要。這些資訊有助我們區分進階使用者,也就是一天花 8 到 16 小時使用 Gmail 或一天收到數百封郵件的使用者,以及每天花幾分鐘使用 Gmail 或每週收到十封郵件的使用者。

這個 API 會傳回三項資料:

  1. jsHeapSizeLimit - JavaScript 堆積限制的記憶體量 (以位元組為單位)。
  2. totalJSHeapSize - JavaScript 堆積分配到的記憶體量 (以位元組為單位),包括可用空間。
  3. useJSHeapSize - 目前使用的記憶體量 (以位元組為單位)。

請注意,這個 API 會傳回整個 Chrome 程序的記憶體值。雖然這不是預設模式,但在某些情況下,Chrome 可能會在同一個轉譯器程序中開啟多個分頁。這表示 performance.memory 傳回的值除了包含應用程式的分頁之外,也可能包含其他瀏覽器分頁的記憶體用量。

大規模測量記憶體

Gmail 檢測了 JavaScript 使用 performance.memory API,大約每 30 分鐘收集一次記憶體資訊。許多 Gmail 使用者一次會離開應用程式數天,因此團隊得以追蹤長期的記憶體成長情形,以及整體記憶體用量統計資料。負責讓 Gmail 從隨機抽樣中收集使用者記憶體資訊,不到幾天的時間,該團隊便有足夠的資料來瞭解一般使用者中有多大的記憶體問題。他們設定了基準,並使用傳入的資料串流來追蹤降低記憶體消耗量的目標進度。最後,這項資料也會用於擷取任何記憶體迴歸問題。

除了追蹤目的外,測量數據還能讓您深入瞭解記憶體用量與應用程式效能之間的關聯。Gmail 團隊發現,使用者大多認為「記憶體用量越高,一般 Gmail 操作的延遲時間就越長。面對這樣的情感,讓牠們更積極參與記憶體消耗。

大規模測量記憶體

使用開發人員工具時間軸找出記憶體問題

要解決任何效能問題,第一步是證明問題確實存在、建立可重現的測試,然後對問題的基準測量結果。少了可重現的程式,這就無法確切衡量問題。如果不使用基準評估,您就無法知道自己的成效改善幅度為何。

開發人員工具時間軸面板非常適合用來證明問題確實存在。可讓你完全掌握系統在載入以及與網頁應用程式或網頁互動時,所花費的時間。所有事件都會以時間軸標示,包括載入資源、剖析 JavaScript、計算樣式、垃圾收集暫停和重新繪製等。為了調查記憶體問題,「時間軸」面板也提供了「記憶體」模式,可用於追蹤分配的記憶體總量、DOM 節點數量、視窗物件數量,以及分配的事件監聽器數量。

證明問題存在

首先,請找出你懷疑記憶體流失的一系列操作。開始記錄時間軸,並執行動作順序。使用底部的垃圾桶按鈕可強制進行完整的垃圾收集。如果經過幾次疊代後,您會看到鋸齒狀形狀的圖表,表示您已分配許多短期存在的物件。但是,如果動作順序不會導致任何保留的記憶體,且 DOM 節點計數並未向下拉回您最初的基準線,您便有充分理由懷疑發生外洩情形。

鋸齒狀圖形

確認問題確實存在後,您就可以透過開發人員工具堆積分析器找出問題來源。

使用開發人員工具堆積分析器找出記憶體流失情形

分析器面板會提供 CPU 分析器和堆積分析器。系統會拍攝物件圖表的快照,進行堆積剖析作業。無論年輕人或新世代,在拍攝快照前都會經過垃圾收集。換句話說,您只會看到拍攝快照當下有效的值。

在本文中,堆積分析器提供的功能過多,無法充分說明。請前往 Chrome 開發人員網站,查看詳細說明。我們會著重介紹堆積配置分析器。

使用堆積配置分析器

堆積配置分析器結合了堆積分析器的詳細快照資訊,以及時間軸面板的漸進式更新和追蹤功能。開啟「Profile」面板,啟動「Record Heap Allocations」設定檔,並執行一系列動作,然後停止記錄,以便進行分析。配置分析器會在整個記錄期間定期拍攝堆積快照 (頻率為每 50 毫秒!),並在記錄結束時提供一個最終快照。

堆積分配分析器

頂端的長條代表何時在堆積中發現新物件。每個長條的高度會對應至最近配置的物件大小,長條的顏色則會指出這些物件在最終堆積快照中是否仍存在:藍色長條表示在時間軸末端仍保持運作的物件,灰色長條則代表在時間軸上分配、但已經進行垃圾收集之後的物件。

在上述範例中,系統執行了 10 次動作。範例程式會快取五個物件,因此預期的最後五個藍色長條是預期。但最左側的藍色長條表示可能發生問題。接著,您可以使用上方時間軸中的滑桿來放大特定快照,並查看在該時間點最近配置的物件。按一下堆積中的特定物件,即可在堆積快照底部顯示其保留樹狀結構。檢查物件的保留路徑應可讓您取得足夠資訊,瞭解系統未收集物件的原因;您也可以對程式碼做出必要的變更,移除不必要的參照。

解決 Gmail 記憶體危機

透過使用上述的工具和技術,Gmail 團隊能夠找出一些錯誤類別:不受限的快取、不斷增加的回呼陣列等待未發生發生的事情,以及在無意中保持目標。修正這些問題後,Gmail 的整體記憶體用量大幅降低。99%% 的使用者使用的記憶體容量比以往少 80%,而中位數使用者的記憶體用量則減少了將近 50%。

Gmail 記憶體用量

由於 Gmail 使用的記憶體較少,因此 GC 暫停延遲時間縮短了,因此整體使用者體驗更加提升。

另外請注意,Gmail 團隊會收集記憶體用量的統計資料,藉此在 Chrome 中發現垃圾收集迴歸問題。具體來說,Gmail 的記憶體資料分配到記憶體總量與即時記憶體之間的差距,他發現兩個分割錯誤。

行動號召

請思考下列問題:

  1. 應用程式使用多少記憶體?您可能使用過多記憶體,與一般的想法相反,會對整體應用程式效能造成負面影響。我們無法確切知道正確的數字為何,但請務必確定網頁使用的其他快取是否確實會造成效能上的顯著影響。
  2. 我的網頁不會洩漏嗎? 如果您的網頁有記憶體流失,不但會影響網頁效能,其他分頁也會受到影響。利用物品追蹤器協助縮小任何漏水範圍。
  3. 我的網頁 GCing 多久發生一次? 您可以在 Chrome 開發人員工具時間軸面板中查看 GC 暫停情形。如果您的網頁經常使用 GC,那麼您可能太常進行配置,因而佔用年輕世代的記憶體。

結論

我們一開始遭到危機著重介紹 JavaScript 和 V8 記憶體管理的核心基本概念。您已瞭解如何使用這些工具,包括最新版 Chrome 中提供的新物件追蹤工具功能。Gmail 團隊從這些知識著手,解決了記憶體用量問題,改善了效能。網頁應用程式也提供同樣的操作!