1. 程式人生 > 實用技巧 >JavaScript垃圾回收機制和效能優化

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>

參考資料