V8引擎的JavaScript記憶體機制

沐華發表於2021-10-05

對於前端攻城師來說,JS的記憶體機制不容忽視。如果想成為行業專家,或者打造高效能前端應用,那就必須要弄清楚JavaScript的記憶體機制了

先看栗子

    function foo (){
        let a = 1
        let b = a
        a = 2
        console.log(a) // 2
        console.log(b) // 1
        
        let c = { name: '掘金' }
        let d = c
        c.name = '沐華'
        console.log(c) // { name: '沐華' }
        console.log(d) // { name: '沐華' }
    }
    foo()

可以看出在我們修改不同資料型別的值後,結果有點不一樣。

這是因為不同資料型別在記憶體中儲存的位置不一樣,在JS執行過程中,主要有三種記憶體空間:程式碼空間

程式碼空間主要就是儲存可執行程式碼,關於這個內容有點多,可以看我另一篇文章有詳細介紹

我們們先看一下棧和堆

棧和堆

在JS中,每一個資料都需要一個記憶體空間。而不同的記憶體空間有什麼區別特點呢?,如圖

未標題-1.jpg

呼叫棧也叫執行棧,它的執行原則是先進後出,後執行的會先出棧,如圖

ac6dbbbaed55b4ca7db2307fad77f1c5 (1).gif
棧:

  • 儲存基礎型別Number, String, Boolean, null, undefined, Symbol, BigInt
  • 儲存和使用方式後進先出(就像一個瓶子,後放進去的東西先拿出來)
  • 自動分配記憶體空間,自動釋放,佔固定大小的空間
  • 儲存引用型別的變數,但實際上儲存的不是變數本身,而是指向該物件的指標(在堆記憶體中存放的地址)
  • 所有方法中定義的變數存在棧中,方法執行結束,這個方法的記憶體棧也自動銷燬
  • 可以遞迴呼叫方法,這樣隨著棧深度增加,JVW維持一條長長的方法呼叫軌跡,記憶體不夠分配,會產生棧溢位

堆:

  • 儲存引用型別Object(Function/Array/Date/RegExp)
  • 動態分配記憶體空間,大小不定也不會自動釋放
  • 堆記憶體中的物件不會因為方法執行結束就銷燬,因為有可能被另一個變數引用(引數傳遞等)

為什麼會有棧和堆之分

通常與垃圾回收機制有關。每一個方法執行時都會建立自己的記憶體棧,然後將方法裡的變數逐個放入這個記憶體棧中,隨著方法執行結束,這個方法的記憶體棧也會自動銷燬

為了使程式執行時佔用的記憶體最小,棧空間都不會設定太大,而堆空間則很大

每建立一個物件時,這個物件會被儲存到堆中,以便反覆複用,即使方法執行結束,也不會銷燬這個物件,因為有可能被另一個變數(引數傳遞等)引用,直到物件沒有任何引用時才會被系統的垃圾回收機制銷燬

而且JS引擎需要用棧來維護程式執行期間上下文的狀態,如果所有的資料都在棧裡在,棧空間大了的話,會影響到上下文切換的效率,進而影響整個程式的執行效率

記憶體洩露和垃圾回收

上面說了在JS中建立變數(物件,字串等)時都分配記憶體,並且在不再使用它們時“自動”釋放記憶體,這個自動釋放記憶體的過程稱為垃圾回收。也正是因為垃圾回收機制的存在,讓很多開發者在開發中不太關心記憶體管理,所在在一些情況下導致記憶體洩露

記憶體生命週期:

  1. 記憶體分配:當我們宣告變數,函式,物件的時候,系統會自動為它們分配記憶體
  2. 記憶體使用:即讀寫記憶體,也就是使用變數,函式,引數等
  3. 記憶體回收:使用完畢,由垃圾回收機制自動回收不再使用的記憶體

區域性變數(函式內部的變數),當函式執行結束,沒有其他引用(閉包),該變數就會被回收

全域性變數的生命週期直到瀏覽器解除安裝頁面才會結束,也就是說全域性變數不會被垃圾回收

記憶體洩露

程式的執行需要記憶體,對於持續執行的服務程式,必須及時釋放不再用到的記憶體,否則記憶體佔用越來越大,輕則影響系統效能,嚴重的會導致程式崩潰

記憶體洩露就是由於疏忽或者錯誤,導致程式不能及時釋放那些不再使用的記憶體,造成記憶體的浪費

判斷記憶體洩露

Chrome瀏覽器中,可以這樣檢視記憶體佔用情況

開發者工具 => Performance => 勾選Memory => 點左上角Record => 頁面操作後點stop

然後就會顯示這段時間內的記憶體使用情況了

  1. 一次檢視記憶體佔用情況後,看當前記憶體佔用趨勢圖,走勢呈上升趨勢,可以認為存在記憶體洩露
  2. 多次檢視記憶體佔用情況後截圖對比,比較每次記憶體佔用情況,如果呈上升趨勢,也可以認為存在記憶體洩露

Node中,使用 process.memoryUsage 方法檢視記憶體情況

console.log(process.memoryUsage());
  • heapUsed:用到的堆的部分。
  • rss(resident set size):所有記憶體佔用,包括指令區和堆疊。
  • heapTotal:"堆"佔用的記憶體,包括用到的和沒用到的。
  • external: V8 引擎內部的 C++ 物件佔用的記憶體

判斷記憶體洩露以heapUsed欄位為準

什麼情況下會造成記憶體洩露

  1. 沒有宣告而意外建立的全域性變數
  2. 被遺忘的定時器和回撥函式,沒有及時關閉定時器中的引用會一直留在記憶體中
  3. 閉包
  4. DOM操作引用(比如引用了td卻刪了整個table,記憶體會保留整個table)

記憶體洩露如何避免

所以記住一個原則:不用的東西,及時歸還,有道是,有借有還,再借不難

  1. 減少不必要的全域性變數,比如使用嚴格模式避免建立意外的全域性變數
  2. 減少生命週期較長的物件,避免過多的物件
  3. 使用完資料後,及時解除引用(閉包中的變數,DOM引用,定時器清除)
  4. 組織好邏輯,避免死迴圈造成瀏覽器卡頓,崩潰

垃圾回收

JS是有自動垃圾回收機制的,那麼這個自動垃圾收集機制是怎麼工作的呢?

回收執行棧中資料

看栗子

function foo(){
    let a = 1
    let b = { name: '沐華' }
    function showName(){
        let c = 2
        let d = { name: '沐華' }
    }
    showName()
}
foo()

執行過程:

  • JS引擎先為foo函式建立執行上下文,並將執行上下文壓入執行棧
  • 執行遇到showName函式,再為showName函式建立執行上下文,並將執行上下文壓入執行棧中,所以在棧中showName壓在foo的上面
  • 然後先執行showName函式的執行上下文,JS引擎中有一個記錄當前執行狀態的指標(ESP),會指向正在執行的上下文,也就是showName
  • 當showName執行結束之後,執行流程就進入下一個執行上下文,也就是foo函式,這時就需要銷燬showName執行上下文了。 主要就是JS引擎將ESP指標下移,指向showName下面的執行上下文,也就是foo,這個下移操作就是銷燬showName函式執行上下文的過程

如圖

未標題-2.jpg

回收堆中的資料

其實就是找出那些不再繼續使用的值,然後釋放其佔用的記憶體。

比如剛才的栗子,當foo函式和showName函式執行上下文都執行結束就清理了,但是裡面的兩個物件還依然佔用著空間,因為物件的資料是存在堆中的,清理掉的棧中的只是物件的引用地址,並不是物件資料

這就需要垃圾回收器

垃圾回收階段最艱難的任務就是找到不需要的變數,所以垃圾回收演算法有很多種,並沒有哪一種能勝任所有場景,需要根據場景權衡選擇

引用計數

引用計數是以前的垃圾回收演算法,該演算法定義"記憶體不再使用"的標準很簡單,就是看一個物件是否有指向它的引用,如果沒有其他物件指向它,就說明該物件不再需要了

但它卻有一個致命的問題:迴圈引用

就是如果有兩個物件互相引用,儘管他們已不再使用,但是垃圾回收不會進行回收,導致記憶體洩露

為了解決迴圈引用造成的問題,現代瀏覽器都沒有采用引用計數的方式

在V8中會把堆分為新生代和老生代兩個區域

新生代和老生代

V8實現了GC演算法,採用了分代式垃圾回收機制,所以V8將堆記憶體分為新生代(副垃圾回收器)和老生代(主垃圾回收器)兩個部分

新生代

新生代中通常只支援1~8M的容量,所以主要存放生存時間較短的物件

新生代中使用Scavenge GC演算法,將新生代空間分為兩個區域:物件區域和空閒區域。如圖:

4f9310c7da631fa5a57f871099bfbeaf.webp

顧名思義,就是說這兩塊空間只使用一個,另一個是空閒的。工作流程是這樣的

  • 將新分配的物件存入物件區域中,當物件區域存滿了,就會啟動GC演算法
  • 對物件區域內的垃圾做標記,標記完成之後將物件區域中還存活的物件複製到空閒區域中,已經不用的物件就銷燬。這個過程不會留下記憶體碎片
  • 複製完成後,再將物件區域和空閒互換。既回收了垃圾也能讓新生代中這兩塊區域無限重複使用下去

正因為新生代中空間不大,所以就容易出現被塞滿的情況,所以

  • 經歷過兩次垃圾回收依然還存活的物件會被移到老生代空間中
  • 如果空閒空間物件的佔比超過25%,為了不影響記憶體分配,就會將物件轉移到老生代空間

老生代

老生代特點就是佔用空間大,所以主要存放存活時間長的物件

老生代中使用標記清除演算法標記壓縮演算法。因為如果也採用Scavenge GC演算法的話,複製大物件就比較花時間了

標記清除

在以下情況下會先啟動標記清除演算法:

  • 某一個空間沒有分塊的時候
  • 物件太多超過空間容量一定限制的時候
  • 空間不能保證新生代中的物件轉移到老生代中的時候

標記清除的流程是這樣的

  • 從根部(js的全域性物件)出發,遍歷堆中所有物件,然後標記存活的物件
  • 標記完成後,銷燬沒有被標記的物件

由於垃圾回收階段,會暫停JS指令碼執行,等垃圾回收完畢後再恢復JS執行,這種行為稱為全停頓(stop-the-world)

比如堆中資料超過1G,那一次完整的垃圾回收可能需要1秒以上,這期間是會暫停JS執行緒執行的,這就導致頁面效能和響應能力下降

增量標記

所以在2011年,V8從 stop-the-world 標記切換到增量標記。使用增量標記演算法,GC 可以將回收任務分解成很多小任務,穿插在JS任務中間執行,這樣避免了應用出現卡頓的情況

併發標記

然後在2018年,GC 技術又有重大突破,就是併發標記讓 GC 掃描和標記物件時,允許JS同時執行

標記壓縮

清除後會造成堆記憶體出現記憶體碎片的情況,當碎片超過一定限制後會啟動標記壓縮演算法,將存活的物件向堆中的一端移動,到所有物件移動完成,就清理掉不需要的記憶體

結語

點贊支援、手留餘香、與有榮焉

參考

瀏覽器工作原理與實踐

相關文章