推薦一個(gè)檢測(cè) JavaScript 內(nèi)存泄漏的神器
大家好,我是 ConardLi?。作為一名 Web? 應(yīng)用程序開(kāi)發(fā)者,排查和修復(fù) JavaScript 代碼的內(nèi)存泄漏一直是最困擾我的問(wèn)題之一。
最近,Meta? 開(kāi)源了一款檢測(cè) JavaScript? 代碼內(nèi)存泄漏的框架:MemLab,我們來(lái)一起看看這個(gè)框架有啥神奇之處吧~
2020? 年,Meta? 的工程師將 Facebook.com? 重構(gòu)為了單頁(yè)應(yīng)用(SPA?),程序的大部分渲染和導(dǎo)航都會(huì)在客戶端使用 JavaScript? 完成。后來(lái)他們又使用類似的架構(gòu)來(lái)重構(gòu)了 Meta? 的大多數(shù)其他流行的網(wǎng)絡(luò)應(yīng)用程序,包括 Instagram? 和 Workplace?。雖然這種架構(gòu)能夠提供更快的用戶交互、更好的開(kāi)發(fā)者體驗(yàn)和更像原生應(yīng)用程序的感覺(jué),但是在客戶端維護(hù) Web 應(yīng)用的狀態(tài)會(huì)讓內(nèi)存的管理變得更加復(fù)雜。
使用 Meta? 網(wǎng)站的用戶經(jīng)常會(huì)快速注意到一些性能和功能正常使用的問(wèn)題。然而,內(nèi)存泄漏就是另一回事了。它不會(huì)立即被察覺(jué)出來(lái),因?yàn)樗淮螘?huì)占用一大塊內(nèi)存 — 然后逐漸影響整個(gè) Web 會(huì)話并讓后續(xù)的交互和響應(yīng)變得更慢。
Meta? 的工程師花費(fèi)了大量時(shí)間來(lái)測(cè)試、優(yōu)化和控制頁(yè)面加載和交互時(shí)間,以及 JavaScript? 的代碼大小。相比之下,他們?cè)诠芾?nbsp;Web? 瀏覽器內(nèi)存方面做的工作并不多。當(dāng)分析新 Facebook.com? 的內(nèi)存使用情況時(shí),發(fā)現(xiàn)客戶端的內(nèi)存使用情況和內(nèi)存不足 (OOM) 崩潰的數(shù)量一直在攀升。較高的內(nèi)存使用對(duì)頁(yè)面加載、交互性能、用戶參與度等核心指標(biāo)都有負(fù)面影響。
為了幫助開(kāi)發(fā)者解決這個(gè)問(wèn)題,Meta? 的工程師構(gòu)建了 MemLab?,這是一個(gè) JavaScript? 內(nèi)存測(cè)試框架,可以自動(dòng)進(jìn)行內(nèi)存泄漏檢測(cè),并且更容易找到內(nèi)存泄漏的根本原因。Meta? 使用 MemLab 成功地控制了不可持續(xù)的內(nèi)存增長(zhǎng),并識(shí)別出了產(chǎn)品和基礎(chǔ)設(shè)施中的內(nèi)存泄漏和內(nèi)存優(yōu)化的一些手段。
導(dǎo)致 Web 應(yīng)用內(nèi)存過(guò)高的原因
因?yàn)閮?nèi)存泄漏通常不是很明顯,在開(kāi)發(fā)過(guò)程中,以及做 Code Review? 的時(shí)候都很難發(fā)現(xiàn),而且在生產(chǎn)環(huán)境中通常也很難找到根本原因。雖然主流的 JavaScript 運(yùn)行時(shí)都有垃圾回收機(jī)制,那么為什么還會(huì)有內(nèi)存泄漏呢?
JavaScript 代碼中可能會(huì)有很多隱藏對(duì)象的引用,而隱藏的引用會(huì)以許多意想不到的方式導(dǎo)致內(nèi)存泄漏。
例如:
var obj = {};
console.log(obj);
obj = null;
在 Chrome? 中,即使我們將引用設(shè)置為 null? ,這段代碼也會(huì)泄漏 obj? 。發(fā)生這種情況是因?yàn)?nbsp;Chrome? 需要保留對(duì)打印對(duì)象的內(nèi)部引用,以便以后可以在 Web 控制臺(tái)中對(duì)其進(jìn)行檢查(即使在 Web 控制臺(tái)沒(méi)打開(kāi)的情況下)。
在某些情況下,內(nèi)存在技術(shù)上并沒(méi)有發(fā)生泄漏,而是在用戶會(huì)話期間線性增長(zhǎng)而且沒(méi)有限制。最常見(jiàn)的原因是客戶端緩存沒(méi)有內(nèi)置任何釋放的邏輯,無(wú)限滾動(dòng)列表沒(méi)有任何虛擬化的功能,無(wú)法在添加新內(nèi)容時(shí)從列表中刪除較早的內(nèi)容。
我們也沒(méi)有適當(dāng)?shù)淖詣?dòng)化系統(tǒng)和流程來(lái)控制內(nèi)存,因此防止此類問(wèn)題的唯一防御措施就是專家通過(guò) Chrome DevTools 定期挖掘內(nèi)存泄漏,一些大型的項(xiàng)目幾乎每天都會(huì)有發(fā)布和變更,這樣的工作方式是不可持續(xù)的。
MemLab 的工作原理
MemLab? 通過(guò)預(yù)定義的測(cè)試場(chǎng)景運(yùn)行無(wú)頭瀏覽器并比較和分析 JavaScript 堆快照來(lái)發(fā)現(xiàn)內(nèi)存泄漏的問(wèn)題。
這個(gè)過(guò)程可以分為下面六個(gè)步驟:
1.「瀏覽器交互」:MemLab? 使用 Puppeteer 自動(dòng)化瀏覽器,在目標(biāo)頁(yè)面上查找泄露的對(duì)象;
2.「區(qū)分堆」:導(dǎo)航到一個(gè)頁(yè)面然后離開(kāi)它,正常情況下該頁(yè)面分配的大部分內(nèi)存也應(yīng)該被釋放,如果沒(méi)有,可能暗示著存在內(nèi)存泄漏。MemLab? 通過(guò)區(qū)分 JavaScript? 堆并記錄在頁(yè)面 B? 上分配的一組對(duì)象,這些對(duì)象沒(méi)有在頁(yè)面 ?A 上分配,但在重新加載頁(yè)面 A 時(shí)仍然存在,從而發(fā)現(xiàn)潛在的內(nèi)存泄漏;
3.「細(xì)化內(nèi)存泄漏列表」:內(nèi)存泄漏檢測(cè)器進(jìn)一步結(jié)合了特定框架的知識(shí)來(lái)細(xì)化泄漏對(duì)象的列表。例如,React? 分配的 Fiber? 節(jié)點(diǎn)(React? 用于渲染虛擬 DOM 的內(nèi)部數(shù)據(jù)結(jié)構(gòu))應(yīng)該在我們?cè)L問(wèn)多個(gè)選項(xiàng)卡后清理時(shí)釋放。
4.「生成 retainer traces」:遍歷堆并為每個(gè)泄漏的對(duì)象生成 retainer traces? 。trace? 顯示了泄漏對(duì)象為何以及如何在內(nèi)存中保持活動(dòng)狀態(tài)。打破引用鏈意味著泄漏的對(duì)象將不再可以從 GC? 的根訪問(wèn),因此可以進(jìn)行垃圾回收。通過(guò)一步步地跟蹤,就可以找到應(yīng)該設(shè)置為 null 的引用;
5.「聚合 retainer traces」:將所有 retainer traces? 聚集在一起,并為每個(gè)共享相似 retainer traces 的泄漏對(duì)象聚合顯示為一個(gè)跟蹤,其中還包括調(diào)試信息,例如支配節(jié)點(diǎn)和保留大小。
6.「報(bào)告泄漏」:定期運(yùn)行 MemLab?,以持續(xù)收集 retainer traces?,任何新的 traces? 都會(huì)記錄到內(nèi)部?jī)x表板,開(kāi)發(fā)者可以查看每個(gè)內(nèi)存泄漏的 retainer traces 上的對(duì)象屬性。
MemLab 有哪些能力
內(nèi)存泄漏檢測(cè)
對(duì)于瀏覽器內(nèi)存泄漏的檢測(cè),MemLab? 需要開(kāi)發(fā)者提供的唯一輸入就是一個(gè)測(cè)試場(chǎng)景文件,這個(gè)文件定義了如何通過(guò)使用 Puppeteer API? 和 CSS? 選擇器覆蓋三個(gè)回調(diào)來(lái)與網(wǎng)頁(yè)交互。MemLab? 會(huì)自動(dòng)區(qū)分 JavaScript 堆、優(yōu)化內(nèi)存泄漏并聚合結(jié)果。
JavaScript 堆的 Graph-view API
MemLab? 支持一個(gè)自定義的泄漏檢測(cè)器,作為篩選器回調(diào),應(yīng)用于每個(gè)由目標(biāo)交互分配的泄漏候選對(duì)象,但之后從不釋放。泄漏過(guò)濾器回調(diào)函數(shù)可以遍歷堆并確定哪些對(duì)象是內(nèi)存泄漏。例如,我們的內(nèi)置檢漏器會(huì)跟蹤 React Fiber? 節(jié)點(diǎn)的返回鏈路,檢查 Fiber? 節(jié)點(diǎn)是否與 React Fiber 樹(shù)分離。
為了分析每個(gè)可能內(nèi)存泄漏的上下文,MemLab? 提供了一個(gè) JavaScript? 堆的內(nèi)存效率圖。這可以在不了解 V8? 堆快照文件結(jié)構(gòu)的任何領(lǐng)域知識(shí)的情況下查詢和遍歷 JavaScript 堆。
在視圖中,堆中的每個(gè) JavaScript? 對(duì)象或原生對(duì)象都是一個(gè)圖節(jié)點(diǎn),堆中的每個(gè) JavaScript? 引用都是一個(gè)圖的邊。實(shí)際應(yīng)用程序的堆大小通常很大,因此圖視圖需要在提供直觀的面向?qū)ο蠖驯闅v API? 的同時(shí)提高內(nèi)存效率。因此,圖節(jié)點(diǎn)被設(shè)計(jì)成了虛擬的,不通過(guò) JavaScript? 引用進(jìn)行連接。當(dāng)分析代碼遍歷堆時(shí),虛擬圖會(huì)部分地即時(shí)構(gòu)建圖的接觸部分。圖的任何部分都可以很容易地釋放,因?yàn)檫@些虛擬節(jié)點(diǎn)彼此之間沒(méi)有 JavaScript 引用。
堆視圖可以從基于 Chromium? 的瀏覽器、Node.js、Electron? 和 Hermes? 獲取的 JavaScript? 堆快照加載。這允許分析復(fù)雜的模式并回答諸如 “有多少 React Fiber? 節(jié)點(diǎn)是備用的 Fiber 節(jié)點(diǎn),它們用于不完整的并發(fā)渲染?”之類的問(wèn)題。
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? 測(cè)試也可以使用 graph-view API 來(lái)獲取其自身狀態(tài)的堆視圖,進(jìn)行自內(nèi)存檢查,并編寫(xiě)各種內(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)存泄漏檢測(cè),MemLab? 還包括一組內(nèi)置的 CLI? 命令和 API,用于尋找可能的內(nèi)存優(yōu)化機(jī)會(huì):
Meta 使用 MemLab 的實(shí)踐
在過(guò)去的幾年中,Meta? 一直在使用 MemLab 檢測(cè)和診斷內(nèi)存泄漏,并收集了很多有助于優(yōu)化內(nèi)存、減少 OOM 崩潰并改善用戶體驗(yàn)的手段。
在 2021? 年上半年, Facebook.com? 上的 OOM? 崩潰減少了 50%。
React Fiber 節(jié)點(diǎn)清理
為了渲染組件,React? 構(gòu)建了 Fiber? 樹(shù) — 一個(gè) React? 用于渲染虛擬 DOM? 的內(nèi)部數(shù)據(jù)結(jié)構(gòu)。雖然 Fiber? 樹(shù)看起來(lái)像一棵樹(shù),但它是一個(gè)雙向圖,將所有 Fiber? 節(jié)點(diǎn)、React? 組件實(shí)例和關(guān)聯(lián)的 HTML DOM? 元素強(qiáng)連接起來(lái)。理想情況下,React? 維護(hù)對(duì)組件 Fiber? 樹(shù)的根的引用,并防止 Fiber? 樹(shù)被垃圾回收。當(dāng)一個(gè)組件被卸載時(shí),React 會(huì)斷開(kāi)組件的根與 Fiber 樹(shù)的其余部分之間的連接,然后這些部分就可以被垃圾回收了。
擁有這樣的強(qiáng)連接圖的缺點(diǎn)是,如果有任何外部引用指向圖的任何部分,就無(wú)法對(duì)整個(gè)圖進(jìn)行垃圾回收。例如,下面 export? 語(yǔ)句在模塊范圍級(jí)別緩存 React? 組件,因此相關(guān)的 Fiber? 樹(shù)和分離的 DOM 元素永遠(yuǎn)不會(huì)被釋放。
export const Component = ((
<List> </List>
): React.Element<typeof List>);
也不僅僅是 React 數(shù)據(jù)結(jié)構(gòu)要 keep alive? ,Hooks? 和它們的閉包也可以讓各種其他對(duì)象?;睢_@意味著單個(gè) React 組件泄漏可能會(huì)導(dǎo)致頁(yè)面對(duì)象的重要部分泄漏,從而導(dǎo)致巨大的內(nèi)存泄漏。
為了防止 Fiber? 樹(shù)中內(nèi)存泄漏的級(jí)聯(lián)效應(yīng),MemLab? 添加了一個(gè)樹(shù)的完整遍歷,當(dāng)組件在 React 18? 中卸載時(shí)會(huì)進(jìn)行清理。這可以讓垃圾回收器在清理未掛載的樹(shù)方面做得更好一點(diǎn)。這個(gè)優(yōu)化將 Facebook? 上的平均內(nèi)存使用量減少了近 25%?,其他使用 React? 的站點(diǎn)在升級(jí)時(shí)也有了很大的改進(jìn)。你可能會(huì)擔(dān)心這種比較激進(jìn)的清理方式可能會(huì)減慢 React 組件的卸載速度,但令人驚訝的是,由于內(nèi)存的減少,性能也有顯著的提升。
string interning
通過(guò)利用 MemLab? 中的 heap analysis API,Meta? 團(tuán)隊(duì)發(fā)現(xiàn)字符串占據(jù)了 70%? 的堆內(nèi)存,其中一半的字符串至少有一個(gè)重復(fù)的實(shí)例。(V8? 對(duì) string interning 支持的不是很好,這是一種對(duì)具有相同值的字符串實(shí)例進(jìn)行重復(fù)數(shù)據(jù)刪除的優(yōu)化。)
另外很大一部分字符串內(nèi)存被 Relay? 中緩存的鍵字符串消耗。通過(guò)與 Relay? 和 React Apps? 團(tuán)隊(duì)合作,可以在客戶端插入和縮短過(guò)長(zhǎng)的字符串鍵來(lái)優(yōu)化 Relay 緩存鍵字符串。
這種優(yōu)化使 Relay? 能夠緩存更多數(shù)據(jù),允許站點(diǎn)向用戶顯示更多內(nèi)容,尤其是在客戶端 RAM? 有限的情況下。內(nèi)存 p99? 和 OOM? 崩潰減少了 20%,頁(yè)面渲染速度更快,用戶體驗(yàn)得到改善,在收入上也有一定提升。
試用 MemLab:
npm i -g memlab
最后:MemLab Github:https://github.com/facebookincubator/memlab