【轉(zhuǎn)】推薦一個檢測 JS 內(nèi)存泄漏的神器

最近,Meta 開源了一款檢測 ,我們來一起看看這個框架有啥神奇之處吧~

2020 年,Meta 的工程師將 Facebook.com 重構(gòu)為了單頁應(yīng)用(SPA),程序的大部分渲染和導(dǎo)航都會在客戶端使用 JavaScript 完成。后來他們又使用類似的架構(gòu)來重構(gòu)了 Meta 的大多數(shù)其他流行的網(wǎng)絡(luò)應(yīng)用程序,包括 Instagram 和 Workplace。雖然這種架構(gòu)能夠提供更快的用戶交互、更好的開發(fā)者體驗和更像原生應(yīng)用程序的感覺,但是在客戶端維護 Web 應(yīng)用的狀態(tài)會讓內(nèi)存的管理變得更加復(fù)雜。

使用 Meta 網(wǎng)站的用戶經(jīng)常會快速注意到一些性能和功能正常使用的問題。然而,內(nèi)存泄漏就是另一回事了。它不會立即被察覺出來,因為它一次會占用一大塊內(nèi)存 — 然后逐漸影響整個 Web 會話并讓后續(xù)的交互和響應(yīng)變得更慢。

Meta 的工程師花費了大量時間來測試、優(yōu)化和控制頁面加載和交互時間,以及 JavaScript 的代碼大小。相比之下,他們在管理 Web 瀏覽器內(nèi)存方面做的工作并不多。當(dāng)分析新 Facebook.com 的內(nèi)存使用情況時,發(fā)現(xiàn)客戶端的內(nèi)存使用情況和內(nèi)存不足 (OOM) 崩潰的數(shù)量一直在攀升。較高的內(nèi)存使用對頁面加載、交互性能、用戶參與度等核心指標(biāo)都有負面影響。

為了幫助開發(fā)者解決這個問題,Meta 的工程師構(gòu)建了 MemLab,這是一個 JavaScript 內(nèi)存測試框架,可以自動進行內(nèi)存泄漏檢測,并且更容易找到內(nèi)存泄漏的根本原因。Meta 使用 MemLab 成功地控制了不可持續(xù)的內(nèi)存增長,并識別出了產(chǎn)品和基礎(chǔ)設(shè)施中的內(nèi)存泄漏和內(nèi)存優(yōu)化的一些手段。

導(dǎo)致 Web 應(yīng)用內(nèi)存過高的原因

因為內(nèi)存泄漏通常不是很明顯,在開發(fā)過程中,以及做 Code Review 的時候都很難發(fā)現(xiàn),而且在生產(chǎn)環(huán)境中通常也很難找到根本原因。雖然主流的 JavaScript 運行時都有垃圾回收機制,那么為什么還會有內(nèi)存泄漏呢?

JavaScript 代碼中可能會有很多隱藏對象的引用,而隱藏的引用會以許多意想不到的方式導(dǎo)致內(nèi)存泄漏。

例如:

var obj = {};
console.log(obj);
obj = null;

在 Chrome 中,即使我們將引用設(shè)置為 null ,這段代碼也會泄漏 obj 。發(fā)生這種情況是因為 Chrome 需要保留對打印對象的內(nèi)部引用,以便以后可以在 Web 控制臺中對其進行檢查(即使在 Web 控制臺沒打開的情況下)。

在某些情況下,內(nèi)存在技術(shù)上并沒有發(fā)生泄漏,而是在用戶會話期間線性增長而且沒有限制。最常見的原因是客戶端緩存沒有內(nèi)置任何釋放的邏輯,無限滾動列表沒有任何虛擬化的功能,無法在添加新內(nèi)容時從列表中刪除較早的內(nèi)容。

我們也沒有適當(dāng)?shù)淖詣踊到y(tǒng)和流程來控制內(nèi)存,因此防止此類問題的唯一防御措施就是專家通過 Chrome DevTools 定期挖掘內(nèi)存泄漏,一些大型的項目幾乎每天都會有發(fā)布和變更,這樣的工作方式是不可持續(xù)的。

MemLab 的工作原理

MemLab 通過預(yù)定義的測試場景運行無頭瀏覽器并比較和分析 JavaScript 堆快照來發(fā)現(xiàn)內(nèi)存泄漏的問題。


這個過程可以分為下面六個步驟:

  • 1.「瀏覽器交互」:MemLab 使用 Puppeteer 自動化瀏覽器,在目標(biāo)頁面上查找泄露的對象;

  • 2.「區(qū)分堆」:導(dǎo)航到一個頁面然后離開它,正常情況下該頁面分配的大部分內(nèi)存也應(yīng)該被釋放,如果沒有,可能暗示著存在內(nèi)存泄漏。MemLab 通過區(qū)分 JavaScript 堆并記錄在頁面 B 上分配的一組對象,這些對象沒有在頁面 A 上分配,但在重新加載頁面 A 時仍然存在,從而發(fā)現(xiàn)潛在的內(nèi)存泄漏;

  • 3.「細化內(nèi)存泄漏列表」:內(nèi)存泄漏檢測器進一步結(jié)合了特定框架的知識來細化泄漏對象的列表。例如,React 分配的 Fiber 節(jié)點(React 用于渲染虛擬 DOM 的內(nèi)部數(shù)據(jù)結(jié)構(gòu))應(yīng)該在我們訪問多個選項卡后清理時釋放。

  • 4.「生成 retainer traces」:遍歷堆并為每個泄漏的對象生成 retainer traces 。trace 顯示了泄漏對象為何以及如何在內(nèi)存中保持活動狀態(tài)。打破引用鏈意味著泄漏的對象將不再可以從 GC 的根訪問,因此可以進行垃圾回收。通過一步步地跟蹤,就可以找到應(yīng)該設(shè)置為 null 的引用;

  • 5.「聚合 retainer traces」:將所有 retainer traces 聚集在一起,并為每個共享相似 retainer traces 的泄漏對象聚合顯示為一個跟蹤,其中還包括調(diào)試信息,例如支配節(jié)點和保留大小。

  • 6.「報告泄漏」:定期運行 MemLab,以持續(xù)收集 retainer traces,任何新的 traces 都會記錄到內(nèi)部儀表板,開發(fā)者可以查看每個內(nèi)存泄漏的 retainer traces 上的對象屬性。

MemLab 有哪些能力

「內(nèi)存泄漏檢測」

對于瀏覽器內(nèi)存泄漏的檢測,MemLab 需要開發(fā)者提供的唯一輸入就是一個測試場景文件,這個文件定義了如何通過使用 Puppeteer API 和 CSS 選擇器覆蓋三個回調(diào)來與網(wǎng)頁交互。MemLab 會自動區(qū)分 JavaScript 堆、優(yōu)化內(nèi)存泄漏并聚合結(jié)果。


「JavaScript 堆的 Graph-view API」

MemLab 支持一個自定義的泄漏檢測器,作為篩選器回調(diào),應(yīng)用于每個由目標(biāo)交互分配的泄漏候選對象,但之后從不釋放。泄漏過濾器回調(diào)函數(shù)可以遍歷堆并確定哪些對象是內(nèi)存泄漏。例如,我們的內(nèi)置檢漏器會跟蹤 React Fiber 節(jié)點的返回鏈路,檢查 Fiber 節(jié)點是否與 React Fiber 樹分離。


為了分析每個可能內(nèi)存泄漏的上下文,MemLab 提供了一個 JavaScript 堆的內(nèi)存效率圖。這可以在不了解 V8 堆快照文件結(jié)構(gòu)的任何領(lǐng)域知識的情況下查詢和遍歷 JavaScript 堆。

在視圖中,堆中的每個 JavaScript 對象或原生對象都是一個圖節(jié)點,堆中的每個 JavaScript 引用都是一個圖的邊。實際應(yīng)用程序的堆大小通常很大,因此圖視圖需要在提供直觀的面向?qū)ο蠖驯闅v API 的同時提高內(nèi)存效率。因此,圖節(jié)點被設(shè)計成了虛擬的,不通過 JavaScript 引用進行連接。當(dāng)分析代碼遍歷堆時,虛擬圖會部分地即時構(gòu)建圖的接觸部分。圖的任何部分都可以很容易地釋放,因為這些虛擬節(jié)點彼此之間沒有 JavaScript 引用。

堆視圖可以從基于 Chromium 的瀏覽器、Node.js、Electron 和 Hermes 獲取的 JavaScript 堆快照加載。這允許分析復(fù)雜的模式并回答諸如 “有多少 React Fiber 節(jié)點是備用的 Fiber 節(jié)點,它們用于不完整的并發(fā)渲染?”之類的問題。

import {getHeapFromFile} from '@memlab/heap-analysis';
const heapGraph = await getHeapFromFile(heapFile);
heapGraph.nodes.forEach(node => {
  // heap node traversal
  node.type
  node.references
);
「內(nèi)存斷言」

Node.js 程序或 Jest 測試也可以使用 graph-view API 來獲取其自身狀態(tài)的堆視圖,進行自內(nèi)存檢查,并編寫各種內(nèi)存斷言。

import type {IHeapSnapshot} from '@memlab/core';
import {config, takeNodeMinimalHeap, tagObject} from '@memlab/core';

test('memory test', async () => {
  config.muteConsole = true;
  const o1 = {};
  let o2 = {};

  // tag o1 with marker: "memlab-mark-1", does not modify o1 in any way
  tagObject(o1, 'memlab-mark-1');
  // tag o2 with marker: "memlab-mark-2", does not modify o2 in any way
  tagObject(o2, 'memlab-mark-2');

  o2 = null;

  const heap: IHeapSnapshot = await takeNodeMinimalHeap();

  // expect object with marker "memlab-mark-1" exists
  expect(heap.hasObjectWithTag('memlab-mark-1')).toBe(true);

  // expect object with marker "memlab-mark-2" can be GCed
  expect(heap.hasObjectWithTag('memlab-mark-2')).toBe(false);

}, 30000);
「內(nèi)存工具箱」

除了內(nèi)存泄漏檢測,MemLab 還包括一組內(nèi)置的 CLI 命令和 API,用于尋找可能的內(nèi)存優(yōu)化機會:



Meta 使用 MemLab 的實踐

在過去的幾年中,Meta 一直在使用 MemLab 檢測和診斷內(nèi)存泄漏,并收集了很多有助于優(yōu)化內(nèi)存、減少 OOM 崩潰并改善用戶體驗的手段。

在 2021 年上半年, Facebook.com 上的 OOM 崩潰減少了 50%。


「React Fiber 節(jié)點清理」

為了渲染組件,React 構(gòu)建了 Fiber 樹 — 一個 React 用于渲染虛擬 DOM 的內(nèi)部數(shù)據(jù)結(jié)構(gòu)。雖然 Fiber 樹看起來像一棵樹,但它是一個雙向圖,將所有 Fiber 節(jié)點、React 組件實例和關(guān)聯(lián)的 HTML DOM 元素強連接起來。理想情況下,React 維護對組件 Fiber 樹的根的引用,并防止 Fiber 樹被垃圾回收。當(dāng)一個組件被卸載時,React 會斷開組件的根與 Fiber 樹的其余部分之間的連接,然后這些部分就可以被垃圾回收了。

擁有這樣的強連接圖的缺點是,如果有任何外部引用指向圖的任何部分,就無法對整個圖進行垃圾回收。例如,下面 export 語句在模塊范圍級別緩存 React 組件,因此相關(guān)的 Fiber 樹和分離的 DOM 元素永遠不會被釋放。

export const Component = (( 
  <List> ... </List> 
): React.Element<typeof List>);

也不僅僅是 React 數(shù)據(jù)結(jié)構(gòu)要 keep alive ,Hooks 和它們的閉包也可以讓各種其他對象保活。這意味著單個 React 組件泄漏可能會導(dǎo)致頁面對象的重要部分泄漏,從而導(dǎo)致巨大的內(nèi)存泄漏。


為了防止 Fiber 樹中內(nèi)存泄漏的級聯(lián)效應(yīng),MemLab 添加了一個樹的完整遍歷,當(dāng)組件在 React 18 中卸載時會進行清理。這可以讓垃圾回收器在清理未掛載的樹方面做得更好一點。這個優(yōu)化將 Facebook 上的平均內(nèi)存使用量減少了近 25%,其他使用 React 的站點在升級時也有了很大的改進。你可能會擔(dān)心這種比較激進的清理方式可能會減慢 React 組件的卸載速度,但令人驚訝的是,由于內(nèi)存的減少,性能也有顯著的提升。

「string interning」

通過利用 MemLab 中的 heap analysis API,Meta 團隊發(fā)現(xiàn)字符串占據(jù)了 70% 的堆內(nèi)存,其中一半的字符串至少有一個重復(fù)的實例。(V8 對 string interning 支持的不是很好,這是一種對具有相同值的字符串實例進行重復(fù)數(shù)據(jù)刪除的優(yōu)化。)

另外很大一部分字符串內(nèi)存被 Relay 中緩存的鍵字符串消耗。通過與 Relay 和 React Apps 團隊合作,可以在客戶端插入和縮短過長的字符串鍵來優(yōu)化 Relay 緩存鍵字符串。

這種優(yōu)化使 Relay 能夠緩存更多數(shù)據(jù),允許站點向用戶顯示更多內(nèi)容,尤其是在客戶端 RAM 有限的情況下。內(nèi)存 p99 和 OOM 崩潰減少了 20%,頁面渲染速度更快,用戶體驗得到改善,在收入上也有一定提升。

試用 MemLab:

npm i -g memlab

最后
MemLab Github:https://github.com/facebookincubator/memlab

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容