一文帶你了解如何排查內存泄漏導致的頁面卡頓現象


不知道在座的各位有沒有被問到過這樣一個問題:如果頁面卡頓,你覺得可能是什么原因造成的?有什么辦法鎖定原因並解決嗎?

這是一個非常寬泛而又有深度的問題,他涉及到很多的頁面性能優化問題,我依稀還記得當初面試被問到這個問題時我是這么回答的:

  1. 先會檢查是否是網絡請求太多,導致數據返回較慢,可以適當做一些緩存
  2. 也有可能是某塊資源的bundle太大,可以考慮拆分一下
  3. 然后排查一下js代碼,是不是某處有過多循環導致占用主線程時間過長
  4. 瀏覽器某幀渲染的東西太多,導致的卡頓
  5. 在頁面渲染過程中,可能有很多重復的重排重繪
  6. emmmmmm....不知道了

后來了解到了,感官上的長時間運行頁面卡頓也有可能是因為內存泄漏引起的

🌟 內存泄漏的定義

那什么是內存泄漏呢?借助別的大佬給出的定義,內存泄漏就是指由於疏忽或者程序的某些錯誤造成未能釋放已經不再使用的內存的情況。簡單來講就是假設某個變量占用100M的內存,而你又用不到這個變量,但是這個變量沒有被手動的回收或自動回收,即仍然占用100M的內存空間,這就是一種內存的浪費,即內存泄漏

🌼 JS的數據存儲

JavaScript的內存空間分為棧內存堆內存,前者用來存放一些簡單變量,后者用來存放復雜對象

  • 簡單變量指的是JS的基本數據類型,例如:StringNumberBooleannullundefinedSymbolBigInt
  • 復雜對象指的是JS的引用數據類型,例如:ObjectArrayFunction...

JS垃圾回收機制

根據內存泄漏的定義,有些變量或數據不再被使用或不需要了,那么它就是垃圾變量或垃圾數據,如果其一直保存在內存中,最終可能會導致內存占用過多的情況。那么此時就需要對這些垃圾數據進行回收,這里引入了垃圾回收機制的概念

垃圾回收的機制分為手動自動兩種

例如C/C++采用的就是手動回收的機制,即先用代碼為某個變量分配一定的內存,然后在不需要了后,再用代碼手動釋放掉內存

JavaScript采用的則是自動回收的機制,即我們不需要關心何時為變量分配多大的內存,也不需要關心何時去釋放內存,因為這一切都是自動的。但這不表示我們不需要關心內存的管理!!!!否則也不會有本文討論的內存泄露了

接下來就講一下JavaScript的垃圾回收機制

通常全局狀態(window)下的變量是不會被自動回收的,所以我們來討論一下局部作用域下的內存回收情況

function fn1 () {
    let a = {
        name: '零一'
    }

    let b = 3

    function fn2() {
        let c = [1, 2, 3]
    }

    fn2()

    return a
}

let res = fn1()
復制代碼

以上代碼的調用棧如下圖所示:

img

圖中左側為棧空間,用於存放一些執行上下文和基本類型數據;右側為堆空間,用於存放一些復雜對象數據

當代碼執行到fn2()時,棧空間內的執行上下文從上往下依次是 fn2函數執行上下文 => fn1函數執行上下文 => 全局執行上下文

fn2函數內部執行完畢以后,就該退出fn2函數執行上下文了,即箭頭向下移動,此時fn2函數執行上下文會被清除並釋放棧內存空間,如圖所示:

img

fn1函數內部執行完畢以后,就該退出fn1函數執行上下文了,即箭頭再向下移動,此時fn1函數執行上下文會被清除並釋放相應的棧內存空間,如圖所示:

blockchain

此時處於全局的執行上下文中。JavaScript的垃圾回收器會每隔一段時間遍歷調用棧,假設此時觸發了垃圾回收機制,當遍歷調用棧時發現變量b和變量c沒有被任何變量所引用,所以認定它們是垃圾數據並給它們打上標記。因為fn1函數執行完后將變量a返回了出去,並存儲在全局變量res中,所以認定其為活動數據並打上相應標記。待空閑時刻就會將標記上垃圾數據的變量給全部清除掉,釋放相應的內存,如圖所示:

img

從這我們得出幾點結論:

  1. JavaScript的垃圾回收機制是自動執行的,並且會通過標記來識別並清除垃圾數據
  2. 在離開局部作用域后,若該作用域內的變量沒有被外部作用域所引用,則在后續會被清除

補充: JavaScript的垃圾回收機制有着很多的步驟,上述只講到了標記-清除,其實還有其它的過程,這里簡單介紹一下就不展開討論了。例如:標記-整理,在清空部分垃圾數據后釋放了一定的內存空間后會可能會留下大面積的不連續內存片段,導致后續可能無法為某些對象分配連續內存,此時需要整理一下內存空間;交替執行,因為JavaScript是運行在主線程上的,所以執行垃圾回收機制時會暫停js的運行,若垃圾回收執行時間過長,則會給用戶帶來明顯的卡頓現象,所以垃圾回收機制會被分成一個個的小任務,穿插在js任務之中,即交替執行,盡可能得保證不會帶來明顯的卡頓感

👋 Chrome devTools查看內存情況

在了解一些常見的內存泄漏的場景之前,先簡單介紹一下如何使用Chrome的開發者工具來查看js內存情況

首先打開Chrome的無痕模式,這樣做的目的是為了屏蔽掉Chrome插件對我們之后測試內存占用情況的影響

img

然后打開開發者工具,找到Performance這一欄,可以看到其內部帶着一些功能按鈕,例如:開始錄制按鈕;刷新頁面按鈕;清空記錄按鈕;記錄並可視化js內存、節點、事件監聽器按鈕;觸發垃圾回收機制按鈕等等

img

簡單錄制一下百度頁面,看看我們能獲得什么,如下動圖所示:

img

從上圖中我們可以看到,在頁面從零到加載完成這個過程中JS Heap(js堆內存)documents(文檔)Nodes(DOM節點)Listeners(監聽器)GPU memory(GPU內存)的最低值、最高值以及隨時間的走勢曲線,這也是我們主要關注的點

再來看看開發者工具中的Memory一欄,其主要是用於記錄頁面堆內存的具體情況以及js堆內存隨加載時間線動態的分配情況

img

堆快照就像照相機一樣,能記錄你當前頁面的堆內存情況,每快照一次就會產生一條快照記錄,如圖所示:

img

如上圖所示,剛開始執行了一次快照,記錄了當時堆內存空間占用為13.9MB,然后我們點擊了頁面中某些按鈕,又執行一次快照,記錄了當時堆內存空間占用為13.4MB。並且點擊對應的快照記錄,能看到當時所有內存中的變量情況(結構、占總占用內存的百分比...)

然后我們還可以看一下頁面動態的內存變化情況,如圖所示:

img

在開始記錄后,我們可以看到圖中右上角有起伏的藍色與灰色的柱形圖,其中藍色表示當前時間線下占用着的內存;灰色表示之前占用的內存空間已被清除釋放。

從上圖過程來看,我們可以看到剛開始處於的tab所對應顯示的頁面中占用了一定的堆內存空間,成藍色柱形,在點擊別的tab后,原tab對應的內容消失,並且原來藍色的柱形變成灰色(表示原占用的內存空間得到了釋放),同時新tab所對應顯示的頁面也占用了一定的堆內存空間。因此后續我們就可以針對這個圖來查看內存的占用與清除情況

🔥 內存泄漏的場景

那么到底有哪些情況會出現內存泄漏的情況呢?這里列舉了常見的幾種:

  1. 閉包使用不當引起內存泄漏
  2. 全局變量
  3. 分離的DOM節點
  4. 控制台的打印
  5. 遺忘的定時器

接下來介紹一下各種情況,並嘗試用剛才講到的兩種方法來捕捉問題所在

1.閉包使用不當

文章開頭的例子中,在退出fn1函數執行上下文后,該上下文中的變量a本應被當作垃圾數據給回收掉,但因fn1函數最終將變量a返回並賦值給全局變量res,其產生了對變量a的引用,所以變量a被標記為活動變量並一直占用着相應的內存,假設變量res后續用不到,這就算是一種閉包使用不當的例子

接下來嘗試使用PerformanceMemory來查看一下閉包導致的內存泄漏問題,為了使內存泄漏的結果更加明顯,我們稍微改動一下文章開頭的例子,代碼如下:

<button onclick="myClick()">執行fn1函數</button>
<script>
    function fn1 () {
        let a = new Array(10000)  // 這里設置了一個很大的數組對象

        let b = 3

        function fn2() {
            let c = [1, 2, 3]
        }

        fn2()

        return a
    }

    let res = []  

    function myClick() {
        res.push(fn1())
    }
</script>
復制代碼

設置了一個按鈕,每次執行就會將fn1函數的返回值添加到全局數組變量res中,是為了能在performacne的曲線圖中看出效果,如圖所示:

img

在每次錄制開始時手動觸發一次垃圾回收機制,這是為了確認一個初始的堆內存基准線,便於后面的對比,然后我們點擊了幾次按鈕,即往全局數組變量res中添加了幾個比較大的數組對象,最后再觸發一次垃圾回收,發現錄制結果的JS Heap曲線剛開始成階梯式上升的,最后的曲線的高度比基准線要高,說明可能是存在內存泄漏的問題

在得知有內存泄漏的情況存在時,我們可以改用Memory來更明確得確認問題和定位問題

首先可以用Allocation instrumentation on timeline來確認問題,如下圖所示:

img

在我們每次點擊按鈕后,動態內存分配情況圖上都會出現一個藍色的柱形,並且在我們觸發垃圾回收后,藍色柱形都沒變成灰色柱形,即之前分配的內存並未被清除

所以此時我們就可以更明確得確認內存泄漏的問題是存在的了,接下來就精准定位問題,可以利用Heap snapshot來定位問題,如圖所示:

img

第一次先點擊快照記錄初始的內存情況,然后我們多次點擊按鈕后再次點擊快照,記錄此時的內存情況,發現從原來的1.1M內存空間變成了1.4M內存空間,然后我們選中第二條快照記錄,可以看到右上角有個All objects的字段,其表示展示的是當前選中的快照記錄所有對象的分配情況,而我們想要知道的是第二條快照與第一條快照的區別在哪,所以選擇Object allocated between Snapshot1 and Snapshot2,即展示第一條快照和第二條快照存在差異的內存對象分配情況,此時可以看到Array的百分比很高,初步可以判斷是該變量存在問題,點擊查看詳情后就能查看到該變量對應的具體數據了

以上就是一個判斷閉包帶來內存泄漏問題並簡單定位的方法了

2.全局變量

全局的變量一般是不會被垃圾回收掉的,在文章開頭也提到過了。當然這並不是說變量都不能存在全局,只是有時候會因為疏忽而導致某些變量流失到全局,例如未聲明變量,卻直接對某變量進行賦值,就會導致該變量在全局創建,如下所示:

function fn1() {
    // 此處變量name未被聲明
    name = new Array(99999999)
}

fn1()
復制代碼

此時這種情況就會在全局自動創建一個變量name,並將一個很大的數組賦值給name,又因為是全局變量,所以該內存空間就一直不會被釋放

解決辦法的話,自己平時要多加注意,不要在變量未聲明前賦值,或者也可以開啟嚴格模式,這樣就會在不知情犯錯時,收到報錯警告,例如:

function fn1() {
    'use strict';
    name = new Array(99999999)
}

fn1()
復制代碼

3.分離的DOM節點

什么叫DOM節點?假設你手動移除了某個dom節點,本應釋放該dom節點所占用的內存,但卻因為疏忽導致某處代碼仍對該被移除節點有引用,最終導致該節點所占內存無法被釋放,例如這種情況:

<div id="root">
    <div class="child">我是子元素</div>
    <button>移除</button>
</div>
<script>

    let btn = document.querySelector('button')
    let child = document.querySelector('.child')
    let root = document.querySelector('#root')
    
    btn.addEventListener('click', function() {
        root.removeChild(child)
    })

</script>
復制代碼

該代碼所做的操作就是點擊按鈕后移除.child的節點,雖然點擊后,該節點確實從dom被移除了,但全局變量child仍對該節點有引用,所以導致該節點的內存一直無法被釋放,可以嘗試用Memory的快照功能來檢測一下,如圖所示:

img

同樣的先記錄一下初始狀態的快照,然后點擊移除按鈕后,再點擊一次快照,此時內存大小我們看不出什么變化,因為移除的節點占用的內存實在太小了可以忽略不計,但我們可以點擊第二條快照記錄,在篩選框里輸入detached,於是就會展示所有脫離了卻又未被清除的節點對象

解決辦法如下圖所示:

<div id="root">
    <div class="child">我是子元素</div>
    <button>移除</button>
</div>
<script>
    let btn = document.querySelector('button')

    btn.addEventListener('click', function() {  
        let child = document.querySelector('.child')
        let root = document.querySelector('#root')

        root.removeChild(child)
    })

</script>
復制代碼

改動很簡單,就是將對.child節點的引用移動到了click事件的回調函數中,那么當移除節點並退出回調函數的執行上文后就會自動清除對該節點的引用,那么自然就不會存在內存泄漏的情況了,我們來驗證一下,如下圖所示:

img

結果很明顯,這樣處理過后就不存在內存泄漏的情況了

4.控制台的打印

控制台的打印也會造成內存泄漏嗎????是的呀,如果瀏覽器不一直保存着我們打印對象的信息,我們為何能在每次打開控制的Console時看到具體的數據呢?先來看一段測試代碼:

<button>按鈕</button>
<script>
    document.querySelector('button').addEventListener('click', function() {
        let obj = new Array(1000000)

        console.log(obj);
    })
</script>
復制代碼

我們在按鈕的點擊回調事件中創建了一個很大的數組對象並打印,用performance來驗證一下:

img

開始錄制,先觸發一次垃圾回收清除初始的內存,然后點擊三次按鈕,即執行了三次點擊事件,最后再觸發一次垃圾回收。查看錄制結果發現JS Heap曲線成階梯上升,並且最終保持的高度比初始基准線高很多,這說明每次執行點擊事件創建的很大的數組對象obj都因為console.log被瀏覽器保存了下來並且無法被回收

接下來注釋掉console.log,再來看一下結果:

<button>按鈕</button>
<script>
    document.querySelector('button').addEventListener('click', function() {
        let obj = new Array(1000000)

        // console.log(obj);
    })
</script>
復制代碼

performance如圖所示:

img

可以看到沒有打印以后,每次創建的obj都立馬被銷毀了,並且最終觸發垃圾回收機制后跟初始的基准線同樣高,說明已經不存在內存泄漏的現象了

其實同理,console.log也可以用Memory來進一步驗證

  • 未注釋console.log
img
  • 注釋掉了console.log
img

最后簡單總結一下:在開發環境下,可以使用控制台打印便於調試,但是在生產環境下,盡可能得不要在控制台打印數據。所以我們經常會在代碼中看到類似如下的操作:

// 如果在開發環境下,打印變量obj
if(isDev) {
    console.log(obj)
}
復制代碼

這樣就避免了生產環境下無用的變量打印占用一定的內存空間,同樣的除了console.log之外,console.errorconsole.infoconsole.dir等等都不要在生產環境下使用

5.遺忘的定時器

其實定時器也是平時很多人會忽略的一個問題,比如定義了定時器后就再也不去考慮清除定時器了,這樣其實也會造成一定的內存泄漏。來看一個代碼示例:

<button>開啟定時器</button>
<script>

    function fn1() {
        let largeObj = new Array(100000)

        setInterval(() => {
            let myObj = largeObj
        }, 1000)
    }

    document.querySelector('button').addEventListener('click', function() {
        fn1()
    })
</script>
復制代碼

這段代碼是在點擊按鈕后執行fn1函數,fn1函數內創建了一個很大的數組對象largeObj,同時創建了一個setInterval定時器,定時器的回調函數只是簡單的引用了一下變量largeObj,我們來看看其整體的內存分配情況吧:

img

按道理來說點擊按鈕執行fn1函數后會退出該函數的執行上下文,緊跟着函數體內的局部變量應該被清除,但圖中performance的錄制結果顯示似乎是存在內存泄漏問題的,即最終曲線高度比基准線高度要高,那么再用Memory來確認一次:

img

在我們點擊按鈕后,從動態內存分配的圖上看到出現一個藍色柱形,說明瀏覽器為變量largeObj分配了一段內存,但是之后這段內存並沒有被釋放掉,說明的確存在內存泄漏的問題,原因其實就是因為setInterval的回調函數內對變量largeObj有一個引用關系,而定時器一直未被清除,所以變量largeObj的內存也自然不會被釋放

那么我們如何來解決這個問題呢,假設我們只需要讓定時器執行三次就可以了,那么我們可以改動一下代碼:

<button>開啟定時器</button>
<script>
    function fn1() {
        let largeObj = new Array(100000)
        let index = 0

        let timer = setInterval(() => {
            if(index === 3) clearInterval(timer);
            let myObj = largeObj
            index ++
        }, 1000)
    }

    document.querySelector('button').addEventListener('click', function() {
        fn1()
    })
</script>
復制代碼

現在我們再通過performancememory來看看還不會存在內存泄漏的問題

  • performance

img

這次的錄制結果就能看出,最后的曲線高度和初始基准線的高度一樣,說明並沒有內存泄漏的情況

  • memory

img

這里做一個解釋,圖中剛開始出現的藍色柱形是因為我在錄制后刷新了頁面,可以忽略;然后我們點擊了按鈕,看到又出現了一個藍色柱形,此時就是為fn1函數中的變量largeObj分配了內存,3s后該內存又被釋放了,即變成了灰色柱形。所以我們可以得出結論,這段代碼不存在內存泄漏的問題

簡單總結一下: 大家在平時用到了定時器,如果在用不到定時器后一定要清除掉,否則就會出現本例中的情況。除了setTimeoutsetInterval,其實瀏覽器還提供了一個API也可能就存在這樣的問題,那就是requestAnimationFrame

👍 總結

在項目過程中,如果遇到了某些性能問題可能跟內存泄漏有關時,就可以參照本文列舉的5種情況去排查,一定能找到問題所在並給到解決辦法的。

雖然JavaScript的垃圾回收是自動的,但我們有時也是需要考慮要不要手動清除某些變量的內存占用的,例如你明確某個變量在一定條件下再也不需要,但是還會被外部變量引用導致內存無法得到釋放時,你可以用null對該變量重新賦值就可以在后續垃圾回收階段釋放該變量的內存了。

😊 我也是因為在業務中一次因內存泄漏而引起頁面卡頓,所以才想起寫這樣一篇文章。當然並不是說頁面卡頓都是因為內存泄漏引起的,可能還有其它原因

大家對於內存泄漏排查的方法有什么建議或者問題的話可以在評論區討論鴨~ ✌️

作者:_零一
鏈接:https://juejin.cn/post/6947841638118998029


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM