從瀏覽器原理分析界面性能優化03---瀏覽器的垃圾回收策略

從瀏覽器原理分析界面性能優化 03—垃圾回收

其他相關文章看這裏:
瀏覽器原理與優化—總攬
瀏覽器原理與優化—網絡篇
瀏覽器原理與優化—渲染篇

前言

對於前端開發來說,JavaScript 的內存機制是一個不經常被提及的概念,因此很容易被忽視.但是,想要開發一個高性能的前端應用,必須要搞清楚 JavaScript 的的內存機制.

爲什麼會有垃圾回收

我們都知道當程序運行時,有的數據在使用過之後可能就不再被需要了,這些數據我們稱之爲垃圾數據.當垃圾數據一直佔用我們的內存時,內存得不到快速的釋放,可能就會造成內存泄漏,導致我們的內存不夠用了.
這個時候就需要對垃圾數據進行垃圾回收,釋放被佔用的內存空間

垃圾回收策略

一般情況下,垃圾回收分手動回收自動回收兩種情況。
手動垃圾回收指的是我們需要自己分配內存和進行內存的回收等操作,如果當一段代碼不需要了但是我們卻沒有進行垃圾回收的時候,就會造成內存泄漏。手動回收的代表語言就是 C 和 C++了
自動垃圾回收是指我們在編碼的過程中不需要手動控制內存的分配和釋放,這些操作都是自動完成的一個過程。自動垃圾回收的語言也有很多,例如:Java、JavaScript、Python 等

JS 中的垃圾回收策略是自動回收.也就是說不需要我們自己去進行垃圾回收,當滿足一定條件的時候 JS 垃圾回收機制就會被觸發,從而進行垃圾回收.

JS 中的垃圾回收

對於 JS 中的垃圾回收而言,他是一個自動完成的過程。在這個過程中我們是不需要關心內存的分配和回收的。
但是,如果我們想要開發出一個高性能的前端應用的話,卻不得不關注垃圾回收。

我們想要了解 JS 中的垃圾回收,首先要關注的就是 JS 中的數據是如何存儲的:

JS 中的內存空間

衆所周知,JS 中的內存空間分爲棧空間堆空間代碼空間,其中代碼空間主要是存儲可執行代碼的,因爲與內存回收的關係不大,所以這裏不做擴展介紹了。

這裏的棧空間就是我們平常代碼執行過程中遇到的調用棧,是用來存儲執行上下文的。堆空間也是用來存儲執行上下文之外的數據類型。
這裏用一段代碼來簡要說明一下:

  function foo(){
    var a = '測試'
    var b = a
    var c = {name:'小明'}
    var d = c
  }

  foo()

JS 執行代碼的時候,需要先進行編譯,創建執行上下文,然後在按照順序執行代碼。
所以上面這段代碼的順序執行到“var b=a”的時候,因爲 a 的值爲基本類型,所以根據 JS 的變量提升的規則,這個時候的調用棧狀態應該是這樣的:
在這裏插入圖片描述

當執行 foo 函數剩餘部分的代碼的時候,我們的調用棧會發生相應變化:
在這裏插入圖片描述
從上面我們可以看出,基本類型的變量的值是存儲在棧空間中的,複雜類型變量的值存儲在堆空間中,對於複雜類型,棧空間中只存放了一個指向其堆內存位置的一個引用,這裏有一點需要注意的是閉包函數中的內部函數如果引用了外部函數聲明的變量,那麼該變量則會存儲在堆內存中,這點我們後面在進行介紹。

這裏簡要解釋一下爲什麼 JS 要分爲棧空間和堆空間:

  • JS 引擎要用調用棧維護程序執行期間的上下文狀態,如果棧空間過大的話,會影響上下文切換的效率,進而影響程序的執行效率
  • 所以,通常情況下棧空間比較小,用來存儲一些原始類型的小數據
  • 堆空間與之相反,用來存儲一些複雜類型的數據,

棧內存垃圾回收

棧內存中的垃圾回收其實就是銷燬執行棧中的執行上下文,我們都知道執行棧中存放的就是函數執行過程中的執行上下文,棧頂就是我們正在執行函數的執行上下文。
當我們的函數執行完畢後,執行棧中對應的執行上下文會被銷燬,這也就是棧垃圾回收的過程。
爲了瞭解這一過程,我們舉個簡單的例子:

  function foo1(){
    var a=1
    function foo2(){
      var b = 2
    }
    foo2()
  }
  foo1()

當執行到"foo2()"這一行時,執行棧中的狀態其實是這樣的:

在這裏插入圖片描述
圖中的ESP 是執行棧中用來記錄當前執行狀態的指針
當我們執行完"foo2()"這一行時,ESP 指針下移,這個下移的操作執行棧中的 foo2 函數對應的上下文被回收的過程。
其實這個指針下移的操作進行垃圾回收很好理解,因爲 ESP 是記錄當前執行的狀態,當 ESP 指針下移的時候,之前的執行上下文就會被認爲是無效的內存了,下次創建新的執行上下文的時候該內存就會被覆蓋,從而達到了內存回收的目的。

綜上所述,JS 引擎是通過 ESP 指針的下移操作來完成棧內存中的垃圾回收的

堆內存垃圾回收

從上面的介紹我們知道,堆內存中主要存放的是複雜數據類型和閉包內部函數引用的基本類型的數據。那麼堆內存又是怎麼進行垃圾回收的,下面我們來看一下。

代際假說和分代收集

在看堆內存中的垃圾回收之前,我們要先看兩個概念:代際假說和分代收集。這兩個概念十分重要,在好多語言的垃圾回收中都有用到。JS 中堆內存的垃圾回收就是建立在這兩個概念之上的。

代際假說的核心有兩點:

  • 大部分對象的存活時間都很短,分配完內存以後很快就變得不可訪問
  • 不死的對象,存活時間都很長

有了上面兩點,我們就很容易理解分代收集的概念了:

  • 堆內存分爲新生代和老生代兩個區域
  • 新生代區域存放的都是存活時間比較短的,佔內存比較小的對象
  • 老生代區域存放的都是存活時間比較長,佔內存比較大的對象

主垃圾回收器和副垃圾回收器

堆空間根據代際假說和分代收集分爲了新生代區域老生代區域,他們的垃圾回收分別對應了副垃圾回收器主垃圾回收器
其實這兩個垃圾回收器的大致工作流程都是相同的,可以簡化爲三步:

  • 標記待回收的內存
  • 垃圾內存回收
  • 內存碎片整理(因爲頻繁的進行垃圾數據回收以後會產生很多不連續的內存空間,不利於後續數據的存儲)

副垃圾回收器工作流程

副垃圾回收器主要是對新生代區域進行垃圾回收。我們上面也介紹了新生代區域的空間比較小,大約是 1 ~ 8M主要是存放一些存活時間比較短,佔內存比較小的對象。

下面我們來看一下副垃圾回收器的工作流程:

副垃圾回收器採用的是 Scavenge 算法進行垃圾回收,這個所謂的 Scavenge 算法,主要是將新生代區域分成了兩部分:空閒區域對象區域

  • 所有進入新生代區域新產生的對象都會存放到對象區域中
  • 當對象區域被寫滿的時候會進行垃圾回收
  • 垃圾回收器會標記垃圾數據(使用的是“標記-清除算法”)
  • 標記完成後對象區域會將有效數據按照一定順序存放到空閒區域的一端
  • 存放好後,對象區域和空閒區域會角色互換
  • 清空當前的空閒區域的內存空間

上面一系列的操作就完成了新生代區域的垃圾回收的工作,該過程完全是按照我們之前說過的垃圾回收器的三步工作流程完成的。
其中,內存整理的過程,因爲是對象區域的有效數據按照一定順序放到了空閒區域中,所以這裏也順便完成了內存碎片的整理。

這裏有一個需要注意的地方,我們之前提到新生代區域的空間是很小的,大約是 1 ~ 8M,所以新生代區域會很快被填滿,這個時候 JS 引擎有一個對象晉升策略用來應對這種情況:

對象晉升策略規定,兩次垃圾回收還存活的對象就會被移動到老生代區域中

主垃圾回收器的工作流程

主垃圾回收器是對老生代區域進行垃圾回收的。我們上面提到了老生代區域主要是存放的存活時間比較長或者佔內存比較大的對象。

我們都知道新生代區域的垃圾回收使用了 Scavenge 算法,但是老生代區域的內存空間明顯要大很多,所以使用 Scavenge 算法進行垃圾回收的效率明顯要低很多。
所以這個時候主垃圾回收器還是得老老實實的按照之前的三步進行垃圾回收操作。

這裏,我們不得不提到的兩個概念:引用計數算法標記-清除算法。這兩個算法都是針對垃圾數據標記的。

  • 引用計數算法:該算法規定,沒有被其他對象應用的數據可以被標記爲垃圾數據。
  • **標記-清除算法:**該算法的主要內容就是從根部出發,如果存在不能直接到達的對象,就可以將其標記爲垃圾數據。

引用計數算法是 JS 引擎早期採用的一個垃圾標記算法,該算法存在一個問題,那就是無法應對互相引用的情況。也就是說當兩個對象互相引用時,就會永遠無法被回收,從而造成內存泄漏。
基於上面的問題,後來提出了標記-清除算法,這也是現在 JS 引擎採用的算法,該算法解決了互相引用的問題,但是也有他的侷限性。例如,當我們在全局環境下定義一個 DOM 的點擊監聽事件的時候,因爲可是直接到達,所以是無法被垃圾回收器回收的。這也就是爲什麼我們在合適的時機清除我們的監聽事件,因爲如果不這樣的話會造成內存泄漏。

介紹完了標記-清除算法,我們開始正式介紹我們的主垃圾回收器的工作流程吧。

首先,通過標記-清除算法,進行垃圾數據的標記。當存在不能直接到達的對象的時候會將數據標記爲垃圾數據。至於什麼是不能直接到達,我們可以對照一下上面提到的對於複雜類型,棧內存中存放的是指向其堆內存地址的一個引用。這兩個算法都是針對垃圾數據標記的。
當我們執行上下文銷燬之後,這個引用也就被銷燬了,也就是說沒有直接到達該對象的路徑了,那麼這個對象會被標記爲可回收對象。

標記好垃圾數據之後,主垃圾回收器開始進行垃圾回收。這個垃圾回收的過程其實就是把可回收對象加入到空閒列表中。
剩下的過程就是內存碎片整理了。主垃圾回收器會將存活的對象移動到一端,然後清理掉邊界以外的內存。

至此,我們就介紹完了真個 JS 引擎的垃圾回收的過程。下面來稍微總結一下:

  • 垃圾回收根據數據的存儲位置不同,分爲棧內存垃圾回收和堆內存的垃圾回收
  • 棧內存的垃圾回收主要是通過狀態指針 ESP 的移動來進行回收
  • 堆內存的垃圾回收根據代際假說分代收集的理論,使用了主、副兩個垃圾回收器
  • 兩個垃圾回收器的大致工作流程都是:垃圾數據標記、垃圾回收、內存碎片整理三部分

全停頓和增量標記算法

介紹完了 JS 引擎的垃圾回收,還有個地方需要我們關注一下。那就是全停頓問題。
我們都知道垃圾回收是運行在 JS 線程上的,一旦執行垃圾回收算法就需要將正在執行的 JS 腳本暫停下來,待垃圾回收完畢後再繼續執行腳本。這個過程就是全停頓
而且,之前的文章我們介紹過,JS 線程和渲染線程是互斥的,也就是說垃圾回收過程中,暫停了 JS 腳本執行同時掛起了渲染引擎。一旦垃圾回收時間過長,就會造成界面卡頓。

通過前面的介紹我們知道,棧內存和新生代區域的垃圾回收都是很快的,也就是說造成全停頓的主要原因就是老生代區域的垃圾回收工作。
爲了解決這一問題,V8 引擎將垃圾數據的標記過程,分爲了一個個的子標記過程,同時讓垃圾回收標記和 JS 腳本邏輯交替執行,直到標記階段完成,這個算法就是增量標記算法。當存在不能直接到達的對象的時候會將數據標記爲垃圾數據。至於什麼是不能直接到達,我們可以對照一下上面提到的

通過增量標記算法的優化,可以把一個較大的垃圾回收任務拆分成多個子任務,通過這些子任務和 JS 腳本的交替執行,規避了全停頓。從而讓用戶感受不到垃圾回收造成的卡頓問題。

前端優化

其實這章主要的是介紹了 JS 引擎的垃圾回收的原理。想必大家也發現了,無論是垃圾回收的策略還是全停頓的的策略,其實都沒有完美的解決方案。
我們在編程的過程的時候,針對垃圾回收的優化其實也很有限,這可能也是一個權衡的過程,需要犧牲某些方面的指標來換取某些性能的提升。

關於垃圾回收方面的優化,我能想到的就是及時回收 JS 引擎不能自動回收的數據,比如我們在全局下注冊的監聽事件等。希望有別的大神可以給一些建設性的建議或者意見

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