記一次網頁記憶體溢位分析及解決實踐

一個不務正業的前端碼農發表於2019-01-15

背景

專案是利用vue框架開發的公司內部的異常監控系統,用於顯示java程式執行時的異常資訊,包括執行堆疊、程式碼、變數等資訊顯示。

在測試過程中,部門同事反映:在不同的異常資訊之間多次切換,會導致網頁崩潰。在案發現場開啟 chrome 的工作管理員,看到這個頁面記憶體佔用已經達到了9.7G,初步懷疑頁面存在洩漏。

驗證猜測

  1. 開啟 devtool -> performance,開始記錄頁面效能
  2. 執行頁面上切換其它異常資訊的操作(頁面最有可能引起記憶體洩漏的操作)
  3. 檢視效能分析結果

記一次網頁記憶體溢位分析及解決實踐

可以看到Nodes、Listeners、JS Head(memory) 的階梯式增長,中間的增長節點對應就是每一次操作。很顯然這個操作會引起記憶體的持續增長,最終發生記憶體溢位也是順理成章了。

問題分析

在動手之前,我已知的資訊有:

  1. 從performace工具,可以看到 JS Heap、Nodes、Listeners 的累積增長
  2. 以上三點,實際上存在依賴關係:
  • 變數引用DOM
  • 子級DOM不能釋放,會導致父級也不會被釋放
  • 如果DOM能被正常GC, 對DOM的事件監聽器也會自動移除
  1. 可以使用 devtool->memory -> take snapshot 收集記憶體快照並分析工具文件;

簡單認識一下 snapshot

記一次網頁記憶體溢位分析及解決實踐

嗯,內容有點多,但是也還算清晰:

  1. 按資料型別進行統計,可以看到一些內建物件、 Vue 物件、自定義物件(比如 Exception、StackFrame 等)、Detached Element、EventListener等等。
  2. 縱座標有Distance, shallow size, Retained Size, 可以不準確理解為:
  • Distance:到root的引用距離
  • Shallow size:物件本身的大小,不包含它引用的資料的大小
  • Retained size:物件自身以及所有引用的大小,就是物件總共佔用的記憶體 (如果它引用的物件不被其他不可回收的物件引用的時候。用google開發者網站的描述叫:將物件本身連同其無法從GC根到達的相關物件一起刪除後釋放的記憶體大小)
  1. 下面的 retainers 皮膚,可以看到變數的具體引用路徑、在哪裡被建立、以及在哪裡被使用

記一次網頁記憶體溢位分析及解決實踐

定位問題:找到那些被引用本該被釋放,但實際沒有的釋放的物件

  1. 執行引起記憶體洩漏的操作

該操作的核心程式碼大致是這樣的

記一次網頁記憶體溢位分析及解決實踐

主要功能是,每次執行setEvent,都將 this.exception 指向新的例項,並交給頁面進行資料展示,而之前被this.exception引用的物件,應該被釋放。

  1. 重新收集新的記憶體快照資訊

  2. 找出差異:將檢視改為差異檢視

記一次網頁記憶體溢位分析及解決實踐
從圖上可以看到在步驟1之後,出現了很多新增的物件,但是刪除的物件是0。

以 Exception物件為例,按照步驟1的程式碼邏輯,新物件建立,舊物件被釋放,Delta 應該為零。所以可以明確知道,這裡是一個問題。不過這裡點開檢視變數的引用詳情,並沒得到太多有用的資訊,只知道被哪個 vue component 引用了,但是component 太多,不太好定位。

檢視 Listenters, 我看到的畫風基本是這樣的:

記一次網頁記憶體溢位分析及解決實踐
跟預期的結果一致,都是由於一些 Nodes 沒有被釋放導致的。不過確實沒有得到太多方便分析的資訊。

另外檢視Nodes相關的資訊,搜尋 Detached, 可以看到一些 Detatched HTMLDivElement等等類似的物件,也就是在記憶體中但是沒有在頁面進行渲染的元素

我找了一個detla比較小的、節點功能也清晰(就是用來在頁面中進行程式碼高亮的元素)的 Detatched HTMLPreElement 進行分析:

記一次網頁記憶體溢位分析及解決實踐

可以看到實際引用關係為 div <= div <= div <= vue component <= var-hover <= events <= ... $platform.event...

在這裡 $platform.event 是由平臺 + 模組的架構設計中,平臺提供的事件 api, 用於全域性的事件通訊。

最終將以上引用關係進行翻譯:由平臺提供的事件 $platform.event (全域性,繫結的事件函式不會被自動釋放),繫結了一個叫做 var-hover 的事件 => var-hover 的事件函式中引用了一個 vue-component => component 的$el屬性 引用了某個Dom => Dom的父級被子級引用導致不能被GC。

可以看看 var-hover 的程式碼:

記一次網頁記憶體溢位分析及解決實踐

var-hover 繫結了一個匿名函式(基本上也可以知道,沒有給這個事件沒有寫過解綁操作),然後匿名函式中使用了 this, 也就是當前 vue component,這也導致了被這個 component 引用的物件都不能被GC。

所以禍根基本上找到了,接下來要做的就是:修復 -> 重新驗證

修復

  1. 第一次簡單修改:在 beforeDestroy 中進行事件解綁,當時驗證確認記憶體溢位問題已解決
  2. 手動解綁這是個大坑,很多地方很多人在編寫程式碼的時候,真不一定有這個好習慣。所有也就有了現在的處理方案:對平臺介面進行改造,支援事件的基於元件的自動解綁。程式碼如下:

記一次網頁記憶體溢位分析及解決實踐
這就是$platform.event 的實際實現

var-hover的事件繫結如下

記一次網頁記憶體溢位分析及解決實踐

移除了 beforeDestroy 鉤子,業務層看起來也好多了。

驗證

  1. 利用 performance 功能,多次進行之前導致記憶體溢位的操作,得到結果如下

記一次網頁記憶體溢位分析及解決實踐
這裡的每次峰值,就是剛執行進行操作時進行記憶體分配的結果,之後每次執行,並沒有出現記憶體及 Nodes, Listensers 的累積

再次對比一下修正之前的效能分析結果

記一次網頁記憶體溢位分析及解決實踐

可怕的樓梯。。。

  1. 順便再 memory 皮膚中出現了什麼變化

記一次網頁記憶體溢位分析及解決實踐
多了一個 StackFrameVar 以及一些為了呈現這個 StackFrameVar 物件多出來的一些EventListener、Observer等,這是由於兩次呈現的資料本身不一樣導致的,屬於正常情況

Exception、 等很多物件的 Delta 已經為0了(按 Delta倒序排列的)

其它說明

以上分析圖是寫這篇文章過程中,回寫部分程式碼之後實時分析的,相對而言沒有實際除錯時處理得那麼細緻。實際除錯過程中還做了其它操作:

  1. 隱私視窗,禁用所有擴充套件(避免影響記憶體分析)
  2. 關閉開發模式HMR功能,因為 VUE_HOT_RELOAD 也會產生一層引用,我並不能完全信任它
  3. 使用模擬資料,每次執行操作,都會渲染一樣的可被人工計算清楚(知道哪個類會產生多少例項)的資料
  4. performance 過程中手動GC

通過以上方式是為了提供一個完全純淨可控的分析環境。

相關文章