動態

詳情 返回 返回

WebGL/Canvas 內存泄露分析 - 動態 詳情

 在構建高性能、長週期運行的 WebGL/Canvas 應用(如 3D 編輯器、數據可視化平台)時,內存管理是一個至關重要且極具挑戰性的課題。

開發者通常面臨的內存泄漏問題,其根源遠比簡單的 JavaScript 對象未釋放要複雜得多。一個現代 WebGL/Canvas 應用的內存版圖實際上跨越了三個截然不同但又相互關聯的內存區域:

  • 圖 V8 引擎管理的 JavaScript 堆(JS Heap),絕大部分情況最關注的是這一層的泄露
  • 圖形處理器(GPU)的顯存(VRAM)
  • Blink 渲染引擎自身用於管理 DOM 等對象的原生 C++ 堆(Native Heap)

這三個內存區域各自遵循不同的分配、管理和回收規則:

  • V8 堆:採用先進的、自動化的垃圾回收(GC)機制,當引用為空的時候會自動釋放
  • GPU 顯存:依賴於開發者通過 WebGL API 進行顯式的手動管理
  • Blink 的原生堆: 由專用 C++ 垃圾回收器負責

大多數難以診斷和修復的內存泄漏問題,其本質都源於對這三個層面之間的邊界、所有權規則以及通信協議缺乏深刻理解。

我們將分別對三個核心部分,系統性地分析每一層內存區域中常見的泄漏模式、底層成因,並介紹實用排查策略和解決方案。通過本篇分享,開發者將能夠建立一個貫穿 GPU、JavaScript 引擎和瀏覽器渲染內核的整體內存心智模型,從而更有效地構建穩定、高效且無泄漏的 WebGL/Canvas 應用。

JavaScript 堆泄漏

堆簡述

Javascript 的解釋器 V8 引擎將瀏覽器內存分為兩個主要部分:

  • 棧(Stack): 用於存儲靜態數據,包括原始類型(Primitive Types)的局部變量(如 number, boolean, null, undefined, string 等)以及指向堆中對象的指針(引用地址)。棧內存的特點是大小固定、自動分配和釋放, 隨着函數調用的開始和結束(執行上下文的入棧和出棧)而進行管理。
  • 堆(Heap): 用於存儲動態分配的內存,即大小不固定的、生命週期可能很長的數據。JavaScript 中的絕大多數“東西”都存在這裏。譬如對象(object),數組(Arrays),函數(Functions),閉包(Clousures),字符串(String),ArrayBuffer / Uint8Array 等

我們重點關注堆上的資源,儘管有自動垃圾回收(GC)機制,JS 堆泄漏仍然是 WebGL 應用中一個常見且棘手的問題。

導致 JS 內存泄漏的常見架構模式:

在 JavaScript 這類具備自動垃圾回收機制的語言中,內存泄漏的本質並非“忘記釋放內存”,而是存在 “意外的引用”(unwanted reference) 。一個在邏輯上已經廢棄、應用不再需要的對象,若仍有一條引用鏈將其與存活的對象圖相連,GC 就會判定它“可達”,從而無法回收 。

GC 標記 – 清除(Mark-and-Sweep)算法遵循明確的規則:從根對象開始,逐條追蹤所有指針。只要從根到某個對象存在一條路徑,該對象就定義為可達,即“存活”。但是 GC 無法理解開發者的語義意圖 —— 它無法判斷一個已經脱離文檔的 DOM 節點是否永遠不會被重新掛載,也無法知曉閉包中捕獲的變量是否永遠不會被訪問。它只能機械性地遵循指針。

因此,修復這些內存泄漏並非尋找 V8 引擎的 bug,而是細緻地管理對象圖,確保當對象在邏輯上不再需要時,通過 myNode = null 或 removeEventListener 等方式顯式地切斷引用。 

以下是一些常見的導致意外引用的模式:

1、 分離的 DOM 元素

分離的 DOM 元素 (Detached DOM Elements) 這是最經典的泄漏模式之一。當一個 DOM 節點通過 element.removeChild() 從文檔樹中移除後,它在頁面上就不再可見。但是,如果此時 JavaScript 代碼中仍有某個變量持有對該節點的引用,那麼這個節點及其整個子樹都無法被 GC 回收。在複雜的單頁應用(SPA)中,視圖組件被動態創建和銷燬,如果銷燬邏輯不完善,很容易留下對舊視圖 DOM 節點的引用。

2、閉包引起的意外作用域捕獲

閉包(Closure)是 JavaScript 的一個強大特性,它允許函數訪問並操作其詞法作用域(lexical scope)中的變量,即使該函數已在其作用域之外被調用。但是這份強大也暗藏風險,閉包往往是內存泄漏的“隱形源頭”。

閉包會完整持有其創建時所在作用域的引用。若一個生命週期很長的內部函數(例如,一個事件回調或定時器回調)是在一個包含大型對象引用的外部函數中創建的,那麼這個大型對象也會被閉包“捕獲”。即使內部函數本身從未使用過它,大型對象也會始終處於可達狀態,最終導致垃圾回收器無法對其回收,從而造成內存泄漏。

3、 懸空的定時器和事件監聽器

傳遞給 setInterval、setTimeout 或 element.addEventListener 的回調函數,其生命週期會持續到定時器被清除或事件監聽器被移除為止。在此期間,若回調函數內部引用了其他對象(比如某個組件的實例或數據),這些被引用的對象也會被“綁定”而保持存活狀態。在組件化開發中,最常見的疏漏之一便是:在組件銷燬時忘記清理這些定時器與事件監聽器。這就會直接導致整個組件實例及其依賴對象始終處於可達狀態,最終無法被回收,從而造成內存泄漏。

4、意外的全局變量

在非嚴格模式下,函數內給未聲明的變量進行賦值,JavaScript 不會報錯,反而會在全局對象(如瀏覽器的 window)上創建一個同名變量。全局變量作為 GC 根節點,它們在應用的整個生命週期內都無法被回收。這種“意外的全局變量”通常由拼寫錯誤或忘記使用 let、const、var 關鍵字引起,是一種隱蔽但危害嚴重的內存泄漏源。

Chrome DevTools 堆分析實戰指南

Chrome DevTools(開發者工具) 的 Memory(內存)面板是診斷 JS 堆泄漏的權威工具。它可以對當前堆進行快照,直觀的展示當前的佔用情況。

具體操作位置在 Chrome DevTools -> Momery 標籤頁(圖中 ①)。

1. 在內存分析中,Heap snapshot(堆快照)是最常用的排查手段,在生成快照前,需先選擇這一類型(圖中 ②)。

2. 在生成快照前,需要先點擊上圖中的 ③ 號按鈕(強制垃圾回收),待完成一次 GC 後,再點 ④ 號按鈕生成快照。這樣做的原因是,我們的核心目標是排查內存泄漏問題,強制 GC 能釋放原本應該被回收的資源,這會讓快照結果更加直觀地顯示出問題。

3. 快照生成後,在 ⑤ 位置會顯示快照信息,展開後如下:

(⑥ 位置會展示堆內存的大小,能快速且直觀地瞭解到整個頁面的堆內存佔用情況。)

在快照信息中,需要重點關注每個對象的兩項核心數據:

  • Shallow Size 淺層大小(圖中 ①):一般用來指對象自身佔用的大小,不包含它引用的其他對象的大小。
  • Retained Size 保留大小(圖中 ②):表示該對象在被 GC 後,所能釋放的總內存大小。通常等於自身的 Shallow Size 加上被它引用的其他對象的 Shallow Size 之和。

在實際分析中,建議優先關注 Retained Size,因其能更全面地反映對象堆內存佔用的實際影響。

快照的摘要視圖

在上圖所示的摘要中,每一項都支持展開,展開後可以看到對象的完整引用鏈。摘要面板適合的運用場景:當單次 Profile 已顯示出大量的內存佔用時,可先按 Retained Size 對列表進行排序,快速定位到佔據了過高的內存的項,展開其中的可疑目標並一路追溯,直到找到根源 —— 通常是掛載到全局 windows 對象上的變量,或被閉包捕獲的變量。

三快照法(推薦的排查步驟)

在多數情況下,泄露是緩慢發生的,單個堆快照包含了數百萬個對象,雜亂無章,不方便直接找到泄漏源。因此,我們更推薦使用“三快照法”來找到泄露的源頭。具體操作步驟:

  1. 快照 1 (基線狀態): 加載頁面,在應用進入穩定狀態後,點快照中的掃把按鈕,做一次強制 GC 後,拍攝第一次堆快照(Heap snapshot),建立內存的基線
  2. 執行可疑操作: 執行一系列你懷疑可能導致內存泄漏的用户操作。這裏的關鍵在於:這個操作序列應具備是可逆性。 例如“打開一個複雜的 UI 面板,隨後再將其關閉”。這個“操作-逆操作”循環是你的受控實驗,假設是“該循環應是內存中性的,即操作後不應遺留任何內存垃圾”。此外,也可測試應用長時間靜置(如半小時以上)的情況。
  3. 快照 2:做完上述的操作之後,繼續強制 GC 一次,再拍攝第二次快照。
  4. 放大泄漏: 重複執行步驟 2 中的“操作-逆操作”循環數次(例如 1-N 次)。這會放大內存泄漏,使其在快照對比中更加明顯。
  5. 快照 3: 完成所有循環後,再次強制 GC,並拍攝第三次快照。

1、使用對比視圖

在完成以上的操作步驟後,選擇第三個快照,並在頂部的視圖選擇器中(下圖 ②),將視圖模式從 Summary 切換為 Comparison,比較對象選擇為快照 2(下圖 ③)。現在視圖只會顯示快照 2 和快照 3 之間發生變化的對象。操作後需要關注以下內容:

  • Delta 列:這是該視圖的核心,它顯示了對象實例數量的淨變化。需重點關注 Delta 值為正數的項,尤其是那些與重複操作次數成正比的構造函數。這些就是在操作循環中被創建但未能被成功回收的對象。
  • Retained Size Delta 列:此列顯示了該類對象及其引用的所有對象所佔內存的淨增量。按此列降序排序,可以快速定位到對內存影響最大的泄漏源。

2、使用摘要視圖

還有一種很重要的排查方式:

  • 選擇第三個快照,頂部的視圖選擇器,切換為 Summary
  • 右側下拉框中選擇篩選快照一和快照二中間創建的對象

該視圖的意圖是:查找出快照 2 較快照 1 新增的內存對象,若這些新增對象在快照 3 中依然存在,那麼它們極有可能是泄露的源頭。

3、使用 Retainers 樹追溯泄漏源

在對比視圖中定位到一個可疑的泄漏對象(即對應的構造函數)後,展開該構造函數,並選中其中一個實例。此時,下方的 Retainers(保留者)面板會自動加載內容。這個面板是定位內存泄漏根源的核心工具,面板展示了一條或多條引用鏈,並清晰地解釋了被選中對象無法被 GC 回收的原因。

具體分析步驟如下:

  • 追溯引用鏈:Retainers 樹以被選中的對象為起點,逐層向上追溯,直到指向某個 GC 根節點(例如 (Global handles) 下的 window 對象)。開發者需要仔細檢查這條鏈路上的每個節點。
  • 識別意外引用:尋找那些本應在操作結束後被切斷的引用。例如,一個已關閉面板的 DOM 節點,仍被一個全局緩存對象 myApp.cache 引用,那麼 myApp.cache 就是那個“意外的引用”。
  • 關注高亮節點:分析分離的 DOM 樹時,DevTools 會用顏色高亮節點。
  • 黃色節點: 表示被 JavaScript 代碼直接引用的節點。
  • 紅色節點:表示無直接引用,但因屬於某個黃色節點的父子節點,而被間接保留在內存中的節點。在排查時,應優先關注黃色節點。

GPU 顯存與 WebGL 上下文管理

本部分內容將聚焦於 GPU 中的關鍵資源,此類資源必須通過 WebGL API 進行顯式的、手動的生命週期管理。這背後的核心邏輯在於:在 GPU 層面不存在自動內存管理機制。從資源的創建、綁定到最終銷燬,開發者須全程主導,主動承擔釋放內存的全部責任。

WebGL 上下文句柄

WebGL 上下文句柄是一種有限且關鍵的資源。現代瀏覽器對單個頁面或同源(origin)下可創建的活動 WebGL 上下文(Context)數量施加了嚴格的限制。例如,在 Chrome 瀏覽器中,這個上限通常是 16 個。Firefox 也有類似的限制,儘管具體數值和配置策略可能略有不同。

這個限制是瀏覽器廠商為保護整個系統穩定性而採取的一項關鍵防禦措施。GPU 是一種系統級的共享資源,如果單個網頁能夠無限制地創建 WebGL 上下文,它將可能耗盡 GPU 驅動程序的資源,導致驅動崩潰或整個操作系統的性能下降,從而影響到其他應用程序和系統界面的正常運行。

我們會經常看到,作為系統級資源管理者的瀏覽器,其抉擇始終是:優先保障宿主操作系統的穩定性,而非滿足單個網頁的無節制資源需求。

當 WebGL Context 超出限制,瀏覽器會採取強制措施:丟棄“最近最少使用”的那個 WebGL 上下文,並在控制枱輸出一條警告,如:“WARNING: Too many active WebGL contexts. Oldest context will be lost.”(警告:活動 WebGL 上下文過多。最舊的上下文將被丟棄。)。對於那些未預料到此行為的應用而言,這可能導致災難性的渲染失敗,且問題難以追蹤。

對於確實需要大量獨立 3D 視圖的應用(例如建築設計軟件、多視圖監控面板),必須採用更高級的架構模式來規避此限制。常見的解決方案推薦複用 gl context,切換場景的時候,做 clear + dispose 操作清空,並使用同一個 g3d 進行反序列化。

貼圖,buffer 等 GPU 資源對象

在 WebGL 環境中,代表 GPU 資源的 JavaScript 對象(例如 WebGLTexture 對象),其生命週期與該資源在 GPU 顯存中實際佔用的內存的生命週期是完全分離的。簡單地將 JavaScript 對象的引用設置為 null,或讓其離開作用域而被垃圾回收,也不會觸發 GPU 顯存的釋放

WebGL API 劃定了一條清晰的界線:JavaScript 的 WebGLTexture 對象僅僅是一個輕量級的句柄(handle),本質上是一個整數 ID。JS GC 可以安全地回收這個句柄對象,且不會對 GPU 產生任何影響。而真正佔用顯存(VRAM)的重量級 GPU 資源,唯有開發者——這個唯一掌握渲染邏輯上下文的角色——顯式調用對應的刪除函數時,才會被徹底釋放。 因此,一旦某個 GPU 資源不再需要,就必須立即調用對應的刪除函數,例如:

  • gl.deleteTexture()
  • gl.deleteBuffer()
  • gl.deleteRenderbuffer()
  • gl.deleteFramebuffer()
  • gl.deleteProgram()
  • gl.deleteShader()

一個標準的 WebGL 資源生命週期應遵循“創建-綁定-使用-解綁-刪除”的模式。GPU 顯存泄漏並非瀏覽器的“缺陷”,而是開發者未能遵守這一顯式契約的結果。

HT 中的 graph3dView 提供了專門的 dispose 方法,當 3D 場景確定要釋放的時候,主動調用 g3d.dispose() 將會徹底把當前的所有跟 WebGL 相關的 GPU 資源徹底釋放。

查看這類資源佔用,通常需要觀察系統顯卡的顯存使用情況。以 Windows 系統為例,可以通過:「任務管理器 → 性能 → GPU → 專用 GPU 內存」這一路徑,直觀地看到顯存佔用的變化趨勢。以一個 6G 顯存的 GPU 為例,儘量將顯存佔用控制在合理範圍(譬如 5G 以內,避免超過 5.5G),否則一旦超標,系統可能會強制回收顯存資源。

原生堆:理解 Blink 的 Oilpan GC

分析完應用層的內存問題,我們的視線將最終聚焦於瀏覽器的 C++ 底層核心 ——Blink 渲染引擎。

Blink Oilpan GC 是 Chromium 瀏覽器引擎 Blink 中用於管理 C++ 對象內存的垃圾回收 (Garbage Collection, GC) 系統。Oilpan 採用的是一種先進的併發標記與增-量清除 (Concurrent Marking and Incremental Sweeping) 垃圾回收機制。這種機制的核心思想是儘可能地將垃圾回收的工作與主線程 (main thread) 的任務(例如 JavaScript 執行、頁面佈局和渲染)並行處理,從而最大限度地減少因 GC 而導致的頁面卡頓 (jank)。

通常這塊內存由 Blink 底層管理,Web 應用層是無法干預的,這裏我們通過一個實際案例來展開説明:JS 堆快照顯示其 24 小時動畫運行後內存增長微乎其微,但 Windows 資源監視器卻顯示 Chrome 進程佔用了 4GB 內存。這種懸殊的差距,讓人不禁好奇。

  • 在 Chrome 地址欄輸入 chrome://tracing 並訪問
  • 點擊頁面中的 “Record” 按鈕,進入錄製配置界面
  • 選擇 “Manually select settings” 選項
  • 點擊 “Edit categories” 按鈕,打開配置列表
  • 在彈出的類別列表中,務必勾選 memory-infra。
  • 點擊 “OK” 確認配置後,再次點擊 “Record” 開始錄製。等待一段時間後點擊結束
  • 錄製結束後,點擊鍵盤的 M 鍵查看具體的內存快照


從上圖可見,blink_gc 佔用 4GB 內存,這可能並非內存泄漏,而是 Blink 的 Oilpan GC 策略導致的正常現象。其核心機制是內存池化 (Memory Pooling):Blink 會預先向操作系統申請大塊的連續內存區域。頁面中幾乎所有的 Blink C++ 對象(包括大量臨時的字符串、數組等)都在這個大內存池中進行分配,以提升效率。

當這些短生命週期的對象不再被引用時,它們在邏輯上被視為“垃圾”,但它們所佔用的物理內存並不會立即歸還給操作系統。GC 回收器會根據當前的內存壓力 (Memory Pressure) 來決定何時執行徹底的清理。

在本案例中,機器總內存高達 64GB,資源充裕,Chrome 判斷無需迫切回收。為避免不必要的性能開銷(一次完整的 GC 會消耗 CPU 資源),GC 選擇推遲迴收操作。因此,我們看到的 4GB 佔用,實際上是 Oilpan GC 持有的一個較大的內存池,其中包含了活動對象和大量待回收的“垃圾”對象。只要這個內存池的大小趨於穩定,沒有出現持續、無節制的增長,通常就不構成內存泄漏問題。

HT 與內存泄露

綜合上述的三部分內容,我們捋清了內存泄漏問題的主要原因,並掌握了對應的排查方法。而在 HT 框架中,內存泄漏的問題在 3D 場景中最為常見,由於 HT 的 3D 是基於 WebGL 實現的,此類泄漏往往會表現得尤為明顯。

為了清晰呈現 HT 3D 中的內存泄漏問題,我們設計了一個簡單的對照實現來進行演示。

對照實驗

在展開實驗前,我們先簡要了解下 HT 框架的核心架構。HT 採用 MV 架構模式,在 HT 的框架設計中, Data 模型和 View 視圖是分離的,二者之間通過 Event 事件監聽和派發機制來建立起數據綁定。

在實驗操作前,我們可以打開 Chrome DevTools -> Performance (性能) 面板,並且點擊面板中的錄製按鈕,記錄整個實驗過程,這能幫助我們在操作結束後,回溯並分析全程性能和內存的變化情況。

實驗環境:

瀏覽器:Chrome 138.0.7204.101(64位)

顯卡:NVIDIA GeForce GTX 1660 Ti

處理器:Intel(R) Core(TM) i5-10400F CPU @ 2.90GHz (2.90 GHz)

第一次實驗

我們通過按鈕不斷創建新的視圖,當頁面中超過一定數量Graph3dView 時,可以看到第一個場景“崩潰”,但是當我們刪除最後一個 Graph3dView 後,第一個場景又恢復了。

我們可以從 Performance 面板中觀察到整個過程:

  • 當首個 WebGL 上下文被銷燬後,JS 堆內存出現明顯下降。
  • 刪除最新的視圖後,首個 WebGL 恢復,且刪除後事件監聽器佔用的內存下降,由於 HT 是 MV 框架,雖然瀏覽器銷燬了 WebGL 上下文,但是視圖的數據模型仍然保留,這也就是首個視圖“復活”的原因。

第二次實驗

我們將所有的 Graph3dView 都綁定到一個 window.dataModel 上。具體可以參考下圖:

同實驗一,我們也通過按鈕創建多個視圖,在告警後刪除最後一個視圖。可以發現,當刪除了最後一個 Graph3dView ,第一個場景也並沒有恢復。

我們從 Performance 面板中觀察到整個過程:

  • 當首個 WebGL 上下文被銷燬後,JS 堆內存出現明顯下降
  • 刪除最新的視圖後,首個 WebGL 沒有恢復,且刪除後事件監聽器佔用的內存也沒有出現下降的情況

照第一組實驗的結論來説,只要數據模型還在,視圖應當“復活”,但是視圖並沒有“復活”。

為什麼會出現上述兩種情況?這是因為第二次,並沒有正確地將 Graph3dView 清除。可以看一下兩次實驗系統的內存對象引用關係。

第一次

第二次

第一次實驗,頁面上有 19 個 Graph3dView,在內存中看到有 19 個 Graph3dView 對象,而第二次頁面上僅有 7 個 Graph3dView,但是內存中有 17 個 Graph3dView 對象。這就説明了第二次的 Graph3dView 並沒有被正確垃圾回收,這也就導致了即使移除了一個 Graph3dView,第一個場景也並不會恢復。打開其中一個 Graph3dView 可以看到,Graph3dView 與 window.dataModel 存在引用關係導致的。

解決方案

從上述的對照試驗中,可以看出使用全局變量存儲視圖實例是導致內存泄漏的主要原因。當多個 Graph3dView 共享同一個全局 dataModel 時,即使刪除視圖,由於全局引用依然存在,這些視圖無法被垃圾回收。

針對於內存泄漏可以通過以下幾個方案解決:

1、避免全局變量引用

該方案從業務架構層面上解決內存泄漏問題,可採用以下實現方式:

  • 使用模塊化設計代替全局變量存儲
  • 採用弱引用等機制管理視圖對象
  • 建立專門的視圖管理器統一管理實例

2、視圖複用機制

從實驗上可以看出,頻繁創建和銷燬視圖會帶來顯著的性能損耗。在實際的業務場景中,可以通過複用視圖來提升性能。在切換視圖時,僅需要通過 dataModel.clear() 清空數據模型,重新對視圖進行反序列化即可。

3、資源釋放

對於必須要頻繁創建/銷燬視圖的特殊場景,可在銷燬前執行以下操作:

const dm = new ht.DataModel();
view.setDataModel(dm);
view.dispose();

關鍵要點:

  • 創建新的 dataModel 實例替換原有引用
  • 有效解除視圖與業務數據的關聯關係
  • 3D 視圖上存在 dispose 方法,用於主動釋放 gl 的資源

需要注意:在具體的項目中,優先考慮上兩個方案,此方案適用於必須銷燬視圖的特殊情況。

4、事件管理優化

在處理模塊通信上,可以考慮使用 HT 的事件派發器進行。項目全局上創建一個事件派發器,模塊間消息傳遞使用派發器進行:

const notifier = new ht.Notifier();
const func = function(e) {}
notifier.add(func); // 添加監聽函數
notifier.remove(func); // 刪除監聽函數
notifier.fire(func); // 派發事件

關鍵要點:

  • 統一使用事件派發器進行跨模塊通信,避免不同模塊間的直接調用、依賴,減少內存泄漏風險
  • 若模塊需要銷燬,在銷燬前需移除相關事件監聽
  • 必須使用具名函數而非匿名函數作為事件處理器

總結

在前端開發過程中,開發者應持續關注內存變化。內存泄漏問題並非都是 “爆發式顯現”,更多是 “漸進式累積” —— 初期往往難以察覺,但隨着時間推移,過高的內存佔用會直接拖慢運行性能;對於基於 WebGL 的應用,甚至可能引發上下文丟失、頁面白屏等嚴重問題。因此,對待內存泄漏,我們必須保持常態化關注的心態。

您可以至圖撲軟件官網查看更多案例及效果:

圖撲軟件 – 構建先進 2D 和 3D 可視化所需要的一切

user avatar yishenjiroudekaixinguo 頭像
點贊 1 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.