首頁 > 軟體

手把手教你如何排查Javascript記憶體漏失

2022-06-18 14:01:41

引言

也許你已經知道,Chrome DevTools裡的Performance面板和Memory面板可以用來定位記憶體問題。但當你真正上手使用它們的時候,往往會覺得不知所措 —— 因為裡面有著各種各樣的選項和功能,讓人眼花繚亂。下面我會通過一些常見的FAQ來帶大家一起學習怎麼用工具定位javascript裡的記憶體問題。

如何判斷我的應用發生了記憶體漏失

為了證明螃蟹的聽覺在腿上,一個專家捉了只螃蟹並衝它大吼,螃蟹很快就跑了。然後捉回來再衝它吼,螃蟹又跑了。最後專家把螃蟹的腿都切了,又對著螃蟹大吼,螃蟹果然一動不動……

定位記憶體問題的過程其實也類似,如果你自己都不知道自己的頁面在使用過程中哪些步驟會導致記憶體增長,那很可能就會錯把一個正常的記憶體增長當作記憶體漏失來排查,最後查了半天白忙活。 其實一個單頁應用在使用過程中,記憶體發生增長是很合理的。例如在開發過程中,為了優化使用體驗,我們可能會對部分資料進行快取,這部分快取的資料其實也會導致記憶體佔用的升高,但它是符合預期的。因此,排查記憶體漏失的第一步,就是要先梳理一遍自己的程式碼,看一下哪部分記憶體的升高是合理的,哪部分記憶體的升高是不合理的。

Performance和Memory都可以用來定位記憶體問題,先用誰呢

答案是先用Performance。 當我們懷疑頁面發生了記憶體漏失的時候,可以先用Performance錄製一段時間內頁面的效能變化。你只需要切換到Performance面板,點選Record,然後在頁面上正常操作一段時間,最後停止錄製即可。

不斷升高的記憶體下限

如果錄製結束後,看到記憶體的下限在不斷升高的話,你就要注意了 —— 這裡有可能發生了記憶體漏失。

除了記憶體增長曲線,Nodes(Dom節點數曲線)、Document曲線以及Listener曲線也同樣值得關注,有時候它們對記憶體問題的定位也很有幫助。

當你懷疑發生了記憶體漏失的時候,你就可以用Memory面板來進一步定位洩漏的源頭了。

通過Memory面板定位記憶體漏失的流程通常是怎麼樣的呢

通常,我們可以從Memory的主介面開始,點選左上角的圓點就可以記錄下當前的堆記憶體快照(heap snapshot)了。

Memory面板

這裡推薦一個Gmail團隊也在用的 “three snapshot”技巧:

  • 開啟DevTools, 切換至Memory面板
  • 先記錄一個堆記憶體快照
  • 在你的頁面上執行可能發生洩漏的操作
  • 再記錄一個堆記憶體快照
  • 重複執行多幾遍步驟3
  • 最後記錄一個堆記憶體快照
  • 選擇最後一個堆記憶體快照,找到頂欄的“All objects”, 切換至”Objects allocated between snapshots 1 and 2”(也可以對2,3執行同樣的操作)

過濾出兩份快照之間新分配的物件

8. 切換後,你就能看到兩個快照之間新生成的物件。你可以選擇其中一項點開,看看它的retaining tree裡面保留了哪些物件沒有釋放。

Tips:在記錄第一個堆快照之前你可以先做一些“預熱”操作,避免一些懶載入和快取策略影響到了對記憶體的分析。

為什麼我的記憶體快照記錄下來之後看不懂,還出現了很多奇怪的變數

這也是我排查記憶體漏失時遇到的第一個問題,為什麼教學裡的記憶體快照簡潔易懂,我的記憶體快照卻像一本天書?

教學裡的記憶體快照

我的記憶體快照

為什麼有這麼大的差異呢?除去教學裡demo程式碼比較簡單之外,提前準備好一個合理的debug環境也是很重要的。這裡我列舉了4點個人覺得對debug記憶體問題很有幫助的措施:

1. 儘量使用沒有混淆的程式碼:

打包後的程式碼往往經過了混淆和壓縮,在生產環境上這是必要的,但在debug時卻會成為我們的絆腳石,不便於閱讀。

2. 排查問題時使用production模式編譯出來的程式碼:

Dev模式下往往會開啟一些方便開發的特性,例如熱更新等。但它們可能會佔用一部分的記憶體,影響到記憶體問題的排查,所以建議還是使用production模式編譯出來的程式碼進行問題排查。

3. 遮蔽所有瀏覽器外掛:

遮蔽瀏覽器外掛最快的方式就是開啟無痕視窗。瀏覽器外掛給我們帶來很多便利,但外掛注入的額外邏輯有時也會影響記憶體問題的排查。例如vue-devtools會記錄下每一個vuex mutaions,導致記憶體無法釋放。

4. 在現場打記憶體快照,便於跳轉到原始碼所在行:

儘管devTools記錄下來的記憶體快照檔案可以單獨載入展示,但還是建議在記錄下記憶體快照的時候“趁熱”分析,因為這時還能從retaining tree上跳轉到程式碼所在行,有時候對定位問題也很有幫助。

跳轉到原始碼所在行

快照裡有一些“Detached DOM tree”,是什麼意思

一個DOM節點只有在沒有被頁面的DOM樹或者Javascript參照時,才會被垃圾回收。當一個節點處於“detached”狀態,表示它已經不在DOM樹上了,但Javascript仍舊對它有參照,所以暫時沒有被回收。通常,Detached DOM tree往往會造成記憶體漏失,我們可以重點分析這部分的資料。

Shallow size 和 Retained size,它們有什麼不同

Shallow size: 這是物件自身佔用記憶體的大小。通常只有陣列和字串的shallow size比較大。

Retain size: 這是將物件本身連同其無法從 GC 根到達的相關物件一起刪除後釋放的記憶體大小。 因此,如果Shallow Size = Retained Size,說明基本沒怎麼洩漏。而如果Retained Size > Shallow Size,就需要多加註意了。

Memory裡的Summary檢視, Comparison檢視, Dominators檢視和Containment檢視分別有什麼不同呢

Summary view

顧名思義,Summary view就是當前記憶體快照的一個概覽。我們先介紹一下這個檢視下的每一列是什麼意思: - Constructor: 物件的構造器。 - Distance:與root的距離。距離越大,處理和載入這個物件的時間就越長。 - Object Count:指定構造器建立的物件的數量。 - Shallow Size:物件自身佔用記憶體的大小。 - Retained Size:釋放掉該物件後,能釋放掉的記憶體。

在這個檢視下你可以看到當前頁面記憶體的具體構成,但如果想定位記憶體問題,下面的Comparison view會更加有用。

Comparison view

Comparison檢視可以讓你對比兩份記憶體快照之間的差異。預設是跟上一份快照做對比,當然你也可以選擇任意兩份記憶體做對比。這個檢視下每一列的資料有點不同: - Constructor: 物件的構造器。 - # New: 該物件構造器下有多少新物件被建立 - # Deleted: 該物件構造器下有多少新物件被銷燬 - # Delta: # New - # Delete的差值 - Alloc.Size:兩份快照之間新分配的記憶體 - Freed Size: 兩份快照之間釋放掉的記憶體 - Size Delta:Alloc Size - Freed Size 的差值

這個檢視絕對是排查記憶體漏失的利器。當你能定位到是哪些操作可能造成記憶體漏失後,比較操作前後的記憶體快照,很容易就能發現發生記憶體漏失的物件。

Containment view

Containment view提供了一個自下而上的檢視,它允許你瀏覽和探索堆記憶體的內容。我們可以用它來分析一些全部變數的參照情況(如window)。

Statistics view

Statistics檢視會用餅圖的形式展示各個型別物件的記憶體佔比

Constructor下的(array), Array, (closure), (compiled code)都對應的哪些內容?

  • (closure): 函數閉包持有的記憶體參照。
  • (array, string, number, regex): 包含著一系列物件,這些物件的屬性上有對應型別變數的參照。
  • (compiled code): Javascript引擎(如V8)為了加快執行速度,會對程式碼進行一次編譯。(compiled code)顧名思義就是指與編譯後的程式碼相關聯的記憶體。
  • Detached HTMLDivElement等:程式碼裡對指定型別Dom節點的參照。

發現有一個叫feedback_cell的欄位經常出現,它是什麼?是它導致了記憶體漏失嗎?

經常出現的feedback_cell

放心,它不會造成記憶體漏失。它是v8對頻繁執行的熱程式碼做出的優化,會被v8自己回收。詳見這篇文章:Feedback vectors in heap snapshots – Rohit Pagariya

常見的記憶體漏失場景有哪些?

這裡列舉了一些常見的記憶體漏失場景,遇到記憶體漏失問題時可以先自查一遍常見場景,個人感覺能解決日常開發中遇到的90%記憶體漏失

  • console導致的記憶體漏失 因為列印後的物件需要支援在控制檯上檢視,所以傳遞給console.log方法的物件是不能被垃圾回收的。我們需要避免在生產環境用console列印物件。
  • 框架配合第三方庫使用時,沒有及時執行銷燬 這點可以參考vue cookbook裡的例子
  • 被遺忘的定時器 例如在元件初始化的時候設定了setInterval,那麼在元件銷燬之前記得呼叫clearInterval方法取消定時器。
  • 沒有正確移除事件監聽器(各種EventBus, dom事件監聽等) 這應該是最容易犯的一個錯誤,無論新手老手都有可能栽在這裡。
    特徵:performance裡,監聽器數量會持續上升

持續上升的監聽器數量

囉嗦一句:儘管大部分同學都會有主動移除監聽器的觀念,但如果姿勢不對,可能依舊會造成記憶體漏失。下面是一個真實案例:

// 版本一
mounted() {
    window.addEventListener('resize', debounce(this.handleWidthChange, 100))
},
beforeDestroy() {
    window.removeEventListener('resize', debounce(this.handleWidthChange, 100)) 
}

乍一看好像寫的還不錯,有及時移除監聽器,對resize這種頻繁觸發的事件也加了debounce處理。但其實這段程式碼就導致了記憶體漏失:每次呼叫debounce(this.handleWidthChange, 100)時, 其實都會返回一個新的函數,導致addEventListener和 removeEventListener方法傳入的回撥函數已經不是同一個回撥函數,監聽器沒有被正確移除,記憶體漏失。

下面來看修改後的程式碼:

// 版本二
data() {
    return {
        debounceWidthChange: null
    }
},
mounted() {
    this.debounceWidthChange = debounce(this.handleWidthChange, 100)
    window.addEventListener('resize', this.debounceWidthChange)
},
beforeDestroyed() {
    window.removeEventListener('resize', this.debounceWidthChange)  
}

修改後,監聽和移除監聽的已經是同一個回撥函數了,看起來似乎已經沒問題。然而,這段程式碼還是有記憶體漏失的問題。沒看出問題的小夥伴可以對比一下正確答案:

// 版本三
data() {
    return {
        debounceWidthChange: null
    }
},
mounted() {
    this.debounceWidthChange = debounce(this.handleWidthChange, 100)
    window.addEventListener('resize', this.debounceWidthChange)
},
beforeDestroy() {
    window.removeEventListener('resize', this.debounceWidthChange)  
}

是的,答案非常狗血:Vue只有destroyedbeforeDestroy這兩個生命週期,沒有 beforeDestroyed,所以上面的beforeDestroyed函數永遠不會執行,導致了記憶體漏失…

結語

簡單總結一下排查記憶體漏失的常見流程:

1. 用performance面板記錄操作一段時間內的記憶體變化,找出可能發生記憶體漏失的操作。

2. 用“three snapshot”技巧,記錄下發生洩漏前後的記憶體快照

3. 用comparison檢視對洩漏前後的記憶體快照進行比較,找出洩漏的物件。

4. 重點關注 Vue Component, Detached HTMLDivElement等Constructor 。

以上就是手把手教你如何排查Javascript記憶體漏失的詳細內容,更多關於Javascript記憶體漏失排查的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com