JavaScript垃圾回收機制和性能優化


JavaScript垃圾回收機制和性能優化


前言

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

JavaScript中的內存管理

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

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

全停頓(Stop The World )

在介紹垃圾回收算法之前,我們先了解一下「全停頓」。垃圾回收算法在執行前,需要將應用邏輯暫停,執行完垃圾回收后再執行應用邏輯,這種行為稱為 「全停頓」(Stop The World)。例如,如果一次GC需要50ms,應用邏輯就會暫停50ms。
全停頓的目的,是為了解決應用邏輯與垃圾回收器看到的情況不一致的問題。舉個例子,在自助餐廳吃飯,高高興興地取完食物回來時,結果發現自己餐具被服務員收走了。這里,服務員好比垃圾回收器,餐具就像是分配的對象,我們就是應用邏輯。在我們看來,只是將餐具臨時放在桌上,但是服務員看來覺得你已經不需要使用了,因此就收走了。你與服務員對於同一個事物看到的情況是不一致,導致服務員做了與我們不期望的事情。因此,為避免應用邏輯與垃圾回收器看到的情況不一致,垃圾回收算法在執行時,需要停止應用邏輯。

JavaScript中的垃圾回收

JavaScript中會被判定為垃圾:

  • 對象不再被引用是垃圾
  • 對象不能從根上訪問到時垃圾

常見的GC算法:

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

(1). 引用計數

早期的瀏覽器最常使用的垃圾回收方法叫做"引用計數"(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通過各自的屬性相互引用,所有它們的引用計數都不為零,這樣就不會被垃圾回收機制回收,造成內存浪費。
引用計數算法其實還有一個比較大的缺點,就是我們需要單獨拿出一片空間去維護每個變量的引用計數,這對於比較大的程序,在空間開銷還是比較大的。

  • 引用計數算法優點:
    • 引用計數為零時,發現垃圾立即回收
    • 最大限度減少程序暫停
  • 引用計數算法缺點:
    • 無法回收循環引用的對象
    • 空間開銷比較大

(2). 標記清除(Mark-Sweep)

  • 核心思想:分標記和清除兩個階段完成
  • 遍歷所有對象找標記活動對象
  • 遍歷所有對象清除沒有標記對象
  • 回收相應的空間

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

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

3. 標記整理(Mark-Compact)

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

  • 標記整理可以看做是標記清除的增強
  • 標記階段的操作和標記清除一致
  • 清除階段會先執行整理,移動對象位置,將存活的對象移動到一邊,然后再清理端邊界外的內存。



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

增量標記(Incremental Marking)

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

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

v8引擎垃圾回收策略

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

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

類型 \ 系統位數 64位 32位
老生代 1400MB 700MB
新生代 32MB 16MB

限制內存的原因:

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

(1). 回收新生代對象

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

Cheney算法將內存分為兩個等大空間,使用空間為From,空閑空間為To。
檢查From空間內的存活對象,若對象存活,檢查對象是否符合晉升條件,若符合條件則晉升到老生代,否則將對象從 From 空間復制到 To 空間。若對象不存活,則釋放不存活對象的空間。完成復制后,將 From 空間與 To 空間進行角色翻轉。

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

(2). 回收老生代對象

  • 回收老生代對象主要采用標記清除、標記整理、增量標記算法,主要使用標記清除算法,只有在內存分配不足時,采用標記整理算法
  • 首先使用標記清除完成垃圾空間的回收
  • 采用標記整理進行空間優化
  • 采用增量標記進行效率優化
新生代和老生代回收對比
  • 新生代由於占用空間比較少,采用空間換時間機制
  • 老生代區域空間比較大,不太適合大量的復制算法和標記整理,所以最常用的是標記清除算法,為了就是讓全停頓的時間盡量減少

內存泄漏識別方法

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

<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 = ['maoxiaoxing', 25, '能被看見的努力,都是膚淺的努力']
    for(i = 0; i < arr.length; i++) {
        console.log(arr[i])
    }
}

// 優化后,將arr.length單獨提出,防止每次循環都獲取一次
var test = () => {
    var i
    var arr = ['maoxiaoxing', 25, '能被看見的努力,都是膚淺的努力']
    var len = arr.length
    for(i = 0; i < len; i++) {
        console.log(arr[i])
    }
}
  • 5.事件綁定優化
<ul class="ul">
    <li>毛小星</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>

參考資料


免責聲明!

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



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