有效管理 Gmail 規模的記憶體

John McCutchan
John McCutchan
Loreena Lee
Loreena Lee

簡介

雖然 JavaScript 會使用垃圾收集功能自動管理記憶體,但這並不能取代應用程式中的有效記憶體管理功能。JavaScript 應用程式會遇到與原生應用程式相同的記憶體相關問題,例如記憶體流失和膨脹,但也必須處理垃圾收集暫停的問題。大規模應用程式 (例如 Gmail) 也會遇到小型應用程式遇到的相同問題。請繼續閱讀,瞭解 Gmail 團隊如何使用 Chrome 開發人員工具來找出、隔離及修正記憶體問題。

Google I/O 2013 大會專題講座

我們在 2013 年 Google I/O 大會上介紹了這項內容。請觀看下方影片:

Gmail,我們遇到問題了…

Gmail 團隊遇到嚴重問題。我們經常聽到有人說,在資源有限的筆電和電腦上,Gmail 分頁會消耗數 GB 的記憶體,而且經常會導致整個瀏覽器當機。處理器使用率達到 100%、應用程式無回應,以及 Chrome 發生「悲劇」分頁 (「He's dead, Jim.」) 的情況。團隊不知道如何開始診斷問題,更別說是修正問題。他們不知道問題的影響範圍有多大,而且現有的工具無法擴充到大型應用程式。該團隊與 Chrome 團隊攜手合作,共同開發新技術來分類記憶體問題、改善現有工具,並收集現場記憶體資料。不過,在介紹工具之前,讓我們先介紹 JavaScript 記憶體管理的基本概念。

記憶體管理基本概念

您必須先瞭解 JavaScript 的基本概念,才能有效管理記憶體。本節將介紹基本類型、物件圖表,並提供一般記憶體膨脹和 JavaScript 記憶體外洩的定義。在 JavaScript 中,記憶體可視為圖表,因此圖表理論在 JavaScript 記憶體管理和堆積分析器中扮演著重要角色。

原始類型

JavaScript 有三種基本類型:

  1. 數字 (例如 4、3.14159)
  2. 布林值 (true 或 false)
  3. 字串 ("Hello World")

這些基本類型無法參照任何其他值。在物件圖中,這些值一律為葉節點或終端節點,也就是說,它們不會有傳出邊。

只有一個容器類型:物件。在 JavaScript 中,物件是關聯陣列。非空物件是內部節點,具有指向其他值 (節點) 的出邊。

陣列又是什麼呢?

JavaScript 中的陣列其實是具有數字鍵的物件。這是簡化版,因為 JavaScript 執行階段會最佳化陣列類型的物件,並在幕後以陣列的形式呈現。

術語

  1. 值:基本型別、物件、陣列等的例項
  2. 變數:參照值的名稱。
  3. 屬性:物件中用來參照值的名稱。

物件圖表

JavaScript 中的所有值都是物件圖的一部分。圖表會從根節點開始,例如 視窗物件。您無法控制 GC 根目錄的生命週期,因為這些根目錄是由瀏覽器建立,並在網頁卸載時遭到刪除。全域變數其實是視窗上的屬性。

物件圖

何時會產生垃圾值?

如果從根節點到值的路徑不存在,該值就會變成垃圾。換句話說,從根開始,並逐一搜尋堆疊框架中所有有效的物件屬性和變數,如果無法取得值,就表示該值已成為垃圾。

垃圾圖

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

JavaScript 記憶體流失最常發生在 DOM 節點無法透過網頁的 DOM 樹狀結構存取,但仍由 JavaScript 物件參照的情況下。雖然現代瀏覽器已使不小心造成資料外洩的機率越來越低,但這類情況仍比想像中容易發生。假設您要將元素附加到 DOM 樹狀結構,如下所示:

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

之後,您可以從顯示清單中移除元素:

displayList.removeAllChildren();

只要 email 存在,系統就不會移除 message 參照的 DOM 元素,即使該元素已從網頁的 DOM 樹狀結構中分離也一樣。

什麼是 Bloat?

如果您為了提升網頁速度而使用過多記憶體,網頁就會膨脹。間接來說,記憶體流失也會導致膨脹,但這並非設計目的。沒有任何大小限制的應用程式快取,通常是記憶體膨脹的常見來源。此外,主機資料 (例如從圖片載入的像素資料) 也可能會導致網頁變得臃腫。

什麼是垃圾收集?

垃圾收集是 JavaScript 中記憶體回收的方式。瀏覽器會決定何時發生這種情況。在收集期間,系統會暫停網頁上的所有指令碼執行作業,並透過從 GC 根目錄開始的物件圖表遍歷作業,找出即時值。所有無法存取的值都會歸類為垃圾。記憶體管理員會回收垃圾值的記憶體。

V8 垃圾收集器詳細資訊

為進一步瞭解垃圾收集的運作方式,我們將詳細介紹 V8 垃圾收集器。V8 使用世代收集器。記憶體分為兩個世代:新舊世代。新生代內的分配和收集作業快速且頻繁。舊世代的配置和收集速度較慢,且頻率較低。

世代收集器

V8 使用兩代收集器。值的年齡定義為自分配以來分配的位元組數量。實際上,值的 age 通常會以其存活的幼齡世代集合數量來估算。當值足夠久時,就會保留至舊世代。

實際上,新分配的值不會存在太久。根據 Smalltalk 程式的研究顯示,在年輕世代收集後,只有 7% 的值會保留下來。在執行階段中進行類似研究後,我們發現平均有 90% 至 70% 的新分配值從未保留至舊世代。

年輕世代

V8 中的新生代堆疊分為兩個空間,分別命名為 from 和 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 會傳回三個資料:

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

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

大規模測量記憶體

Gmail 會將 JavaScript 做為檢測工具,使用 performance.memory API 大約每 30 分鐘收集一次記憶體資訊。由於許多 Gmail 使用者會讓應用程式開啟好幾天,因此團隊能夠追蹤記憶體隨時間的成長情形,以及整體記憶體占用空間的統計資料。在對 Gmail 進行檢測,並從隨機抽樣的使用者中收集記憶體資訊後,團隊在幾天內就取得足夠的資料,瞭解一般使用者記憶體問題的普遍程度。他們設定了基準,並使用傳入資料的資料流,追蹤減少記憶體用量的目標達成進度。這項資料最終也會用於擷取任何記憶體回歸現象。

除了追蹤用途之外,實地測量結果還能讓您深入瞭解記憶體占用空間與應用程式效能之間的關聯性。與普遍認為「記憶體越大,效能越好」的說法相反,Gmail 團隊發現,記憶體占用空間越大,常見的 Gmail 操作延遲時間就越長。有了這項資訊,他們更有動力控制記憶體用量。

大規模測量記憶體

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

解決任何效能問題的第一步,就是證明問題確實存在、建立可重現的測試,並取得問題的基準評估。如未提供可重現的程式,您就無法可靠地評估問題。沒有基準評估項目,您就無法得知成效提升了多少。

開發人員工具的時間軸面板是證明問題存在的理想選擇。這項資訊可完整概略說明載入及與網頁或應用程式互動時的時間分配情形。從載入資源到剖析 JavaScript、計算樣式、垃圾收集暫停和重繪,所有事件都會繪製在時間軸上。為了調查記憶體問題,時間軸面板也提供記憶體模式,可追蹤已分配的記憶體總數、DOM 節點數、視窗物件數,以及已分配的事件監聽器數。

證明問題確實存在

首先,請找出您懷疑會造成記憶體外洩的動作序列。開始記錄時間軸,並執行一連串動作。使用底部的垃圾桶按鈕,強制執行完整垃圾收集作業。如果在幾次疊代後,您看到鋸齒狀圖表,表示您分配了許多壽命短暫的物件。不過,如果動作序列不會導致任何保留記憶體,且 DOM 節點數量未降回您開始時的基準,您就有理由懷疑記憶體有洩漏情形。

鋸齒形圖表

確認問題存在後,您可以透過 DevTools 堆積分析工具,找出問題來源。

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

Profiler 面板提供 CPU 剖析器和堆積剖析器。堆積分析會擷取物件圖表的快照。在擷取快照之前,新舊世代都會進行垃圾收集作業。換句話說,您只會看到快照拍攝時有效的值。

堆積分析工具的功能太多,無法在本文中詳細介紹,但您可以前往 Chrome Developers 網站查看詳細說明文件。我們將在這裡著重於堆積分配剖析工具。

使用堆積分配 Profiler

堆積分配分析器會將堆積分析器的詳細快照資訊,與時間軸面板的遞增更新和追蹤功能結合。開啟「Profiles」面板,啟動「Record Heap Allocations」設定檔,執行一連串動作,然後停止錄製以進行分析。配置分析器會在錄製期間定期擷取堆積快照 (頻率高達每 50 毫秒一次),並在錄製結束時擷取最後一個快照。

堆積分配分析器

頂端的長條會指出堆積中何時找到新物件。每個長條的高度對應至最近分配的物件大小,而長條的顏色則表示這些物件是否仍在最終堆積快照中:藍色長條表示在時間軸結束時仍在運作的物件,灰色長條則表示在時間軸期間分配的物件,但已進行垃圾收集。

在上述範例中,動作已執行 10 次。範例程式會快取五個物件,因此會顯示最後五個藍色長條。但最左邊的藍色長條表示可能有問題。接著,您可以使用上方時間軸中的滑桿,放大該特定快照,查看該時間點最近分配的物件。按一下堆積中的特定物件,即可在堆積快照的底部顯示其保留樹狀結構。檢查物件的保留路徑,即可取得足夠資訊,瞭解為何未收集物件,並進行必要的程式碼變更,移除不必要的參照。

解決 Gmail 記憶體問題

透過上述工具和技術,Gmail 團隊成功找出幾類錯誤:無限的快取、無限擴增的回呼陣列,等待發生的事件從未實際發生,以及事件監聽器不小心保留目標。修正這些問題後,Gmail 的整體記憶體用量大幅降低。99% 的使用者記憶體用量比之前減少了 80%,而中位使用者的記憶體用量則減少了近 50%。

Gmail 記憶體用量

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

值得一提的是,Gmail 團隊收集記憶體使用量的統計資料後,發現 Chrome 中的垃圾收集回歸現象。具體來說,當 Gmail 記憶體資料開始顯示分配的記憶體總量與實際記憶體之間的差距大幅增加時,我們發現了兩個分散式錯誤。

行動號召

請思考下列問題:

  1. 我的應用程式使用多少記憶體?您可能使用了過多記憶體,這會對整體應用程式效能造成負面影響。很難準確判斷正確的數量,但請務必確認網頁使用的任何額外快取功能,是否有可測量的效能影響。
  2. 我的網頁是否有資訊外洩?如果網頁發生記憶體流失情形,不僅會影響網頁效能,也會影響其他分頁。使用物件追蹤器,即可縮小任何漏洞的範圍。
  3. 我的網頁多久執行一次 GC?您可以使用 Chrome 開發人員工具中的時間軸面板,查看任何 GC 暫停情形。如果網頁經常執行 GC,表示您可能配置得太頻繁,導致新生代記憶體不斷消耗。

結論

我們一開始就面臨危機。涵蓋 JavaScript 和 V8 中記憶體管理的核心基礎知識。您已瞭解如何使用這些工具,包括最新 Chrome 版本提供的新物件追蹤器功能。有了這項知識,Gmail 團隊解決了記憶體用量問題,並提升了效能。您也可以對網頁應用程式執行相同操作!