前端面試常考題:JS垃圾回收機制

摘要:衆所周知,應用程序在運行過程中需要佔用一定的內存空間,且在運行過後就必須將不再用到的內存釋放掉,否則就會出現下圖中內存的佔用持續升高的情況,一方面會影響程序的運行速度,另一方面嚴重的話則會導致整個程序的崩潰。

衆所周知,應用程序在運行過程中需要佔用一定的內存空間,且在運行過後就必須將不再用到的內存釋放掉,否則就會出現下圖中內存的佔用持續升高的情況,一方面會影響程序的運行速度,另一方面嚴重的話則會導致整個程序的崩潰。

 

 

JavaScript中的內存管理

  • 內存:由可讀寫單元組成,表示一片可操作空間;
  • 管理:人爲的去操作一片空間的申請、使用和釋放;
  • 內存管理:開發者主動申請空間、使用空間、釋放空間;
  • 管理流程:申請-使用-釋放

部分語言需要(例如C語言)需要手動去釋放內存,但是會很麻煩,所以很多語言,例如JAVA都會提供自動的內存管理機制,稱爲“垃圾回收機制”,JavaScript語言中也提供了垃圾回收機制(Garbage Collecation),簡稱GC機制

全停頓(Stop The World )

在介紹垃圾回收算法之前,我們先了解一下「全停頓」。垃圾回收算法在執行前,需要將應用邏輯暫停,執行完垃圾回收後再執行應用邏輯,這種行爲稱爲 「全停頓」(Stop The World)。例如,如果一次GC需要50ms,應用邏輯就會暫停50ms。

全停頓的目的,是爲了解決應用邏輯與垃圾回收器看到的情況不一致的問題。

舉個例子,在自助餐廳喫飯,高高興興地取完食物回來時,結果發現自己餐具被服務員收走了。這裏,服務員好比垃圾回收器,餐具就像是分配的對象,我們就是應用邏輯。在我們看來,只是將餐具臨時放在桌上,但是服務員看來覺得你已經不需要使用了,因此就收走了。你與服務員對於同一個事物看到的情況是不一致,導致服務員做了與我們不期望的事情。因此,爲避免應用邏輯與垃圾回收器看到的情況不一致,垃圾回收算法在執行時,需要停止應用邏輯。

JavaScript中的垃圾回收

JavaScript中會被判定爲垃圾的情形如下:

  • 對象不再被引用;
  • 對象不能從根上訪問到;

GC算法

常見的GC算法如下:

  • 引用計數
  • 標記清除
  • 標記整理
  • 分代回收

引用計數

早期的瀏覽器最常使用的垃圾回收方法叫做"引用計數"(reference counting):語言引擎有一張"引用表",保存了內存裏面所有的資源(通常是各種值)的引用次數。如果一個值的引用次數是0,就表示這個值不再用到了,因此可以將這塊內存釋放。

const user1 = {age: 11}
const user2 = {age: 22}
const user3 = {age: 33}

const userList = [user1.age, user2.age, user3.age]

上面這段代碼,當執行過一遍過後,user1、user2、user3都是被userList引用的,所以它們的引用計數不爲零,就不會被回收

function fn() {
    const num1 = 1
    const num2 = 2
}

fn()

上面代碼中fn函數執行完畢,num1、num2都是局部變量,執行過後,它們的引用計數就都爲零,所有這樣的代碼就會被當做“垃圾”,進行回收。

引用計數算法有一個比較大的問題: 循環引用

function objGroup(obj1, obj2) {
    obj1.next = obj2
    obj2.prev = obj1

    return {
        o1: obj1,
        o2: obj2,
    }
}

let obj = objGroup({name: 'obj1'}, {name: 'obj2'})
console.log(obj)

上面的這個例子中,obj1和obj2通過各自的屬性相互引用,所有它們的引用計數都不爲零,這樣就不會被垃圾回收機制回收,造成內存浪費。

引用計數算法其實還有一個比較大的缺點,就是我們需要單獨拿出一片空間去維護每個變量的引用計數,這對於比較大的程序,空間開銷還是比較大的。

引用計數算法優點:

  • 引用計數爲零時,發現垃圾立即回收;
  • 最大限度減少程序暫停;

引用計數算法缺點:

  • 無法回收循環引用的對象;
  • 空間開銷比較大;

標記清除(Mark-Sweep)

核心思想:分標記和清除兩個階段完成。

  1. 遍歷所有對象找標記活動對象;
  2. 遍歷所有對象清除沒有標記對象;
  3. 回收相應的空間。

標記清除算法的優點是:對比引用計數算法,標記清除算法最大的優點是能夠回收循環引用的對象,它也是v8引擎使用最多的算法。
標記清除算法的缺點是:

 

 

上圖我們可以看到,紅色區域是一個根對象,就是一個全局變量,會被標記;而藍色區域就是沒有被標記的對象,會被回收機制回收。這時就會出現一個問題,表面上藍色區域被回收了三個空間,但是這三個空間是不連續的,當我們有一個需要三個空間的對象,那麼我們剛剛被回收的空間是不能被分配的,這就是“空間碎片化”。

標記整理(Mark-Compact)

爲了解決內存碎片化的問題,提高對內存的利用,引入了標記整理算法。

標記整理可以看做是標記清除的增強。標記階段的操作和標記清除一致。

清除階段會先執行整理,移動對象位置,將存活的對象移動到一邊,然後再清理端邊界外的內存。

 

 

 

 

 

 

標記整理的缺點是:移動對象位置,不會立即回收對象,回收的效率比較慢。

增量標記(Incremental Marking)

爲了減少全停頓的時間,V8對標記進行了優化,將一次停頓進行的標記過程,分成了很多小步。每執行完一小步就讓應用邏輯執行一會兒,這樣交替多次後完成標記。

 

 

長時間的GC,會導致應用暫停和無響應,將會導致糟糕的用戶體驗。從2011年起,v8就將「全暫停」標記換成了增量標記。改進後的標記方式,最大停頓時間減少到原來的1/6。

v8引擎垃圾回收策略

  • 採用分代回收的思想;
  • 內存分爲新生代、老生代;

針對不同對象採用不同算法:
(1)新生代:對象的存活時間較短。新生對象或只經過一次垃圾回收的對象。
(2)老生代:對象存活時間較長。經歷過一次或多次垃圾回收的對象。

 

 

V8堆的空間等於新生代空間加上老生代空間。且針對不同的操作系統對空間做了內存的限制。

 

 

針對瀏覽器來說,這樣的內存是足夠使用的。限制內存的原因:

針對瀏覽器的GC機制,經過不斷的測試,如果內存再設置大一點,GC回收的時間就會達到用戶的感知,會造成感知上的卡頓。

回收新生代對象

回收新生代對象主要採用複製算法Scavenge 算法)加標記整理算法。而Scavenge 算法的具體實現,主要採用了Cheney算法

 

 

Cheney算法將內存分爲兩個等大空間,使用空間爲From,空閒空間爲To

檢查From空間內的存活對象,若對象存活,檢查對象是否符合晉升條件,若符合條件則晉升到老生代,否則將對象從 From 空間複製到 To 空間。若對象不存活,則釋放不存活對象的空間。完成複製後,將 From 空間與 To 空間進行角色翻轉。

對象晉升機制

一輪GC還存活的新生代需要晉升。
當對象從From 空間複製到 To 空間時,若 To 空間使用超過 25%,則對象直接晉升到老生代中。設置爲25%的比例的原因是,當完成 Scavenge 回收後,To 空間將翻轉成From 空間,繼續進行對象內存的分配。若佔比過大,將影響後續內存分配。

回收老生代對象

回收老生代對象主要採用標記清除標記整理增量標記算法,主要使用標記清除算法,只有在內存分配不足時,採用標記整理算法。

  1. 首先使用標記清除完成垃圾空間的回收
  2. 採用標記整理進行空間優化
  3. 採用增量標記進行效率優化

新生代和老生代回收對比

新生代由於佔用空間比較少,採用空間換時間機制。
老生代區域空間比較大,不太適合大量的複製算法和標記整理,所以最常用的是標記清除算法,爲了就是讓全停頓的時間儘量減少。

內存泄漏識別方法

我們先寫一段比較消耗內存的代碼:

<button class="btn">點擊</button>

<script>
    const btn = document.querySelector('.btn')
    const arrList = []

    btn.onclick = function() {
        for(let i = 0; i < 100000; i++) {
            const p = document.createElement('p')
            // p.innerHTML = '我是一個p元素'
            document.body.appendChild(p)
        }

        arrList.push(new Array(1000000).join('x'))
    }
</script>

使用瀏覽器的Performance來監控內存變化

 

 

點擊錄製,然後我們操作們感覺消耗性能的操作,操作完成之後,點擊stop停止錄製。

 

 

然後我們看一看是那些地方引起了內存的泄漏,我們只需要關注內存即可。

 

 

可以看到內存在短時間消耗的比較快,下降的小凹槽,就是瀏覽器在進行垃圾回收。

性能優化

1.避免使用全局變量

  • 全局變量會掛載在window下;
  • 全局變量至少有一個引用計數;
  • 全局變量存活更久,持續佔用內存;
  • 在明確數據作用域的情況下,儘量使用局部變量;

2.減少判斷層級

function doSomething(part, chapter) {
    const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']

    if (part) {
        if (parts.includes(part)) {
            console.log('屬於當前課程')
            if (chapter > 5) {
                console.log('您需要提供 VIP 身份')
            }
        }
    } else {
        console.log('請確認模塊信息')
    }
}

doSomething('Vue', 6)

// 減少判斷層級
function doSomething(part, chapter) {
    const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']

    if (!part) {
        console.log('請確認模塊信息')
        return
    }

    if (!parts.includes(part)) return
    console.log('屬於當前課程')

    if (chapter > 5) {
        console.log('您需要提供 VIP 身份')
    }
}

doSomething('Vue', 6)

3.減少數據讀取次數
對於頻繁使用的數據,我們要對數據進行緩存。

<div id="skip" class="skip"></div>

<script>
    var oBox = document.getElementById('skip')

    // function hasEle (ele, cls) {
    //     return ele.className === cls
    // }

    function hasEle (ele, cls) {
        const className = ele.className
        return className === cls
    }

    console.log(hasEle(oBox, 'skip'))
</script>

4.減少循環體中的活動

var test = () => {
    var i
    var arr = ['Hello World!', 25, '豈曰無衣,與子同袍']
    for(i = 0; i < arr.length; i++) {
        console.log(arr[i])
    }
}

// 優化後,將arr.length單獨提出,防止每次循環都獲取一次
var test = () => {
    var i
    var arr = ['Hello World!', 25, '豈曰無衣,與子同袍']
    var len = arr.length
    for(i = 0; i < len; i++) {
        console.log(arr[i])
    }
}

5.事件綁定優化

<ul class="ul">
    <li>Hello World!</li>
    <li>25</li>
    <li>豈曰無衣,與子同袍</li>
</ul>

<script>
    var list = document.querySelectorAll('li')
    function showTxt(ev) {
        console.log(ev.target.innerHTML)
    }

    for (item of list) {
        item.onclick = showTxt
    }

    // 優化後
    function showTxt(ev) {
        var target = ev.target
        if (target.nodeName.toLowerCase() === 'li') {
            console.log(ev.target.innerHTML)
        }
    }

    var ul = document.querySelector('.ul')
    ul.addEventListener('click', showTxt)
</script>

6.避開閉包陷阱

<button class="btn">點擊</button>

<script>
    function foo() {
        let el = document.querySelector('.btn')
        el.onclick = function() {
            console.log(el.className)
        }
    }
    foo()

    // 優化後
    function foo1() {
        let el = document.querySelector('.btn')
        el.onclick = function() {
            console.log(el.className)
        }
        el = null // 將el置爲 null 防止閉包中的引用使得不能被回收
    }
    foo1()
</script>

本文分享自華爲雲社區《Vue進階(幺陸玖):JS垃圾回收機制》,原文作者:SHQ5785 。

點擊關注,第一時間瞭解華爲雲新鮮技術~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章